优化
This commit is contained in:
@@ -12,8 +12,8 @@ export interface DispatchQuery {
|
||||
}
|
||||
|
||||
export interface DispatchPalletCandidateDto {
|
||||
planId: string
|
||||
planPalletId: string
|
||||
planId?: string | null
|
||||
planPalletId?: string | null
|
||||
warehousePalletId?: string | null
|
||||
orderId: string
|
||||
customerId?: string | null
|
||||
@@ -36,8 +36,9 @@ export interface DispatchPalletCandidateDto {
|
||||
fbaWarehouseCode?: string | null
|
||||
packageCount?: number | null
|
||||
volumeCbm?: number | null
|
||||
sourceStage?: 'PLAN' | 'RECEIVED' | null
|
||||
sourceStage?: 'PLAN' | 'RECEIVED' | 'RELABEL' | null
|
||||
cartonMarks?: string | null
|
||||
relabelCartonMarks?: string | null
|
||||
}
|
||||
|
||||
export interface DispatchCandidateQuery {
|
||||
@@ -57,6 +58,7 @@ export interface PreArrangeDispatchPayload {
|
||||
fbaAppointmentStatus?: string
|
||||
vehicleAppointmentStatus?: string
|
||||
draftToken?: string
|
||||
relabelNo?: string
|
||||
}
|
||||
|
||||
export interface DispatchSummaryDto {
|
||||
@@ -147,6 +149,13 @@ export async function cancelDispatch(dispatchNo: string) {
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function removeDispatchItem(dispatchNo: string, itemId: string) {
|
||||
const response = await overseasHttp.delete<ApiEnvelope<DispatchSummaryDto>, ApiEnvelope<DispatchSummaryDto>>(
|
||||
`/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(itemId)}`,
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function listDispatchCandidates(query: DispatchCandidateQuery) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<DispatchPalletCandidateDto[]>, ApiEnvelope<DispatchPalletCandidateDto[]>>(
|
||||
'/platform/v1/last-leg/dispatches/candidates',
|
||||
@@ -219,7 +228,6 @@ export interface TransferDeliveryPayload {
|
||||
paidAt?: string; paymentMethod?: 'ONLINE' | 'BANK_TRANSFER' | 'CASH' | 'OTHER'; paymentReferenceNo?: string
|
||||
paymentVoucherFileIds?: string[]; chargeableQuantity?: number; quantityUnit?: string; unitPrice?: number
|
||||
periodStart?: string; periodEnd?: string; remark?: string }>
|
||||
palletCartonMarks?: Record<string, string[]>
|
||||
}
|
||||
export async function transferDispatchToDelivery(dispatchNo: string, payload: TransferDeliveryPayload) {
|
||||
const response = await overseasHttp.post<ApiEnvelope<TransferDeliveryResult>, ApiEnvelope<TransferDeliveryResult>>(
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface LastMileOrderQuery {
|
||||
page: number
|
||||
size: number
|
||||
orderNo?: string
|
||||
relabelNo?: string
|
||||
orderType?: string
|
||||
inboundCode?: string
|
||||
lxCode?: string
|
||||
@@ -60,7 +61,6 @@ export interface LastMileOrderSummaryDto {
|
||||
transferWarehouseCode?: string | null
|
||||
fbaWarehouseCode?: string | null
|
||||
containerNo?: string | null
|
||||
containerStatus?: string | null
|
||||
abnormal?: boolean | null
|
||||
detained?: boolean | null
|
||||
holdChargeable?: boolean | null
|
||||
@@ -77,6 +77,7 @@ export interface LastMileOrderSummaryDto {
|
||||
actualArrivalAt?: string | null
|
||||
estimatedWarehouseAt?: string | null
|
||||
inboundOrderNo?: string | null
|
||||
dispatchStatus?: 'SCHEDULED' | 'UNSCHEDULED' | null
|
||||
/** Legacy read-only association retained for compatibility; absent from current service DTO. */
|
||||
deliveryOrderNo?: string | null
|
||||
outboundOrderNo?: string | null
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { getWarehouseTransfer, listWarehouseTransfers, reviewWarehouseTransfer } from './warehouseTransfers'
|
||||
|
||||
vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn() } }))
|
||||
|
||||
describe('warehouseTransfers API', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('always scopes platform list and detail requests to the selected customer', async () => {
|
||||
vi.mocked(overseasHttp.get).mockResolvedValueOnce({ code: '200', data: { records: [], page: 1, size: 10, total: 0, pages: 0 } })
|
||||
vi.mocked(overseasHttp.get).mockResolvedValueOnce({ code: '200', data: { transferNo: 'WT-1', items: [], cartons: [], relabelBindings: [] } })
|
||||
await listWarehouseTransfers('20001', { page: 1, size: 10, status: 'PENDING_REVIEW' })
|
||||
await getWarehouseTransfer('20001', 'WT-1')
|
||||
expect(vi.mocked(overseasHttp.get).mock.calls[0]).toEqual(['/platform/v1/last-leg/warehouse-transfers', { params: { page: 1, size: 10, status: 'PENDING_REVIEW', customerId: '20001' } }])
|
||||
expect(vi.mocked(overseasHttp.get).mock.calls[1]).toEqual(['/platform/v1/last-leg/warehouse-transfers/WT-1', { params: { customerId: '20001' } }])
|
||||
})
|
||||
|
||||
it('posts the real review decision with customer scope', async () => {
|
||||
vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: { transferNo: 'WT-1', items: [], cartons: [], relabelBindings: [] } })
|
||||
await reviewWarehouseTransfer('20001', 'WT-1', 'APPROVE', '资料无误')
|
||||
expect(overseasHttp.post).toHaveBeenCalledWith(
|
||||
'/platform/v1/last-leg/warehouse-transfers/WT-1/review',
|
||||
{ decision: 'APPROVE', remark: '资料无误' },
|
||||
{ params: { customerId: '20001' } },
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { overseasHttp } from '@/shared/api/overseasHttp'
|
||||
import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
|
||||
|
||||
export interface WarehouseTransferQuery {
|
||||
page: number
|
||||
size: number
|
||||
transferNo?: string
|
||||
operatingWarehouseId?: string
|
||||
fbaWarehouseCode?: string
|
||||
status?: string
|
||||
needRelabel?: boolean
|
||||
}
|
||||
|
||||
export interface WarehouseTransferSummaryDto {
|
||||
id: string
|
||||
transferNo: string
|
||||
inboundCode?: string | null
|
||||
lastlegOrderId?: string | null
|
||||
lastlegOrderNo?: string | null
|
||||
operatingWarehouseId?: string | null
|
||||
fbaWarehouseCode?: string | null
|
||||
packageCount: number
|
||||
deliveryMethod?: string | null
|
||||
needRelabel: boolean
|
||||
relabelNo?: string | null
|
||||
status: string
|
||||
estimatedArrivalAt?: string | null
|
||||
createdAt?: string | null
|
||||
}
|
||||
|
||||
export interface WarehouseTransferItemDto {
|
||||
id: string
|
||||
skuCode: string
|
||||
productNameZh: string
|
||||
packageCount: number
|
||||
shippingMark?: string | null
|
||||
}
|
||||
|
||||
export interface WarehouseTransferCartonDto {
|
||||
cartonMark?: string | null
|
||||
customerCartonMark?: string | null
|
||||
cartonIndex: number
|
||||
totalCartons: number
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface WarehouseTransferRelabelBindingDto {
|
||||
operationType: string
|
||||
customerCartonMark: string
|
||||
originalSku: string
|
||||
newSku?: string | null
|
||||
quantity: number
|
||||
fbaCartonMark?: string | null
|
||||
destinationWarehouseCode?: string | null
|
||||
remark?: string | null
|
||||
}
|
||||
|
||||
export interface WarehouseTransferPalletDto {
|
||||
id: string
|
||||
sequenceNo: number
|
||||
palletNo?: string | null
|
||||
cartonCount: number
|
||||
grossWeightKg: number
|
||||
volumeCbm: number
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface WarehouseTransferDetailDto extends WarehouseTransferSummaryDto {
|
||||
inboundOrderId?: string | null
|
||||
orderType?: string | null
|
||||
orderStatus?: string | null
|
||||
relabelApplicationId?: string | null
|
||||
relabelStatus?: string | null
|
||||
deliveryAddress?: string | null
|
||||
estimatedShipAt?: string | null
|
||||
fbaShipmentNo?: string | null
|
||||
trackingNo?: string | null
|
||||
fbaBoxNos?: string | null
|
||||
remark?: string | null
|
||||
items: WarehouseTransferItemDto[]
|
||||
cartons: WarehouseTransferCartonDto[]
|
||||
pallets: WarehouseTransferPalletDto[]
|
||||
relabelBindings: WarehouseTransferRelabelBindingDto[]
|
||||
customerConfirmedAt?: string | null
|
||||
actualShipAt?: string | null
|
||||
}
|
||||
|
||||
export async function listWarehouseTransfers(customerId: string, query: WarehouseTransferQuery) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<OverseasPagePayload<WarehouseTransferSummaryDto>>, ApiEnvelope<OverseasPagePayload<WarehouseTransferSummaryDto>>>(
|
||||
'/platform/v1/last-leg/warehouse-transfers',
|
||||
{ params: { ...query, customerId } },
|
||||
)
|
||||
return normalizeOverseasPage(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function getWarehouseTransfer(customerId: string, transferNo: string) {
|
||||
const response = await overseasHttp.get<ApiEnvelope<WarehouseTransferDetailDto>, ApiEnvelope<WarehouseTransferDetailDto>>(
|
||||
`/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}`,
|
||||
{ params: { customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function reviewWarehouseTransfer(
|
||||
customerId: string,
|
||||
transferNo: string,
|
||||
decision: 'APPROVE' | 'REJECT',
|
||||
remark?: string,
|
||||
) {
|
||||
const response = await overseasHttp.post<ApiEnvelope<WarehouseTransferDetailDto>, ApiEnvelope<WarehouseTransferDetailDto>>(
|
||||
`/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(transferNo)}/review`,
|
||||
{ decision, remark },
|
||||
{ params: { customerId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -8,16 +8,33 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import { createDispatchDraftContainer, deleteDispatchDraftContainer, listDispatchCandidates, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDispatch, releaseDispatchDraft, unlockDispatchDraftPallet, type DispatchPalletCandidateDto } from '../api/dispatches'
|
||||
import { createDispatchDraftContainer, deleteDispatchDraftContainer, listDispatchCandidates, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDispatch, unlockDispatchDraftPallet, type DispatchPalletCandidateDto } from '../api/dispatches'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
import type { InboundOrderListItem } from '../types'
|
||||
import OrderReferenceText from './OrderReferenceText.vue'
|
||||
|
||||
type VehicleSpec = { value: string; label: string; capacity: number; maxWeightLb: number }
|
||||
interface LoadingContainer { id: string; capacity: number; maxWeightLb: number; vehicleLabel: string; palletIds: string[] }
|
||||
type VehicleSpec = {
|
||||
value: string
|
||||
label: string
|
||||
capacity: number
|
||||
maxWeightLb: number
|
||||
}
|
||||
interface LoadingContainer {
|
||||
id: string
|
||||
capacity: number
|
||||
maxWeightLb: number
|
||||
vehicleLabel: string
|
||||
palletIds: string[]
|
||||
}
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; orders: InboundOrderListItem[] }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; created: [] }>()
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
orders: InboundOrderListItem[]
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
created: []
|
||||
}>()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
@@ -29,10 +46,14 @@ const activeOrderKey = ref('')
|
||||
const containers = ref<LoadingContainer[]>([])
|
||||
const busyPalletIds = ref<string[]>([])
|
||||
const draftToken = ref('')
|
||||
const finalized = ref(false)
|
||||
const vehicleDialogOpen = ref(false)
|
||||
const selectedVehicle = ref('')
|
||||
const query = reactive({ orderNo: '', orderType: '', sourceStage: 'RECEIVED' as '' | 'PLAN' | 'RECEIVED', fbaWarehouseCode: '' })
|
||||
const query = reactive({
|
||||
orderNo: '',
|
||||
orderType: '',
|
||||
sourceStage: 'RECEIVED' as '' | 'PLAN' | 'RECEIVED',
|
||||
fbaWarehouseCode: '',
|
||||
})
|
||||
const warehouseTab = ref<'ALL' | 'LAX' | 'NYC'>('ALL')
|
||||
|
||||
const orderTypeOptions = [
|
||||
@@ -44,22 +65,26 @@ const orderTypeOptions = [
|
||||
]
|
||||
|
||||
const planIds = computed(() => [...new Set(props.orders.map((item) => item.planId).filter(Boolean))])
|
||||
const visibleCandidates = computed(() => candidates.value.filter((item) => {
|
||||
const typeMatches = !query.orderType || String(item.orderType || '') === query.orderType
|
||||
if (!typeMatches) return false
|
||||
if (warehouseTab.value === 'ALL') return true
|
||||
const warehouse = `${item.overseasWarehouseId || ''} ${item.overseasWarehouseName || ''}`.toLowerCase()
|
||||
return warehouseTab.value === 'LAX'
|
||||
? /lax|洛杉矶|los angeles/.test(warehouse)
|
||||
: /nyc|jfk|纽约|new york/.test(warehouse)
|
||||
}))
|
||||
const visibleCandidates = computed(() =>
|
||||
candidates.value.filter((item) => {
|
||||
const typeMatches = !query.orderType || String(item.orderType || '') === query.orderType
|
||||
if (!typeMatches) return false
|
||||
if (warehouseTab.value === 'ALL') return true
|
||||
const warehouse = `${item.overseasWarehouseId || ''} ${item.overseasWarehouseName || ''}`.toLowerCase()
|
||||
return warehouseTab.value === 'LAX' ? /lax|洛杉矶|los angeles/.test(warehouse) : /nyc|jfk|纽约|new york/.test(warehouse)
|
||||
}),
|
||||
)
|
||||
const orderGroups = computed(() => {
|
||||
const groups = new Map<string, DispatchPalletCandidateDto[]>()
|
||||
for (const item of visibleCandidates.value) {
|
||||
const key = String(item.orderId || item.orderNo || item.planId)
|
||||
groups.set(key, [...(groups.get(key) || []), item])
|
||||
}
|
||||
return [...groups.entries()].map(([key, pallets]) => ({ key, first: pallets[0]!, pallets }))
|
||||
return [...groups.entries()].map(([key, pallets]) => ({
|
||||
key,
|
||||
first: pallets[0]!,
|
||||
pallets,
|
||||
}))
|
||||
})
|
||||
const activePallets = computed(() => orderGroups.value.find((item) => item.key === activeOrderKey.value)?.pallets || [])
|
||||
const assignedIds = computed(() => containers.value.flatMap((item) => item.palletIds))
|
||||
@@ -67,95 +92,219 @@ const assigned = computed(() => assignedIds.value.map((id) => palletSnapshot(id)
|
||||
const assignedPackages = computed(() => assigned.value.reduce((sum, item) => sum + Number(item.packageCount || 0), 0))
|
||||
const assignedVolume = computed(() => assigned.value.reduce((sum, item) => sum + Number(item.volumeCbm || 0), 0))
|
||||
const vehicleSpecs: VehicleSpec[] = [
|
||||
{ value: 'LARGE_30', label: '大车(30托)', capacity: 30, maxWeightLb: 40000 },
|
||||
{ value: 'SMALL_15', label: '小车(15托)', capacity: 15, maxWeightLb: 10000 },
|
||||
{ value: 'SMALL_14', label: '小车(14托)', capacity: 14, maxWeightLb: 10000 },
|
||||
{ value: 'SMALL_12', label: '小车(12托)', capacity: 12, maxWeightLb: 10000 },
|
||||
{
|
||||
value: 'LARGE_30',
|
||||
label: '大车(30托)',
|
||||
capacity: 30,
|
||||
maxWeightLb: 40000,
|
||||
},
|
||||
{
|
||||
value: 'SMALL_15',
|
||||
label: '小车(15托)',
|
||||
capacity: 15,
|
||||
maxWeightLb: 10000,
|
||||
},
|
||||
{
|
||||
value: 'SMALL_14',
|
||||
label: '小车(14托)',
|
||||
capacity: 14,
|
||||
maxWeightLb: 10000,
|
||||
},
|
||||
{
|
||||
value: 'SMALL_12',
|
||||
label: '小车(12托)',
|
||||
capacity: 12,
|
||||
maxWeightLb: 10000,
|
||||
},
|
||||
]
|
||||
const selectedVehicleSpec = computed(() => vehicleSpecs.find((item) => item.value === selectedVehicle.value))
|
||||
const weightLb = (item?: DispatchPalletCandidateDto) => Number(item?.volumeWeightKg || 0) * 2.2046226218
|
||||
const containerWeightLb = (container: LoadingContainer) => container.palletIds.reduce((sum, id) => sum + weightLb(palletSnapshot(id)), 0)
|
||||
|
||||
function palletId(item: DispatchPalletCandidateDto) { return String(item.planPalletId) }
|
||||
function text(value: unknown) { const normalized = String(value ?? '').trim(); return normalized || '—' }
|
||||
function number(value: unknown, digits = 0) { const normalized = Number(value); return Number.isFinite(normalized) ? normalized.toFixed(digits) : '0' }
|
||||
function dateTime(value: unknown) { return formatOverseasDateTime(value) }
|
||||
function orderType(value: unknown) { return ({ FIRST_LEG: '头程订单', RELABEL_APPLICATION: '换标下单', RETURN_RELABEL: '退货换标', LOCAL_LOOSEBOARD: '本土散板', WAREHOUSE_TRANSFER: '仓储转运', ONE_CLICK_DROPSHIP: '一件代发', REJECTED: '拒收订单', EXPRESS: '快递订单' } as Record<string, string>)[String(value || '')] || text(value) }
|
||||
function cartonMarks(item: DispatchPalletCandidateDto) { return String(item.cartonMarks || '').split(',').map((value) => value.trim()).filter(Boolean) }
|
||||
function containerFor(id: string) { return containers.value.find((item) => item.palletIds.includes(id)) }
|
||||
function palletSnapshot(id: string) { return palletSnapshots.value[id] }
|
||||
function makeDraftToken() { return globalThis.crypto?.randomUUID?.() || `dispatch-${Date.now()}-${Math.random().toString(16).slice(2)}` }
|
||||
function palletId(item: DispatchPalletCandidateDto) {
|
||||
return String(item.planPalletId || item.warehousePalletId || '')
|
||||
}
|
||||
function text(value: unknown) {
|
||||
const normalized = String(value ?? '').trim()
|
||||
return normalized || '—'
|
||||
}
|
||||
function number(value: unknown, digits = 0) {
|
||||
const normalized = Number(value)
|
||||
return Number.isFinite(normalized) ? normalized.toFixed(digits) : '0'
|
||||
}
|
||||
function dateTime(value: unknown) {
|
||||
return formatOverseasDateTime(value)
|
||||
}
|
||||
function orderType(value: unknown) {
|
||||
return (
|
||||
(
|
||||
{
|
||||
FIRST_LEG: '头程订单',
|
||||
RELABEL_APPLICATION: '换标下单',
|
||||
RETURN_RELABEL: '退货换标',
|
||||
LOCAL_LOOSEBOARD: '本土散板',
|
||||
WAREHOUSE_TRANSFER: '仓储转运',
|
||||
ONE_CLICK_DROPSHIP: '一件代发',
|
||||
REJECTED: '拒收订单',
|
||||
EXPRESS: '快递订单',
|
||||
} as Record<string, string>
|
||||
)[String(value || '')] || text(value)
|
||||
)
|
||||
}
|
||||
function relabelCartonMarks(item: DispatchPalletCandidateDto) {
|
||||
return String(item.relabelCartonMarks || '')
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
function containerFor(id: string) {
|
||||
return containers.value.find((item) => item.palletIds.includes(id))
|
||||
}
|
||||
function palletSnapshot(id: string) {
|
||||
return palletSnapshots.value[id]
|
||||
}
|
||||
function makeDraftToken() {
|
||||
return globalThis.crypto?.randomUUID?.() || `dispatch-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
}
|
||||
function ensureDraftToken() {
|
||||
if (!draftToken.value.trim()) draftToken.value = makeDraftToken()
|
||||
return draftToken.value
|
||||
}
|
||||
function rememberPallets(items: DispatchPalletCandidateDto[]) {
|
||||
palletSnapshots.value = { ...palletSnapshots.value, ...Object.fromEntries(items.map((item) => [palletId(item), item])) }
|
||||
palletSnapshots.value = {
|
||||
...palletSnapshots.value,
|
||||
...Object.fromEntries(items.map((item) => [palletId(item), item])),
|
||||
}
|
||||
}
|
||||
async function hydrateDraftPallets() {
|
||||
const restored = await listDispatchCandidates({ draftToken: ensureDraftToken() })
|
||||
const restored = await listDispatchCandidates({
|
||||
draftToken: ensureDraftToken(),
|
||||
})
|
||||
rememberPallets(restored)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true; loadError.value = null
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
candidates.value = await listDispatchCandidates({ planIds: planIds.value.length ? planIds.value.join(',') : undefined, orderNo: query.orderNo.trim() || undefined, fbaWarehouseCode: query.fbaWarehouseCode.trim() || undefined, sourceStage: query.sourceStage || undefined, draftToken: ensureDraftToken() })
|
||||
candidates.value = await listDispatchCandidates({
|
||||
planIds: planIds.value.length ? planIds.value.join(',') : undefined,
|
||||
orderNo: query.orderNo.trim() || undefined,
|
||||
fbaWarehouseCode: query.fbaWarehouseCode.trim() || undefined,
|
||||
sourceStage: query.sourceStage || undefined,
|
||||
draftToken: ensureDraftToken(),
|
||||
})
|
||||
rememberPallets(candidates.value)
|
||||
activeOrderKey.value = orderGroups.value[0]?.key || ''
|
||||
} catch (error) {
|
||||
candidates.value = []; activeOrderKey.value = ''
|
||||
candidates.value = []
|
||||
activeOrderKey.value = ''
|
||||
loadError.value = getApiErrorDetails(error, '可排车入仓单加载失败,请重试')
|
||||
} finally { loading.value = false }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
function reset() {
|
||||
query.orderNo = ''
|
||||
query.orderType = ''
|
||||
query.sourceStage = 'RECEIVED'
|
||||
query.fbaWarehouseCode = ''
|
||||
warehouseTab.value = 'ALL'
|
||||
void load()
|
||||
}
|
||||
function reset() { query.orderNo = ''; query.orderType = ''; query.sourceStage = 'RECEIVED'; query.fbaWarehouseCode = ''; warehouseTab.value = 'ALL'; void load() }
|
||||
|
||||
function addContainer() { selectedVehicle.value = ''; vehicleDialogOpen.value = true }
|
||||
function addContainer() {
|
||||
selectedVehicle.value = ''
|
||||
vehicleDialogOpen.value = true
|
||||
}
|
||||
async function confirmAddContainer() {
|
||||
const spec = selectedVehicleSpec.value
|
||||
if (!spec) { ElMessage.warning('请选择车型'); return }
|
||||
if (!spec) {
|
||||
ElMessage.warning('请选择车型')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const created = await createDispatchDraftContainer(ensureDraftToken(), spec.capacity)
|
||||
containers.value.push({ id: created.id, capacity: created.capacity, maxWeightLb: spec.maxWeightLb, vehicleLabel: spec.label, palletIds: created.palletIds || [] })
|
||||
containers.value.push({
|
||||
id: created.id,
|
||||
capacity: created.capacity,
|
||||
maxWeightLb: spec.maxWeightLb,
|
||||
vehicleLabel: spec.label,
|
||||
palletIds: created.palletIds || [],
|
||||
})
|
||||
vehicleDialogOpen.value = false
|
||||
} catch (error) { mutationError.value = getApiErrorDetails(error, '添加装车车型失败,请重试') }
|
||||
} catch (error) {
|
||||
mutationError.value = getApiErrorDetails(error, '添加装车车型失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
async function movePallet(item: DispatchPalletCandidateDto, containerId: string) {
|
||||
const id = palletId(item)
|
||||
const target = containers.value.find((container) => container.id === containerId)
|
||||
if (!target || target.palletIds.includes(id)) return
|
||||
if (!String(item.overseasWarehouseId || '').trim()) { ElMessage.warning('该托盘尚未关联海外仓,不能排车'); return }
|
||||
if (containerFor(id)) { ElMessage.warning('该托盘已在其他装车托盘中'); return }
|
||||
if (target.palletIds.length >= target.capacity) { ElMessage.warning('该装车托盘已达到设置的托数'); return }
|
||||
if (!String(item.overseasWarehouseId || '').trim()) {
|
||||
ElMessage.warning('该托盘尚未关联海外仓,不能排车')
|
||||
return
|
||||
}
|
||||
if (containerFor(id)) {
|
||||
ElMessage.warning('该托盘已在其他装车托盘中')
|
||||
return
|
||||
}
|
||||
if (target.palletIds.length >= target.capacity) {
|
||||
ElMessage.warning('该装车托盘已达到设置的托数')
|
||||
return
|
||||
}
|
||||
const nextWeight = containerWeightLb(target) + weightLb(item)
|
||||
if (nextWeight > target.maxWeightLb * 1.1) ElMessage.error(`${target.vehicleLabel}重量将达到 ${nextWeight.toFixed(0)}LB,已超过限重10%,请注意调整`)
|
||||
else if (nextWeight > target.maxWeightLb) ElMessage.warning(`${target.vehicleLabel}当前重量 ${nextWeight.toFixed(0)}LB,已超过额定限重 ${target.maxWeightLb}LB`)
|
||||
const anchor = assigned.value[0]
|
||||
if (anchor?.customerId && anchor.customerId !== item.customerId) { ElMessage.warning('一次排车只能装同一客户的托盘'); return }
|
||||
if (anchor?.overseasWarehouseId && anchor.overseasWarehouseId !== item.overseasWarehouseId) { ElMessage.warning('一次排车只能装同一海外仓的托盘'); return }
|
||||
busyPalletIds.value.push(id); mutationError.value = null
|
||||
try { await lockDispatchDraftPallet(id, ensureDraftToken(), target.id); target.palletIds.push(id) }
|
||||
catch (error) { mutationError.value = getApiErrorDetails(error, '托盘锁定失败,请刷新后重试') }
|
||||
finally { busyPalletIds.value = busyPalletIds.value.filter((value) => value !== id) }
|
||||
if (anchor?.customerId && anchor.customerId !== item.customerId) {
|
||||
ElMessage.warning('一次排车只能装同一客户的托盘')
|
||||
return
|
||||
}
|
||||
if (anchor?.overseasWarehouseId && anchor.overseasWarehouseId !== item.overseasWarehouseId) {
|
||||
ElMessage.warning('一次排车只能装同一海外仓的托盘')
|
||||
return
|
||||
}
|
||||
busyPalletIds.value.push(id)
|
||||
mutationError.value = null
|
||||
try {
|
||||
if (item.sourceStage !== 'RELABEL') await lockDispatchDraftPallet(id, ensureDraftToken(), target.id)
|
||||
target.palletIds.push(id)
|
||||
} catch (error) {
|
||||
mutationError.value = getApiErrorDetails(error, '托盘锁定失败,请刷新后重试')
|
||||
} finally {
|
||||
busyPalletIds.value = busyPalletIds.value.filter((value) => value !== id)
|
||||
}
|
||||
}
|
||||
|
||||
async function removePallet(id: string) {
|
||||
const owner = containerFor(id)
|
||||
if (!owner) return
|
||||
busyPalletIds.value.push(id); mutationError.value = null
|
||||
try { await unlockDispatchDraftPallet(id, ensureDraftToken()); owner.palletIds = owner.palletIds.filter((value) => value !== id) }
|
||||
catch (error) { mutationError.value = getApiErrorDetails(error, '托盘解锁失败,请重试') }
|
||||
finally { busyPalletIds.value = busyPalletIds.value.filter((value) => value !== id) }
|
||||
busyPalletIds.value.push(id)
|
||||
mutationError.value = null
|
||||
try {
|
||||
if (palletSnapshot(id)?.sourceStage !== 'RELABEL') await unlockDispatchDraftPallet(id, ensureDraftToken())
|
||||
owner.palletIds = owner.palletIds.filter((value) => value !== id)
|
||||
} catch (error) {
|
||||
mutationError.value = getApiErrorDetails(error, '托盘解锁失败,请重试')
|
||||
} finally {
|
||||
busyPalletIds.value = busyPalletIds.value.filter((value) => value !== id)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteContainer(container: LoadingContainer) {
|
||||
for (const id of [...container.palletIds]) {
|
||||
await removePallet(id)
|
||||
}
|
||||
if (!container.palletIds.length) { await deleteDispatchDraftContainer(ensureDraftToken(), container.id); containers.value = containers.value.filter((item) => item.id !== container.id) }
|
||||
if (!container.palletIds.length) {
|
||||
await deleteDispatchDraftContainer(ensureDraftToken(), container.id)
|
||||
containers.value = containers.value.filter((item) => item.id !== container.id)
|
||||
}
|
||||
}
|
||||
function dragStart(event: DragEvent, item: DispatchPalletCandidateDto) {
|
||||
event.dataTransfer?.setData('text/plain', palletId(item))
|
||||
}
|
||||
function dragStart(event: DragEvent, item: DispatchPalletCandidateDto) { event.dataTransfer?.setData('text/plain', palletId(item)) }
|
||||
function dropInto(event: DragEvent, containerId: string) {
|
||||
const id = event.dataTransfer?.getData('text/plain') || ''
|
||||
const item = candidates.value.find((candidate) => palletId(candidate) === id)
|
||||
@@ -163,31 +312,77 @@ function dropInto(event: DragEvent, containerId: string) {
|
||||
}
|
||||
|
||||
async function close(value = false) {
|
||||
const token = ensureDraftToken()
|
||||
if (!value && token && !finalized.value) {
|
||||
try { await releaseDispatchDraft(token) } catch { /* server-side expiry remains the fallback */ }
|
||||
}
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!assignedIds.value.length) { ElMessage.warning('请至少移入一个托盘'); return }
|
||||
if (assigned.value.some((item) => !String(item.overseasWarehouseId || '').trim())) { ElMessage.warning('所选托盘中存在尚未关联海外仓的托盘'); return }
|
||||
saving.value = true; mutationError.value = null
|
||||
if (!assignedIds.value.length) {
|
||||
ElMessage.warning('请至少移入一个托盘')
|
||||
return
|
||||
}
|
||||
if (assigned.value.some((item) => !String(item.overseasWarehouseId || '').trim())) {
|
||||
ElMessage.warning('所选托盘中存在尚未关联海外仓的托盘')
|
||||
return
|
||||
}
|
||||
const relabelOrders = [...new Set(assigned.value.filter((item) => item.sourceStage === 'RELABEL')
|
||||
.map((item) => String(item.orderNo || '').trim()).filter(Boolean))]
|
||||
if (relabelOrders.length && (relabelOrders.length !== 1 || assigned.value.some((item) => item.sourceStage !== 'RELABEL'))) {
|
||||
ElMessage.warning('一张换标单的托盘需要单独排车')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
mutationError.value = null
|
||||
try {
|
||||
await preArrangeDispatch({ planPalletIds: assignedIds.value, draftToken: ensureDraftToken() })
|
||||
finalized.value = true; emit('update:modelValue', false); emit('created'); ElMessage.success('预排车创建成功')
|
||||
await preArrangeDispatch({
|
||||
planPalletIds: assignedIds.value,
|
||||
...(relabelOrders.length ? { relabelNo: relabelOrders[0] } : { draftToken: ensureDraftToken() }),
|
||||
})
|
||||
emit('update:modelValue', false)
|
||||
emit('created')
|
||||
ElMessage.success('排车明细已同步到预排列表')
|
||||
await router.push({ name: 'overseas-dispatches' })
|
||||
} catch (error) { mutationError.value = getApiErrorDetails(error, '预排车创建失败,请重试') }
|
||||
finally { saving.value = false }
|
||||
} catch (error) {
|
||||
mutationError.value = getApiErrorDetails(error, '预排车创建失败,请重试')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (!open) return
|
||||
draftToken.value = makeDraftToken(); finalized.value = false; containers.value = []; palletSnapshots.value = {}
|
||||
query.orderNo = ''; query.orderType = ''; query.sourceStage = 'RECEIVED'; query.fbaWarehouseCode = ''; warehouseTab.value = 'ALL'; mutationError.value = null
|
||||
void (async () => { const drafts = await listDispatchDrafts().catch(() => []); const draft = drafts[0]; if (draft?.draftToken) { draftToken.value = draft.draftToken; containers.value = (draft.containers || []).map((c) => { const spec = vehicleSpecs.find((item) => item.capacity === c.capacity) || vehicleSpecs[0]!; return { id: c.id, capacity: c.capacity, maxWeightLb: spec.maxWeightLb, vehicleLabel: spec.label, palletIds: c.palletIds || [] } }); await hydrateDraftPallets().catch(() => undefined) } await load() })()
|
||||
}, { immediate: true })
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (!open) return
|
||||
draftToken.value = makeDraftToken()
|
||||
containers.value = []
|
||||
palletSnapshots.value = {}
|
||||
query.orderNo = ''
|
||||
query.orderType = ''
|
||||
query.sourceStage = 'RECEIVED'
|
||||
query.fbaWarehouseCode = ''
|
||||
warehouseTab.value = 'ALL'
|
||||
mutationError.value = null
|
||||
void (async () => {
|
||||
const drafts = await listDispatchDrafts().catch(() => [])
|
||||
const draft = drafts[0]
|
||||
if (draft?.draftToken) {
|
||||
draftToken.value = draft.draftToken
|
||||
containers.value = (draft.containers || []).map((c) => {
|
||||
const spec = vehicleSpecs.find((item) => item.capacity === c.capacity) || vehicleSpecs[0]!
|
||||
return {
|
||||
id: c.id,
|
||||
capacity: c.capacity,
|
||||
maxWeightLb: spec.maxWeightLb,
|
||||
vehicleLabel: spec.label,
|
||||
palletIds: c.palletIds || [],
|
||||
}
|
||||
})
|
||||
await hydrateDraftPallets().catch(() => undefined)
|
||||
}
|
||||
await load()
|
||||
})()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch([warehouseTab, () => query.orderType], () => {
|
||||
activeOrderKey.value = orderGroups.value[0]?.key || ''
|
||||
@@ -206,38 +401,111 @@ watch([warehouseTab, () => query.orderType], () => {
|
||||
</section>
|
||||
|
||||
<nav class="warehouse-tabs" aria-label="海外仓筛选">
|
||||
<button v-for="tab in [{ value: 'ALL', label: '全部' }, { value: 'LAX', label: '洛杉矶' }, { value: 'NYC', label: '纽约' }]" :key="tab.value" type="button" :class="['warehouse-tab', { active: warehouseTab === tab.value }]" @click="warehouseTab = tab.value as 'ALL' | 'LAX' | 'NYC'">{{ tab.label }}</button>
|
||||
<button
|
||||
v-for="tab in [
|
||||
{ value: 'ALL', label: '全部' },
|
||||
{ value: 'LAX', label: '洛杉矶' },
|
||||
{ value: 'NYC', label: '纽约' },
|
||||
]"
|
||||
:key="tab.value"
|
||||
type="button"
|
||||
:class="['warehouse-tab', { active: warehouseTab === tab.value }]"
|
||||
@click="warehouseTab = tab.value as 'ALL' | 'LAX' | 'NYC'"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<LoadErrorState v-if="loadError" title="可排车入仓单加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<LoadErrorState v-if="loadError" title="可排车入仓单加载失败" :message="loadError.message" :loading="loading" @retry="load"
|
||||
><template #details><ApiErrorMeta :details="loadError" /></template
|
||||
></LoadErrorState>
|
||||
<div v-else class="dispatch-workbench">
|
||||
<section class="order-pane" aria-label="可排车入仓单">
|
||||
<header><div><h3>出库单列表 <span>({{ orderGroups.length }}单)</span></h3><div class="order-type-tabs"><button v-for="option in [{ value: '', label: '全部' }, ...orderTypeOptions]" :key="option.value || 'all'" type="button" :class="{ active: query.orderType === option.value }" @click="query.orderType = option.value">{{ option.label }}</button></div></div></header>
|
||||
<header>
|
||||
<div>
|
||||
<h3>
|
||||
出库单列表 <span>({{ orderGroups.length }}单)</span>
|
||||
</h3>
|
||||
<div class="order-type-tabs">
|
||||
<button v-for="option in [{ value: '', label: '全部' }, ...orderTypeOptions]" :key="option.value || 'all'" type="button" :class="{ active: query.orderType === option.value }" @click="query.orderType = option.value">
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div v-loading="loading" class="order-table-wrap">
|
||||
<el-table :data="orderGroups" border height="520" highlight-current-row row-key="key" @current-change="activeOrderKey = $event?.key || ''">
|
||||
<el-table-column label="订单号" min-width="170"><template #default="{ row }"><OrderReferenceText :value="text(row.first.orderNo)" /></template></el-table-column>
|
||||
<el-table-column label="订单类型" width="100"><template #default="{ row }">{{ orderType(row.first.orderType) }}</template></el-table-column>
|
||||
<el-table-column label="重量(lb)" width="100" align="right"><template #default="{ row }">{{ number(Number(row.first.volumeWeightKg || 0) * 2.2046226218, 0) }}LB</template></el-table-column>
|
||||
<el-table-column label="托盘数量" width="90" align="right"><template #default="{ row }">{{ row.pallets.length }}</template></el-table-column>
|
||||
<el-table-column label="目的地FBA" width="110"><template #default="{ row }">{{ text(row.first.fbaWarehouseCode) }}</template></el-table-column>
|
||||
<el-table-column label="体积(m³)" width="100" align="right"><template #default="{ row }">{{ number(row.pallets.reduce((sum: number, item: DispatchPalletCandidateDto) => sum + Number(item.volumeCbm || 0), 0), 2) }}</template></el-table-column>
|
||||
<el-table-column label="货柜号" min-width="125"><template #default="{ row }">{{ text(row.first.containerNo) }}</template></el-table-column>
|
||||
<el-table-column label="预约送仓时间" min-width="145"><template #default="{ row }">{{ dateTime(row.first.appointmentAt) }}</template></el-table-column>
|
||||
<el-table-column label="预计装车时间" min-width="145"><template #default="{ row }">{{ dateTime(row.first.expectedLoadingAt) }}</template></el-table-column>
|
||||
<el-table-column label="订单号" min-width="170"
|
||||
><template #default="{ row }"><OrderReferenceText :value="text(row.first.orderNo)" /></template
|
||||
></el-table-column>
|
||||
<el-table-column label="换标单号" min-width="180"
|
||||
><template #default="{ row }">{{ row.first.sourceStage === 'RELABEL' ? text(row.first.orderRefs) : '—' }}</template
|
||||
></el-table-column>
|
||||
<el-table-column label="原入仓码" min-width="150"
|
||||
><template #default="{ row }">{{ text(row.first.inboundCode) }}</template
|
||||
></el-table-column>
|
||||
<el-table-column label="订单类型" width="100"
|
||||
><template #default="{ row }">{{ orderType(row.first.orderType) }}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="重量(lb)" width="100" align="right"
|
||||
><template #default="{ row }">{{ number(Number(row.first.volumeWeightKg || 0) * 2.2046226218, 0) }}LB</template></el-table-column
|
||||
>
|
||||
<el-table-column label="托盘数量" width="90" align="right"
|
||||
><template #default="{ row }">{{ row.pallets.length }}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="目的地FBA" width="110"
|
||||
><template #default="{ row }">{{ text(row.first.fbaWarehouseCode) }}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="体积(m³)" width="100" align="right"
|
||||
><template #default="{ row }">{{
|
||||
number(
|
||||
row.pallets.reduce((sum: number, item: DispatchPalletCandidateDto) => sum + Number(item.volumeCbm || 0), 0),
|
||||
2,
|
||||
)
|
||||
}}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="货柜号" min-width="125"
|
||||
><template #default="{ row }">{{ text(row.first.containerNo) }}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="预约送仓时间" min-width="145"
|
||||
><template #default="{ row }">{{ dateTime(row.first.appointmentAt) }}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="预计装车时间" min-width="145"
|
||||
><template #default="{ row }">{{ dateTime(row.first.expectedLoadingAt) }}</template></el-table-column
|
||||
>
|
||||
</el-table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="detail-pane" aria-label="入仓单托盘和箱号详情">
|
||||
<header><h3>托盘明细 <span>{{ activePallets.length ? `(${activePallets.length}托)` : '' }}</span></h3><span class="detail-order-reference">订单号:<OrderReferenceText :value="text(activePallets[0]?.orderNo)" /></span></header>
|
||||
<header>
|
||||
<h3>
|
||||
托盘明细
|
||||
<span>{{ activePallets.length ? `(${activePallets.length}托)` : '' }}</span>
|
||||
</h3>
|
||||
<span class="detail-order-reference">订单号:<OrderReferenceText :value="text(activePallets[0]?.orderNo)" /></span>
|
||||
</header>
|
||||
<div class="pallet-list">
|
||||
<div v-for="item in activePallets" :key="palletId(item)" class="pallet-row" :class="{ assigned: containerFor(palletId(item)) }" draggable="true" @dragstart="dragStart($event, item)">
|
||||
<GripVertical :size="16" class="drag-handle" />
|
||||
<div class="pallet-copy"><strong>{{ text(item.palletNo) }}</strong><span>{{ Number(item.packageCount || 0) }}箱 · {{ number(weightLb(item), 0) }}LB</span><div class="carton-marks"><el-tag v-for="mark in cartonMarks(item).slice(0, 4)" :key="mark" size="small" effect="plain">{{ mark }}</el-tag><span v-if="cartonMarks(item).length > 4">+{{ cartonMarks(item).length - 4 }}</span><span v-if="!cartonMarks(item).length">暂无箱号</span></div></div>
|
||||
<div class="pallet-copy">
|
||||
<strong>{{ text(item.palletNo) }}</strong
|
||||
><span>{{ Number(item.packageCount || 0) }}箱 · {{ number(weightLb(item), 0) }}LB</span>
|
||||
<div v-if="relabelCartonMarks(item).length" class="carton-marks">
|
||||
<div v-for="mark in relabelCartonMarks(item)" :key="`relabel-${mark}`" class="carton-mark-line">
|
||||
<span>{{ mark }}</span
|
||||
><el-tag size="small" type="warning">换标</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-tag v-if="containerFor(palletId(item))" type="success" size="small">已锁定</el-tag>
|
||||
<el-dropdown v-else trigger="click" :disabled="!containers.length || busyPalletIds.includes(palletId(item))" @command="movePallet(item, String($event))">
|
||||
<el-button text type="primary" :loading="busyPalletIds.includes(palletId(item))">移入 <ArrowRight :size="14" /></el-button>
|
||||
<template #dropdown><el-dropdown-menu><el-dropdown-item v-for="(container, index) in containers" :key="container.id" :command="container.id" :disabled="container.palletIds.length >= container.capacity">装车托盘 {{ index + 1 }}({{ container.palletIds.length }}/{{ container.capacity }})</el-dropdown-item></el-dropdown-menu></template>
|
||||
<template #dropdown
|
||||
><el-dropdown-menu
|
||||
><el-dropdown-item v-for="(container, index) in containers" :key="container.id" :command="container.id" :disabled="container.palletIds.length >= container.capacity">装车托盘 {{ index + 1 }}({{ container.palletIds.length }}/{{ container.capacity }})</el-dropdown-item></el-dropdown-menu
|
||||
></template
|
||||
>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
<el-empty v-if="!activePallets.length" :image-size="64" description="点击左侧入仓单查看托盘和箱号" />
|
||||
@@ -245,12 +513,41 @@ watch([warehouseTab, () => query.orderType], () => {
|
||||
</section>
|
||||
|
||||
<aside class="container-pane" aria-label="装车托盘容器">
|
||||
<header><div><h3>装车托盘</h3><p>托盘 {{ assigned.length }} · 箱数 {{ assignedPackages }} · 立方 {{ assignedVolume.toFixed(2) }}</p></div><el-button type="primary" plain @click="addContainer"><Plus :size="15" />添加装车托盘</el-button></header>
|
||||
<header>
|
||||
<div>
|
||||
<h3>装车托盘</h3>
|
||||
<p>
|
||||
托盘 {{ assigned.length }} · 箱数 {{ assignedPackages }} · 立方
|
||||
{{ assignedVolume.toFixed(2) }}
|
||||
</p>
|
||||
</div>
|
||||
<el-button type="primary" plain @click="addContainer"><Plus :size="15" />添加装车托盘</el-button>
|
||||
</header>
|
||||
<div class="container-list">
|
||||
<div v-for="(container, index) in containers" :key="container.id" class="loading-container" :class="{ 'loading-container--overweight': containerWeightLb(container) > container.maxWeightLb * 1.1 }" @dragover.prevent @drop.prevent="dropInto($event, container.id)">
|
||||
<div class="container-head"><strong>{{ container.vehicleLabel }}</strong><span>托数 <b>{{ container.palletIds.length }}</b>/{{ container.capacity }} · 重量 <b>{{ number(containerWeightLb(container), 0) }}LB</b>/{{ container.maxWeightLb }}LB <em v-if="containerWeightLb(container) > container.maxWeightLb * 1.1">超限10%</em></span><el-button text type="danger" :aria-label="`删除装车车型${index + 1}`" @click="deleteContainer(container)"><Trash2 :size="15" /></el-button></div>
|
||||
<div
|
||||
v-for="(container, index) in containers"
|
||||
:key="container.id"
|
||||
class="loading-container"
|
||||
:class="{
|
||||
'loading-container--overweight': containerWeightLb(container) > container.maxWeightLb * 1.1,
|
||||
}"
|
||||
@dragover.prevent
|
||||
@drop.prevent="dropInto($event, container.id)"
|
||||
>
|
||||
<div class="container-head">
|
||||
<strong>{{ container.vehicleLabel }}</strong
|
||||
><span
|
||||
>托数 <b>{{ container.palletIds.length }}</b
|
||||
>/{{ container.capacity }} · 重量 <b>{{ number(containerWeightLb(container), 0) }}LB</b>/{{ container.maxWeightLb }}LB <em v-if="containerWeightLb(container) > container.maxWeightLb * 1.1">超限10%</em></span
|
||||
><el-button text type="danger" :aria-label="`删除装车车型${index + 1}`" @click="deleteContainer(container)"><Trash2 :size="15" /></el-button>
|
||||
</div>
|
||||
<div class="container-body">
|
||||
<div v-for="id in container.palletIds" :key="id" class="loaded-pallet"><span><strong>{{ text(palletSnapshot(id)?.palletNo || '托盘号待补充') }}</strong><OrderReferenceText :value="text(palletSnapshot(id)?.orderNo)" /></span><el-button text type="danger" :loading="busyPalletIds.includes(id)" @click="removePallet(id)">移除</el-button></div>
|
||||
<div v-for="id in container.palletIds" :key="id" class="loaded-pallet">
|
||||
<span
|
||||
><strong>{{ text(palletSnapshot(id)?.palletNo || '托盘号待补充') }}</strong
|
||||
><OrderReferenceText :value="text(palletSnapshot(id)?.orderNo)" /></span
|
||||
><el-button text type="danger" :loading="busyPalletIds.includes(id)" @click="removePallet(id)">移除</el-button>
|
||||
</div>
|
||||
<span v-if="!container.palletIds.length" class="drop-hint">拖入托盘,移入后立即锁定</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -259,7 +556,10 @@ watch([warehouseTab, () => query.orderType], () => {
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<footer class="dialog-footer"><span>托盘移入即锁定,重量按 1kg = 2.2046lb 换算。</span><div><el-button :disabled="saving" @click="close(false)">取消</el-button><el-button type="primary" :loading="saving" :disabled="!assignedIds.length" @click="submit">预排车</el-button></div></footer>
|
||||
<footer class="dialog-footer">
|
||||
<span>托盘移入即锁定,重量按 1kg = 2.2046lb 换算。</span>
|
||||
<div><el-button :disabled="saving" @click="close(false)">取消</el-button><el-button type="primary" :loading="saving" :disabled="!assignedIds.length" @click="submit">预排车</el-button></div>
|
||||
</footer>
|
||||
</AppDialog>
|
||||
<el-dialog v-model="vehicleDialogOpen" title="选择车型" width="360px" append-to-body>
|
||||
<el-radio-group v-model="selectedVehicle" class="vehicle-options">
|
||||
@@ -270,41 +570,274 @@ watch([warehouseTab, () => query.orderType], () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dispatch-query { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border: 1px solid #e6eaf2; border-radius: 8px; background: #f7f8fb; }
|
||||
.dispatch-query :deep(.el-input), .dispatch-query :deep(.el-select) { width: 190px; }
|
||||
.warehouse-tabs { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.warehouse-tab, .order-type-tabs button { border: 1px solid #dfe4ed; background: #fff; color: #667085; cursor: pointer; transition: all .18s ease; }
|
||||
.warehouse-tab { min-width: 92px; padding: 9px 20px; border-radius: 7px; font-size: 14px; }
|
||||
.warehouse-tab:hover, .order-type-tabs button:hover { border-color: #7654d6; color: #6941c6; }
|
||||
.warehouse-tab.active, .order-type-tabs button.active { border-color: #6f42d8; background: #6f42d8; color: #fff; }
|
||||
.dispatch-workbench { display: grid; grid-template-columns: minmax(550px, 1.5fr) minmax(330px, .85fr) minmax(390px, 1fr); gap: 16px; margin-top: 16px; }
|
||||
.order-pane, .detail-pane, .container-pane { min-width: 0; }
|
||||
.order-pane header, .detail-pane header, .container-pane header { display: flex; min-height: 74px; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.detail-order-reference { display: flex; min-width: 0; max-width: 240px; align-items: baseline; gap: 3px; }
|
||||
h3, p { margin: 0; } h3 { font-size: 16px; } h3 span, header > span, p { color: var(--cc-muted); font-size: 12px; font-weight: 400; }
|
||||
.order-type-tabs { display: flex; gap: 6px; margin-top: 10px; overflow-x: auto; }
|
||||
.order-type-tabs button { flex: 0 0 auto; padding: 5px 12px; border-radius: 16px; font-size: 12px; }
|
||||
.order-table-wrap { min-height: 520px; }
|
||||
.detail-pane, .container-pane { border-left: 1px solid var(--cc-border); padding-left: 16px; }
|
||||
.pallet-list, .container-list { height: 520px; overflow-y: auto; border: 1px solid var(--cc-border); border-radius: 6px; background: #fafbfc; }
|
||||
.pallet-row { display: grid; grid-template-columns: 20px minmax(0, 1fr) auto; min-height: 94px; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--cc-border); background: #fff; cursor: grab; transition: background .18s ease; }
|
||||
.pallet-row:hover { background: #f8f6ff; }
|
||||
.pallet-row.assigned { background: #f2fbf6; }.drag-handle { color: var(--cc-muted); }
|
||||
.pallet-copy { display: flex; min-width: 0; flex-direction: column; gap: 4px; }.pallet-copy > span, .carton-marks > span { color: var(--cc-muted); font-size: 12px; }
|
||||
.carton-marks { display: flex; min-width: 0; flex-wrap: wrap; gap: 4px; }
|
||||
.container-pane > .el-input { margin-bottom: 10px; }.container-list { height: 460px; padding: 8px; }
|
||||
.loading-container { margin-bottom: 10px; border: 1px dashed #bcc6d5; border-radius: 6px; background: #fff; }
|
||||
.loading-container--overweight { border-color: var(--el-color-danger); }
|
||||
.loading-container--overweight .container-head { background: var(--el-color-danger-light-9); }
|
||||
.loading-container--overweight .container-head span,.loading-container--overweight .container-head b { color: var(--el-color-danger); }
|
||||
.container-head em { margin-left: 4px; color: var(--el-color-danger); font-style: normal; font-weight: 700; }
|
||||
.container-head { display: grid; grid-template-columns: 1fr auto 32px; min-height: 44px; align-items: center; gap: 8px; padding: 0 8px 0 12px; background: #f4f6fa; }
|
||||
.container-head span { color: var(--cc-muted); font-size: 12px; }.container-head b { color: var(--el-color-primary); }
|
||||
.container-body { min-height: 76px; padding: 8px; }.drop-hint { display: grid; min-height: 60px; place-items: center; color: var(--cc-muted); font-size: 12px; }
|
||||
.loaded-pallet { display: flex; min-height: 52px; align-items: center; justify-content: space-between; gap: 8px; border-bottom: 1px solid var(--cc-border); }
|
||||
.loaded-pallet > span { display: flex; min-width: 0; flex: 1; flex-direction: column; color: var(--cc-muted); font-size: 12px; }.loaded-pallet strong { color: var(--cc-text); font-size: 13px; }
|
||||
.dialog-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 16px -20px -20px; padding: 14px 20px; border-top: 1px solid var(--cc-border); background: #fafbfc; }
|
||||
.dialog-footer > span { color: var(--cc-muted); font-size: 12px; }
|
||||
.vehicle-options { display: grid; gap: 14px; }
|
||||
@media (max-width: 1280px) { .dispatch-workbench { grid-template-columns: minmax(500px, 1.3fr) minmax(320px, 1fr); }.container-pane { grid-column: 1 / -1; border-left: 0; padding-left: 0; }.container-list { height: 300px; } }
|
||||
.dispatch-query {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #e6eaf2;
|
||||
border-radius: 8px;
|
||||
background: #f7f8fb;
|
||||
}
|
||||
.dispatch-query :deep(.el-input),
|
||||
.dispatch-query :deep(.el-select) {
|
||||
width: 190px;
|
||||
}
|
||||
.warehouse-tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.warehouse-tab,
|
||||
.order-type-tabs button {
|
||||
border: 1px solid #dfe4ed;
|
||||
background: #fff;
|
||||
color: #667085;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
}
|
||||
.warehouse-tab {
|
||||
min-width: 92px;
|
||||
padding: 9px 20px;
|
||||
border-radius: 7px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.warehouse-tab:hover,
|
||||
.order-type-tabs button:hover {
|
||||
border-color: #7654d6;
|
||||
color: #6941c6;
|
||||
}
|
||||
.warehouse-tab.active,
|
||||
.order-type-tabs button.active {
|
||||
border-color: #6f42d8;
|
||||
background: #6f42d8;
|
||||
color: #fff;
|
||||
}
|
||||
.dispatch-workbench {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(550px, 1.5fr) minmax(330px, 0.85fr) minmax(390px, 1fr);
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.order-pane,
|
||||
.detail-pane,
|
||||
.container-pane {
|
||||
min-width: 0;
|
||||
}
|
||||
.order-pane header,
|
||||
.detail-pane header,
|
||||
.container-pane header {
|
||||
display: flex;
|
||||
min-height: 74px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.detail-order-reference {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-width: 240px;
|
||||
align-items: baseline;
|
||||
gap: 3px;
|
||||
}
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
h3 span,
|
||||
header > span,
|
||||
p {
|
||||
color: var(--cc-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.order-type-tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.order-type-tabs button {
|
||||
flex: 0 0 auto;
|
||||
padding: 5px 12px;
|
||||
border-radius: 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.order-table-wrap {
|
||||
min-height: 520px;
|
||||
}
|
||||
.detail-pane,
|
||||
.container-pane {
|
||||
border-left: 1px solid var(--cc-border);
|
||||
padding-left: 16px;
|
||||
}
|
||||
.pallet-list,
|
||||
.container-list {
|
||||
height: 520px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--cc-border);
|
||||
border-radius: 6px;
|
||||
background: #fafbfc;
|
||||
}
|
||||
.pallet-row {
|
||||
display: grid;
|
||||
grid-template-columns: 20px minmax(0, 1fr) auto;
|
||||
min-height: 94px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--cc-border);
|
||||
background: #fff;
|
||||
cursor: grab;
|
||||
transition: background 0.18s ease;
|
||||
}
|
||||
.pallet-row:hover {
|
||||
background: #f8f6ff;
|
||||
}
|
||||
.pallet-row.assigned {
|
||||
background: #f2fbf6;
|
||||
}
|
||||
.drag-handle {
|
||||
color: var(--cc-muted);
|
||||
}
|
||||
.pallet-copy {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.pallet-copy > span,
|
||||
.carton-marks > span {
|
||||
color: var(--cc-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.carton-marks {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.carton-mark-line {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--cc-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.carton-mark-line > span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.container-pane > .el-input {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.container-list {
|
||||
height: 460px;
|
||||
padding: 8px;
|
||||
}
|
||||
.loading-container {
|
||||
margin-bottom: 10px;
|
||||
border: 1px dashed #bcc6d5;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
.loading-container--overweight {
|
||||
border-color: var(--el-color-danger);
|
||||
}
|
||||
.loading-container--overweight .container-head {
|
||||
background: var(--el-color-danger-light-9);
|
||||
}
|
||||
.loading-container--overweight .container-head span,
|
||||
.loading-container--overweight .container-head b {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
.container-head em {
|
||||
margin-left: 4px;
|
||||
color: var(--el-color-danger);
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
.container-head {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 32px;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 8px 0 12px;
|
||||
background: #f4f6fa;
|
||||
}
|
||||
.container-head span {
|
||||
color: var(--cc-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.container-head b {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
.container-body {
|
||||
min-height: 76px;
|
||||
padding: 8px;
|
||||
}
|
||||
.drop-hint {
|
||||
display: grid;
|
||||
min-height: 60px;
|
||||
place-items: center;
|
||||
color: var(--cc-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.loaded-pallet {
|
||||
display: flex;
|
||||
min-height: 52px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid var(--cc-border);
|
||||
}
|
||||
.loaded-pallet > span {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
color: var(--cc-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.loaded-pallet strong {
|
||||
color: var(--cc-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin: 16px -20px -20px;
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--cc-border);
|
||||
background: #fafbfc;
|
||||
}
|
||||
.dialog-footer > span {
|
||||
color: var(--cc-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.vehicle-options {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
@media (max-width: 1280px) {
|
||||
.dispatch-workbench {
|
||||
grid-template-columns: minmax(500px, 1.3fr) minmax(320px, 1fr);
|
||||
}
|
||||
.container-pane {
|
||||
grid-column: 1 / -1;
|
||||
border-left: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
.container-list {
|
||||
height: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { CheckCircle2, XCircle } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getWarehouseTransfer, reviewWarehouseTransfer, type WarehouseTransferDetailDto } from '../api/warehouseTransfers'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; customerId: string; customerName: string; transferNo: string }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; reviewed: [] }>()
|
||||
const open = computed({ get: () => props.modelValue, set: (value) => emit('update:modelValue', value) })
|
||||
const detail = ref<WarehouseTransferDetailDto | null>(null)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const error = ref<ApiErrorDetails | null>(null)
|
||||
const form = reactive<{ decision: 'APPROVE' | 'REJECT'; remark: string }>({ decision: 'APPROVE', remark: '' })
|
||||
const itemColumns: DataTableColumn[] = [
|
||||
{ key: 'skuCode', label: 'SKU', minWidth: 140 }, { key: 'productNameZh', label: '品名', minWidth: 160 },
|
||||
{ key: 'packageCount', label: '箱数', width: 80, align: 'right' }, { key: 'shippingMark', label: '客户箱唛', minWidth: 160 },
|
||||
]
|
||||
const bindingColumns: DataTableColumn[] = [
|
||||
{ key: 'operationType', label: '换标类型', minWidth: 120 }, { key: 'customerCartonMark', label: '客户箱唛', minWidth: 150 },
|
||||
{ key: 'originalSku', label: '原 SKU', minWidth: 120 }, { key: 'newSku', label: '新 SKU', minWidth: 120 },
|
||||
{ key: 'quantity', label: '数量', width: 80, align: 'right' }, { key: 'fbaCartonMark', label: '新箱唛', minWidth: 150 },
|
||||
]
|
||||
const palletColumns: DataTableColumn[] = [
|
||||
{ key: 'sequenceNo', label: '托序', width: 70, align: 'center' }, { key: 'palletNo', label: '托盘号', minWidth: 150 },
|
||||
{ key: 'cartonCount', label: '箱数', width: 80, align: 'right' }, { key: 'volumeCbm', label: '托盘总体积(m³)', width: 150, align: 'right' },
|
||||
{ key: 'grossWeightKg', label: '托盘总重量(kg)', width: 150, align: 'right' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
if (!props.customerId || !props.transferNo) return
|
||||
loading.value = true; error.value = null; detail.value = null
|
||||
try { detail.value = await getWarehouseTransfer(props.customerId, props.transferNo) }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '转运单审核资料加载失败,请重试') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
async function submit() {
|
||||
if (saving.value) return
|
||||
if (form.decision === 'REJECT' && !form.remark.trim()) { ElMessage.warning('驳回时请填写审核原因'); return }
|
||||
saving.value = true
|
||||
try {
|
||||
await reviewWarehouseTransfer(props.customerId, props.transferNo, form.decision, form.remark.trim() || undefined)
|
||||
ElMessage.success(form.decision === 'APPROVE' ? '转运单审核通过,已推送 PDA 收货打托' : '转运单已驳回')
|
||||
open.value = false; emit('reviewed')
|
||||
} catch (cause) { ElMessage.error(getApiErrorDetails(cause, '审核提交失败,请重试').message) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
watch(() => props.modelValue, (value) => { if (value) { form.decision = 'APPROVE'; form.remark = ''; void load() } })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" title="审核仓储转运单" width="min(920px, calc(100vw - 28px))" confirm-text="提交审核" :confirm-loading="saving" :loading="loading" @confirm="submit">
|
||||
<LoadErrorState v-if="error" compact title="审核资料加载失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<el-form v-else-if="detail" label-position="top" class="review-form">
|
||||
<div class="review-heading"><strong>{{ detail.transferNo }}</strong><span>{{ customerName }} · {{ detail.packageCount }} 箱 · {{ detail.needRelabel ? '需要换标' : '无需换标' }}</span></div>
|
||||
<h3>货物明细</h3><DataTable :data="detail.items" :columns="itemColumns" :show-pagination="false" row-key="id" empty-text="暂无货物明细" />
|
||||
<template v-if="detail.needRelabel"><h3>换标绑定明细</h3><DataTable :data="detail.relabelBindings" :columns="bindingColumns" :show-pagination="false" row-key="customerCartonMark" empty-text="暂无换标绑定明细" /></template>
|
||||
<template v-if="detail.pallets?.length"><h3>PDA 打托实测</h3><DataTable :data="detail.pallets" :columns="palletColumns" :show-pagination="false" row-key="id" empty-text="暂无打托数据" /></template>
|
||||
<el-form-item label="审核结论" required><el-radio-group v-model="form.decision"><el-radio-button value="APPROVE"><CheckCircle2 :size="15" />审核通过</el-radio-button><el-radio-button value="REJECT"><XCircle :size="15" />驳回</el-radio-button></el-radio-group></el-form-item>
|
||||
<el-form-item :label="form.decision === 'REJECT' ? '驳回原因' : '审核备注'" :required="form.decision === 'REJECT'"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item>
|
||||
</el-form>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.review-form { display: grid; gap: 12px; }
|
||||
.review-heading { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
||||
.review-heading span { color: var(--cc-muted); font-size: 12px; }
|
||||
h3 { margin: 2px 0 0; font-size: 14px; }
|
||||
.review-form :deep(.el-form-item) { margin: 4px 0 0; }
|
||||
.review-form :deep(.el-radio-button__inner) { display: inline-flex; align-items: center; gap: 6px; }
|
||||
</style>
|
||||
@@ -100,6 +100,8 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
|
||||
customerId: String(dto.customerId),
|
||||
sourceContainerBookingId: String(dto.sourceContainerBookingId ?? '').trim(),
|
||||
orderNo: lastMileOrderReference(dto.orderNo),
|
||||
orderTypeCode: String(dto.orderType ?? '').trim(),
|
||||
currentStatusCode: String(dto.currentStatus ?? '').trim(),
|
||||
warning: dto.abnormal ? '异常' : '—',
|
||||
orderType: text(dto.orderTypeName || lastMileOrderTypeLabel(dto.orderType)),
|
||||
rejectedAt: '—',
|
||||
@@ -112,7 +114,6 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
|
||||
transferWarehouse: text(dto.transferWarehouseName),
|
||||
fbaWarehouse: text(dto.fbaWarehouseCode),
|
||||
containerNo: text(dto.containerNo),
|
||||
containerStatus: text(dto.containerStatus),
|
||||
abnormal: yesNo(dto.abnormal),
|
||||
held: yesNo(dto.detained),
|
||||
detained: Boolean(dto.detained),
|
||||
@@ -129,6 +130,7 @@ export function toLastMileOrderListItem(dto: LastMileOrderSummaryDto): LastMileO
|
||||
portAta: formatOverseasDateTime(dto.actualArrivalAt),
|
||||
warehouseEta: formatOverseasDateTime(dto.estimatedWarehouseAt),
|
||||
inboundOrderNo: text(dto.inboundOrderNo),
|
||||
dispatchStatus: dto.dispatchStatus === 'SCHEDULED' ? '已排车' : '未排车',
|
||||
outboundOrderNo: text(dto.outboundOrderNo),
|
||||
dispatchOrderNo: text(dto.deliveryOrderNo),
|
||||
relabelOrderNo: text(dto.relabelOrderNo),
|
||||
|
||||
@@ -8,7 +8,7 @@ const component = (name: string) => readFileSync(resolve(process.cwd(), 'src/mod
|
||||
describe('overseas warehouse 1920px layout acceptance', () => {
|
||||
it('keeps action columns first and wide enough for every visible action', () => {
|
||||
const widths: Record<string, number> = {
|
||||
'LastMileOrdersView.vue': 178,
|
||||
'LastMileOrdersView.vue': 230,
|
||||
'InboundOrdersView.vue': 300,
|
||||
'DispatchesView.vue': 270,
|
||||
'OutboundsView.vue': 180,
|
||||
@@ -20,7 +20,7 @@ describe('overseas warehouse 1920px layout acceptance', () => {
|
||||
for (const [name, width] of Object.entries(widths)) {
|
||||
const source = page(name)
|
||||
const columns = source.slice(source.indexOf('const columns'), source.indexOf('async function', source.indexOf('const columns')))
|
||||
expect(columns).toContain(`{ key: 'actions', label: '操作', width: ${width}, fixed: 'left'`)
|
||||
expect(columns).toMatch(new RegExp(`key: 'actions',[\\s\\S]*?label: '操作',[\\s\\S]*?width: ${width},[\\s\\S]*?fixed: 'left'`))
|
||||
expect(columns.indexOf("key: 'actions'")).toBeLessThan(columns.indexOf("label:", columns.indexOf("key: 'actions'") + 20))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,12 +4,12 @@ import { resolve } from 'node:path'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import QueryToolbar, { type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { cancelDispatch, listDispatches, transferDispatchToDelivery, updateDispatch } from '../api/dispatches'
|
||||
import { cancelDispatch, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch } from '../api/dispatches'
|
||||
import DispatchesView from './DispatchesView.vue'
|
||||
|
||||
const canPermission = vi.hoisted(() => vi.fn(() => true))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
|
||||
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), updateDispatch: vi.fn(), cancelDispatch: vi.fn(), transferDispatchToDelivery: vi.fn() }))
|
||||
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), updateDispatch: vi.fn(), removeDispatchItem: vi.fn(), cancelDispatch: vi.fn(), transferDispatchToDelivery: vi.fn() }))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
observe() {}
|
||||
@@ -47,6 +47,7 @@ describe('DispatchesView', () => {
|
||||
page: 1, size: 20, total: 1, pages: 1,
|
||||
})
|
||||
vi.mocked(updateDispatch).mockReset()
|
||||
vi.mocked(removeDispatchItem).mockReset()
|
||||
vi.mocked(cancelDispatch).mockReset()
|
||||
vi.mocked(transferDispatchToDelivery).mockReset()
|
||||
canPermission.mockReset().mockReturnValue(true)
|
||||
@@ -94,10 +95,13 @@ describe('DispatchesView', () => {
|
||||
expect(source).toContain('permission.dispatchEdit')
|
||||
expect(source).toContain('permission.dispatchCancel')
|
||||
expect(source).toContain('permission.dispatchTransferDelivery')
|
||||
expect(source).toContain("function isPendingDeparture(row: DispatchListItem) { return row.statusCode === 'PENDING_DEPARTURE' }")
|
||||
expect(source).toContain('access.can(permission.dispatchEdit) && isPendingDeparture(row)')
|
||||
expect(source).toContain("access.can(permission.dispatchTransferDelivery) && ['PENDING_DEPARTURE', 'PICKING', 'READY_TO_DEPART'].includes(row.statusCode)")
|
||||
expect(source).toContain('普通编辑不会修改排车状态')
|
||||
expect(source).toMatch(/function isPendingDeparture\(row: DispatchListItem\)\s*{\s*return row\.statusCode === 'PENDING_DEPARTURE'/)
|
||||
expect(source).toMatch(/access\.can\(permission\.dispatchEdit\)\s*&&\s*isPendingDeparture\(row\)/)
|
||||
expect(source).toMatch(/access\.can\(permission\.dispatchTransferDelivery\)\s*&&\s*\['PENDING_DEPARTURE', 'PICKING', 'READY_TO_DEPART'\]\.includes\(row\.statusCode\)/)
|
||||
expect(source).toContain('未生成出库单前可移除托盘')
|
||||
expect(source).toContain('removeDispatchItem')
|
||||
expect(source).not.toContain('托盘箱唛确认')
|
||||
expect(source).not.toContain('palletCartonMarks')
|
||||
expect(source).not.toContain('departDispatch')
|
||||
|
||||
canPermission.mockReturnValue(false)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Eye, MoreHorizontal, Pencil, Truck, XCircle } from '@lucide/vue'
|
||||
import { Eye, MoreHorizontal, Pencil, Trash2, Truck, XCircle } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -13,7 +13,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { cancelDispatch, listDispatches, transferDispatchToDelivery, updateDispatch, type DispatchSummaryDto } from '../api/dispatches'
|
||||
import { cancelDispatch, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch, type DispatchItemDto, type DispatchSummaryDto } from '../api/dispatches'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import type { DispatchListItem } from '../types'
|
||||
import { formatOverseasDateTime } from '../lastMilePresentation'
|
||||
@@ -27,31 +27,84 @@ const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const filters = ref<QueryModel>({ dispatchNo: '', vehiclePlate: '', status: '', fbaAppointmentNo: '' })
|
||||
const transferOpen = ref(false); const transferRow = ref<DispatchListItem | null>(null); const transferSaving = ref(false)
|
||||
const filters = ref<QueryModel>({
|
||||
dispatchNo: '',
|
||||
vehiclePlate: '',
|
||||
status: '',
|
||||
fbaAppointmentNo: '',
|
||||
})
|
||||
const transferOpen = ref(false)
|
||||
const transferRow = ref<DispatchListItem | null>(null)
|
||||
const transferSaving = ref(false)
|
||||
const mutationError = ref<ApiErrorDetails | null>(null)
|
||||
const editOpen = ref(false); const editRow = ref<DispatchListItem | null>(null); const editSaving = ref(false)
|
||||
const editOpen = ref(false)
|
||||
const editRow = ref<DispatchListItem | null>(null)
|
||||
const editSaving = ref(false)
|
||||
const editItems = ref<DispatchItemDto[]>([])
|
||||
const removingItemId = ref('')
|
||||
const dispatchSnapshots = new Map<string, DispatchSummaryDto>()
|
||||
const editForm = reactive({ vehiclePlate: '', driverName: '', driverPhone: '', carrierName: '', route: '', departAt: '', fbaAppointmentNo: '', fbaAppointmentStatus: '', vehicleAppointmentStatus: '', estimatedArrivalAt: '' })
|
||||
const transferForm = reactive({ plannedDeliveryAt: '', deliveryMethod: '', waybillNo: '', carrierName: '', pickupMethod: '', loadingType: '', vehicleType: '', vehicleSize: '', driverName: '', driverPhone: '', vehiclePlate: '', appointmentAccount: '', sellerName: '', deliveryFee: undefined as number | undefined, loadingFee: undefined as number | undefined, appointmentFee: undefined as number | undefined, cartonMarksJson: '', remark: '' })
|
||||
const editForm = reactive({
|
||||
vehiclePlate: '',
|
||||
driverName: '',
|
||||
driverPhone: '',
|
||||
carrierName: '',
|
||||
route: '',
|
||||
departAt: '',
|
||||
fbaAppointmentNo: '',
|
||||
fbaAppointmentStatus: '',
|
||||
vehicleAppointmentStatus: '',
|
||||
estimatedArrivalAt: '',
|
||||
})
|
||||
const transferForm = reactive({
|
||||
plannedDeliveryAt: '',
|
||||
deliveryMethod: '',
|
||||
waybillNo: '',
|
||||
carrierName: '',
|
||||
pickupMethod: '',
|
||||
loadingType: '',
|
||||
vehicleType: '',
|
||||
vehicleSize: '',
|
||||
driverName: '',
|
||||
driverPhone: '',
|
||||
vehiclePlate: '',
|
||||
appointmentAccount: '',
|
||||
sellerName: '',
|
||||
deliveryFee: undefined as number | undefined,
|
||||
loadingFee: undefined as number | undefined,
|
||||
appointmentFee: undefined as number | undefined,
|
||||
remark: '',
|
||||
})
|
||||
|
||||
const deliveryMethodOptions = [
|
||||
{ label: 'FBA自提', value: 'FBA_PICKUP' }, { label: '客户自提', value: 'CUSTOMER_PICKUP' },
|
||||
{ label: '卡派打托', value: 'TRUCK_PALLET' }, { label: '卡派地板', value: 'TRUCK_FLOOR' },
|
||||
{ label: '快递', value: 'EXPRESS' }, { label: '外配', value: 'OUTSOURCED' },
|
||||
{ label: '专车直送', value: 'DEDICATED_TRUCK' }, { label: '零担派送', value: 'LTL' },
|
||||
{ label: 'FBA自提', value: 'FBA_PICKUP' },
|
||||
{ label: '客户自提', value: 'CUSTOMER_PICKUP' },
|
||||
{ label: '卡派打托', value: 'TRUCK_PALLET' },
|
||||
{ label: '卡派地板', value: 'TRUCK_FLOOR' },
|
||||
{ label: '快递', value: 'EXPRESS' },
|
||||
{ label: '外配', value: 'OUTSOURCED' },
|
||||
{ label: '专车直送', value: 'DEDICATED_TRUCK' },
|
||||
{ label: '零担派送', value: 'LTL' },
|
||||
]
|
||||
const pickupMethodOptions = [
|
||||
{ label: '一提一卸', value: 'ONE_PICKUP_ONE_DROP' }, { label: '一提多卸', value: 'ONE_PICKUP_MULTI_DROP' },
|
||||
{ label: '多提一卸', value: 'MULTI_PICKUP_ONE_DROP' }, { label: '多提多卸', value: 'MULTI_PICKUP_MULTI_DROP' },
|
||||
{ label: '一提一卸', value: 'ONE_PICKUP_ONE_DROP' },
|
||||
{ label: '一提多卸', value: 'ONE_PICKUP_MULTI_DROP' },
|
||||
{ label: '多提一卸', value: 'MULTI_PICKUP_ONE_DROP' },
|
||||
{ label: '多提多卸', value: 'MULTI_PICKUP_MULTI_DROP' },
|
||||
{ label: '仓库自提', value: 'WAREHOUSE_PICKUP' },
|
||||
]
|
||||
const loadingTypeOptions = [{ label: '托盘', value: 'PALLET' }, { label: '地板', value: 'FLOOR' }, { label: '混装', value: 'MIXED' }]
|
||||
const loadingTypeOptions = [
|
||||
{ label: '托盘', value: 'PALLET' },
|
||||
{ label: '地板', value: 'FLOOR' },
|
||||
{ label: '混装', value: 'MIXED' },
|
||||
]
|
||||
const vehicleTypeOptions = ['厢式货车', '平板车', '冷藏车', '集装箱卡车', '尾板车'].map((label) => ({ label, value: label }))
|
||||
const vehicleSizeOptions = ['53尺大车(30托)', '26尺中车(14托)', '16尺小车(8托)', 'Cargo Van(4托)'].map((label) => ({ label, value: label }))
|
||||
const appointmentAccountOptions = [
|
||||
{ label: '4号账号(美西)', value: '4' }, { label: '8号账号(美东)', value: '8' },
|
||||
{ label: '10号账号(美中)', value: '10' }, { label: '12号账号(加拿大)', value: '12' }, { label: '买约', value: 'BUY_APPOINTMENT' },
|
||||
{ label: '4号账号(美西)', value: '4' },
|
||||
{ label: '8号账号(美东)', value: '8' },
|
||||
{ label: '10号账号(美中)', value: '10' },
|
||||
{ label: '12号账号(加拿大)', value: '12' },
|
||||
{ label: '买约', value: 'BUY_APPOINTMENT' },
|
||||
]
|
||||
const isBuyAppointment = computed(() => transferForm.appointmentAccount === 'BUY_APPOINTMENT')
|
||||
const needsLoadingFee = computed(() => ['FLOOR', 'MIXED'].includes(transferForm.loadingType))
|
||||
@@ -67,29 +120,109 @@ const statusOptions = [
|
||||
]
|
||||
|
||||
const fields = computed<QueryField[]>(() => [
|
||||
{ key: 'dispatchNo', type: 'input', label: '排车单号', placeholder: '请输入排车单号', width: 220 },
|
||||
{ key: 'vehiclePlate', type: 'input', label: '车牌号', placeholder: '请输入车牌号', width: 180 },
|
||||
{ key: 'status', type: 'select', label: '车辆状态', placeholder: '全部状态', options: statusOptions, width: 180 },
|
||||
{ key: 'fbaAppointmentNo', type: 'input', label: 'FBA预约号', placeholder: '请输入FBA预约号', width: 220 },
|
||||
{
|
||||
key: 'dispatchNo',
|
||||
type: 'input',
|
||||
label: '排车单号',
|
||||
placeholder: '请输入排车单号',
|
||||
width: 220,
|
||||
},
|
||||
{
|
||||
key: 'vehiclePlate',
|
||||
type: 'input',
|
||||
label: '车牌号',
|
||||
placeholder: '请输入车牌号',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
type: 'select',
|
||||
label: '车辆状态',
|
||||
placeholder: '全部状态',
|
||||
options: statusOptions,
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
key: 'fbaAppointmentNo',
|
||||
type: 'input',
|
||||
label: 'FBA预约号',
|
||||
placeholder: '请输入FBA预约号',
|
||||
width: 220,
|
||||
},
|
||||
])
|
||||
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 270, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'dispatchNo', prop: 'dispatchNo', label: '排车单号', minWidth: 170, fixed: 'left' },
|
||||
{
|
||||
key: 'actions',
|
||||
label: '操作',
|
||||
width: 270,
|
||||
fixed: 'left',
|
||||
slotName: 'actions',
|
||||
},
|
||||
{
|
||||
key: 'dispatchNo',
|
||||
prop: 'dispatchNo',
|
||||
label: '排车单号',
|
||||
minWidth: 170,
|
||||
fixed: 'left',
|
||||
},
|
||||
{ key: 'vehiclePlate', prop: 'vehiclePlate', label: '车牌号', minWidth: 130 },
|
||||
{ key: 'driverName', prop: 'driverName', label: '司机', minWidth: 120 },
|
||||
{ key: 'driverPhone', prop: 'driverPhone', label: '联系电话', minWidth: 140 },
|
||||
{ key: 'carrierName', prop: 'carrierName', label: '托车公司名称', minWidth: 160 },
|
||||
{
|
||||
key: 'carrierName',
|
||||
prop: 'carrierName',
|
||||
label: '托车公司名称',
|
||||
minWidth: 160,
|
||||
},
|
||||
{ key: 'orderNos', prop: 'orderNos', label: '关联订单', minWidth: 220 },
|
||||
{ key: 'route', prop: 'route', label: '路线', minWidth: 180 },
|
||||
{ key: 'overseasWarehouse', prop: 'overseasWarehouse', label: '海外仓', minWidth: 160 },
|
||||
{
|
||||
key: 'overseasWarehouse',
|
||||
prop: 'overseasWarehouse',
|
||||
label: '海外仓',
|
||||
minWidth: 160,
|
||||
},
|
||||
{ key: 'departAt', prop: 'departAt', label: '发车时间', minWidth: 170 },
|
||||
{ key: 'fbaAppointment', prop: 'fbaAppointment', label: 'FBA预约', minWidth: 180 },
|
||||
{ key: 'vehicleAppointment', prop: 'vehicleAppointment', label: '车辆预约', minWidth: 130 },
|
||||
{ key: 'estimatedArrivalAt', prop: 'estimatedArrivalAt', label: '预计到达时间', minWidth: 170 },
|
||||
{ key: 'palletCount', prop: 'palletCount', label: '托盘数', width: 90, align: 'right' },
|
||||
{ key: 'packageCount', prop: 'packageCount', label: '箱数', width: 80, align: 'right' },
|
||||
{ key: 'status', prop: 'status', label: '车辆状态', width: 150, slotName: 'status' },
|
||||
{
|
||||
key: 'fbaAppointment',
|
||||
prop: 'fbaAppointment',
|
||||
label: 'FBA预约',
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
key: 'vehicleAppointment',
|
||||
prop: 'vehicleAppointment',
|
||||
label: '车辆预约',
|
||||
minWidth: 130,
|
||||
},
|
||||
{
|
||||
key: 'estimatedArrivalAt',
|
||||
prop: 'estimatedArrivalAt',
|
||||
label: '预计到达时间',
|
||||
minWidth: 170,
|
||||
},
|
||||
{
|
||||
key: 'palletCount',
|
||||
prop: 'palletCount',
|
||||
label: '托盘数',
|
||||
width: 90,
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
key: 'packageCount',
|
||||
prop: 'packageCount',
|
||||
label: '箱数',
|
||||
width: 80,
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
prop: 'status',
|
||||
label: '车辆状态',
|
||||
width: 150,
|
||||
slotName: 'status',
|
||||
},
|
||||
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 170 },
|
||||
]
|
||||
|
||||
@@ -152,7 +285,10 @@ function toListItem(dto: DispatchSummaryDto): DispatchListItem {
|
||||
function dispatchError(error: unknown) {
|
||||
const details = getApiErrorDetails(error, '排车列表加载失败,请重试')
|
||||
return details.code === 'HTTP_404'
|
||||
? { ...details, message: '排车接口未启用或不存在,请确认海外尾程排车接口已发布' }
|
||||
? {
|
||||
...details,
|
||||
message: '排车接口未启用或不存在,请确认海外尾程排车接口已发布',
|
||||
}
|
||||
: details
|
||||
}
|
||||
|
||||
@@ -182,48 +318,332 @@ async function load(model: QueryModel = filters.value) {
|
||||
}
|
||||
}
|
||||
|
||||
function search(model: QueryModel) { page.value = 1; void load(model) }
|
||||
function reset(model: QueryModel) { page.value = 1; void load(model) }
|
||||
function openDetail(row: DispatchListItem) { void router.push({ name: 'overseas-dispatch-detail', params: { dispatchNo: row.dispatchNo } }) }
|
||||
function isPendingDeparture(row: DispatchListItem) { return row.statusCode === 'PENDING_DEPARTURE' }
|
||||
function canEdit(row: DispatchListItem) { return access.can(permission.dispatchEdit) && isPendingDeparture(row) }
|
||||
function canCancel(row: DispatchListItem) { return access.can(permission.dispatchCancel) && row.statusCode === 'PENDING_DEPARTURE' }
|
||||
function canTransfer(row: DispatchListItem) { return access.can(permission.dispatchTransferDelivery) && ['PENDING_DEPARTURE', 'PICKING', 'READY_TO_DEPART'].includes(row.statusCode) }
|
||||
function openEdit(row: DispatchListItem) { if (!canEdit(row)) return; const source = dispatchSnapshots.get(row.dispatchNo); editRow.value = row; Object.assign(editForm, { vehiclePlate: source?.vehiclePlate || '', driverName: source?.driverName || '', driverPhone: source?.driverPhone || '', carrierName: source?.carrierName || '', route: source?.route || '', departAt: source?.departAt || '', fbaAppointmentNo: source?.fbaAppointmentNo || '', fbaAppointmentStatus: source?.fbaAppointmentStatus || '', vehicleAppointmentStatus: source?.vehicleAppointmentStatus || '', estimatedArrivalAt: source?.estimatedArrivalAt || '' }); mutationError.value = null; editOpen.value = true }
|
||||
function openTransfer(row: DispatchListItem) { if (!canTransfer(row)) return; transferRow.value = row; Object.assign(transferForm, { plannedDeliveryAt: '', deliveryMethod: '', waybillNo: '', carrierName: row.carrierName === '—' ? '' : row.carrierName, pickupMethod: '', loadingType: '', vehicleType: '', vehicleSize: '', driverName: row.driverName === '—' ? '' : row.driverName, driverPhone: row.driverPhone === '—' ? '' : row.driverPhone, vehiclePlate: row.vehiclePlate === '—' ? '' : row.vehiclePlate, appointmentAccount: '', sellerName: '', deliveryFee: undefined, loadingFee: undefined, appointmentFee: undefined, cartonMarksJson: '', remark: '' }); mutationError.value = null; transferOpen.value = true }
|
||||
async function submitEdit() { if (!editRow.value || !canEdit(editRow.value) || editSaving.value) return; editSaving.value = true; mutationError.value = null; try { await updateDispatch(editRow.value.dispatchNo, { vehiclePlate: editForm.vehiclePlate.trim() || null, driverName: editForm.driverName.trim() || null, driverPhone: editForm.driverPhone.trim() || null, carrierName: editForm.carrierName.trim() || null, route: editForm.route.trim() || null, departAt: editForm.departAt || null, fbaAppointmentNo: editForm.fbaAppointmentNo.trim() || null, fbaAppointmentStatus: editForm.fbaAppointmentStatus || null, vehicleAppointmentStatus: editForm.vehicleAppointmentStatus || null, estimatedArrivalAt: editForm.estimatedArrivalAt || null }); editOpen.value = false; ElMessage.success('排车信息已保存'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '排车信息保存失败,请重试') } finally { editSaving.value = false } }
|
||||
async function cancel(row: DispatchListItem) { if (!canCancel(row)) return; try { await ElMessageBox.confirm(`确认取消排车单 ${row.dispatchNo}?取消后将释放托盘锁。`, '取消排车', { confirmButtonText: '确认取消', cancelButtonText: '返回', type: 'warning' }) } catch { return } mutationError.value = null; try { await cancelDispatch(row.dispatchNo); ElMessage.success('排车单已取消'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '排车取消失败,请重试') } }
|
||||
function handleMore(command: string, row: DispatchListItem) { if (command === 'edit') openEdit(row); else if (command === 'cancel') void cancel(row) }
|
||||
function search(model: QueryModel) {
|
||||
page.value = 1
|
||||
void load(model)
|
||||
}
|
||||
function reset(model: QueryModel) {
|
||||
page.value = 1
|
||||
void load(model)
|
||||
}
|
||||
function openDetail(row: DispatchListItem) {
|
||||
void router.push({
|
||||
name: 'overseas-dispatch-detail',
|
||||
params: { dispatchNo: row.dispatchNo },
|
||||
})
|
||||
}
|
||||
function isPendingDeparture(row: DispatchListItem) {
|
||||
return row.statusCode === 'PENDING_DEPARTURE'
|
||||
}
|
||||
function canEdit(row: DispatchListItem) {
|
||||
return access.can(permission.dispatchEdit) && isPendingDeparture(row)
|
||||
}
|
||||
function canCancel(row: DispatchListItem) {
|
||||
return access.can(permission.dispatchCancel) && row.statusCode === 'PENDING_DEPARTURE'
|
||||
}
|
||||
function canTransfer(row: DispatchListItem) {
|
||||
return access.can(permission.dispatchTransferDelivery) && ['PENDING_DEPARTURE', 'PICKING', 'READY_TO_DEPART'].includes(row.statusCode)
|
||||
}
|
||||
function openEdit(row: DispatchListItem) {
|
||||
if (!canEdit(row)) return
|
||||
const source = dispatchSnapshots.get(row.dispatchNo)
|
||||
editRow.value = row
|
||||
editItems.value = [...(source?.items || [])]
|
||||
Object.assign(editForm, {
|
||||
vehiclePlate: source?.vehiclePlate || '',
|
||||
driverName: source?.driverName || '',
|
||||
driverPhone: source?.driverPhone || '',
|
||||
carrierName: source?.carrierName || '',
|
||||
route: source?.route || '',
|
||||
departAt: source?.departAt || '',
|
||||
fbaAppointmentNo: source?.fbaAppointmentNo || '',
|
||||
fbaAppointmentStatus: source?.fbaAppointmentStatus || '',
|
||||
vehicleAppointmentStatus: source?.vehicleAppointmentStatus || '',
|
||||
estimatedArrivalAt: source?.estimatedArrivalAt || '',
|
||||
})
|
||||
mutationError.value = null
|
||||
editOpen.value = true
|
||||
}
|
||||
function openTransfer(row: DispatchListItem) {
|
||||
if (!canTransfer(row)) return
|
||||
transferRow.value = row
|
||||
Object.assign(transferForm, {
|
||||
plannedDeliveryAt: '',
|
||||
deliveryMethod: '',
|
||||
waybillNo: '',
|
||||
carrierName: row.carrierName === '—' ? '' : row.carrierName,
|
||||
pickupMethod: '',
|
||||
loadingType: '',
|
||||
vehicleType: '',
|
||||
vehicleSize: '',
|
||||
driverName: row.driverName === '—' ? '' : row.driverName,
|
||||
driverPhone: row.driverPhone === '—' ? '' : row.driverPhone,
|
||||
vehiclePlate: row.vehiclePlate === '—' ? '' : row.vehiclePlate,
|
||||
appointmentAccount: '',
|
||||
sellerName: '',
|
||||
deliveryFee: undefined,
|
||||
loadingFee: undefined,
|
||||
appointmentFee: undefined,
|
||||
remark: '',
|
||||
})
|
||||
mutationError.value = null
|
||||
transferOpen.value = true
|
||||
}
|
||||
async function submitEdit() {
|
||||
if (!editRow.value || !canEdit(editRow.value) || editSaving.value) return
|
||||
editSaving.value = true
|
||||
mutationError.value = null
|
||||
try {
|
||||
await updateDispatch(editRow.value.dispatchNo, {
|
||||
vehiclePlate: editForm.vehiclePlate.trim() || null,
|
||||
driverName: editForm.driverName.trim() || null,
|
||||
driverPhone: editForm.driverPhone.trim() || null,
|
||||
carrierName: editForm.carrierName.trim() || null,
|
||||
route: editForm.route.trim() || null,
|
||||
departAt: editForm.departAt || null,
|
||||
fbaAppointmentNo: editForm.fbaAppointmentNo.trim() || null,
|
||||
fbaAppointmentStatus: editForm.fbaAppointmentStatus || null,
|
||||
vehicleAppointmentStatus: editForm.vehicleAppointmentStatus || null,
|
||||
estimatedArrivalAt: editForm.estimatedArrivalAt || null,
|
||||
})
|
||||
editOpen.value = false
|
||||
ElMessage.success('排车信息已保存')
|
||||
await load()
|
||||
} catch (cause) {
|
||||
mutationError.value = getApiErrorDetails(cause, '排车信息保存失败,请重试')
|
||||
} finally {
|
||||
editSaving.value = false
|
||||
}
|
||||
}
|
||||
async function removePallet(itemValue: unknown) {
|
||||
const item = itemValue as DispatchItemDto
|
||||
if (!editRow.value || removingItemId.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认从排车单移除托盘 ${text(item.palletNo)}?移除后可重新排车。`, '移除排车托盘', {
|
||||
confirmButtonText: '确认移除',
|
||||
cancelButtonText: '返回',
|
||||
type: 'warning',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
removingItemId.value = String(item.id)
|
||||
mutationError.value = null
|
||||
try {
|
||||
const updated = await removeDispatchItem(editRow.value.dispatchNo, String(item.id))
|
||||
editItems.value = [...(updated.items || [])]
|
||||
dispatchSnapshots.set(editRow.value.dispatchNo, updated)
|
||||
const row = rows.value.find((value) => value.dispatchNo === editRow.value?.dispatchNo)
|
||||
if (row) {
|
||||
row.palletCount = number(updated.palletCount)
|
||||
row.packageCount = number(updated.packageCount)
|
||||
row.orderNos = text(updated.orderNos)
|
||||
}
|
||||
ElMessage.success('托盘已移除,可重新排车')
|
||||
} catch (cause) {
|
||||
mutationError.value = getApiErrorDetails(cause, '托盘移除失败,请重试')
|
||||
} finally {
|
||||
removingItemId.value = ''
|
||||
}
|
||||
}
|
||||
async function cancel(row: DispatchListItem) {
|
||||
if (!canCancel(row)) return
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认取消排车单 ${row.dispatchNo}?取消后将释放托盘锁。`, '取消排车', {
|
||||
confirmButtonText: '确认取消',
|
||||
cancelButtonText: '返回',
|
||||
type: 'warning',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
mutationError.value = null
|
||||
try {
|
||||
await cancelDispatch(row.dispatchNo)
|
||||
ElMessage.success('排车单已取消')
|
||||
await load()
|
||||
} catch (cause) {
|
||||
mutationError.value = getApiErrorDetails(cause, '排车取消失败,请重试')
|
||||
}
|
||||
}
|
||||
function handleMore(command: string, row: DispatchListItem) {
|
||||
if (command === 'edit') openEdit(row)
|
||||
else if (command === 'cancel') void cancel(row)
|
||||
}
|
||||
async function submitTransfer() {
|
||||
if (!transferRow.value || !canTransfer(transferRow.value)) return
|
||||
if (!transferForm.plannedDeliveryAt || !transferForm.deliveryMethod || !transferForm.pickupMethod || !transferForm.loadingType) { ElMessage.warning('请填写预计派送时间、派送方式、提货方式和装车类型'); return }
|
||||
if (transferForm.deliveryFee == null) { ElMessage.warning('请填写派送费用'); return }
|
||||
if (needsLoadingFee.value && transferForm.loadingFee == null) { ElMessage.warning('当前装车类型需要填写装车费'); return }
|
||||
if (isBuyAppointment.value && (!transferForm.sellerName.trim() || transferForm.appointmentFee == null)) { ElMessage.warning('买约时请填写卖家姓名和买约费用'); return }
|
||||
const feeInputs = [['DELIVERY_FEE', transferForm.deliveryFee, '派送费用'], ['LOADING_FEE', needsLoadingFee.value ? transferForm.loadingFee : undefined, '装车费'], ['APPOINTMENT_FEE', isBuyAppointment.value ? transferForm.appointmentFee : undefined, '买约费用']] as const
|
||||
const fees = feeInputs.filter(([, amount]) => amount != null).map(([feeType, amount, feeName]) => ({ feeType, feeName, direction: 'RECEIVABLE' as const, currency: 'USD' as const, amount: Number(amount), remark: feeName }))
|
||||
let palletCartonMarks: Record<string, string[]> | undefined
|
||||
if (transferForm.cartonMarksJson.trim()) {
|
||||
try { const parsed = JSON.parse(transferForm.cartonMarksJson); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(); palletCartonMarks = Object.fromEntries(Object.entries(parsed).map(([pallet, marks]) => [pallet, Array.isArray(marks) ? marks.map((mark) => String(mark).trim()).filter(Boolean) : []])) } catch { ElMessage.warning('托盘箱唛分配格式不正确,请使用 JSON:{"托盘号":["箱唛1","箱唛2"]}'); return }
|
||||
if (!transferForm.plannedDeliveryAt || !transferForm.deliveryMethod || !transferForm.pickupMethod || !transferForm.loadingType) {
|
||||
ElMessage.warning('请填写预计派送时间、派送方式、提货方式和装车类型')
|
||||
return
|
||||
}
|
||||
if (transferForm.deliveryFee == null) {
|
||||
ElMessage.warning('请填写派送费用')
|
||||
return
|
||||
}
|
||||
if (needsLoadingFee.value && transferForm.loadingFee == null) {
|
||||
ElMessage.warning('当前装车类型需要填写装车费')
|
||||
return
|
||||
}
|
||||
if (isBuyAppointment.value && (!transferForm.sellerName.trim() || transferForm.appointmentFee == null)) {
|
||||
ElMessage.warning('买约时请填写卖家姓名和买约费用')
|
||||
return
|
||||
}
|
||||
const feeInputs = [
|
||||
['DELIVERY_FEE', transferForm.deliveryFee, '派送费用'],
|
||||
['LOADING_FEE', needsLoadingFee.value ? transferForm.loadingFee : undefined, '装车费'],
|
||||
['APPOINTMENT_FEE', isBuyAppointment.value ? transferForm.appointmentFee : undefined, '买约费用'],
|
||||
] as const
|
||||
const fees = feeInputs
|
||||
.filter(([, amount]) => amount != null)
|
||||
.map(([feeType, amount, feeName]) => ({
|
||||
feeType,
|
||||
feeName,
|
||||
direction: 'RECEIVABLE' as const,
|
||||
currency: 'USD' as const,
|
||||
amount: Number(amount),
|
||||
remark: feeName,
|
||||
}))
|
||||
transferSaving.value = true
|
||||
try { await transferDispatchToDelivery(transferRow.value.dispatchNo, { idempotencyKey: `web-transfer-${transferRow.value.dispatchNo}-${Date.now()}`, plannedDeliveryAt: transferForm.plannedDeliveryAt, deliveryMethod: transferForm.deliveryMethod, waybillNo: transferForm.waybillNo.trim() || undefined, carrierName: transferForm.carrierName.trim() || undefined, pickupMethod: transferForm.pickupMethod, loadingType: transferForm.loadingType, vehicleType: transferForm.vehicleType || undefined, vehicleSize: transferForm.vehicleSize || undefined, driverName: transferForm.driverName.trim() || undefined, driverPhone: transferForm.driverPhone.trim() || undefined, vehiclePlate: transferForm.vehiclePlate.trim() || undefined, appointmentAccount: transferForm.appointmentAccount || undefined, sellerName: isBuyAppointment.value ? transferForm.sellerName.trim() : undefined, remark: transferForm.remark.trim() || undefined, fees, palletCartonMarks }); transferOpen.value = false; ElMessage.success('已生成出库单和派送单'); await load() } catch (cause) { mutationError.value = getApiErrorDetails(cause, '转派送失败,请重试') } finally { transferSaving.value = false }
|
||||
try {
|
||||
await transferDispatchToDelivery(transferRow.value.dispatchNo, {
|
||||
idempotencyKey: `web-transfer-${transferRow.value.dispatchNo}-${Date.now()}`,
|
||||
plannedDeliveryAt: transferForm.plannedDeliveryAt,
|
||||
deliveryMethod: transferForm.deliveryMethod,
|
||||
waybillNo: transferForm.waybillNo.trim() || undefined,
|
||||
carrierName: transferForm.carrierName.trim() || undefined,
|
||||
pickupMethod: transferForm.pickupMethod,
|
||||
loadingType: transferForm.loadingType,
|
||||
vehicleType: transferForm.vehicleType || undefined,
|
||||
vehicleSize: transferForm.vehicleSize || undefined,
|
||||
driverName: transferForm.driverName.trim() || undefined,
|
||||
driverPhone: transferForm.driverPhone.trim() || undefined,
|
||||
vehiclePlate: transferForm.vehiclePlate.trim() || undefined,
|
||||
appointmentAccount: transferForm.appointmentAccount || undefined,
|
||||
sellerName: isBuyAppointment.value ? transferForm.sellerName.trim() : undefined,
|
||||
remark: transferForm.remark.trim() || undefined,
|
||||
fees,
|
||||
})
|
||||
transferOpen.value = false
|
||||
ElMessage.success('已生成出库单和派送单')
|
||||
await load()
|
||||
} catch (cause) {
|
||||
mutationError.value = getApiErrorDetails(cause, '转派送失败,请重试')
|
||||
} finally {
|
||||
transferSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage><ListPageLayout aria-label="海外仓排车列表">
|
||||
<OperationErrorToast :details="mutationError" />
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
|
||||
<template v-if="loadError" #state><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
|
||||
<section v-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-dispatches" row-key="id" empty-text="暂无排车记录" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #actions="{ row }"><el-button size="small" plain type="primary" @click="openDetail(row as DispatchListItem)"><Eye :size="15" />查看</el-button><el-button v-if="canTransfer(row as DispatchListItem)" size="small" type="success" plain @click="openTransfer(row as DispatchListItem)"><Truck :size="15" />转派送</el-button><el-dropdown v-if="canEdit(row as DispatchListItem) || canCancel(row as DispatchListItem)" trigger="click" @command="handleMore(String($event), row as DispatchListItem)"><el-button size="small" plain><MoreHorizontal :size="15" />更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="canEdit(row as DispatchListItem)" command="edit"><Pencil :size="15" />编辑</el-dropdown-item><el-dropdown-item v-if="canCancel(row as DispatchListItem)" command="cancel" divided><XCircle :size="15" />取消</el-dropdown-item></el-dropdown-menu></template></el-dropdown></template>
|
||||
<template #status="{ row }"><StatusBadge :status="String((row as DispatchListItem).status || '—')" /></template>
|
||||
<template #pagination-left>共 {{ total }} 条排车记录</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout></AdminPage><AppDialog v-model="transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" @confirm="submitTransfer"><el-form label-position="top"><h3>派送信息</h3><div class="transfer-grid"><el-form-item label="排车单号"><el-input :model-value="transferRow?.dispatchNo" readonly /></el-form-item><el-form-item label="预计派送时间" required><el-date-picker v-model="transferForm.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计派送时间" /></el-form-item><el-form-item label="派送方式" required><el-select v-model="transferForm.deliveryMethod" placeholder="请选择"><el-option v-for="option in deliveryMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="快递/车队"><el-input v-model="transferForm.carrierName" placeholder="请输入快递或车队名称" /></el-form-item><el-form-item label="运单号"><el-input v-model="transferForm.waybillNo" placeholder="请输入运单号" /></el-form-item><el-form-item label="提货方式" required><el-select v-model="transferForm.pickupMethod" placeholder="请选择"><el-option v-for="option in pickupMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="装车类型" required><el-select v-model="transferForm.loadingType" placeholder="请选择"><el-option v-for="option in loadingTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="司机/车队名称"><el-input v-model="transferForm.driverName" placeholder="请输入司机或车队名称" /></el-form-item><el-form-item label="联系电话"><el-input v-model="transferForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="车牌号"><el-input v-model="transferForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="车辆类型"><el-select v-model="transferForm.vehicleType" clearable placeholder="请选择"><el-option v-for="option in vehicleTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="车辆大小"><el-select v-model="transferForm.vehicleSize" clearable placeholder="请选择"><el-option v-for="option in vehicleSizeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item label="预约账号"><el-select v-model="transferForm.appointmentAccount" clearable placeholder="请选择"><el-option v-for="option in appointmentAccountOptions" :key="option.value" v-bind="option" /></el-select></el-form-item><el-form-item v-if="isBuyAppointment" label="卖家姓名" required><el-input v-model="transferForm.sellerName" placeholder="请输入卖家姓名" /></el-form-item></div><el-form-item label="托盘箱唛确认"><el-input v-model="transferForm.cartonMarksJson" type="textarea" :rows="3" placeholder="按实际托盘填写 JSON,例如 {"PALLET-001":["箱唛1","箱唛2"]}" /><div class="form-hint">入库未逐箱绑定时,在出库前确认箱唛属于哪个实际托盘。</div></el-form-item><h3>费用信息(USD)</h3><div class="transfer-grid"><el-form-item label="派送费用" required><el-input-number v-model="transferForm.deliveryFee" :min="0" :precision="2" controls-position="right" placeholder="请输入派送费用" /></el-form-item><el-form-item v-if="needsLoadingFee" label="装车费" required><el-input-number v-model="transferForm.loadingFee" :min="0" :precision="2" controls-position="right" placeholder="请输入装车费" /></el-form-item><el-form-item v-if="isBuyAppointment" label="买约费用" required><el-input-number v-model="transferForm.appointmentFee" :min="0" :precision="2" controls-position="right" placeholder="请输入买约费用" /></el-form-item></div><el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" :rows="3" placeholder="请输入派送备注(选填)" /></el-form-item><el-alert title="费用不会自动带出,请手工填写后提交;提交后将生成待出库单、待派送单,并保存费用明细" type="info" :closable="false" /></el-form></AppDialog>
|
||||
<AppDialog v-model="editOpen" title="编辑排车" width="min(900px, calc(100vw - 48px))" confirm-text="保存" :confirm-loading="editSaving" @confirm="submitEdit"><el-alert :title="`当前状态:${editRow?.status || '—'};普通编辑不会修改排车状态。`" type="info" :closable="false" /><el-form label-position="top"><div class="edit-grid"><el-form-item label="车牌号"><el-input v-model="editForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="司机"><el-input v-model="editForm.driverName" placeholder="请输入司机姓名" /></el-form-item><el-form-item label="联系电话"><el-input v-model="editForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="editForm.carrierName" placeholder="请输入拖车公司名称" /></el-form-item><el-form-item label="路线"><el-input v-model="editForm.route" placeholder="请输入运输路线" /></el-form-item><el-form-item label="发车时间"><el-date-picker v-model="editForm.departAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="editForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="FBA预约号"><el-input v-model="editForm.fbaAppointmentNo" placeholder="请输入 FBA 预约号" /></el-form-item><el-form-item label="FBA预约状态"><el-select v-model="editForm.fbaAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item><el-form-item label="车辆预约状态"><el-select v-model="editForm.vehicleAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item></div></el-form></AppDialog>
|
||||
<AdminPage
|
||||
><ListPageLayout aria-label="海外仓排车列表">
|
||||
<OperationErrorToast :details="mutationError" />
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" :fields="fields" :loading="loading" ariaLabel="排车列表筛选" preference-key="overseas-dispatches-query" :search-on-reset="false" @search="search" @reset="reset" /></template>
|
||||
<template v-if="loadError" #state
|
||||
><LoadErrorState title="排车列表加载失败" :message="loadError.message" :loading="loading" @retry="load"
|
||||
><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState
|
||||
></template>
|
||||
<section v-if="!loadError" class="surface-panel table-panel">
|
||||
<DataTable
|
||||
v-model:page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:data="rows"
|
||||
:columns="columns"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
preference-key="overseas-dispatches"
|
||||
row-key="id"
|
||||
empty-text="暂无排车记录"
|
||||
@update:page="load()"
|
||||
@update:page-size="page = 1; load()"
|
||||
>
|
||||
<template #actions="{ row }"
|
||||
><el-button size="small" plain type="primary" @click="openDetail(row as DispatchListItem)"><Eye :size="15" />查看</el-button><el-button v-if="canTransfer(row as DispatchListItem)" size="small" type="success" plain @click="openTransfer(row as DispatchListItem)"><Truck :size="15" />转派送</el-button
|
||||
><el-dropdown v-if="canEdit(row as DispatchListItem) || canCancel(row as DispatchListItem)" trigger="click" @command="handleMore(String($event), row as DispatchListItem)"
|
||||
><el-button size="small" plain><MoreHorizontal :size="15" />更多</el-button
|
||||
><template #dropdown
|
||||
><el-dropdown-menu
|
||||
><el-dropdown-item v-if="canEdit(row as DispatchListItem)" command="edit"><Pencil :size="15" />编辑</el-dropdown-item><el-dropdown-item v-if="canCancel(row as DispatchListItem)" command="cancel" divided><XCircle :size="15" />取消</el-dropdown-item></el-dropdown-menu
|
||||
></template
|
||||
></el-dropdown
|
||||
></template
|
||||
>
|
||||
<template #status="{ row }"><StatusBadge :status="String((row as DispatchListItem).status || '—')" /></template>
|
||||
<template #pagination-left>共 {{ total }} 条排车记录</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout></AdminPage
|
||||
><AppDialog v-model="transferOpen" title="转派送" width="min(1120px, calc(100vw - 28px))" confirm-text="确认提交" :confirm-loading="transferSaving" @confirm="submitTransfer"
|
||||
><el-form label-position="top"
|
||||
><h3>派送信息</h3>
|
||||
<div class="transfer-grid">
|
||||
<el-form-item label="排车单号"><el-input :model-value="transferRow?.dispatchNo" readonly /></el-form-item><el-form-item label="预计派送时间" required><el-date-picker v-model="transferForm.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计派送时间" /></el-form-item
|
||||
><el-form-item label="派送方式" required
|
||||
><el-select v-model="transferForm.deliveryMethod" placeholder="请选择"><el-option v-for="option in deliveryMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item
|
||||
><el-form-item label="快递/车队"><el-input v-model="transferForm.carrierName" placeholder="请输入快递或车队名称" /></el-form-item><el-form-item label="运单号"><el-input v-model="transferForm.waybillNo" placeholder="请输入运单号" /></el-form-item
|
||||
><el-form-item label="提货方式" required
|
||||
><el-select v-model="transferForm.pickupMethod" placeholder="请选择"><el-option v-for="option in pickupMethodOptions" :key="option.value" v-bind="option" /></el-select></el-form-item
|
||||
><el-form-item label="装车类型" required
|
||||
><el-select v-model="transferForm.loadingType" placeholder="请选择"><el-option v-for="option in loadingTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item
|
||||
><el-form-item label="司机/车队名称"><el-input v-model="transferForm.driverName" placeholder="请输入司机或车队名称" /></el-form-item><el-form-item label="联系电话"><el-input v-model="transferForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="车牌号"><el-input v-model="transferForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item
|
||||
><el-form-item label="车辆类型"
|
||||
><el-select v-model="transferForm.vehicleType" clearable placeholder="请选择"><el-option v-for="option in vehicleTypeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item
|
||||
><el-form-item label="车辆大小"
|
||||
><el-select v-model="transferForm.vehicleSize" clearable placeholder="请选择"><el-option v-for="option in vehicleSizeOptions" :key="option.value" v-bind="option" /></el-select></el-form-item
|
||||
><el-form-item label="预约账号"
|
||||
><el-select v-model="transferForm.appointmentAccount" clearable placeholder="请选择"><el-option v-for="option in appointmentAccountOptions" :key="option.value" v-bind="option" /></el-select></el-form-item
|
||||
><el-form-item v-if="isBuyAppointment" label="卖家姓名" required><el-input v-model="transferForm.sellerName" placeholder="请输入卖家姓名" /></el-form-item>
|
||||
</div>
|
||||
<h3>费用信息(USD)</h3>
|
||||
<div class="transfer-grid">
|
||||
<el-form-item label="派送费用" required><el-input-number v-model="transferForm.deliveryFee" :min="0" :precision="2" controls-position="right" placeholder="请输入派送费用" /></el-form-item><el-form-item v-if="needsLoadingFee" label="装车费" required><el-input-number v-model="transferForm.loadingFee" :min="0" :precision="2" controls-position="right" placeholder="请输入装车费" /></el-form-item><el-form-item v-if="isBuyAppointment" label="买约费用" required><el-input-number v-model="transferForm.appointmentFee" :min="0" :precision="2" controls-position="right" placeholder="请输入买约费用" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="备注"><el-input v-model="transferForm.remark" type="textarea" :rows="3" placeholder="请输入派送备注(选填)" /></el-form-item><el-alert title="费用不会自动带出,请手工填写后提交;提交后将生成待出库单、待派送单,并保存费用明细" type="info" :closable="false" /></el-form
|
||||
></AppDialog>
|
||||
<AppDialog v-model="editOpen" title="编辑排车" width="min(1000px, calc(100vw - 48px))" confirm-text="保存" :confirm-loading="editSaving" @confirm="submitEdit"
|
||||
><el-alert :title="`当前状态:${editRow?.status || '—'};未生成出库单前可移除托盘。`" type="info" :closable="false" /><el-form label-position="top"
|
||||
><div class="edit-grid">
|
||||
<el-form-item label="车牌号"><el-input v-model="editForm.vehiclePlate" placeholder="请输入车牌号" /></el-form-item><el-form-item label="司机"><el-input v-model="editForm.driverName" placeholder="请输入司机姓名" /></el-form-item><el-form-item label="联系电话"><el-input v-model="editForm.driverPhone" placeholder="请输入联系电话" /></el-form-item><el-form-item label="拖车公司名称"><el-input v-model="editForm.carrierName" placeholder="请输入拖车公司名称" /></el-form-item><el-form-item label="路线"><el-input v-model="editForm.route" placeholder="请输入运输路线" /></el-form-item><el-form-item label="发车时间"><el-date-picker v-model="editForm.departAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item><el-form-item label="预计到达时间"><el-date-picker v-model="editForm.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" /></el-form-item
|
||||
><el-form-item label="FBA预约号"><el-input v-model="editForm.fbaAppointmentNo" placeholder="请输入 FBA 预约号" /></el-form-item
|
||||
><el-form-item label="FBA预约状态"
|
||||
><el-select v-model="editForm.fbaAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item
|
||||
><el-form-item label="车辆预约状态"
|
||||
><el-select v-model="editForm.vehicleAppointmentStatus" clearable><el-option label="待预约" value="PENDING" /><el-option label="已预约" value="BOOKED" /><el-option label="已确认" value="CONFIRMED" /><el-option label="已取消" value="CANCELLED" /></el-select
|
||||
></el-form-item></div
|
||||
></el-form>
|
||||
<h3>已排车托盘</h3>
|
||||
<el-table :data="editItems" border max-height="280" empty-text="暂无托盘"
|
||||
><el-table-column prop="palletNo" label="托盘号" min-width="160" /><el-table-column prop="orderNo" label="订单号" min-width="160" /><el-table-column prop="inboundCode" label="入仓码" min-width="140" /><el-table-column prop="packageCount" label="箱数" width="80" align="right" /><el-table-column label="操作" width="90"
|
||||
><template #default="{ row }"
|
||||
><el-button text type="danger" :loading="removingItemId === String(row.id)" :disabled="Boolean(removingItemId)" @click="removePallet(row)"><Trash2 :size="15" />移除</el-button></template
|
||||
></el-table-column
|
||||
></el-table
|
||||
></AppDialog
|
||||
>
|
||||
</template>
|
||||
<style scoped>.transfer-grid,.edit-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:0 16px}.transfer-grid :deep(.el-select),.transfer-grid :deep(.el-date-editor),.transfer-grid :deep(.el-input-number),.edit-grid :deep(.el-select),.edit-grid :deep(.el-date-editor){width:100%}h3{margin:4px 0 12px}@media(max-width:900px){.transfer-grid,.edit-grid{grid-template-columns:1fr}}</style>
|
||||
<style scoped>
|
||||
.transfer-grid,
|
||||
.edit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0 16px;
|
||||
}
|
||||
.transfer-grid :deep(.el-select),
|
||||
.transfer-grid :deep(.el-date-editor),
|
||||
.transfer-grid :deep(.el-input-number),
|
||||
.edit-grid :deep(.el-select),
|
||||
.edit-grid :deep(.el-date-editor) {
|
||||
width: 100%;
|
||||
}
|
||||
h3 {
|
||||
margin: 4px 0 12px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.transfer-grid,
|
||||
.edit-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -70,7 +70,6 @@ const containerFields = computed<DetailField[]>(() => {
|
||||
{ label: '预计到港时间', value: formatOverseasDateTime(order?.estimatedArrivalAt) },
|
||||
{ label: '实际到港时间', value: formatOverseasDateTime(order?.actualArrivalAt) },
|
||||
{ label: '预计到仓时间', value: formatOverseasDateTime(order?.estimatedWarehouseAt) },
|
||||
{ label: '清关状态', value: text(order?.containerStatus) },
|
||||
]
|
||||
})
|
||||
const planFields = computed<DetailField[]>(() => {
|
||||
|
||||
@@ -621,7 +621,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
|
||||
expect(source).toContain('.container-detail-summary > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }')
|
||||
expect(source).toContain('.container-workbench-card__warehouse strong { min-width: 0; overflow: hidden; color: var(--cc-text); font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }')
|
||||
expect(source).toContain('.container-workbench-card__actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 2px; white-space: nowrap; }')
|
||||
expect(source).toContain("{ key: 'actions', label: '操作', width: 178, fixed: 'left'")
|
||||
expect(source).toContain("{ key: 'actions', label: '操作', width: 230, fixed: 'left'")
|
||||
})
|
||||
|
||||
it('loads only active FBA master data and submits exact string fields for a cross-customer transfer', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { Download, Eye, Pencil } from '@lucide/vue'
|
||||
import { CheckCircle2, Download, Eye, Pencil } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
@@ -15,6 +15,7 @@ import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import { changeContainerReceivingWarehouse, convertLastMileContainerToInboundPlans, createManualInboundPlan, downloadLastMileOrders, getContainerInboundPlanPreview, getContainerPlanProgress, getLastMileOrderManualNodes, listContainerPlanProgress, listLastMileOrders, transferLastMileOrders, updateLastMileOrderHold, updateLastMileOrderManualNodes, type ContainerInboundPlanPreviewDto, type ContainerInboundPlanPreviewGroupDto, type ContainerInboundPlanPreviewOrderDto, type ContainerPlanProgressDetailDto, type ContainerPlanProgressSummaryDto, type ManualNodeCode } from '../api/lastMileOrders'
|
||||
import OrderReferenceText from '../components/OrderReferenceText.vue'
|
||||
import WarehouseTransferReviewDialog from '../components/WarehouseTransferReviewDialog.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { overseasWarehousePermissions as permission } from '../permissions'
|
||||
import { lastMileStatusLabel, toLastMileOrderListItem } from '../lastMilePresentation'
|
||||
@@ -35,6 +36,8 @@ const containerDetailOpen = ref(false)
|
||||
const containerDetailLoading = ref(false)
|
||||
const containerDetailError = ref<ApiErrorDetails | null>(null)
|
||||
const containerDetail = ref<ContainerPlanProgressDetailDto | null>(null)
|
||||
const transferReviewOpen = ref(false)
|
||||
const transferReviewTarget = ref<LastMileOrderListItem | null>(null)
|
||||
const selectedRows = ref<LastMileOrderListItem[]>([])
|
||||
const actionLoading = ref(false)
|
||||
const exportLoading = ref(false)
|
||||
@@ -101,9 +104,10 @@ const receivingWarehouseForm = reactive({ warehouseId: '', remark: '' })
|
||||
let receivingWarehouseIdempotencyKey = ''
|
||||
let containerOptionRequest = 0
|
||||
const statusTab = ref<'全部' | '待分配' | '柜子'>('全部')
|
||||
const filters = ref<QueryModel>({ orderNo: '', orderType: '', warehouse: '', inboundCode: '', lxCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] })
|
||||
const filters = ref<QueryModel>({ orderNo: '', relabelNo: '', orderType: '', warehouse: '', inboundCode: '', lxCode: '', sourceOrderNo: '', containerNo: '', status: '', transferWarehouseCode: '', abnormal: '', detained: '', createdAt: [] })
|
||||
const allFields: QueryField[] = [
|
||||
{ key: 'orderNo', type: 'input', label: '尾程单号', width: 190, placeholder: '请输入尾程单号' },
|
||||
{ key: 'relabelNo', type: 'input', label: '换标单号', width: 190, placeholder: '请输入换标单号' },
|
||||
{ key: 'inboundCode', type: 'input', label: '入仓码', width: 180, placeholder: '请输入入仓码' },
|
||||
{ key: 'lxCode', type: 'input', label: 'LX代码', width: 180, placeholder: '请输入LX代码' },
|
||||
{ key: 'orderType', type: 'select', label: '订单类型', width: 170, placeholder: '全部类型', options: [
|
||||
@@ -183,7 +187,7 @@ function newReceivingWarehouseIdempotencyKey() {
|
||||
return `container-receiving-warehouse-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 178, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'actions', label: '操作', width: 230, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'selection', type: 'selection', width: 48, fixed: 'left' },
|
||||
{ key: 'orderNo', prop: 'orderNo', label: '尾程单号', minWidth: 170, slotName: 'orderNo' },
|
||||
{ key: 'consolidated', prop: 'consolidated', label: '合托标记', width: 100, hideable: false },
|
||||
@@ -209,6 +213,7 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'portAta', prop: 'portAta', label: '实际到港时间', minWidth: 150 },
|
||||
{ key: 'warehouseEta', prop: 'warehouseEta', label: '预计到仓', minWidth: 140 },
|
||||
{ key: 'inboundOrderNo', prop: 'inboundOrderNo', label: '入库单号', minWidth: 150 },
|
||||
{ key: 'dispatchStatus', prop: 'dispatchStatus', label: '排车状态', width: 110 },
|
||||
{ key: 'outboundOrderNo', prop: 'outboundOrderNo', label: '出库单号', minWidth: 150 },
|
||||
{ key: 'dispatchOrderNo', prop: 'dispatchOrderNo', label: '派送单号', minWidth: 150 },
|
||||
{ key: 'relabelOrderNo', prop: 'relabelOrderNo', label: '换标单号', minWidth: 150 },
|
||||
@@ -305,6 +310,7 @@ async function load(model: QueryModel = filters.value) {
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
orderNo: String(filters.value.orderNo || '').trim() || undefined,
|
||||
relabelNo: String(filters.value.relabelNo || '').trim() || undefined,
|
||||
orderType: String(filters.value.orderType || '').trim() || undefined,
|
||||
warehouse: String(filters.value.warehouse || '').trim() || undefined,
|
||||
inboundCode: String(filters.value.inboundCode || '').trim() || undefined,
|
||||
@@ -709,6 +715,11 @@ function openDetail(source: object) {
|
||||
void router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.id } })
|
||||
}
|
||||
|
||||
function openTransferReview(source: object) {
|
||||
transferReviewTarget.value = source as LastMileOrderListItem
|
||||
transferReviewOpen.value = true
|
||||
}
|
||||
|
||||
onMounted(() => { void load() })
|
||||
</script>
|
||||
|
||||
@@ -769,11 +780,12 @@ onMounted(() => { void load() })
|
||||
</section>
|
||||
<section v-else-if="!loadError" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-last-mile-orders" row-key="id" empty-text="暂无尾程订单" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-actions><el-button v-if="access.can(permission.lastMileOrderExport)" plain :loading="exportLoading" @click="exportOrders"><Download :size="16" />导出订单</el-button><el-button v-if="access.can(permission.lastMileOrderTransfer)" plain :disabled="!selectedRows.length || actionLoading" @click="openTransfer">转仓</el-button><el-button v-if="access.can(permission.inboundOrderCreate)" type="primary" plain :disabled="selectedRows.length !== 1 || actionLoading" @click="openInboundPlan()">生成入库计划</el-button><el-button v-if="statusTab === '待分配' && access.can(permission.inboundOrderCreate)" type="primary" :disabled="actionLoading" @click="openContainerConversion">按柜批量生成</el-button></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-dropdown v-if="access.can(permission.lastMileOrderTransfer) || access.can(permission.inboundOrderCreate)" trigger="click"><el-button size="small" plain>更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="access.can(permission.inboundOrderCreate)" :disabled="row.detained" @click="openInboundPlan(row)">填写入库计划</el-dropdown-item><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openHold(row)">{{ row.detained ? '解除暂扣' : '暂扣订单' }}</el-dropdown-item><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openManualNodes(row)">人工修改节点</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><el-button size="small" plain type="primary" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW'" size="small" plain type="success" @click="openTransferReview(row)"><CheckCircle2 :size="15" />审核</el-button><el-dropdown v-if="access.can(permission.lastMileOrderTransfer) || access.can(permission.inboundOrderCreate)" trigger="click"><el-button size="small" plain>更多</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-if="access.can(permission.inboundOrderCreate)" :disabled="row.detained" @click="openInboundPlan(row)">填写入库计划</el-dropdown-item><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openHold(row)">{{ row.detained ? '解除暂扣' : '暂扣订单' }}</el-dropdown-item><el-dropdown-item v-if="access.can(permission.lastMileOrderTransfer)" @click="openManualNodes(row)">人工修改节点</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
|
||||
<template #orderNo="{ row }"><OrderReferenceText :value="String(row.orderNo || '')" copyable /></template>
|
||||
<template #pagination-left>共 {{ total }} 条尾程订单 · 已选 {{ selectedRows.length }} 条</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout></AdminPage>
|
||||
<WarehouseTransferReviewDialog v-if="transferReviewTarget" v-model="transferReviewOpen" :customer-id="transferReviewTarget.customerId" :customer-name="transferReviewTarget.owner" :transfer-no="transferReviewTarget.orderNo" @reviewed="load()" />
|
||||
<AppDialog v-model="transferOpen" title="转仓" confirm-text="确认转仓" :loading="transferWarehouseLoading" :confirm-loading="actionLoading" :confirm-disabled="!transferForm.warehouseId || Boolean(transferWarehouseError)" @confirm="submitTransfer">
|
||||
<LoadErrorState v-if="transferWarehouseError" title="FBA 仓候选加载失败" :message="transferWarehouseError.message" compact @retry="loadTransferWarehouseOptions">
|
||||
<template #details><ApiErrorMeta :details="transferWarehouseError" /></template>
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Printer, Search } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { listPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
||||
import { listPlatformTenants, listSharedReceivingOverseasWarehouseOptions, type PlatformTenantResource } from '@/modules/iam'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import { getReturnCarton, getReturnCartonLabel, listReturnCartons, listReturnInventoryMovements, listReturnStock, type ReturnCartonDetail, type ReturnCartonRow, type ReturnInventoryMovement, type ReturnStockRow } from '../api/returnInventory'
|
||||
@@ -13,7 +12,7 @@ const stockRows = ref<ReturnStockRow[]>([]), cartonRows = ref<ReturnCartonRow[]>
|
||||
const inventoryTab = ref<'stock' | 'movements'>('stock')
|
||||
const movementRows = ref<ReturnInventoryMovement[]>([])
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const warehouses = ref<PlatformWarehouseSummaryResource[]>([])
|
||||
const warehouses = ref<Array<{ id: string; warehouseName: string }>>([])
|
||||
const optionsLoading = ref(false)
|
||||
const query = reactive({ page: 1, size: 20, customerId: '', overseasWarehouseId: '', skuCode: '', cartonMark: '', returnNo: '', status: 'AVAILABLE' })
|
||||
function params() { return { ...query, customerId: query.customerId || undefined, overseasWarehouseId: query.overseasWarehouseId || undefined, skuCode: query.skuCode || undefined, cartonMark: query.cartonMark || undefined, returnNo: query.returnNo || undefined, status: query.status || undefined } }
|
||||
@@ -21,19 +20,25 @@ async function load() { loading.value = true; try { if (props.mode === 'inventor
|
||||
function search() { query.page = 1; void load() }
|
||||
function changeInventoryTab() { query.page = 1; void load() }
|
||||
const customerOptions = computed(() => customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })))
|
||||
const warehouseOptions = computed(() => warehouses.value.filter((item) => item.status === 'ACTIVE' && item.warehouseType === 'OVERSEAS').map((item) => ({ label: item.warehouseName, value: String(item.id) })))
|
||||
const warehouseOptions = computed(() => warehouses.value.map((item) => ({ label: item.warehouseName, value: String(item.id) })))
|
||||
function customerName(customerId?: string) { return customers.value.find((item) => String(item.id) === String(customerId))?.tenantName || '客户名称待补充' }
|
||||
function warehouseName(warehouseId?: string) { return warehouses.value.find((item) => String(item.id) === String(warehouseId))?.warehouseName || '海外仓名称待补充' }
|
||||
function statusLabel(status?: string) { return status === 'AVAILABLE' ? '可用' : status === 'MERGED' ? '已合箱' : '—' }
|
||||
async function loadOptions() {
|
||||
optionsLoading.value = true
|
||||
try {
|
||||
const [customerPage, warehousePage] = await Promise.all([
|
||||
const [customerResult, warehouseResult] = await Promise.allSettled([
|
||||
listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' }),
|
||||
listPlatformWarehouses({ page: 1, size: 200, warehouseType: 'OVERSEAS', status: 'ACTIVE' }),
|
||||
listSharedReceivingOverseasWarehouseOptions(),
|
||||
])
|
||||
customers.value = customerPage.records
|
||||
warehouses.value = warehousePage.records
|
||||
if (customerResult.status === 'fulfilled') customers.value = customerResult.value.records
|
||||
else customers.value = []
|
||||
if (warehouseResult.status === 'fulfilled') warehouses.value = warehouseResult.value
|
||||
else warehouses.value = []
|
||||
if (customerResult.status === 'rejected' || warehouseResult.status === 'rejected') {
|
||||
const failed = customerResult.status === 'rejected' ? '客户' : '海外仓'
|
||||
ElMessage.warning(`${failed}筛选数据加载失败,请检查服务或数据库迁移`)
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.warning(error instanceof Error ? `筛选名称加载失败:${error.message}` : '筛选名称加载失败')
|
||||
} finally { optionsLoading.value = false }
|
||||
|
||||
@@ -14,6 +14,14 @@ function columnKeys(fileName: string) {
|
||||
}
|
||||
|
||||
describe('overseas warehouse first-batch prototype contract', () => {
|
||||
it('shows warehouse transfer review only for pending transfers with its dedicated authority', () => {
|
||||
const value = source('LastMileOrdersView.vue')
|
||||
expect(value).toContain('access.can(permission.warehouseTransferApprove)')
|
||||
expect(value).toContain("row.orderTypeCode === 'WAREHOUSE_TRANSFER'")
|
||||
expect(value).toContain("row.currentStatusCode === 'PENDING_REVIEW'")
|
||||
expect(value).toContain('<WarehouseTransferReviewDialog')
|
||||
})
|
||||
|
||||
it('gives every formal list query toolbar a unique stable preference key', () => {
|
||||
const expected = new Map([
|
||||
['LastMileOrdersView.vue', 'overseas-last-mile-orders-query'],
|
||||
@@ -247,7 +255,8 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
expect(dialog).toContain('planPalletIds: assignedIds.value')
|
||||
expect(dialog).toContain('lockDispatchDraftPallet')
|
||||
expect(dialog).toContain('unlockDispatchDraftPallet')
|
||||
expect(dialog).toContain('releaseDispatchDraft')
|
||||
expect(dialog).not.toContain('releaseDispatchDraft')
|
||||
expect(dialog).toContain('排车明细已同步到预排列表')
|
||||
expect(dialog).toContain("router.push({ name: 'overseas-dispatches' })")
|
||||
expect(dialog).not.toContain('customerId:')
|
||||
expect(dialog).toContain("planIds: planIds.value.length ? planIds.value.join(',') : undefined")
|
||||
@@ -269,6 +278,11 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
expect(dialog).toContain('退货换标')
|
||||
expect(dialog).toContain('本土散板')
|
||||
expect(dialog).toContain('仓储转运')
|
||||
expect(dialog).toContain('relabelCartonMarks(item)')
|
||||
expect(dialog).toContain('type="warning">换标</el-tag>')
|
||||
expect(dialog).not.toContain('function cartonMarks(')
|
||||
expect(dialog).not.toContain('`normal-${mark}`')
|
||||
expect(dialog).not.toContain('暂无箱唛')
|
||||
expect(dialog).toContain('class="warehouse-tabs"')
|
||||
expect(dialog).not.toContain('排车路线(选填)')
|
||||
expect(dialog).not.toContain('v-model="route"')
|
||||
@@ -355,7 +369,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
|
||||
'actions', 'selection', 'orderNo', 'consolidated', 'palletMergeRelation', 'warning', 'orderType', 'rejectedAt', 'inboundCode', 'lxCode', 'owner', 'warehouse',
|
||||
'transferWarehouse', 'fbaWarehouse', 'containerNo', 'abnormal', 'held', 'pallets',
|
||||
'cartons', 'volume', 'quantity', 'volumeWeight', 'portEta', 'portAta', 'warehouseEta', 'inboundOrderNo',
|
||||
'outboundOrderNo', 'dispatchOrderNo', 'relabelOrderNo', 'relabelStatus', 'status',
|
||||
'dispatchStatus', 'outboundOrderNo', 'dispatchOrderNo', 'relabelOrderNo', 'relabelStatus', 'status',
|
||||
'createdAt', 'internalRemark', 'customerRemark', 'warehouseRemark',
|
||||
])
|
||||
expect(columnKeys('InboundOrdersView.vue')).toEqual([
|
||||
|
||||
@@ -2,6 +2,7 @@ export const overseasWarehousePermissions = {
|
||||
lastMileOrderList: 'platform:overseas-warehouse:last-mile-order:list',
|
||||
lastMileOrderExport: 'platform:overseas-warehouse:last-mile-order:export',
|
||||
lastMileOrderTransfer: 'platform:overseas-warehouse:last-mile-order:edit',
|
||||
warehouseTransferApprove: 'platform:overseas-warehouse:warehouse-transfer:approve',
|
||||
inboundOrderList: 'platform:overseas-warehouse:inbound-order:list',
|
||||
inboundOrderCreate: 'platform:overseas-warehouse:inbound-order:create',
|
||||
inboundOrderEdit: 'platform:overseas-warehouse:inbound-order:edit',
|
||||
|
||||
@@ -51,6 +51,7 @@ describe('overseas warehouse routes', () => {
|
||||
permission.customerVisitEdit,
|
||||
permission.dispatchEdit, permission.dispatchCancel, permission.dispatchTransferDelivery,
|
||||
permission.outboundCancel, permission.outboundCreate,
|
||||
]).size).toBe(17)
|
||||
permission.warehouseTransferApprove,
|
||||
]).size).toBe(18)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface LastMileOrderListItem {
|
||||
customerId: string
|
||||
sourceContainerBookingId: string
|
||||
orderNo: string
|
||||
orderTypeCode: string
|
||||
currentStatusCode: string
|
||||
warning: string
|
||||
orderType: string
|
||||
rejectedAt: string
|
||||
@@ -20,7 +22,6 @@ export interface LastMileOrderListItem {
|
||||
transferWarehouse: string
|
||||
fbaWarehouse: string
|
||||
containerNo: string
|
||||
containerStatus: string
|
||||
abnormal: string
|
||||
held: string
|
||||
detained: boolean
|
||||
@@ -37,6 +38,7 @@ export interface LastMileOrderListItem {
|
||||
portAta: string
|
||||
warehouseEta: string
|
||||
inboundOrderNo: string
|
||||
dispatchStatus: string
|
||||
/** Read-only association fact; this module does not implement the outbound workflow. */
|
||||
outboundOrderNo: string
|
||||
/** Read-only association fact; this module does not implement overseas delivery. */
|
||||
|
||||
Reference in New Issue
Block a user