diff --git a/src/modules/inbounds/measurement/InboundMeasurementPanel.vue b/src/modules/inbounds/measurement/InboundMeasurementPanel.vue
index 2a5ffbac..bc6f56cc 100644
--- a/src/modules/inbounds/measurement/InboundMeasurementPanel.vue
+++ b/src/modules/inbounds/measurement/InboundMeasurementPanel.vue
@@ -922,7 +922,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
.measurement-comparison thead th { background: #f8fafc; }
.measurement-comparison thead th:nth-child(2) { background: #f7f9ff; color: #465fc4; }
.measurement-comparison thead th:nth-child(3) { background: #f4fbf8; color: #18795d; }
-.measurement-comparison__difference { color: var(--el-color-warning-dark-2); font-weight: 600; }
+.measurement-comparison__difference { color: var(--el-color-danger-dark-2); font-weight: 600; }
.measurement-extra-facts { display: flex; flex-wrap: wrap; gap: var(--admin-space-3, 8px) var(--admin-space-6, 16px); color: var(--cc-muted); font-size: var(--admin-font-caption, 12px); }
.measurement-extra-facts strong { color: var(--cc-text); }
.measurement-panel { overflow: hidden; }
@@ -947,7 +947,7 @@ defineExpose({ saveDraft, submitForReview, updateWeightEvidence, submissionBlock
.measurement-summary-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
.measurement-summary-context { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: var(--admin-space-3, 8px) 20px; }
.measurement-summary-header span { color: var(--cc-muted); font-size: var(--admin-font-caption, 11px); }
-.measurement-deviation-alert { margin: 0; width: auto; }
+.measurement-deviation-alert { --el-color-warning: var(--el-color-danger-dark-2); --el-color-warning-dark-2: var(--el-color-danger-dark-2); margin: 0; width: auto; }
.measurement-deviation-alert p { margin: 3px 0 0; line-height: 1.55; }
.measurement-summary-context p { display: inline-flex; min-width: 0; align-items: baseline; gap: var(--admin-space-2, 6px); margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: var(--admin-font-caption, 12px); font-weight: 600; }
.measurement-summary-status { display: flex; flex: 0 0 auto; align-items: center; gap: 9px; }
diff --git a/src/modules/loading-lists/api/containerFees.test.ts b/src/modules/loading-lists/api/containerFees.test.ts
index 586f080f..d272f360 100644
--- a/src/modules/loading-lists/api/containerFees.test.ts
+++ b/src/modules/loading-lists/api/containerFees.test.ts
@@ -1,12 +1,22 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
-import { adjustContainerFee, approveContainerFee, approveContainerFeeAllocationGroup, createContainerFee, createContainerFeeAllocationGroup, getContainerSettlementDetails, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, uploadContainerFeePaymentVoucher, voidContainerFee, voidContainerFeeAllocationGroup } from './containerFees'
+import { adjustContainerFee, approveContainerFee, approveContainerFeeAllocationGroup, createContainerFee, createContainerFeeAllocationGroup, getContainerSettlementDetails, listApplicableFeeItems, listContainerFeeSupplierOptions, listContainerFees, rejectContainerFee, uploadContainerFeePaymentVoucher, voidContainerFee, voidContainerFeeAllocationGroup } from './containerFees'
import { listSuppliers } from '@/modules/suppliers'
vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn() }))
describe('container fee API', () => {
beforeEach(() => vi.restoreAllMocks())
+ it('retains configured, zero and empty default prices when loading fee choices', async () => {
+ vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: { records: [
+ { id: 1, feeTypeName: '操作费', defaultPrice: '60.25' },
+ { id: 2, feeTypeName: '零元费', defaultPrice: 0 },
+ { id: 3, feeTypeName: '手工费', defaultPrice: null },
+ ] } })
+ await expect(listApplicableFeeItems()).resolves.toMatchObject([
+ { id: '1', defaultPrice: 60.25 }, { id: '2', defaultPrice: 0 }, { id: '3', defaultPrice: null },
+ ])
+ })
it('passes accountId and exact controller DTOs', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: [] })
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { id: 3, loadingPlanId: 9, containerBookingId: 99, feeItemId: 7, feeItemNameSnapshot: '操作费', direction: 'PAYABLE', supplierId: 41, supplierName: '承运商A', supplierCategory: 'TRUCKING', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:30:00', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: 'BANK-7', paymentVoucherFileIds: [9001], businessDate: '2026-08-12', amount: 100, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1 } })
diff --git a/src/modules/loading-lists/api/containerFees.ts b/src/modules/loading-lists/api/containerFees.ts
index 5d16c89d..94f9db27 100644
--- a/src/modules/loading-lists/api/containerFees.ts
+++ b/src/modules/loading-lists/api/containerFees.ts
@@ -84,6 +84,7 @@ export interface ContainerFeeAllocationGroupAdjustRequest {
}
export interface ApplicableFeeItem {
+ defaultPrice: number | null
id: string
name: string
feeCode: string
@@ -224,7 +225,7 @@ export async function voidContainerFee(planId: string, accountId: string, feeId:
export async function listApplicableFeeItems() {
const response = await http.get
[] }>, ApiEnvelope<{ records: Record[] }>>('/platform/v1/fee-items', { params: { status: 'ACTIVE', page: 1, size: 200 } })
- return (unwrapApiResponse(response).records || []).map((raw) => ({ id: id(raw.id), name: String(raw.feeTypeName || ''), feeCode: String(raw.feeCode || ''), supplierCategory: supplierCategory(raw.supplierCategory), warehouseReceivable: Boolean(raw.warehouseReceivable), warehousePayable: Boolean(raw.warehousePayable), pickupReceivable: Boolean(raw.pickupReceivable), pickupPayable: Boolean(raw.pickupPayable) }))
+ return (unwrapApiResponse(response).records || []).map((raw): ApplicableFeeItem => ({ id: id(raw.id), name: String(raw.feeTypeName || ''), defaultPrice: raw.defaultPrice == null ? null : Number(raw.defaultPrice), feeCode: String(raw.feeCode || ''), supplierCategory: supplierCategory(raw.supplierCategory), warehouseReceivable: Boolean(raw.warehouseReceivable), warehousePayable: Boolean(raw.warehousePayable), pickupReceivable: Boolean(raw.pickupReceivable), pickupPayable: Boolean(raw.pickupPayable) }))
}
export async function listContainerFeeSupplierOptions(category: SupplierCategory | null): Promise {
diff --git a/src/modules/loading-lists/components/ContainerFeesPanel.test.ts b/src/modules/loading-lists/components/ContainerFeesPanel.test.ts
index 1339a279..18b94839 100644
--- a/src/modules/loading-lists/components/ContainerFeesPanel.test.ts
+++ b/src/modules/loading-lists/components/ContainerFeesPanel.test.ts
@@ -39,6 +39,90 @@ beforeEach(() => {
})
describe('ContainerFeesPanel contract', () => {
+ it.each(['success', 'failure'])('ignores a closed editor request that finishes late: %s', async (outcome) => {
+ const item = { id: '7', name: '操作费', feeCode: 'OPERATION', defaultPrice: 60, supplierCategory: null, warehouseReceivable: true, warehousePayable: false, pickupReceivable: false, pickupPayable: false }
+ let resolveOld!: (items: typeof item[]) => void
+ let rejectOld!: (reason: Error) => void
+ vi.mocked(listApplicableFeeItems).mockImplementationOnce(() => new Promise((resolve, reject) => { resolveOld = resolve; rejectOld = reject }))
+ vi.mocked(listApplicableFeeItems).mockResolvedValueOnce([{ ...item, defaultPrice: 80 }])
+ const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
+ const state = (wrapper.vm.$ as unknown as { setupState: {
+ openCreate(): void
+ editorOpen: boolean
+ form: { amount: number }
+ feeItems: typeof item[]
+ feeItemsLoading: boolean
+ feeItemsError: unknown
+ } }).setupState
+ state.openCreate(); await flushPromises()
+ state.editorOpen = false; await flushPromises()
+ state.openCreate(); await flushPromises()
+ expect(state.feeItems[0]?.defaultPrice).toBe(80)
+ if (outcome === 'success') resolveOld([item])
+ else rejectOld(new Error('旧窗口请求失败'))
+ await flushPromises()
+ expect(state.feeItemsError).toBeNull()
+ expect(state.feeItemsLoading).toBe(false)
+ const select = wrapper.findAllComponents({ name: 'ElFormItem' }).find((field) => field.props('prop') === 'feeItemId')!.findComponent({ name: 'ElSelect' })
+ select.vm.$emit('update:modelValue', '7'); select.vm.$emit('change', '7')
+ await flushPromises()
+ expect(state.form.amount).toBe(80)
+ wrapper.unmount()
+ })
+
+ it('does not let an old completion clear the current loading state or refill a closed editor', async () => {
+ const item = { id: '7', name: '操作费', feeCode: 'OPERATION', defaultPrice: 60, supplierCategory: null, warehouseReceivable: true, warehousePayable: false, pickupReceivable: false, pickupPayable: false }
+ let resolveOld!: (items: typeof item[]) => void
+ let resolveCurrent!: (items: typeof item[]) => void
+ vi.mocked(listApplicableFeeItems)
+ .mockImplementationOnce(() => new Promise((resolve) => { resolveOld = resolve }))
+ .mockImplementationOnce(() => new Promise((resolve) => { resolveCurrent = resolve }))
+ const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
+ const state = (wrapper.vm.$ as unknown as { setupState: {
+ openCreate(): void; editorOpen: boolean; feeItems: unknown[]; feeItemsLoading: boolean; feeItemsError: unknown
+ } }).setupState
+ state.openCreate(); await flushPromises()
+ state.editorOpen = false
+ state.openCreate(); await flushPromises()
+ resolveOld([item]); await flushPromises()
+ expect(state.feeItems).toEqual([])
+ expect(state.feeItemsLoading).toBe(true)
+ state.editorOpen = false; await flushPromises()
+ resolveCurrent([{ ...item, defaultPrice: 80 }]); await flushPromises()
+ expect(state.feeItems).toEqual([])
+ expect(state.feeItemsLoading).toBe(false)
+ expect(state.feeItemsError).toBeNull()
+ wrapper.unmount()
+ })
+
+ it('prefills the selected default, clears stale prices and retains manual overrides', async () => {
+ const base = { name: '操作费', feeCode: 'OPERATION', supplierCategory: null, warehouseReceivable: true, warehousePayable: false, pickupReceivable: false, pickupPayable: false }
+ vi.mocked(listApplicableFeeItems).mockResolvedValue([
+ { ...base, id: '7', defaultPrice: 60 }, { ...base, id: '8', defaultPrice: null }, { ...base, id: '9', defaultPrice: 0 },
+ ])
+ const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
+ const state = (wrapper.vm.$ as unknown as { setupState: {
+ openCreate(): void; save(): Promise
+ resetForm(row: Record): void
+ form: { feeItemId: string; amount: number }
+ } }).setupState
+ state.resetForm({ feeItemId: '7', direction: 'RECEIVABLE', businessDate: '2026-08-12', amount: 100 })
+ await flushPromises()
+ expect(state.form.amount).toBe(100)
+ state.openCreate(); await flushPromises()
+ const select = wrapper.findAllComponents({ name: 'ElFormItem' }).find((item) => item.props('prop') === 'feeItemId')!.findComponent({ name: 'ElSelect' })
+ for (const [id, price] of [['7', 60], ['8', 0], ['9', 0]] as const) {
+ select.vm.$emit('update:modelValue', id); select.vm.$emit('change', id)
+ await flushPromises()
+ expect(state.form.amount).toBe(price)
+ }
+ select.vm.$emit('update:modelValue', '7'); select.vm.$emit('change', '7')
+ await flushPromises()
+ state.form.amount = 75
+ await state.save(); await flushPromises()
+ expect(createContainerFee).toHaveBeenCalledWith('9', '11', expect.objectContaining({ feeItemId: '7', amount: 75 }))
+ })
+
it('keeps fee controls and table utilities in compact aligned toolbars', () => {
expect(panel).toContain('class="fees-header__actions"')
expect(panel).toContain('class="customer-context"')
@@ -179,7 +263,7 @@ describe('ContainerFeesPanel contract', () => {
})
it('keeps receivable usable when supplier permission is missing', async () => {
- vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'OPERATION', supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
+ vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'OPERATION', defaultPrice: null, supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; form: { direction: string; supplierId: string | null } } }).setupState
state.openCreate(); await flushPromises()
@@ -190,7 +274,7 @@ describe('ContainerFeesPanel contract', () => {
})
it('isolates a supplier load failure and clears supplier fields for receivable', async () => {
- vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'TRUCKING', supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
+ vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'TRUCKING', defaultPrice: null, supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
vi.mocked(listContainerFeeSupplierOptions).mockRejectedValue(new Error('suppliers unavailable'))
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list'])
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; form: { direction: string; feeItemId: string; supplierId: string | null }; feeItems: unknown[] } }).setupState
@@ -203,7 +287,7 @@ describe('ContainerFeesPanel contract', () => {
})
it('submits only the selected supplier ID for payable fees', async () => {
- vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'TRUCKING', supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
+ vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'TRUCKING', defaultPrice: null, supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
vi.mocked(listContainerFeeSupplierOptions).mockResolvedValue([{ id: '41', supplierName: '承运商A', category: 'TRUCKING' }])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list'])
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise; form: { direction: string; feeItemId: string; supplierId: string | null; settlementMode: string | null; amount: number } } }).setupState
@@ -215,7 +299,7 @@ describe('ContainerFeesPanel contract', () => {
})
it('allows any loaded ACTIVE supplier when the payable fee item has no supplier category', async () => {
- vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '未映射费用', feeCode: 'UNMAPPED', supplierCategory: null, warehouseReceivable: false, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
+ vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '未映射费用', feeCode: 'UNMAPPED', defaultPrice: null, supplierCategory: null, warehouseReceivable: false, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
vi.mocked(listContainerFeeSupplierOptions).mockResolvedValue([{ id: '41', supplierName: '报关行', category: 'CUSTOMS' }])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list'])
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise; form: { direction: string; feeItemId: string; supplierId: string | null; settlementMode: string | null; amount: number } } }).setupState
@@ -227,7 +311,7 @@ describe('ContainerFeesPanel contract', () => {
})
it('fails closed when a supplier candidate does not match the fee-item category', async () => {
- vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '拖车费', feeCode: 'TRUCKING', supplierCategory: 'TRUCKING', warehouseReceivable: false, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
+ vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '拖车费', feeCode: 'TRUCKING', defaultPrice: null, supplierCategory: 'TRUCKING', warehouseReceivable: false, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
vi.mocked(listContainerFeeSupplierOptions).mockResolvedValue([{ id: '41', supplierName: '报关行', category: 'CUSTOMS' }])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list'])
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise; form: { direction: string; feeItemId: string; supplierId: string | null; amount: number } } }).setupState
@@ -249,7 +333,7 @@ describe('ContainerFeesPanel contract', () => {
it('clears paid inputs for RECEIVABLE and ON_ACCOUNT and shows backend settlement semantics', async () => {
vi.mocked(listContainerFees).mockResolvedValue([{ id: '2', loadingPlanId: '9', containerBookingId: '99', containerNo: null, feeItemId: '7', feeItemName: '已付拖车费', direction: 'PAYABLE', supplierId: '41', supplierName: '承运商A', supplierCategory: 'TRUCKING', settlementMode: 'DIRECT_PAID', paidAt: '2026-08-12T10:00:00', paymentMethod: 'BANK_TRANSFER', paymentReferenceNo: 'BANK-1', paymentVoucherFileIds: ['9001'], businessDate: '2026-08-12', amount: 100, currency: 'CNY', reviewStatus: 'PENDING', sourceStatus: 'ACTIVE', sourceVersion: 1, remark: null, reviewerName: null, reviewedAt: null, reviewReason: null }])
- vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'TRUCKING', supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
+ vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'TRUCKING', defaultPrice: null, supplierCategory: 'TRUCKING', warehouseReceivable: true, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list'])
expect(wrapper.text()).toContain('已付成本'); expect(wrapper.text()).toContain('银行转账'); expect(wrapper.text()).toContain('BANK-1')
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; form: { direction: string; settlementMode: string | null; paidAt: string | null; paymentMethod: string | null; paymentReferenceNo: string | null; paymentVoucherFileIds: string[] | null } } }).setupState
@@ -259,7 +343,7 @@ describe('ContainerFeesPanel contract', () => {
})
it('fails closed without DIRECT_PAID evidence and submits a scoped uploaded voucher', async () => {
- vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'TRUCKING', supplierCategory: 'TRUCKING', warehouseReceivable: false, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
+ vi.mocked(listApplicableFeeItems).mockResolvedValue([{ id: '7', name: '操作费', feeCode: 'TRUCKING', defaultPrice: null, supplierCategory: 'TRUCKING', warehouseReceivable: false, warehousePayable: true, pickupReceivable: false, pickupPayable: false }])
vi.mocked(listContainerFeeSupplierOptions).mockResolvedValue([{ id: '41', supplierName: '承运商A', category: 'TRUCKING' }])
const wrapper = await mountPanel(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view', 'platform:supplier:list'])
const state = (wrapper.vm.$ as unknown as { setupState: { openCreate(): void; save(): Promise; selectVoucher(event: Event): Promise; form: { direction: string; feeItemId: string; supplierId: string | null; settlementMode: string | null; paidAt: string | null; paymentMethod: string | null; paymentReferenceNo: string | null; amount: number } } }).setupState
diff --git a/src/modules/loading-lists/components/ContainerFeesPanel.vue b/src/modules/loading-lists/components/ContainerFeesPanel.vue
index e6ac949a..13387736 100644
--- a/src/modules/loading-lists/components/ContainerFeesPanel.vue
+++ b/src/modules/loading-lists/components/ContainerFeesPanel.vue
@@ -55,6 +55,7 @@ const voucherUploading = ref(false)
const voucherError = ref(null)
const rejection = ref('')
let loadSequence = 0
+let feeItemLoadSequence = 0
let supplierLoadSequence = 0
const canView = computed(() => access.can('firstleg:inbound:fee-detail:view'))
@@ -122,6 +123,11 @@ function clearPaidFields() {
form.paidAt = null; form.paymentMethod = null; form.paymentReferenceNo = null; form.paymentVoucherFileIds = null
voucherFiles.value = []; voucherError.value = null
}
+
+// 仅在用户主动选费时带价,保留已有费用回显金额与人工修改。
+function applyFeeItemDefaultPrice() {
+ form.amount = selectedFeeItem.value?.defaultPrice ?? 0
+}
function settlementLabel(item: ContainerFeeResource) { return item.direction !== 'PAYABLE' ? '—' : item.settlementMode === 'DIRECT_PAID' ? '已付成本' : item.settlementMode === 'ON_ACCOUNT' ? '账期应付' : '未设置' }
function paymentMethodLabel(value: string | null | undefined) { return ({ ONLINE: '线上支付', BANK_TRANSFER: '银行转账', CASH: '现金', OTHER: '其他' } as Record)[value || ''] || '—' }
function customerLabel(customerId: string) { return customerOptions.value.find((item) => item.value === customerId)?.label || '客户信息缺失' }
@@ -153,12 +159,17 @@ function removeVoucher(fileId: string) {
}
async function loadFeeItems() {
+ const sequence = ++feeItemLoadSequence
feeItems.value = []; feeItemsError.value = null
if (!canViewFeeItems.value) return
feeItemsLoading.value = true
- try { feeItems.value = await listApplicableFeeItems() }
- catch (cause) { feeItemsError.value = getApiErrorDetails(cause, '费用项目加载失败') }
- finally { feeItemsLoading.value = false }
+ try {
+ const items = await listApplicableFeeItems()
+ // 只接收当前窗口最新请求,旧响应不得覆盖价格或清除新请求的加载状态。
+ if (sequence === feeItemLoadSequence) feeItems.value = items
+ }
+ catch (cause) { if (sequence === feeItemLoadSequence) feeItemsError.value = getApiErrorDetails(cause, '费用项目加载失败') }
+ finally { if (sequence === feeItemLoadSequence) feeItemsLoading.value = false }
}
async function loadSuppliers() {
@@ -340,6 +351,12 @@ function clearCustomerContext() {
}
watch(customerOptions, initializeCustomer, { immediate: true })
watch(accountId, () => { clearCustomerContext(); void load() })
+watch(editorOpen, (open) => {
+ if (open) return
+ // 同步失效旧窗口请求,覆盖关闭后立即重开及切换客户的情况。
+ feeItemLoadSequence += 1
+ feeItems.value = []; feeItemsError.value = null; feeItemsLoading.value = false
+}, { flush: 'sync' })
watch(() => form.direction, (direction) => {
if (!applicableItems.value.some((item) => item.id === form.feeItemId)) form.feeItemId = ''
if (direction === 'RECEIVABLE') { supplierLoadSequence += 1; form.supplierId = null; form.settlementMode = null; clearPaidFields(); suppliers.value = []; loadedSupplierCategory.value = undefined; suppliersError.value = null; suppliersLoading.value = false }
@@ -401,7 +418,7 @@ onMounted(() => void load())
-
+
账期应付已付成本
diff --git a/src/modules/overseas-master-data/AGENTS.md b/src/modules/overseas-master-data/AGENTS.md
index 007951d4..83209a82 100644
--- a/src/modules/overseas-master-data/AGENTS.md
+++ b/src/modules/overseas-master-data/AGENTS.md
@@ -17,7 +17,7 @@
- 适用应用规则见 `../../../AGENTS.md`;原型与交互矩阵为本目录 `PROTOTYPE_MATRIX.md`,新增司机页面使用其中用户提供的新版原型入口。
- 报价、汇率、车辆现有 API 在 `api/overseasMasterData.ts`;司机 API 在 `api/drivers.ts`,字段与标签在 `driverTypes.ts`,后端合同为 Lastleg `fleet-drivers-v1.yaml`。
- 共享车次接口在 `api/trips.ts`,类型在 `tripTypes.ts`,后端合同为 `fleet-trips-v1.yaml`;覆盖计划/分配、客户排车关联、实际装载、货物结案和分别结束车辆/司机任务。接口保留稳定幂等键、版本与货物hash,长ID按字符串透传;数量和容量展示服务端结果,不在浏览器推算整车可发或可结案。新页面承载按 `PROTOTYPE_MATRIX.md` 确定,不因API存在推导页面能力。
-- 用户已于2026-10-02确认首版车次交互:`pages/TripsView.vue`、`pages/TripDetailView.vue` 为独立路由;`TripPlanDialog.vue` 1080px居中计划弹框,`TripAssignmentPanel.vue`、`TripLoadingPanel.vue` 在详情分区。列表一次分页请求,历史/装载/结案检查进入对应页签才读取;候选聚焦或搜索时调用车辆/司机目录,不逐行读取详情。先分配资源、关联排车及匹配卸货站,每张排车单调度审核通过后由PDA拣货、平台确认实测装载、整车理货审核通过后PDA整车发车;平台不提供替代PDA的发车按钮。审核使用独立trip:dispatch-review/trip:loading-review权限,详情展示通过/打回,通过意见选填、打回原因必填,不加审核清单;普通说明选填并提供标准审计说明,仍保留版本、幂等、容量和证照期限约束。
+- 当前日常作业统一从排车列表进入,侧栏`fleet-trips`为hidden;“出车记录”保留实际发车后的运营查询。原`pages/TripsView.vue`、`pages/TripDetailView.vue`及附加能力代码保留,不能作为新排车的前置步骤。旧详情仍按页签按需读取,资源候选聚焦或搜索时查询,不逐行读取。新整车审核、PDA真实拣货、双端装车确认及实际发车规则以`../../../../../docs/海外仓业务/排车列表统一作业改造契约.md`为准;详情只有已确认四页签,不把旧八项附加功能自动迁入或以派送完成自动释放资源。
- 车辆实际托位/载重kg/容积m³分别由服务端返回,三项同填正数或全空待确认;不按车型名称推算。占用期间主档关键变更由服务端拒绝,下载中心 XLSX 导出使用车牌业务标识并包含容量字段。
- 车辆运维在 `VehicleDetailView.vue` 的页签内,表格与编辑框为 `components/VehicleCareTable.vue`、`VehicleCareEditor.vue`;`api/vehicleCare.ts` 对接 `fleet-vehicle-records-v1.yaml`。保养/加油680px,维修/故障/救援/异常620px居中弹框,行操作按 `PROTOTYPE_MATRIX.md` 的逐类型入口执行。进入页签才分页查询,保养加油图片点击才鉴权读取并在新标签页预览;卸载释放Blob。车辆list包含记录只读,record-create/edit/upload独立;未知保存结果保留原请求键,照片重试不重复建记录。处理状态与付款状态独立,费用未填和零值区分;无财务关联时只显示服务端UNLINKED,不自动修改车辆公里码、状态或财务。
- 维修/故障/救援的“更新处理”已获用户确认,复用原620px弹框及record-edit权限;更正说明选填,空值由页面填标准动作说明,不移除服务端不可变历史。
diff --git a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md
index a5b7f32a..df916d5f 100644
--- a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md
+++ b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md
@@ -89,6 +89,21 @@
以上仅为代码接入依据,真实界面验收仍待执行。
+## 2026-10-19 出车记录统计与列表优化
+
+依据:当前对话图1(现状)、图2(原型);用户确认优先在前端优化。沿当前实际发车口径与接口,不恢复原型手填发车事实。
+
+| 页面 | 操作 | 原型承载/布局 | 当前实现 | 本轮结论 |
+|---|---|---|---|---|
+| 出车记录 | 统计与查询 | 图2上方独立四栏饼图,下方记录与筛选 | 图1统计位于列表筛选和表格之间,条形明细内部滚动 | 独立四栏饼图及彩色图例;用户追加确认只改饼图、保留现有日期/目的地统计分组;列表一套日期/关键词/货源/状态查询,同一响应更新统计与记录;币种切换独立展示,顶部均价明确为最近统计日值,不相加或简单平均每日均价;长图例与完整统计按需展开 |
+| 出车记录 | 列表、导出 | 图2列表独立卡片,操作第一列,导出在右上 | integrated列表、字段设置、XLSX下载任务 | 保留公共连续列表,整理默认列;完整字段仍可通过字段设置显示;导出沿原筛选及权限 |
+| 出车记录 | 详情、实测编辑、凭证预览 | 图2仅能确认列表编辑入口;当前操作以既有确认矩阵为准 | 详情独立路由;实测820px居中及按需凭证预览 | 不改变承载、读写权限或请求方式 |
+| 出车记录 | 新增、审核、删除 | 图2有新增记录,未定义审核/删除 | 当前列表无新增/审核/删除 | 不新增业务动作,实际发车事实仍由既有作业链产生 |
+
+用户追加截图确认统计区文字过多,并要求改善图表质感:使用项目已有 ECharts / vue-echarts 替换手写SVG,统一原生饼图、圆点图例和悬停数值;默认仅保留指标标题、主数值、四个饼图及短图例,日期图例使用月日,完整值在统计详情读取;默认不显示各图说明、重复系列标题、整行成本和额外明细入口。页头“统计详情”统一按需展开统计口径、服务端汇总、成本及完整图例明细,原有数据与权限保留。
+
+本轮只调整前端;自动检查与人工视觉验收分别记录,修改后1920×1080、100%截图待用户确认。
+
## 新版报价与出车记录接入
| 入口 | 原型依据 | 当前承载 | 核对边界 |
diff --git a/src/modules/overseas-master-data/components/CapacityInquiryDialog.vue b/src/modules/overseas-master-data/components/CapacityInquiryDialog.vue
index 08b3c08d..9988b712 100644
--- a/src/modules/overseas-master-data/components/CapacityInquiryDialog.vue
+++ b/src/modules/overseas-master-data/components/CapacityInquiryDialog.vue
@@ -26,7 +26,7 @@ const followupActions = computed(() => {
{ key: 'confirm', label: '核对并确认', visible: manage && (row.actions.canEdit || row.actions.canConfirm) },
{ key: 'incoming', label: '安排来货', visible: manage && row.progress.primaryAction === 'INCOMING' && access.can('platform:fleet:trip:booking-edit') },
{ key: 'receipt', label: '核对实收与运费', visible: manage && row.progress.primaryAction === 'RECEIPT' && access.can('platform:fleet:trip:booking-confirm') },
- { key: 'bind', label: '确认排入本车', visible: manage && row.progress.primaryAction === 'BIND' && access.can('platform:fleet:trip:booking-edit') && access.can('platform:fleet:trip:edit') && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch') },
+ { key: 'bind', label: '确认排入本车', visible: manage && row.progress.primaryAction === 'BIND' && access.can('platform:fleet:trip:booking-edit') && access.can('platform:fleet:trip:edit') && access.can('platform:overseas-warehouse:dispatch:create') },
{ key: 'fulfillment', label: '查看收货进度', visible: !!row.incomingNo },
{ key: 'dispatch', label: '查看排车详情', visible: !!row.dispatchNo && access.can('platform:overseas-warehouse:dispatch:list') },
{ key: 'trip', label: '前往车次审核', visible: !!row.dispatchNo && access.can('platform:fleet:trip:list') },
diff --git a/src/modules/overseas-master-data/components/CapacityInquiryTable.vue b/src/modules/overseas-master-data/components/CapacityInquiryTable.vue
index 16c4632e..5b9d6ddf 100644
--- a/src/modules/overseas-master-data/components/CapacityInquiryTable.vue
+++ b/src/modules/overseas-master-data/components/CapacityInquiryTable.vue
@@ -13,7 +13,7 @@ function actions(row: CapacityInquiry, busy: boolean): RowActionItem[] {
const items: RowActionItem[] = [{ key: 'view', label: '查看进度', priority: 10 },
{ key: 'incoming', label: '安排来货', visible: manage && row.progress.primaryAction === 'INCOMING' && access.can('platform:fleet:trip:booking-edit'), disabled: busy },
{ key: 'receipt', label: '查看收货并核量', visible: manage && row.progress.primaryAction === 'RECEIPT' && access.can('platform:fleet:trip:booking-confirm'), disabled: busy },
- { key: 'bind', label: '确认排入本车', visible: manage && row.progress.primaryAction === 'BIND' && access.can('platform:fleet:trip:booking-edit') && access.can('platform:fleet:trip:edit') && access.can('platform:overseas-warehouse:inbound-order:smart-dispatch'), disabled: busy },
+ { key: 'bind', label: '确认排入本车', visible: manage && row.progress.primaryAction === 'BIND' && access.can('platform:fleet:trip:booking-edit') && access.can('platform:fleet:trip:edit') && access.can('platform:overseas-warehouse:dispatch:create'), disabled: busy },
{ key: 'fulfillment', label: '收货进度', visible: !!row.incomingNo, disabled: busy },
{ key: 'edit', label: '补充资料', visible: row.actions.canEdit && write('edit'), disabled: busy },
{ key: 'confirm', label: '核对并确认', visible: (row.actions.canConfirm || row.actions.canEdit) && manage, disabled: busy },
diff --git a/src/modules/overseas-master-data/components/TripOperationsCharts.test.ts b/src/modules/overseas-master-data/components/TripOperationsCharts.test.ts
new file mode 100644
index 00000000..9470429a
--- /dev/null
+++ b/src/modules/overseas-master-data/components/TripOperationsCharts.test.ts
@@ -0,0 +1,101 @@
+import { afterEach, expect, it, vi } from 'vitest'
+import { mount, type VueWrapper } from '@vue/test-utils'
+import type { EChartsOption, LegendComponentOption, PieSeriesOption } from 'echarts'
+import TripOperationsCharts from './TripOperationsCharts.vue'
+import type { TripOperationsCharts as Charts, TripOperationsSummary } from '../api/tripOperations'
+
+vi.mock('vue-echarts', () => ({ default: { name: 'VChart', props: ['option', 'initOptions', 'updateOptions', 'autoresize'], template: '' } }))
+const wrappers: VueWrapper[] = []
+const emptyCharts = (): Charts => ({ physicalPallets: [], destinationTrips: [], externalAgreedAmounts: [], externalPalletRates: [] })
+const summary = (): TripOperationsSummary => ({ trips: 20, inTransit: 5, completed: 15, physicalPallets: 90, palletSpaces: 100, ownPackages: 50, externalPackages: 20, externalAgreedAmounts: [{ currency: 'CNY', amount: 390 }, { currency: 'USD', amount: 100 }], costs: { visible: false, unrecordedTrips: null, unallocatedTrips: null, totals: [] } })
+function render(charts: Charts, totals = summary()) {
+ const wrapper = mount(TripOperationsCharts, { props: { charts, summary: totals }, global: { stubs: {
+ ElButton: { template: '' },
+ } } })
+ wrappers.push(wrapper)
+ return wrapper
+}
+const option = (wrapper: VueWrapper, selector: string) => wrapper.find(selector).findComponent({ name: 'VChart' }).props('option') as EChartsOption
+const series = (options: EChartsOption) => (options.series as PieSeriesOption[])[0]!
+afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
+
+it('uses four ECharts pies with compact native legends, accurate server totals and daily rates', () => {
+ const charts = emptyCharts()
+ charts.physicalPallets = [{ label: '每日实运托盘', unit: '托', points: [{ label: '2026-10-02', value: 10 }] }]
+ charts.destinationTrips = [{ label: '目的地涉及车次', unit: '次', points: [{ label: 'ONT8', value: 3 }, { label: 'ABE2', value: 8 }] }]
+ charts.externalAgreedAmounts = [{ label: 'CNY', unit: 'CNY', points: [{ label: '2026-10-02', value: 390 }] }]
+ charts.externalPalletRates = [{ label: 'CNY', unit: 'CNY/标准托位', points: [{ label: '2026-10-07', value: 30 }, { label: '2026-10-02', value: 90 }] }]
+ const original = structuredClone(charts)
+ const wrapper = render(charts)
+ expect(wrapper.findAllComponents({ name: 'VChart' })).toHaveLength(4)
+ for (const chart of wrapper.findAllComponents({ name: 'VChart' })) {
+ expect(series(chart.props('option'))).toMatchObject({ type: 'pie', label: { show: false } })
+ expect(chart.props('initOptions')).toEqual({ renderer: 'canvas' })
+ expect(chart.props('updateOptions')).toEqual({ notMerge: true })
+ }
+ expect(wrapper.find('svg').exists()).toBe(false)
+ expect(wrapper.find('.series-caption').exists()).toBe(false)
+ expect(wrapper.find('.chart > .chart-note').exists()).toBe(false)
+ expect(wrapper.find('.chart--pallets .chart-metric').text()).toBe('90 托')
+ expect(wrapper.find('.chart--rates .chart-metric').text()).toContain('CNY/标准托位 30.00')
+ expect(wrapper.find('.chart--rates .chart-metric').attributes('title')).toContain('最近统计日:2026-10-07')
+ const legend = option(wrapper, '.chart--pallets').legend as LegendComponentOption
+ expect(legend.icon).toBe('circle')
+ expect(typeof legend.formatter === 'function' && legend.formatter('2026-10-02')).toBe('10-02')
+ expect(option(wrapper, '.chart--pallets').tooltip).toMatchObject({ trigger: 'item', confine: true, renderMode: 'richText' })
+ expect(series(option(wrapper, '.chart--rates')).data).toEqual([
+ { name: '2026-10-02', value: 90, itemStyle: { color: '#3b82f6' } },
+ { name: '2026-10-07', value: 30, itemStyle: { color: '#14b8a6' } },
+ ])
+ expect(charts).toEqual(original)
+})
+
+it('switches currency without combining monetary amounts or leaving stale ECharts slices', async () => {
+ const charts = emptyCharts()
+ charts.externalAgreedAmounts = [{ label: 'CNY', unit: 'CNY', points: [{ label: '2026-10-02', value: 390 }] }, { label: 'USD', unit: 'USD', points: [{ label: '2026-10-03', value: 100 }] }]
+ const wrapper = render(charts)
+ expect(wrapper.find('.chart--amounts .chart-metric').text()).toBe('CNY 390.00')
+ expect(wrapper.find('.chart--amounts').findAllComponents({ name: 'VChart' })).toHaveLength(1)
+ const usd = wrapper.findAll('.chart--amounts .series-selector button').find(button => button.text() === 'USD')!
+ await usd.trigger('click')
+ expect(usd.attributes('aria-pressed')).toBe('true')
+ expect(wrapper.find('.chart--amounts .chart-metric').text()).toBe('USD 100.00')
+ expect(series(option(wrapper, '.chart--amounts')).data).toEqual([{ name: '2026-10-03', value: 100, itemStyle: { color: '#3b82f6' } }])
+ await wrapper.setProps({ charts: { ...charts, externalAgreedAmounts: [charts.externalAgreedAmounts[0]!] } })
+ expect(wrapper.find('.chart--amounts .chart-metric').text()).toBe('CNY 390.00')
+})
+
+it('keeps every destination in its pie and exposes full legend data only on demand', async () => {
+ const charts = emptyCharts()
+ charts.destinationTrips = [{ label: '目的地涉及车次', unit: '次', points: Array.from({ length: 7 }, (_, index) => ({ label: `FBA-${index}`, value: index + 1 })) }]
+ const wrapper = render(charts)
+ expect(wrapper.find('.chart--destinations .chart-metric').text()).toBe('7 个目的地')
+ const options = option(wrapper, '.chart--destinations')
+ expect(series(options).data).toHaveLength(7)
+ expect((options.legend as LegendComponentOption).data).toHaveLength(5)
+ expect(wrapper.find('.chart-details').exists()).toBe(false)
+ await wrapper.find('.chart-more').trigger('click')
+ expect(wrapper.emitted('show-details')).toHaveLength(1)
+ await wrapper.setProps({ detailsVisible: true })
+ expect(wrapper.find('[aria-label="派送目的地明细"]').findAll('tbody tr')).toHaveLength(7)
+ expect(wrapper.find('[aria-label="派送目的地明细"]').text()).toContain('FBA-0')
+})
+
+it('preserves known zeros and does not draw equal positive shares for an empty sum', () => {
+ const charts = emptyCharts()
+ charts.physicalPallets = [{ label: '托盘', unit: '托', points: [{ label: '2026-10-01', value: 0 }, { label: '2026-10-02', value: 2 }] }]
+ charts.externalPalletRates = [{ label: 'USD', unit: 'USD/标准托位', points: [{ label: '2026-10-01', value: 0 }] }]
+ const wrapper = render(charts)
+ expect(series(option(wrapper, '.chart--rates'))).toMatchObject({ stillShowZeroSum: false, showEmptyCircle: true, data: [{ name: '2026-10-01', value: 0 }] })
+ expect(option(wrapper, '.chart--rates').graphic).toMatchObject([{ style: { text: '暂无正值数据' } }])
+ expect(wrapper.find('.chart--rates .chart-metric').text()).toContain('0.00')
+ expect(series(option(wrapper, '.chart--pallets')).data).toHaveLength(2)
+})
+
+it('shows missing amounts and rates as unknown and omits empty chart widgets', () => {
+ const wrapper = render(emptyCharts(), { ...summary(), physicalPallets: 0, externalAgreedAmounts: [] })
+ expect(wrapper.findAll('.chart-empty')).toHaveLength(4)
+ expect(wrapper.find('.chart--amounts .chart-metric').text()).toBe('—')
+ expect(wrapper.find('.chart--rates .chart-metric').text()).toBe('—')
+ expect(wrapper.findComponent({ name: 'VChart' }).exists()).toBe(false)
+})
diff --git a/src/modules/overseas-master-data/components/TripOperationsCharts.vue b/src/modules/overseas-master-data/components/TripOperationsCharts.vue
index 4f1d0a58..e9c499a6 100644
--- a/src/modules/overseas-master-data/components/TripOperationsCharts.vue
+++ b/src/modules/overseas-master-data/components/TripOperationsCharts.vue
@@ -1,28 +1,140 @@
-
- {{ section.title }}{{ section.note }}
- 暂无对应统计
- {{ series.label }}({{ series.unit }})
-
{{ point.label }}{{ point.value }}
-
-
+
+
+
+
+
+ {{ section.title }}
+
+ {{ number(summary.physicalPallets) }} 托
+ {{ number(destinationCount) }} 个目的地
+
+ {{ section.active.label }} {{ number(amount(section.active.label)!.amount, true) }}
+ —
+
+
+ {{ section.active.unit }} {{ number(section.active.points[section.active.points.length - 1]!.value, true) }}
+ —
+
+
+
+ {{ series.label }}
+
+
+
+
+ +{{ section.active.points.length - 5 }}
+
+
+ 暂无对应统计
+
+
+
+
+
+ {{ section.title }}
+ {{ section.note }}
+ 各日均价按确认计价托位加权,排除整单报价;不将每日均价相加或简单平均为期间均价。
+ 暂无对应统计
+
+ {{ seriesTitle(series) }}
+ | {{ section.key === 'destinations' ? '目的地' : '日期(UTC)' }} | 数值 |
+ | {{ point.label }} | {{ number(point.value, section.money) }} |
+
+
+
+
+
+
+
diff --git a/src/modules/overseas-master-data/index.ts b/src/modules/overseas-master-data/index.ts
index 1e1acf6b..0be6075b 100644
--- a/src/modules/overseas-master-data/index.ts
+++ b/src/modules/overseas-master-data/index.ts
@@ -10,3 +10,6 @@ export { default as TripDispatchSelector } from './components/TripDispatchSelect
export { listTripDispatchCandidates, prepareTrip, type TripPreparation, type DispatchCandidate } from './api/trips'
export type { TripPreviousDispatch } from './tripTypes'
+export { listVehicles } from './api/overseasMasterData'
+export { listDrivers } from './api/drivers'
+export type { DriverDetail } from './driverTypes'
diff --git a/src/modules/overseas-master-data/pages/CapacityOfferDetailView.vue b/src/modules/overseas-master-data/pages/CapacityOfferDetailView.vue
index c803d2fb..2d799deb 100644
--- a/src/modules/overseas-master-data/pages/CapacityOfferDetailView.vue
+++ b/src/modules/overseas-master-data/pages/CapacityOfferDetailView.vue
@@ -89,7 +89,7 @@ watch([page, size], loadInquiries)
onBeforeUnmount(() => { followupSequence++; sequence++; inquirySequence++ })
-
+
- 车次号
- {{ detail.offer.tripNo }}{{ detail.offer.tripNo }}
- 起运仓
- {{ detail.offer.warehouseName }}
- 计划发车
- {{ offerTime(detail.offer.plannedDepartureAt) }}
- 最晚送仓
- {{ offerTime(detail.offer.cutoffAt) }}
- 调度联系人
- {{ detail.offer.contactName || '—' }}
- 调度联系方式
- {{ detail.offer.contactPhone || '—' }}
diff --git a/src/modules/overseas-master-data/pages/CapacityOffersBehavior.test.ts b/src/modules/overseas-master-data/pages/CapacityOffersBehavior.test.ts
index c37a83b8..f8d575e1 100644
--- a/src/modules/overseas-master-data/pages/CapacityOffersBehavior.test.ts
+++ b/src/modules/overseas-master-data/pages/CapacityOffersBehavior.test.ts
@@ -246,6 +246,31 @@ it('highlights only the allowed current step and keeps other actions secondary',
expect(section.findAll('button').filter(item => !item.element.closest('details')).map(item => item.text())).toEqual(['安排来货'])
})
+it.each([
+ ['all current permissions', ['platform:fleet:capacity-offer:manage', 'platform:fleet:trip:booking-edit', 'platform:fleet:trip:edit', 'platform:overseas-warehouse:dispatch:create'], true],
+ ['legacy smart permission', ['platform:fleet:capacity-offer:manage', 'platform:fleet:trip:booking-edit', 'platform:fleet:trip:edit', 'platform:overseas-warehouse:inbound-order:smart-dispatch'], false],
+ ['missing offer manage', ['platform:fleet:trip:booking-edit', 'platform:fleet:trip:edit', 'platform:overseas-warehouse:dispatch:create'], false],
+ ['missing booking edit', ['platform:fleet:capacity-offer:manage', 'platform:fleet:trip:edit', 'platform:overseas-warehouse:dispatch:create'], false],
+ ['missing trip edit', ['platform:fleet:capacity-offer:manage', 'platform:fleet:trip:booking-edit', 'platform:overseas-warehouse:dispatch:create'], false],
+ ['missing dispatch create', ['platform:fleet:capacity-offer:manage', 'platform:fleet:trip:booking-edit', 'platform:fleet:trip:edit'], false],
+])('keeps table and dialog arrange-dispatch authority aligned: %s', async (_, codes, allowed) => {
+ state.links = new Set(codes)
+ state.manage = codes.includes('platform:fleet:capacity-offer:manage')
+ const row: CapacityInquiry = { ...inquiry, progress: { ...inquiry.progress, primaryAction: 'BIND' } }
+ const table = shallowMount(CapacityInquiryTable, { props: { rows: [row], total: 1, loading: false }, global: { stubs: {
+ DataTable: { props: ['data'], template: '
' },
+ RowActions: { name: 'RowActions', props: ['actions'], template: '' },
+ } } })
+ wrappers.push(table)
+ const actions = table.getComponent({ name: 'RowActions' }).props('actions') as { key: string; visible?: boolean }[]
+ expect(actions.find(action => action.key === 'bind')?.visible).toBe(allowed)
+ const { wrapper: dialog } = mount(CapacityInquiryDialog, { modelValue: true, offer, inquiry: row, readonly: true })
+ await flushPromises()
+ const bind = dialog.findAll('button').find(button => button.text() === '确认排入本车')
+ expect(Boolean(bind)).toBe(allowed)
+ if (bind) { await bind.trigger('click'); expect(dialog.emitted('action')?.at(-1)).toEqual(['bind', row]) }
+})
+
it('shows the selected destination capacity when confirming and never treats unknown capacity as zero', async () => {
state.manage = true
const knownOffer = { ...offer, capacityKnown: true, destinations: [
diff --git a/src/modules/overseas-master-data/pages/CapacityOffersView.vue b/src/modules/overseas-master-data/pages/CapacityOffersView.vue
index 4bbcc950..28847777 100644
--- a/src/modules/overseas-master-data/pages/CapacityOffersView.vue
+++ b/src/modules/overseas-master-data/pages/CapacityOffersView.vue
@@ -85,7 +85,7 @@ async function load() {
const query: InquiryQuery = { page: page.value, size: size.value, keyword: String(q.keyword || '') || undefined, status: String(q.status || '') || undefined, scope: manage.value && q.scope === 'TODO' ? 'TODO' : manage.value && q.scope === 'ALL' ? 'ALL' : 'MINE' }
const value = await listCapacityInquiries(query); if (current === sequence) { inquiries.value = value.records; total.value = value.total }
}
- } catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '拼车接货读取失败') }
+ } catch (cause) { if (current === sequence) error.value = getApiErrorDetails(cause, '散板需求读取失败') }
finally { if (current === sequence) loading.value = false }
}
function search() { applied.value = { ...filters.value }; if (page.value !== 1) page.value = 1; else void load() }
@@ -120,12 +120,12 @@ async function recheckEditor() {
}
watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { followupSequence++; sequence++; editorSequence++ })
-
+
- 拼车接货
待我处理选择车次发布余位
+ 散板需求
待我处理选择车次发布余位
@@ -142,7 +142,7 @@ watch([tab, page, size], load); onMounted(load); onBeforeUnmount(() => { followu
{{ applied.scope === 'TODO' ? '待我处理' : applied.scope === 'ALL' ? '全部登记' : '我的登记' }}
diff --git a/src/modules/overseas-master-data/pages/FleetReportingViews.test.ts b/src/modules/overseas-master-data/pages/FleetReportingViews.test.ts
index b04bbf60..08edfa7b 100644
--- a/src/modules/overseas-master-data/pages/FleetReportingViews.test.ts
+++ b/src/modules/overseas-master-data/pages/FleetReportingViews.test.ts
@@ -2,6 +2,8 @@ import { afterEach, expect, it, vi } from 'vitest'
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
import FleetQuotesView from './FleetQuotesView.vue'
import TripOperationsView from './TripOperationsView.vue'
+import QueryToolbar from '@/shared/components/QueryToolbar.vue'
+import TripOperationsCharts from '../components/TripOperationsCharts.vue'
import * as quotes from '../api/fleetQuotes'
import * as reports from '../api/tripOperations'
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (p: string) => p.endsWith(':list') }) }))
@@ -9,10 +11,10 @@ vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('../api/fleetQuotes', () => ({ listFleetQuotes: vi.fn(), setFleetQuoteStatus: vi.fn(), deleteFleetQuote: vi.fn(), exportFleetQuotes: vi.fn(), saveFleetCsv: vi.fn() }))
vi.mock('../api/tripOperations', () => ({ listTripOperations: vi.fn(), exportTripOperations: vi.fn() }))
const wrappers: VueWrapper[] = []
-const layout = { template: '
' }
+const layout = { template: '
' }
function mount(component: object) { const wrapper = shallowMount(component, { global: { stubs: { AdminPage: layout, ListPageLayout: layout, QueryToolbar: true,
DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '
' }, ElButton: { template: '' },
- LoadErrorState: true, FleetQuoteEditor: true, FleetQuoteImport: true, ApiErrorMeta: true } } }); wrappers.push(wrapper); return wrapper }
+ ElTooltip: true, LoadErrorState: true, FleetQuoteEditor: true, FleetQuoteImport: true, ApiErrorMeta: true } } }); wrappers.push(wrapper); return wrapper }
afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.clearAllMocks() })
it('quotes read-only view fetches a page once and hides all write/export tools', async () => {
vi.mocked(quotes.listFleetQuotes).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 })
@@ -23,5 +25,42 @@ it('quotes read-only view fetches a page once and hides all write/export tools',
it('operations uses server totals rather than displayed page size', async () => {
vi.mocked(reports.listTripOperations).mockResolvedValue({ page: { records: [], total: 21, page: 1, size: 20, pages: 2 }, summary: { trips: 21, inTransit: 12, completed: 9, physicalPallets: 40, palletSpaces: 40, ownPackages: 70, externalPackages: 10, externalAgreedAmounts: [], costs: { visible: false, unrecordedTrips: null, unallocatedTrips: null, totals: [] } }, charts: { physicalPallets: [], destinationTrips: [], externalAgreedAmounts: [], externalPalletRates: [] } })
const wrapper = mount(TripOperationsView); await flushPromises()
- expect(reports.listTripOperations).toHaveBeenCalledTimes(1); expect(wrapper.text()).toContain('实际出车 21 次'); expect(wrapper.text()).not.toContain('导出CSV')
+ expect(reports.listTripOperations).toHaveBeenCalledTimes(1); expect(wrapper.text()).not.toContain('导出CSV')
+ expect(wrapper.find('#operations-statistics-details').exists()).toBe(false)
+ expect(wrapper.findComponent(TripOperationsCharts).props('detailsVisible')).toBe(false)
+ await wrapper.find('[aria-controls="operations-statistics-details"]').trigger('click')
+ expect(wrapper.text()).toContain('实际出车 21 次')
+ expect(wrapper.findComponent(TripOperationsCharts).props('detailsVisible')).toBe(true)
+ expect(reports.listTripOperations).toHaveBeenCalledTimes(1)
+ await wrapper.find('[aria-controls="operations-statistics-details"]').trigger('click')
+ expect(wrapper.find('#operations-statistics-details').exists()).toBe(false)
+ wrapper.findComponent(TripOperationsCharts).vm.$emit('show-details'); await flushPromises()
+ expect(wrapper.find('#operations-statistics-details').exists()).toBe(true)
+ expect(reports.listTripOperations).toHaveBeenCalledTimes(1)
+ expect(wrapper.findComponent(TripOperationsCharts).props('summary').physicalPallets).toBe(40)
+ expect(wrapper.find('.operations-overview').element.compareDocumentPosition(wrapper.findComponent({ name: 'DataTable' }).element) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
+ expect(wrapper.findComponent({ name: 'DataTable' }).props('columns').find((column: { key: string }) => column.key === 'finance')).toMatchObject({ defaultVisible: false })
+})
+it('operations queries charts and records together and resets with only one request', async () => {
+ vi.mocked(reports.listTripOperations).mockResolvedValue({ page: { records: [], total: 0, page: 1, size: 20, pages: 0 }, summary: { trips: 0, inTransit: 0, completed: 0, physicalPallets: 0, palletSpaces: 0, ownPackages: 0, externalPackages: 0, externalAgreedAmounts: [], costs: { visible: false, unrecordedTrips: null, unallocatedTrips: null, totals: [] } }, charts: { physicalPallets: [], destinationTrips: [], externalAgreedAmounts: [], externalPalletRates: [] } })
+ const wrapper = mount(TripOperationsView); await flushPromises()
+ const initial = vi.mocked(reports.listTripOperations).mock.calls[0]![0]
+ const toolbar = wrapper.findComponent(QueryToolbar)
+ toolbar.vm.$emit('update:modelValue', { from: '2026-10-01', to: '2026-10-07', keyword: 'TRIP', sourceType: 'EXTERNAL', status: 'IN_TRANSIT' })
+ toolbar.vm.$emit('search'); await flushPromises()
+ expect(reports.listTripOperations).toHaveBeenLastCalledWith(expect.objectContaining({ from: '2026-10-01T00:00:00Z', to: '2026-10-08T00:00:00.000Z', keyword: 'TRIP', sourceType: 'EXTERNAL', status: 'IN_TRANSIT' }))
+ expect(wrapper.text()).toContain('2026-10-01 至 2026-10-07(UTC)')
+ toolbar.vm.$emit('update:modelValue', { from: '2026-09-01', to: '2026-09-07' }); await flushPromises()
+ expect(wrapper.text()).toContain('2026-10-01 至 2026-10-07(UTC)')
+ toolbar.vm.$emit('reset'); toolbar.vm.$emit('search'); await flushPromises()
+ expect(reports.listTripOperations).toHaveBeenCalledTimes(3)
+ expect(reports.listTripOperations).toHaveBeenLastCalledWith(initial)
+ expect(toolbar.props('modelValue')).toMatchObject({ keyword: '', sourceType: '', status: '' })
+})
+it('operations shows query failures without leaving stale statistics', async () => {
+ vi.mocked(reports.listTripOperations).mockRejectedValue(new Error('统计暂不可用'))
+ const wrapper = mount(TripOperationsView); await flushPromises()
+ expect(wrapper.findComponent(TripOperationsCharts).exists()).toBe(false)
+ expect(wrapper.findComponent({ name: 'LoadErrorState' }).attributes('message')).toBe('运营记录加载失败')
+ expect(wrapper.text()).not.toContain('实际出车 0 次')
})
diff --git a/src/modules/overseas-master-data/pages/TripOperationsView.vue b/src/modules/overseas-master-data/pages/TripOperationsView.vue
index 918fa278..36b32796 100644
--- a/src/modules/overseas-master-data/pages/TripOperationsView.vue
+++ b/src/modules/overseas-master-data/pages/TripOperationsView.vue
@@ -19,49 +19,82 @@ const submitExport = useExportTask()
const router = useRouter(), access = useAccessPreviewStore()
const result = ref(null), page = ref(1), size = ref(20), loading = ref(false), exporting = ref(false), error = ref(null)
const recordOpen = ref(false), selected = ref(null)
+const statisticsOpen = ref(false)
const today = new Date().toISOString().slice(0, 10), monthAgo = new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10)
-const filters = ref({ from: monthAgo, to: today, keyword: '', sourceType: '', status: '' })
+const defaultFilters = () => ({ from: monthAgo, to: today, keyword: '', sourceType: '', status: '' })
+const filters = ref(defaultFilters())
+const displayedRange = ref('')
let applied: TripOperationsQuery = range(), sequence = 0
const fields: QueryField[] = [
- { key: 'from', label: '实际发车起日(UTC)', type: 'date', width: 185 }, { key: 'to', label: '实际发车止日(UTC)', type: 'date', width: 185 },
- { key: 'keyword', label: '关键词', type: 'input', width: 235, placeholder: '车次 / 发车车牌 / 司机 / 目的仓' },
- { key: 'sourceType', label: '货源', type: 'select', width: 145, options: [{ value: 'OWN', label: '含自有业务' }, { value: 'EXTERNAL', label: '含外接业务' }] },
- { key: 'status', label: '状态', type: 'select', width: 130, options: [{ value: 'IN_TRANSIT', label: '在途' }, { value: 'COMPLETED', label: '货物已结案' }] },
+ { key: 'from', label: '实际发车起日(UTC)', placeholder: '发车起日(UTC)', type: 'date', width: 150, props: { 'aria-label': '实际发车起日(UTC)' } },
+ { key: 'to', label: '实际发车止日(UTC)', placeholder: '发车止日(UTC)', type: 'date', width: 150, props: { 'aria-label': '实际发车止日(UTC)' } },
+ { key: 'keyword', label: '关键词', type: 'input', width: 270, placeholder: '车次 / 车牌 / 司机 / 目的仓', props: { 'aria-label': '车次、车牌、司机或目的仓' } },
+ { key: 'sourceType', label: '货源', placeholder: '全部货源', type: 'select', width: 140, props: { 'aria-label': '货源' }, options: [{ value: 'OWN', label: '含自有业务' }, { value: 'EXTERNAL', label: '含外接业务' }] },
+ { key: 'status', label: '状态', placeholder: '全部状态', type: 'select', width: 140, props: { 'aria-label': '状态' }, options: [{ value: 'IN_TRANSIT', label: '在途' }, { value: 'COMPLETED', label: '货物已结案' }] },
]
-const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 185, fixed: 'left', slotName: 'actions' }, { key: 'tripNo', label: '车次号', minWidth: 220 },
+const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'tripNo', label: '车次号', minWidth: 220 },
{ key: 'status', label: '状态', width: 125, slotName: 'status' }, { key: 'vehiclePlate', label: '发车车牌', minWidth: 125 }, { key: 'driverName', label: '发车司机', minWidth: 100 },
{ key: 'warehouseName', label: '起运仓', minWidth: 150 }, { key: 'destinations', label: '目的地', minWidth: 180, slotName: 'destinations' },
- { key: 'departedAt', label: '实际发车(UTC)', minWidth: 190 }, { key: 'completedAt', label: '货物结案(UTC)', minWidth: 190 },
- { key: 'physicalPallets', label: '实物托盘', width: 100 }, { key: 'palletSpaces', label: '标准托位', width: 100 }, { key: 'ownPackages', label: '自有箱数', width: 100 }, { key: 'externalPackages', label: '外接箱数', width: 100 },
- { key: 'agreed', label: '外接约定金额', minWidth: 170, slotName: 'agreed' }, { key: 'distance', label: '实际里程(mile)', width: 140, slotName: 'distance' }, { key: 'fuel', label: '实际油耗(L)', width: 130, slotName: 'fuel' }, { key: 'finance', label: '应收 / 实收', minWidth: 260, slotName: 'finance' }, { key: 'cost', label: '已登记直接成本', minWidth: 200, slotName: 'cost' }, { key: 'allocated', label: '自有 / 外接分摊', minWidth: 240, slotName: 'allocated' }]
+ { key: 'departedAt', label: '实际发车(UTC)', minWidth: 190 }, { key: 'physicalPallets', label: '实物托盘', width: 100 },
+ { key: 'agreed', label: '外接约定金额', minWidth: 170, slotName: 'agreed' },
+ { key: 'completedAt', label: '货物结案(UTC)', minWidth: 190, defaultVisible: false },
+ { key: 'palletSpaces', label: '标准托位', width: 100, defaultVisible: false }, { key: 'ownPackages', label: '自有箱数', width: 100, defaultVisible: false }, { key: 'externalPackages', label: '外接箱数', width: 100, defaultVisible: false },
+ { key: 'distance', label: '实际里程(mile)', width: 140, slotName: 'distance', defaultVisible: false }, { key: 'fuel', label: '实际油耗(L)', width: 130, slotName: 'fuel', defaultVisible: false },
+ { key: 'finance', label: '应收 / 实收', minWidth: 260, slotName: 'finance', defaultVisible: false }, { key: 'cost', label: '已登记直接成本', minWidth: 200, slotName: 'cost', defaultVisible: false }, { key: 'allocated', label: '自有 / 外接分摊', minWidth: 240, slotName: 'allocated', defaultVisible: false }]
function range(): TripOperationsQuery {
const from = String(filters.value.from || monthAgo), to = String(filters.value.to || today)
if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to) || Number.isNaN(Date.parse(from)) || Number.isNaN(Date.parse(to))) throw new Error('请输入有效日期 YYYY-MM-DD')
return { from: `${from}T00:00:00Z`, to: new Date(Date.parse(`${to}T00:00:00Z`) + 86400000).toISOString(), keyword: String(filters.value.keyword || '') || undefined, sourceType: (filters.value.sourceType || undefined) as TripOperationsQuery['sourceType'], status: String(filters.value.status || '') || undefined }
}
-async function load() { const current = ++sequence; loading.value = true; error.value = null; try { const data = await listTripOperations({ ...applied, page: page.value, size: size.value }); if (current === sequence) result.value = data } catch (e) { if (current === sequence) { result.value = null; error.value = getApiErrorDetails(e, '运营记录加载失败') } } finally { if (current === sequence) loading.value = false } }
+async function load() {
+ const current = ++sequence, query = { ...applied, page: page.value, size: size.value }
+ loading.value = true; error.value = null
+ try {
+ const data = await listTripOperations(query)
+ if (current === sequence) {
+ result.value = data
+ displayedRange.value = `${query.from.slice(0, 10)} 至 ${new Date(Date.parse(query.to) - 86400000).toISOString().slice(0, 10)}(UTC)`
+ }
+ } catch (e) {
+ if (current === sequence) { result.value = null; error.value = getApiErrorDetails(e, '运营记录加载失败') }
+ } finally { if (current === sequence) loading.value = false }
+}
function search() { try { applied = range(); if (page.value !== 1) page.value = 1; else void load() } catch (e) { error.value = getApiErrorDetails(e, '日期范围无效') } }
+function resetFilters() { filters.value = defaultFilters() }
function openRecord(tripNo: unknown) { const row = result.value?.page.records.find(item => item.tripNo === tripNo); if (row) { selected.value = row; recordOpen.value = true } }
async function download() { exporting.value = true; try { await submitExport('TRIP_OPERATIONS_LIST', '出车记录', applied) } catch (e) { error.value = getApiErrorDetails(e, '导出失败') } finally { exporting.value = false } }
const money = (rows: TripOperationsMoney[]) => rows.length ? rows.map(r => `${r.currency} ${r.amount.toFixed(2)}`).join(' / ') : '—'
watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ })
+
+
+
+
+
+
+
+ 实际出车 {{ result.summary.trips }} 次在途 {{ result.summary.inTransit }} 次货物结案 {{ result.summary.completed }} 次
+
+ 已登记直接成本:{{ result.summary.costs.totals.length ? result.summary.costs.totals.map(c => `${c.currency} ${c.directCost.toFixed(2)}`).join(' / ') : '—' }};未登记 {{ result.summary.costs.unrecordedTrips ?? '—' }} 趟,已登记但未能分摊 {{ result.summary.costs.unallocatedTrips ?? '—' }} 趟。
+ 按实际发车时间查询,每趟车只计一次;货源筛选展示含该类货物的整趟车。物理托盘去重;同车多站可分别计入目的地。外接约定金额不代表应收或实收,结案不代表车辆与司机已释放。
+
+
+ {{ loading ? '正在加载出车统计…' : '暂无统计数据' }}
+
+
+
+
+
出车记录
-
-
-
-
-
-
- 实际出车 {{ result.summary.trips }} 次在途 {{ result.summary.inTransit }} 次货物结案 {{ result.summary.completed }} 次
- 实物托盘 {{ result.summary.physicalPallets }} 托外接约定 {{ money(result.summary.externalAgreedAmounts) }}
-
-
已登记直接成本:{{ result.summary.costs.totals.length ? result.summary.costs.totals.map(c => `${c.currency} ${c.directCost.toFixed(2)}`).join(' / ') : '—' }};未登记 {{ result.summary.costs.unrecordedTrips }} 趟,已登记但未能分摊 {{ result.summary.costs.unallocatedTrips }} 趟。
-
按实际发车时间查询,每趟车只计一次;货源筛选展示含该类货物的整趟车。约定金额不代表应收或实收,结案不代表车辆与司机已释放。
-
-
+
导出
详情实测记录
{{ row.status === 'COMPLETED' ? '货物已结案' : '在途' }}{{ row.destinations.map((d: TripOperationsRow['destinations'][number]) => d.label).join(' / ') }}
@@ -73,7 +106,11 @@ watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ }
diff --git a/src/modules/overseas-master-data/routes.ts b/src/modules/overseas-master-data/routes.ts
index f3b57145..a7a7d332 100644
--- a/src/modules/overseas-master-data/routes.ts
+++ b/src/modules/overseas-master-data/routes.ts
@@ -6,13 +6,13 @@ export const overseasMasterDataRoutes = [
path: 'overseas-master-data/capacity-offers',
name: 'overseas-master-data-capacity-offers',
component: () => import('./pages/CapacityOffersView.vue'),
- meta: { title: '拼车接货', description: '查看车次可接运力并跟进拼车意向', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
+ meta: { title: '散板需求', description: '查看车次可接运力并跟进拼车意向', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
},
{
path: 'overseas-master-data/capacity-offers/:offerNo',
name: 'overseas-master-data-capacity-offer-detail',
component: () => import('./pages/CapacityOfferDetailView.vue'),
- meta: { title: '拼车需求详情', description: '核对可接运力与本车拼车意向', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
+ meta: { title: '散板需求详情', description: '核对可接运力与本车拼车意向', permission: permission.capacityOfferList, permissionAnyOf: [permission.capacityOfferManage], showPageHeading: false },
},
{
path: 'overseas-master-data/fleet-quotes',
diff --git a/src/modules/overseas-warehouse/AGENTS.md b/src/modules/overseas-warehouse/AGENTS.md
index f43afd76..074b3cfc 100644
--- a/src/modules/overseas-warehouse/AGENTS.md
+++ b/src/modules/overseas-warehouse/AGENTS.md
@@ -21,11 +21,11 @@
|---|---|---|
| 退货预报审核、退货驳回 | `pages/LastMileOrdersView.vue`、`api/lastMileOrders.ts` 的 `reviewReturnOrder` | 尾程订单内 `RETURN_RELABEL + PENDING_REVIEW` 行进入审核,来源退货号取 `sourceOrderNo`;请求 `/platform/v1/last-leg/return-orders/{returnNo}/review` 携带 `X-Customer-Id`,结论 `APPROVE/REJECT`,驳回必填原因。按钮权限入口为 `permissions.ts` 的 `returnOrderReview`;服务端归 `returnreceipt/`,通过后才能签收,不是仓储转运审核或换标完工确认 |
| 换标申请审核、待审核按钮与驳回原因 | `pages/RelabelOrdersView.vue`、`pages/RelabelOrderDetailView.vue`、`api/relabelOrders.ts` | 列表审核动作仅在 `PENDING_REVIEW` 显示,动作选择为审核通过/已驳回,驳回原因必填;审核后不再显示审核按钮。回归见 `pages/RelabelOrdersView.test.ts`、`pages/RelabelOrderDetailView.test.ts`;PDA 完工、客户确认与申请审核分别定位,不混用状态入口 |
-| 排车车型、托位容量与限重提示 | `dispatchVehicleSpecs.ts`、`components/SmartDispatchDialog.vue`、`pages/DispatchesView.vue` | 预排车型与排车编辑选项共用规格入口:大车 30 托/40000 LB,小车 15 托/10000 LB;超重提示在预排组件,不能把前端提示当作服务端允许提交的依据。回归见 `pages/DispatchesView.test.ts`、`pages/prototypeContract.test.ts` |
+| 排车资源、草稿容器与实际容量 | `components/DispatchResourceFields.vue`、`components/SmartDispatchDialog.vue`、`dispatchContracts.ts` | 车辆/司机分别选择已有档案或临时外配,容量取真实档案或本趟填写的托位、千克及立方米,不按车型名称推算。草稿容器容量不代替车辆实测或装载校验;取证、审核前补齐规则见当前排车正式契约 |
| 仓储转运审核、通过与驳回 | `pages/LastMileOrdersView.vue`、`components/WarehouseTransferReviewDialog.vue` | `api/warehouseTransfers.ts` 的列表、详情及 `/warehouse-transfers/{transferNo}/review`;审核按钮要求 `permissions.ts` 的 `warehouseTransferApprove` 且行类型为 `WAREHOUSE_TRANSFER`、状态为 `PENDING_REVIEW`。请求携带该行客户范围和转运单号,审核结论为 `APPROVE/REJECT`,驳回原因必填;这是尾程订单内的审核弹框,不是独立审核路由或排车转派送入口 |
| 尾程订单控制与柜级入仓计划转换 | `pages/LastMileOrdersView.vue`、`pages/LastMileOrderDetailView.vue` | 柜子工作台按 `sourceContainerBookingId` 消费 `/orders/container-summaries`;服务端分页返回全部稳定来源柜,计划已经齐全的柜仍保留用于接柜仓查询和修改,并按冻结 `plannedArrivalAt` 升序、空值最后排序。详情返回全柜订单且不受列表分页截断;单票手工计划保存后,由服务端在整柜最后一票计划齐全时自动生成并绑定全柜托盘码,一票柜立即生成;`resourcePendingPlanCount` 只表示自动生成异常的重试入口,不是正常人工步骤。柜级确认弹框先调用 `/inbound-plans/container-preview`,按服务端 FBA/一件代发/私人地址分组展示头程已有、海外仓补充和最终托数以及 `readiness/blockers`;散货最终托数不得少于头程已有托盘码,差额连续补码且不阻断流程;一件代发必须严格按不可变头程封托托盘数整体入库,单票表单托数锁定且服务端再次失败关闭校验。前端不计算托数、不逐票建计划、不预占托盘,再把同一 `snapshotVersion/snapshotHash` 原样提交 `/inbound-plans/convert-container`,快照冲突后重新获取预览。服务端只为剩余订单逐票生成独立计划,并为异常遗留的人工计划补齐真实托盘,整批原子且重放不得产生第二批资源。单票完整入库计划、暂扣/解除和八行人工节点均从 `api/lastMileOrders.ts` 提交订单版本与幂等键;预计到仓时间只读展示来源柜冻结值,不进入人工计划请求,服务端按整柜校验并写入同一日期;入仓码只读展示并提交订单冻结值,不允许人工改写。新增暂扣只在 `INBOUND_COMPLETED` 开放,解除暂扣保持可用;暂扣不阻断入库计划,只限制后续排车和出库。人工节点一经保存不得回退柜级节点。转仓候选只取 `../iam/api/platformIam.ts` 的平台公共 `ACTIVE + FBA` 仓主档;Backend `lastleg-service/` |
| 入仓单、入仓计划、FBA 预约 | `pages/InboundOrdersView.vue`、`pages/InboundOrderDetailView.vue` | 列表/预约 `api/inboundOrders.ts`,计划列表/详情 `api/inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
-| 排车查询、智能预排与转派送 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `api/dispatches.ts`;Backend `lastleg-service/dispatch/`、`delivery/`;覆盖详情、候选、草稿柜、托盘锁、`pre-arrange` 和 `transfer-delivery`。路线只读并由已排托盘关联的 FBA 仓去重后以英文逗号连接,预排和普通编辑均不接收人工路线。一次转派送仍生成一张车辆级出库单,响应的 `deliveries` 按目的 FBA 仓返回多张派送单。页面能力必须由正式路由、API 调用和测试共同证明 |
+| 排车查询、整车草稿、审核及派送安排 | `pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue`、`components/DispatchWorkflowDialog.vue` | `api/dispatches.ts`、`dispatchContracts.ts`、`dispatchCommandRecovery.ts`;Backend `lastleg-service/dispatch/`、`delivery/`。一单一趟、同仓多客户多目的地,空单或先选货均可;候选使用来源版本,草稿走服务器token及公共版本,全部容器原子转换。当前规则见`../../../../../docs/海外仓业务/排车列表统一作业改造契约.md`,不恢复旧单客户预排协议 |
| 海外出库与派送 | `pages/OutboundsView.vue`、`pages/OutboundDetailView.vue`、`pages/DeliveryOrdersView.vue`、`pages/DeliveryOrderDetailView.vue` | `api/outbounds.ts`、`api/deliveryOrders.ts`;出库详情使用 `deliveries` 展示按目的 FBA 仓拆出的全部派送单,派送列表和详情必须显示目的 FBA 仓。人工状态入口不提供完成派送;签收资料保存成功后提示自动完成并刷新列表。出库列表按类型筛选并展示中文类型,状态须覆盖 `WAIT_PICK/待拣货`、`PICKING/拣货中`、`PICKED/已拣货`、`WAIT_OUTBOUND/待出库`、`OUTBOUNDED/已出库` 和 `CANCELLED/已取消`;仅未开始的 `WAIT_PICK/WAIT_OUTBOUND` 允许取消。一件代发新建弹窗直接跨客户加载服务端可用候选,不单独展示客户筛选,海外仓必选并优先默认洛杉矶仓;订单条件可按订单号、入库单号、客户、托盘号、SKU 或品名查询/重置,以托盘+SKU 行分页多选。提交时从所选行解析唯一客户,并校验所选行属于当前唯一海外仓,不要求先选客户。退货出库客户下拉同时展示客户名称与联系电话并支持手机号检索,客户和仓库选项独立容错加载;选择客户后须立即按当前默认/已选海外仓加载库存。SKU 与箱号查询均使用输入态/已查询态分离,只允许点击查询按钮后生效;支持英文逗号批量条件,查询时清除空格并按任一条件命中,左右列表分别提供每页 5/10/20/50 条选择。SKU 列表只展示 `availableQuantity > 0` 的未占用可用数量,合箱后已扣减为零的 SKU 不再作为可选库存显示,实际作业以可用箱唛选择和箱内 SKU 明细扣减为准。取消、派送列表/详情及退回入口对应 Backend `lastleg-service/delivery/`、`dropship/` |
| 异常退回与客户回访 | `pages/ExceptionOrdersView.vue`、`pages/CustomerVisitsView.vue` | `api/exceptionOrders.ts`、`api/customerVisits.ts`;异常关系解析、异常建档、派送退回、完成/状态和回访附件对应 Backend `lastleg-service/exceptionorder/`、`customervisit/` |
| 库存余额、库位与流水 | `pages/InventoryView.vue`、`pages/InventoryDetailView.vue`、`pages/InventoryTransactionsView.vue` | `api/inventory.ts`;业务库存归 `lastleg-service/inventory/`,实物资源归 `warehouse-overseas-service/` |
@@ -34,18 +34,19 @@
## 履约硬边界
-- 智能排车来源支持 `RELABEL`(待出库换标单),切换后不传已选入库计划 ID,按客户已确认、卡车派送且未排车的换标库存查询。提交换标号从候选 `orderRefs` 读取,不能把展示的原入库单号 `orderNo` 当换标号;沿用来源库存、托盘与箱唛,不生成新入库单。
+- 排车详情固定四个彩色图标页签:车辆信息、排车信息、单据信息、关联订单明细。五阶段及审核分区、逐动作Gate由服务端返回;取消独立于阶段。装车完成须真实PDA拣货核平,装车与实际发车分别确认;Admin和PDA共用正式版本化命令。已有单写入提交recordVersion(0合法)、原幂等键和完整载荷;未知结果保留原请求核对,成功后另读当前事实。
+- 智能排车来源支持 `RELABEL`(待出库换标单),按服务端候选的`selection.relabelNo/sourceVersion`提交,不用展示订单号或原入仓号替代换标身份;沿用真实库存、托盘与箱唛,不生成新入库单。客户和仓库归属由服务端核验,不在前端拼接客户子排车。
- 托盘管理单张打印:`pages/PalletsView.vue` 每行“打印”经 `api/pallets.ts` 的 `getPalletLabelPdf` 下载当前托盘一页 PDF;使用该行仓库 ID 与托盘 ID,权限复用托盘列表。Backend `warehouse-overseas-service` 的 `PalletLabelPrintService` 读取公共托盘;有 FBA 仓码的托盘与国内统一使用 Print `FBA_PALLET_A4_LANDSCAPE_V1`(297×210 mm,FBA 仓码、完整托盘号 Code 128、托盘号文字),其他托盘使用 `INBOUND_MARK_V1`。不在前端生成标签、改码或维护第二套模板。
-- API 为 `api/dispatches.ts`、`api/outbounds.ts`、`api/deliveryOrders.ts`;Backend `lastleg-service/dispatch/`、`delivery/`、`dropship/`。一件代发独立出库,从服务端库存位置选择并携带幂等键;普通出库经头程排车转出库;换标只有平台卡车进入预排车,普通自提、亚马逊自提、客户自提及快递由客户确认直接创建出库任务;普通库存仍按托盘快照绑定头程箱唛,PDA 按托盘作业,详情展示箱唛与已拣数量;排车转派送按稳定 `dispatchNo` 进入服务端命令,可在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 状态执行并须在 PDA 发车前生成一张待出库单及按目的 FBA 仓拆分的派送单
+- API 为 `api/dispatches.ts`、`api/outbounds.ts`、`api/deliveryOrders.ts`;Backend `lastleg-service/dispatch/`、`delivery/`、`dropship/`。一件代发独立出库,从服务端库存位置选择并携带幂等键;换标只有平台卡车进入排车,普通自提、亚马逊自提、客户自提及快递由客户确认直接创建出库任务。整车派送安排在装车完成后、实际发车前进行;每客户一张出库单,每客户每目的地一张派送单,公开完整数组,不能回退为单个outboundNo或按客户拆排车头。
- API 为 `api/exceptionOrders.ts`、`api/customerVisits.ts`;Backend `lastleg-service/exceptionorder/`、`customervisit/`。异常处理只消费服务端 `allowedActions/version`;完成时至少上传一份异常证据。派送退回先从服务端原出库拣货事实选择入仓单和可退箱数,不允许自由填写 SKU/数量;退回海外仓时必须选择服务端校验的目标托盘和库位,库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进。回访附件和派送完成/状态均走海外真实 API
## 验证
- 一件代发出库候选选择:`pages/OutboundsView.vue` 按 `inventoryPositionId + skuCode` 保留跨页选择,只提供本页全选;查询/重置回到第一页,切换海外仓清空选择。提交仍校验同客户、同海外仓,数量取所选行的 `availablePackages`(箱数),不能按 SKU 件数替代;候选同时展示当前可出箱数对应的毛重、材积重和体积,创建出库单时服务端按本次出库箱数占实际入库箱数的比例冻结三项测量快照。候选交互回归见 `pages/OutboundsView.selection.test.ts`;状态展示与取消门禁见 `pages/OutboundsView.test.ts`、`pages/OutboundDetailView.test.ts`。
- 转运审核定向测试:从应用根运行 `pnpm test src/modules/overseas-warehouse/api/warehouseTransfers.test.ts src/modules/overseas-warehouse/pages/prototypeContract.test.ts`,分别核对客户作用域/API 与审核入口权限、状态。
-- 排车转派送弹框打开时并行读取一次`getDispatchExternalFreight`和`getDispatchFeeCoverage`;不逐行查费用。`DeliveryFeeCoveragePanel`按实际各票原费用明确勾选派送/装车/买约,提交来源ID/版本/hash与货物摘要,不按金额推断覆盖。`fullyCoveredServices`只控制输入展示,服务端重新核验;只有全部货物已包含的项目才不提交新增应收,部分覆盖仍需填剩余项目。外接报价只读并提交externalFreightHash。派送详情显示确认时的原费用覆盖快照,原费用金额与本次新增费用分开展示;不能将原费用再次记入新费用行。
+- 派送安排弹框通过`getDispatchArrangement`读取整车分组、实际货物、外接报价及原费用依据,`DispatchArrangeFields.vue`按真实客户目的地组提交;不逐行查费用,不按金额推断覆盖,不把原费用再次记入新费用行。移货必须使用`GET items`返回的真实dispatchItemId及逐项remove Gate;路线仅重排`GET route`返回的既有全部目的地,包含外接点,不能新增或猜测目的地。附件沿`DispatchEvidenceFiles.vue`的本车鉴权上传/读取,上传与主单引用保存分开。
- 出库取消提示先解除车次关联,取消后仍须在排车列表释放原排车占用;不提示库存已自动回滚。派送详情费用读取reviewStatus/sourceStatus/voidStatus,同一详情响应展示待财务确认、待财务处理、确认作废,不逐行查询,也不把取消成功视为费用作废。货柜费用面板直接消费同一费用来源接口,待确认时同样禁止调整和重复作废,沿用原页面/弹框承载。
- 排车列表编辑、托盘移除、BOL PDF 和转派送从 `pages/DispatchesView.vue` → `api/dispatches.ts` 进入;智能预排的候选、草稿柜与托盘锁从 `components/SmartDispatchDialog.vue` 进入。回归为 `pages/DispatchesView.test.ts`、`pages/prototypeContract.test.ts`,不要把列表操作与预排草稿当作同一入口。
- 从应用根按目标页面选择相邻测试:`pnpm test src/modules/overseas-warehouse/<目标测试路径>`;API 或类型变化同时运行 `pnpm typecheck`。应用完整门禁继续遵循上层指引。
diff --git a/src/modules/overseas-warehouse/api/dispatches.test.ts b/src/modules/overseas-warehouse/api/dispatches.test.ts
index 84d2b070..dbb7cf7e 100644
--- a/src/modules/overseas-warehouse/api/dispatches.test.ts
+++ b/src/modules/overseas-warehouse/api/dispatches.test.ts
@@ -1,161 +1,131 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
-import { cancelDispatch, createDispatchDraftContainer, deleteDispatchDraftContainer, getDispatch, getDispatchBolPdf, getDispatchExternalFreight, getDispatchFeeCoverage, listDispatchCandidates, listDispatchDrafts, listDispatches, lockDispatchDraftPallet, preArrangeDispatch, preArrangeDraftDispatch, releaseDispatchDraft, transferDispatchToDelivery, unlockDispatchDraftPallet, updateDispatch, getBatchDispatchCandidates } from './dispatches'
-
-vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() } }))
-
-describe('dispatch overseas API', () => {
- it('reads an immutable batch and preserves its exact per-source allocations on grouped submission', async () => {
- const allocation = { planPalletId: '9007199254740993', batchKey: 'FBA_APPOINTMENT:9', customerId: '9007199254740999', sourceKey: 'BOOKING:2', destinationKey: 'FBA:ONT8', packageCount: 12 }
- vi.mocked(http.get).mockResolvedValue({ code: '200', data: { batchKey: allocation.batchKey, version: 4, candidates: [], blockReasons: [] } })
- await getBatchDispatchCandidates(allocation.batchKey)
- expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/appointment-planning/batches/FBA_APPOINTMENT%3A9/dispatch-candidates')
- vi.mocked(http.post).mockResolvedValue({ code: '200', data: [] })
- const request = { draftToken: 'draft-1', groups: [{ draftContainerId: 'container-1', planPalletIds: [allocation.planPalletId], returnReceiptIds: [], batchAllocations: [allocation] }] }
- await preArrangeDraftDispatch(request)
- expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts/pre-arrange', request)
- })
- it('submits all explicit draft groups once and preserves container and source identities', async () => {
- const payload = { draftToken: 'original-draft', groups: [
- { draftContainerId: '190000000000000099', planPalletIds: ['11', '13'], returnReceiptIds: [] },
- { draftContainerId: '190000000000000100', planPalletIds: ['12'], returnReceiptIds: ['22'] },
- ] }
- const result = payload.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: { id: String(index), dispatchNo: `DS-${index}` } }))
- vi.mocked(http.post).mockResolvedValue({ code: '200', data: result })
- expect(await preArrangeDraftDispatch(payload)).toEqual(result)
- expect(http.post).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/dispatches/drafts/pre-arrange', payload)
- })
- it('propagates a batch rejection without falling back to a flat or repeated command', async () => {
- vi.mocked(http.post).mockResolvedValue({ code: 'A0409', message: '草稿来源已变化', data: null })
- await expect(preArrangeDraftDispatch({ draftToken: 'original-draft', groups: [
- { draftContainerId: 'container', planPalletIds: ['11'], returnReceiptIds: [] },
- ] })).rejects.toThrow('草稿来源已变化')
- expect(http.post).toHaveBeenCalledTimes(1)
- })
- it('locks and submits actual return receipts without pretending they are plan pallets', async () => {
- vi.mocked(http.post).mockResolvedValue({ code: '200', data: null })
- await lockDispatchDraftPallet('RETURN:190000000000000099', 'draft-return', 'container-1')
- expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/dispatches/draft-locks/lock', {
- sourceType: 'RETURN_RECEIPT', returnReceiptId: '190000000000000099', draftToken: 'draft-return', draftContainerId: 'container-1',
- })
- await unlockDispatchDraftPallet('RETURN:190000000000000099', 'draft-return')
- expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/dispatches/draft-locks/unlock', {
- sourceType: 'RETURN_RECEIPT', returnReceiptId: '190000000000000099', draftToken: 'draft-return',
- })
- await preArrangeDispatch({ planPalletIds: [], returnReceiptIds: ['190000000000000099'], draftToken: 'draft-return' })
- expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/dispatches/pre-arrange', {
- planPalletIds: [], returnReceiptIds: ['190000000000000099'], draftToken: 'draft-return',
- })
- })
- it('reads external freight from the scoped dispatch without a supplied customer', async () => {
- vi.mocked(http.get).mockResolvedValue({ code: '200', data: { snapshotHash: 'hash', requiresDeliveryFee: false, quotes: [] } })
- expect(await getDispatchExternalFreight('DP/1')).toEqual({ snapshotHash: 'hash', requiresDeliveryFee: false, quotes: [] })
- expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/DP%2F1/external-freight')
- })
- it('loads per-shipment coverage without exposing a caller-supplied customer or losing long IDs', async () => {
- vi.mocked(http.get).mockResolvedValue({ code: '200', data: { dispatchNo: 'DP/1', sourceComplete: true, blockReason: null, cargoHash: 'hash', cargo: [], feeSources: [{ financeFeeId: '190000000000000099', sourceFeeId: '190000000000000100', customerId: '101', sourceInboundId: '11', sourceVersion: '2', amount: '100.25' }] } })
- const result = await getDispatchFeeCoverage('DP/1')
- expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/DP%2F1/fee-coverage')
- expect(result.feeSources[0]).toMatchObject({ financeFeeId: '190000000000000099', sourceVersion: 2, amount: 100.25 })
- })
- beforeEach(() => {
- vi.mocked(http.get).mockReset()
- vi.mocked(http.post).mockReset()
- vi.mocked(http.put).mockReset()
- vi.mocked(http.delete).mockReset()
- })
-
- it('uses the platform dispatch page without customer scope', async () => {
- vi.mocked(http.get).mockResolvedValue({
- code: '200', message: 'success', data: { records: [], page: 2, size: 50, total: 0, pages: 0 },
- })
-
- await listDispatches({
- page: 2, size: 50, dispatchNo: 'PC-1',
- vehiclePlate: 'CA-100', status: 'PENDING_DEPARTURE', fbaAppointmentNo: 'FBA-1',
- })
-
- expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches', {
- params: {
- page: 2, size: 50, dispatchNo: 'PC-1',
- vehiclePlate: 'CA-100', status: 'PENDING_DEPARTURE', fbaAppointmentNo: 'FBA-1',
- },
- })
- })
-
- it('loads a platform dispatch detail by encoded dispatch number', async () => {
- vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', dispatchNo: 'PC/1' } })
-
- await getDispatch('PC/1')
-
- expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1')
- })
-
- it('requests the generated BOL as a PDF blob', async () => {
- vi.mocked(http.get).mockResolvedValue(new Blob(['%PDF-test']))
- await getDispatchBolPdf('PC/1')
- expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/prints/bol', { responseType: 'blob' })
- })
-
- it('loads candidates and creates a pre-arrange dispatch in an explicit customer scope', async () => {
- vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
- vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', dispatchNo: 'PC-1' } })
- await listDispatchCandidates({ planIds: '101,102', fbaWarehouseCode: 'ONT8', sourceStage: 'PLAN' })
- await preArrangeDispatch({ planPalletIds: ['190000000000000099'] })
- expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', { params: { planIds: '101,102', fbaWarehouseCode: 'ONT8', sourceStage: 'PLAN' } })
- expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/pre-arrange', { planPalletIds: ['190000000000000099'] })
- })
-
- it('loads platform candidates without selected inbound plans', async () => {
- vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
-
- await listDispatchCandidates({ sourceStage: 'RECEIVED' })
-
- expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', {
- params: { sourceStage: 'RECEIVED' },
- })
- })
-
- it('locks, unlocks, and releases intelligent dispatch drafts through dedicated endpoints', async () => {
- vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: null })
-
- await lockDispatchDraftPallet('99', 'draft-1')
- await unlockDispatchDraftPallet('99', 'draft-1')
- await releaseDispatchDraft('draft-1')
-
- expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/dispatches/draft-locks/lock', { planPalletId: '99', draftToken: 'draft-1' })
- expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/dispatches/draft-locks/unlock', { planPalletId: '99', draftToken: 'draft-1' })
- expect(http.post).toHaveBeenNthCalledWith(3, '/platform/v1/last-leg/dispatches/draft-locks/release', { draftToken: 'draft-1' })
- })
-
- it('edits and cancels only by dispatchNo without customer scope or status mutation', async () => {
- vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: { id: '1', dispatchNo: 'PC/1' } })
- vi.mocked(http.delete).mockResolvedValue({ code: '200', message: 'success', data: null })
- await updateDispatch('PC/1', { vehiclePlate: 'CA-100' })
- await cancelDispatch('PC/1')
- expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1', { vehiclePlate: 'CA-100' })
- expect(http.put).not.toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ status: expect.anything() }))
- expect(http.delete).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1')
- })
-
- it('transfers to delivery only by dispatchNo without customerId', async () => {
- vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { dispatchNo: 'PC/1', outboundNo: 'OUT-1', outboundStatus: 'PICKED', palletCount: 2, packageCount: 20, deliveries: [{ deliveryNo: 'DEL-1', deliveryStatus: 'WAIT_DELIVERY', destinationWarehouseCode: 'ACY2', palletCount: 2, packageCount: 20 }], replayed: false } })
- const payload = { idempotencyKey: 'transfer-1', plannedDeliveryAt: '2026-09-01T10:00:00', deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费用', direction: 'RECEIVABLE' as const, currency: 'USD' as const, amount: 10 }] }
- await transferDispatchToDelivery('PC/1', payload)
- expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/transfer-delivery', payload)
- })
-
- it('loads drafts and preserves long container ids in create and delete paths', async () => {
- vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [] })
- vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: { id: '190000000000000099', capacity: 30, palletIds: [] } })
- vi.mocked(http.delete).mockResolvedValue({ code: '200', message: 'success', data: null })
-
- await listDispatchDrafts()
- await createDispatchDraftContainer('draft/1', 30)
- await deleteDispatchDraftContainer('draft/1', '190000000000000099')
-
- expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts')
- expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts/containers', { draftToken: 'draft/1', capacity: 30 })
- expect(http.delete).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts/draft%2F1/containers/190000000000000099')
- })
+import * as api from './dispatches'
+import { candidate, command, detail, loadingPreview, page } from '../dispatchTestFixtures'
+import type { ArrangeDelivery, CreateDispatch, DispatchBatchAllocation, DraftSelection, LoadingComplete, ReasonCommand, ReviewCommand, UpdateDispatch, Version, VersionCommand } from '../dispatchContracts'
+vi.mock('@/shared/api/http', () => ({ http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() }, unwrapApiResponse: (value: { data: unknown }) => value.data }))
+const body = { recordVersion: 0, idempotencyKey: 'original-key', reason: '核对原因' }
+const draft = () => ({ draftToken: 'server-token', recordVersion: 0, status: 'ACTIVE', warehouseId: '321', containers: [], sources: [candidate()], expiresAt: '' })
+const file = () => ({ fileId: '9007199254740993', fileName: '证据.png', contentType: 'image/png', sizeBytes: 3 })
+beforeEach(() => {
+ vi.clearAllMocks()
+ vi.mocked(http.get).mockImplementation(async url => ({ data: url.endsWith('/items') ? { dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [] } : url.includes('/evidence/') ? file() : url.endsWith('/loading') ? loadingPreview() : url.includes('/commands/') ? command('EDIT') : detail() }))
+ vi.mocked(http.post).mockImplementation(async url => ({ data: url.endsWith('/pre-arrange') ? { draftToken: 'server-token', vehicles: [], replayed: false } : url.includes('/draft') ? draft() : url.includes('/evidence/') ? file() : command('EDIT') }))
+ vi.mocked(http.put).mockResolvedValue({ data: command('EDIT') })
+ vi.mocked(http.delete).mockResolvedValue({ data: draft() })
+})
+
+describe('native whole-vehicle dispatch API', () => {
+ it.each([0, '0', '9007199254740993'])('preserves authoritative batch version %s across direct create, add cargo and grouped conversion', async recordVersion => {
+ const allocation: DispatchBatchAllocation = { planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion }
+ const create: CreateDispatch = { warehouseId: '321', idempotencyKey: 'create', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [candidate().selection], batchAllocations: [allocation] }
+ await api.createDispatch(create); await api.addDispatchCargo('PC/1', { ...body, cargo: create.cargo, batchAllocations: [allocation] }); await api.convertDispatchDraft({ ...body, draftToken: 'draft', groups: [{ draftContainerId: 'container', dispatch: create }] })
+ expect(vi.mocked(http.post).mock.calls[0]![1]).toBe(create)
+ expect(vi.mocked(http.post).mock.calls[1]![1]).toMatchObject({ batchAllocations: [{ recordVersion }] })
+ expect(vi.mocked(http.post).mock.calls[2]![1]).toMatchObject({ groups: [{ dispatch: { batchAllocations: [{ recordVersion }] } }] })
+ })
+ it.each([undefined, null, -1, '', 'bad', 9007199254740992])('rejects missing or invalid nested batch version %s before any write or candidate handoff', async value => {
+ const allocation = { planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion: value } as unknown as DispatchBatchAllocation
+ const create: CreateDispatch = { warehouseId: '321', idempotencyKey: 'create', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [candidate().selection], batchAllocations: [allocation] }
+ await expect(Promise.resolve().then(() => api.createDispatch(create))).rejects.toThrow('重新读取候选')
+ await expect(Promise.resolve().then(() => api.addDispatchCargo('PC/1', { ...body, cargo: create.cargo, batchAllocations: [allocation] }))).rejects.toThrow('重新读取候选')
+ await expect(Promise.resolve().then(() => api.convertDispatchDraft({ ...body, draftToken: 'draft', groups: [{ draftContainerId: 'container', dispatch: create }] }))).rejects.toThrow('重新读取候选')
+ expect(http.post).not.toHaveBeenCalled()
+ vi.mocked(http.get).mockResolvedValueOnce({ data: { batchKey: allocation.batchKey, version: 5, candidates: [{ allocations: [allocation] }], blockReasons: [] } })
+ await expect(api.getBatchDispatchCandidates(allocation.batchKey)).rejects.toThrow('重新读取候选')
+ })
+ it('rejects inconsistent original versions of the same batch across conversion groups', () => {
+ const allocation = { planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion: '9007199254740993' }
+ const create: CreateDispatch = { warehouseId: '321', idempotencyKey: 'create', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [], batchAllocations: [allocation] }
+ expect(() => api.convertDispatchDraft({ ...body, draftToken: 'draft', groups: [{ draftContainerId: 'A', dispatch: create }, { draftContainerId: 'B', dispatch: { ...create, batchAllocations: [{ ...allocation, recordVersion: '9007199254740994' }] } }] })).toThrow('同一预约批次的版本不一致')
+ expect(http.post).not.toHaveBeenCalled()
+ })
+ it('uses actual item, route and evidence endpoints with native multipart and authenticated blob content', async () => {
+ await api.getDispatchItems('PC/1'); await api.getDispatchRoute('PC/1'); await api.getDispatchEvidence('PC/1', 'DRIVER', '9007199254740993')
+ expect(vi.mocked(http.get).mock.calls.map(call => call[0])).toEqual(['/platform/v1/last-leg/dispatches/PC%2F1/items', '/platform/v1/last-leg/dispatches/PC%2F1/route', '/platform/v1/last-leg/dispatches/PC%2F1/evidence/DRIVER/9007199254740993'])
+ const file = new File(['png'], '证据.png', { type: 'image/png' }); await api.uploadDispatchEvidence('PC/1', 'VEHICLE', file)
+ const upload = vi.mocked(http.post).mock.calls[0]!
+ expect(upload[0]).toBe('/platform/v1/last-leg/dispatches/PC%2F1/evidence/VEHICLE'); expect(upload[1]).toBeInstanceOf(FormData); expect((upload[1] as FormData).get('file')).toBe(file)
+ await api.getDispatchEvidenceContent('PC/1', 'VEHICLE', '301'); expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/evidence/VEHICLE/301/content', { responseType: 'blob' })
+ })
+ it('reorders only the supplied existing destination keys with explicit native version zero', async () => {
+ await expect(api.reorderDispatchRoute('PC/1', { recordVersion: null as unknown as number, idempotencyKey: 'key', destinationKeys: [], reason: '路线调整' })).rejects.toThrow('版本')
+ expect(http.put).not.toHaveBeenCalled()
+ const request = { recordVersion: 0, idempotencyKey: 'key', destinationKeys: ['CODE:ONT8', 'PRIVATE:actual'], reason: '现场卸货顺序调整' }
+ await api.reorderDispatchRoute('PC/1', request); expect(http.put).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/dispatches/PC%2F1/route', request)
+ })
+ it('queries five-stage rows with server review sections and preserves statistics', async () => {
+ vi.mocked(http.get).mockResolvedValueOnce({ data: page() })
+ const query = { page: 2, size: 20, stage: 'BEFORE_LOADING' as const, beforeLoadingSection: 'DISPATCH_REVIEW' as const, reviewStatus: 'PENDING' as const, status: 'ACTIVE' as const }
+ const result = await api.listDispatches(query)
+ expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches', { params: query }); expect(result.statistics.stageCounts.DELIVERING).toBe(1)
+ })
+ it('reads the four-object detail and loading without customer scope, encoding business numbers', async () => {
+ await api.getDispatch('PC/1'); await api.getDispatchLoading('PC/1'); await api.getDispatchCommand('PC/1', 'key/1')
+ expect(vi.mocked(http.get).mock.calls.map(call => call[0])).toEqual(['/platform/v1/last-leg/dispatches/PC%2F1', '/platform/v1/last-leg/dispatches/PC%2F1/loading', '/platform/v1/last-leg/dispatches/PC%2F1/commands/key%2F1'])
+ })
+ it('reads candidates with their exact sourceVersion and repeated plan query parameters', async () => {
+ const data = { candidates: [candidate()], snapshotAt: '2026-10-19T10:00:00Z', complete: true }
+ vi.mocked(http.get).mockResolvedValueOnce({ data })
+ expect(await api.listDispatchCandidates({ warehouseId: '321', planIds: ['11', '12'] })).toEqual(data)
+ expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', { params: { warehouseId: '321', planIds: ['11', '12'] }, paramsSerializer: { indexes: null } })
+ })
+ it('creates an empty real dispatch without an artificial customer or cargo', async () => {
+ const request: CreateDispatch = { idempotencyKey: 'create', warehouseId: '321', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [], batchAllocations: [] }
+ await api.createDispatch(request); expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches', request)
+ })
+ it.each(['cancel', 'remove', 'review', 'loading-review', 'return', 'loading', 'depart', 'arrange', 'add', 'submit', 'draft-container', 'draft-select', 'draft-release', 'draft-convert', 'draft-snapshot'])('rejects missing/null/negative versions before %s requests and accepts zero', async action => {
+ const invoke = (value: unknown) => {
+ const request = { ...body, recordVersion: value as Version }
+ switch (action) {
+ case 'cancel': return api.cancelDispatch('PC/1', request)
+ case 'remove': return api.removeDispatchItem('PC/1', '11', request)
+ case 'review': return api.reviewDispatch('PC/1', request as unknown as ReviewCommand)
+ case 'loading-review': return api.reviewDispatchLoading('PC/1', request as unknown as ReviewCommand)
+ case 'return': return api.returnDispatchToReview('PC/1', request)
+ case 'loading': return api.completeDispatchLoading('PC/1', request as unknown as LoadingComplete)
+ case 'depart': return api.departDispatch('PC/1', { ...request, remark: null })
+ case 'arrange': return api.transferDispatchToDelivery('PC/1', request as unknown as ArrangeDelivery)
+ case 'add': return api.addDispatchCargo('PC/1', { ...request, cargo: [], batchAllocations: [] })
+ case 'submit': return api.submitDispatchReview('PC/1', { ...request, remark: null })
+ case 'draft-container': return api.createDispatchDraftContainer({ ...request, draftToken: 'draft', capacity: 30 })
+ case 'draft-select': return api.lockDispatchDraftPallet(request as unknown as DraftSelection)
+ case 'draft-release': return api.releaseDispatchDraft({ ...request, draftToken: 'draft' })
+ case 'draft-convert': return api.convertDispatchDraft({ ...request, draftToken: 'draft', groups: [] })
+ default: return api.saveDispatchDraftSnapshot({ ...request, draftToken: 'draft', planIds: [], containers: [] })
+ }
+ }
+ for (const invalid of [undefined, null, -1, '', 'bad', Number.MAX_SAFE_INTEGER + 1]) await expect(Promise.resolve().then(() => invoke(invalid))).rejects.toThrow('版本')
+ expect(http.post).not.toHaveBeenCalled(); await invoke(0); expect(vi.mocked(http.post).mock.calls[0]![1]).toEqual(expect.objectContaining({ recordVersion: 0 }))
+ })
+ it('updates only an existing appointment reference and its explicit version', async () => {
+ const request: UpdateDispatch = { ...body, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, vehicleAppointment: { referenceNo: 'AP-1', recordVersion: 0 } }
+ await api.updateDispatch('PC/1', request); expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1', request)
+ await expect(api.updateDispatch('PC/1', { ...request, vehicleAppointment: { referenceNo: 'AP-1', recordVersion: null as unknown as Version } })).rejects.toThrow('版本')
+ await expect(api.updateDispatch('PC/1', { ...request, recordVersion: undefined as unknown as Version })).rejects.toThrow('版本')
+ })
+ it('uses versioned removal and cancellation POSTs instead of unversioned deletes', async () => {
+ await api.cancelDispatch('PC/1', body); await api.removeDispatchItem('PC/1', 'item/1', body)
+ expect(vi.mocked(http.post).mock.calls.map(call => call[0])).toEqual(['/platform/v1/last-leg/dispatches/PC%2F1/cancel', '/platform/v1/last-leg/dispatches/PC%2F1/items/item%2F1/remove']); expect(http.delete).not.toHaveBeenCalled()
+ })
+ it('preserves complete grouped arrangement evidence and returns an immutable command envelope', async () => {
+ const outbound = (customerId: string) => ({ customerId, customerName: `客户${customerId}`, outboundNo: `OUT-${customerId}`, status: 'WAIT_OUTBOUND', packageCount: 2, palletCount: 1 })
+ const delivery = (customerId: string) => ({ ...outbound(customerId), groupKey: customerId, deliveryNo: `DEL-${customerId}`, destination: candidate().destination })
+ const data = { ...command('ARRANGE_DELIVERY'), result: { dispatchNo: 'PC/1', recordVersion: '9007199254740993', arrangementKey: 'arrange', cargoHash: 'cargo', arrangedAt: '2026-10-19T10:00:00Z', outbounds: [outbound('A'), outbound('B')], deliveries: [delivery('A'), delivery('B')] } }
+ vi.mocked(http.post).mockResolvedValueOnce({ data })
+ const request: ArrangeDelivery = { ...body, recordVersion: '9007199254740993', cargoHash: 'cargo', loadingCompletionHash: 'loaded', groups: [] }
+ expect(await api.transferDispatchToDelivery('PC/1', request)).toEqual(data); expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/transfer-delivery', request)
+ })
+ it('keeps draft version, server token and source evidence and converts explicit groups atomically', async () => {
+ const source = candidate().selection
+ await api.lockDispatchDraftPallet({ ...body, draftToken: 'server-token', draftContainerId: '190000000000000011', source })
+ expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/dispatches/draft-locks/lock', { ...body, draftToken: 'server-token', draftContainerId: '190000000000000011', source })
+ await api.convertDispatchDraft({ ...body, draftToken: 'server-token', groups: [] }); expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/dispatches/drafts/pre-arrange', { ...body, draftToken: 'server-token', groups: [] })
+ })
+ it('deletes draft containers with the body version and never invents successful response data', async () => {
+ vi.mocked(http.delete).mockRejectedValueOnce(new Error('server failure'))
+ await expect(api.deleteDispatchDraftContainer('draft/1', 'container/1', body as ReasonCommand)).rejects.toThrow('server failure')
+ expect(http.delete).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/drafts/draft%2F1/containers/container%2F1', { data: body })
+ })
+ it('reads BOL as a PDF blob', async () => { await api.getDispatchBolPdf('PC/1'); expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/PC%2F1/prints/bol', { responseType: 'blob' }) })
})
diff --git a/src/modules/overseas-warehouse/api/dispatches.ts b/src/modules/overseas-warehouse/api/dispatches.ts
index a66df138..66e1527d 100644
--- a/src/modules/overseas-warehouse/api/dispatches.ts
+++ b/src/modules/overseas-warehouse/api/dispatches.ts
@@ -1,346 +1,65 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
+import type { DispatchEvidenceKind, DispatchRoute, DispatchRouteCommand } from '../dispatchContracts'
+import { requireVersion, requireBatchVersions, type AddCargo, type ArrangeDelivery, type CandidateQuery, type ConvertDraft, type CreateDispatch, type DispatchQuery, type DraftContainer, type DraftSelection, type LoadingComplete, type ReasonCommand, type ReviewCommand, type UpdateDispatch, type VersionCommand, type DispatchBatchAllocation } from '../dispatchContracts'
+import { normalizeArrangementPreview, normalizeCandidateList, normalizeConvertedDraft, normalizeDispatchCommand, normalizeDispatchDetail, normalizeDispatchDraft, normalizeDispatchItems, normalizeDispatchPage, normalizeEvidenceFile, normalizeLoadingPreview, type ArrangementPreviewWire, type CandidateListWire, type CommandResultWire, type ConvertedDraftWire, type DispatchDetailWire, type DispatchDraftWire, type DispatchEvidenceFileWire, type DispatchItemsWire, type DispatchPageWire, type LoadingPreviewWire } from '../dispatchWire'
+export type { DispatchBatchReference, DispatchBatchAllocation } from '../dispatchContracts'
-import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders'
-
-export interface DispatchQuery {
- page: number
- size: number
- dispatchNo?: string
- vehiclePlate?: string
- status?: string
- fbaAppointmentNo?: string
- stage?: 'BEFORE_LOADING' | 'LOADING' | 'AFTER_LOADING' | 'CANCELLED'
-}
-
-export interface DispatchReview { status: 'PENDING' | 'APPROVED' | 'REJECTED'; reason: string | null; reviewedAt: string | null }
-export interface DispatchWorkflow { tripNo: string; dispatchReview: DispatchReview; loadingReview: DispatchReview }
-export interface DispatchActions {
- canEdit: boolean
- editBlockReason: string | null
- canCancel: boolean
- cancelBlockReason: string | null
- canRemovePallet: boolean
- removePalletBlockReason: string | null
- canTransfer: boolean
- transferBlockReason: string | null
- outboundNo: string | null
- outboundStatus: string | null
-}
-
-export interface DispatchPalletCandidateDto {
- sourceType?: 'INBOUND_PLAN' | 'RETURN_RECEIPT'
- returnReceiptId?: string | null
- returnReceiptNo?: string | null
- planId?: string | null
- planPalletId?: string | null
- warehousePalletId?: string | null
- orderId: string
- customerId?: string | null
- customerName?: string | null
- planNo?: string | null
- orderType?: string | null
- volumeWeightKg?: number | null
- grossWeightKg?: number | null
- containerNo?: string | null
- appointmentAt?: string | null
- expectedLoadingAt?: string | null
- overseasWarehouseId?: string | null
- overseasWarehouseCode?: string | null
- overseasWarehouseName?: string | null
- orderNo?: string | null
- orderRefs?: string | null
- inboundCode?: string | null
- palletNo?: string | null
- destinationWarehouseId?: string | null
- destinationWarehouseCode?: string | null
- destinationWarehouseName?: string | null
- fbaWarehouseCode?: string | null
- packageCount?: number | null
- volumeCbm?: number | null
- sourceStage?: 'PLAN' | 'RECEIVED' | 'RELABEL' | null
- cartonMarks?: string | null
- relabelCartonMarks?: string | null
- destinationType?: 'FBA' | 'PRIVATE' | null
- deliveryAddress?: string | null
- postalCode?: string | null
- contactName?: string | null
- contactPhone?: string | null
-}
-
-export interface DispatchCandidateQuery {
- planIds?: string
- orderNo?: string
- fbaWarehouseCode?: string
- sourceStage?: 'PLAN' | 'RECEIVED' | 'RELABEL'
- draftToken?: string
-}
-export interface DispatchDraftContainerDto { id: string; capacity: number; palletIds: string[] }
-export interface DispatchDraftDto { draftToken: string; containers?: DispatchDraftContainerDto[] }
-
-export interface PreArrangeDispatchPayload {
- batchAllocations?: DispatchBatchAllocation[]
- planPalletIds: string[]
- returnReceiptIds?: string[]
- fbaAppointmentNo?: string
- fbaAppointmentStatus?: string
- vehicleAppointmentStatus?: string
- draftToken?: string
- relabelNo?: string
-}
-
-export interface PreArrangeDispatchDraftGroup {
- batchAllocations?: DispatchBatchAllocation[]
- draftContainerId: string
- planPalletIds: string[]
- returnReceiptIds: string[]
-}
-export interface PreArrangeDispatchDraftPayload {
- draftToken: string
- groups: PreArrangeDispatchDraftGroup[]
-}
-export interface PreArrangeDispatchDraftResult {
- draftContainerId: string
- dispatch: DispatchSummaryDto
-}
-export interface DispatchBatchAllocation { planPalletId: string; batchKey: string; customerId: string; sourceKey: string; destinationKey: string; packageCount: number }
-export interface BatchDispatchCandidate { planPalletId: string; palletNo: string; planId: string; planNo: string; customerId: string; sourceStage: string; packageCount: number | null; allocations: DispatchBatchAllocation[]; blockReasons: string[] }
-export interface BatchDispatchCandidates { batchKey: string; version: number; candidates: BatchDispatchCandidate[]; blockReasons: string[] }
-export async function getBatchDispatchCandidates(batchKey: string) {
- return unwrapApiResponse(await http.get, ApiEnvelope>(`/platform/v1/last-leg/appointment-planning/batches/${encodeURIComponent(batchKey)}/dispatch-candidates`))
-}
-
-export interface DispatchBatchReference { batchKey: string; batchNo: string | null; customerId: string; sources: Array<{ sourceKey: string; destinationKey: string; packageCount: number }> }
-export interface DispatchSummaryDto {
- actions?: DispatchActions
- appointmentBatches: DispatchBatchReference[]
- workflow?: DispatchWorkflow | null
- id: string
- dispatchNo: string
- vehiclePlate?: string | null
- driverName?: string | null
- driverPhone?: string | null
- carrierName?: string | null
- orderNos?: string | null
- route?: string | null
- overseasWarehouseId?: string | null
- overseasWarehouseName?: string | null
- departAt?: string | null
- fbaAppointmentNo?: string | null
- fbaAppointmentStatus?: string | null
- vehicleAppointmentStatus?: string | null
- estimatedArrivalAt?: string | null
- status?: string | null
- itemCount?: number | null
- palletCount?: number | null
- packageCount?: number | null
- pickedPackageCount?: number | null
- shippedPackageCount?: number | null
- remainingPackageCount?: number | null
- partialCompletionReason?: string | null
- createdAt?: string | null
- items?: DispatchItemDto[]
- bolLines?: DispatchBolLineDto[]
-}
-
-export interface DispatchBolLineDto {
- containerNo?: string | null
- palletNo?: string | null
- fbaWarehouseCode?: string | null
- destinationWarehouseName?: string | null
- warehouseNo?: string | null
- fbaShipmentId?: string | null
- poReferenceNo?: string | null
- packageCount: number
-}
-
-export interface UpdateDispatchPayload {
- vehiclePlate?: string | null
- driverName?: string | null
- driverPhone?: string | null
- carrierName?: string | null
- departAt?: string | null
- fbaAppointmentNo?: string | null
- fbaAppointmentStatus?: string | null
- vehicleAppointmentStatus?: string | null
- estimatedArrivalAt?: string | null
-}
-
-export interface DispatchItemDto {
- id: string
- planPalletId: string
- warehousePalletId?: string | null
- palletNo?: string | null
- overseasWarehouseId?: string | null
- overseasWarehouseName?: string | null
- orderNo?: string | null
- orderRefs?: string | null
- inboundCode?: string | null
- destinationWarehouseCode?: string | null
- destinationWarehouseName?: string | null
- fbaWarehouseCode?: string | null
- packageCount?: number | null
- grossWeightKg?: number | null
- volumeWeightKg?: number | null
- volumeCbm?: number | null
- sourceStage?: string | null
-}
+const base = '/platform/v1/last-leg/dispatches'
+const path = (dispatchNo: string) => `${base}/${encodeURIComponent(dispatchNo)}`
+async function get(url: string) { return unwrapApiResponse(await http.get, ApiEnvelope>(url)) }
+async function post(url: string, body: object) { return unwrapApiResponse(await http.post, ApiEnvelope>(url, body)) }
+const postCommand = (url: string, body: object) => post(url, body).then(normalizeDispatchCommand)
+const postDraft = (url: string, body: object) => post(url, body).then(normalizeDispatchDraft)
+function versioned(body: T): T { requireVersion(body.recordVersion); return body }
export async function listDispatches(query: DispatchQuery) {
- const response = await http.get>, ApiEnvelope>>(
- '/platform/v1/last-leg/dispatches',
- { params: query },
- )
- return normalizeOverseasPage(unwrapApiResponse(response))
+ return normalizeDispatchPage(unwrapApiResponse(await http.get, ApiEnvelope>(base, { params: query })))
}
+export const getDispatch = (dispatchNo: string) => get(path(dispatchNo)).then(normalizeDispatchDetail)
+export const getDispatchItems = (no: string) => get(`${path(no)}/items`).then(normalizeDispatchItems)
+export const getDispatchRoute = (no: string) => get(`${path(no)}/route`)
+export async function reorderDispatchRoute(no: string, body: DispatchRouteCommand) { return normalizeDispatchCommand(unwrapApiResponse(await http.put, ApiEnvelope>(`${path(no)}/route`, versioned(body)))) }
+const evidencePath = (no: string, kind: DispatchEvidenceKind, id?: string) => `${path(no)}/evidence/${kind}${id ? `/${encodeURIComponent(id)}` : ''}`
+export async function uploadDispatchEvidence(no: string, kind: DispatchEvidenceKind, file: File) { const form = new FormData(); form.append('file', file); return post(evidencePath(no, kind), form).then(normalizeEvidenceFile) }
+export const getDispatchEvidence = (no: string, kind: DispatchEvidenceKind, id: string) => get(evidencePath(no, kind, id)).then(normalizeEvidenceFile)
+export const getDispatchEvidenceContent = (no: string, kind: DispatchEvidenceKind, id: string) => http.get(`${evidencePath(no, kind, id)}/content`, { responseType: 'blob' })
+export const getDispatchLoading = (dispatchNo: string) => get(`${path(dispatchNo)}/loading`).then(normalizeLoadingPreview)
+export const getDispatchCommand = (dispatchNo: string, key: string) => get(`${path(dispatchNo)}/commands/${encodeURIComponent(key)}`).then(normalizeDispatchCommand)
+export const createDispatch = (body: CreateDispatch) => { requireBatchVersions(body.batchAllocations); return postCommand(base, body) }
+export async function updateDispatch(dispatchNo: string, body: UpdateDispatch) {
+ versioned(body)
+ if (body.vehicleAppointment) requireVersion(body.vehicleAppointment.recordVersion)
+ return normalizeDispatchCommand(unwrapApiResponse(await http.put, ApiEnvelope>(path(dispatchNo), body)))
+}
+export const addDispatchCargo = (no: string, body: AddCargo) => { versioned(body); requireBatchVersions(body.batchAllocations); return postCommand(`${path(no)}/items`, body) }
+export const removeDispatchItem = (no: string, itemId: string, body: ReasonCommand) => postCommand(`${path(no)}/items/${encodeURIComponent(itemId)}/remove`, versioned(body))
+export const cancelDispatch = (no: string, body: ReasonCommand) => postCommand(`${path(no)}/cancel`, versioned(body))
+export const submitDispatchReview = (no: string, body: VersionCommand & { remark: string | null }) => postCommand(`${path(no)}/submit-review`, versioned(body))
+export const reviewDispatch = (no: string, body: ReviewCommand) => postCommand(`${path(no)}/dispatch-review`, versioned(body))
+export const reviewDispatchLoading = (no: string, body: ReviewCommand) => postCommand(`${path(no)}/loading-review`, versioned(body))
+export const returnDispatchToReview = (no: string, body: ReasonCommand) => postCommand(`${path(no)}/return-to-review`, versioned(body))
+export const completeDispatchLoading = (no: string, body: LoadingComplete) => postCommand(`${path(no)}/loading-complete`, versioned(body))
+export const departDispatch = (no: string, body: VersionCommand & { remark: string | null }) => postCommand(`${path(no)}/depart`, versioned(body))
+export const getDispatchArrangement = (no: string) => get(`${path(no)}/delivery-arrangement`).then(normalizeArrangementPreview)
+export const transferDispatchToDelivery = (no: string, body: ArrangeDelivery) => postCommand(`${path(no)}/transfer-delivery`, versioned(body))
+export const getDispatchBolPdf = (no: string) => http.get(`${path(no)}/prints/bol`, { responseType: 'blob' })
-export async function getDispatch(dispatchNo: string) {
- const response = await http.get, ApiEnvelope>(
- `/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}`,
- )
- return unwrapApiResponse(response)
+export async function listDispatchCandidates(query: CandidateQuery) {
+ return normalizeCandidateList(unwrapApiResponse(await http.get, ApiEnvelope>(`${base}/candidates`, { params: query, paramsSerializer: { indexes: null } })))
}
+export const listDispatchDrafts = () => get(`${base}/drafts`).then(values => values.map(normalizeDispatchDraft))
+export const createDispatchDraft = (body: { idempotencyKey: string; warehouseId: string }) => postDraft(`${base}/drafts`, body)
+export const createDispatchDraftContainer = (body: VersionCommand & { draftToken: string; capacity: number }) => postDraft(`${base}/drafts/containers`, versioned(body))
+export const saveDispatchDraftSnapshot = (body: VersionCommand & { draftToken: string; planIds: string[]; containers: DraftContainer[] }) => postDraft(`${base}/drafts/snapshot`, versioned(body))
+export const lockDispatchDraftPallet = (body: DraftSelection) => postDraft(`${base}/draft-locks/lock`, versioned(body))
+export const unlockDispatchDraftPallet = (body: DraftSelection) => postDraft(`${base}/draft-locks/unlock`, versioned(body))
+export const releaseDispatchDraft = (body: VersionCommand & { draftToken: string }) => postDraft(`${base}/draft-locks/release`, versioned(body))
+export async function deleteDispatchDraftContainer(token: string, id: string, body: ReasonCommand) {
+ return normalizeDispatchDraft(unwrapApiResponse(await http.delete, ApiEnvelope>(`${base}/drafts/${encodeURIComponent(token)}/containers/${encodeURIComponent(id)}`, { data: versioned(body) })))
+}
+export const convertDispatchDraft = (body: ConvertDraft) => { versioned(body); requireBatchVersions(body.groups.flatMap(group => group.dispatch.batchAllocations)); return post(`${base}/drafts/pre-arrange`, body).then(normalizeConvertedDraft) }
-export async function getDispatchBolPdf(dispatchNo: string) {
- return http.get(
- `/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/prints/bol`,
- { responseType: 'blob' },
- )
-}
-
-export async function updateDispatch(dispatchNo: string, payload: UpdateDispatchPayload) {
- const response = await http.put, ApiEnvelope>(
- `/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}`, payload,
- )
- return unwrapApiResponse(response)
-}
-
-export async function cancelDispatch(dispatchNo: string) {
- const response = await http.delete, ApiEnvelope>(
- `/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}`,
- )
- return unwrapApiResponse(response)
-}
-
-export async function removeDispatchItem(dispatchNo: string, itemId: string) {
- const response = await http.delete, ApiEnvelope>(
- `/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(itemId)}`,
- )
- return unwrapApiResponse(response)
-}
-
-export async function listDispatchCandidates(query: DispatchCandidateQuery) {
- const response = await http.get, ApiEnvelope>(
- '/platform/v1/last-leg/dispatches/candidates',
- { params: query },
- )
- return unwrapApiResponse(response)
-}
-
-export async function preArrangeDispatch(payload: PreArrangeDispatchPayload) {
- const response = await http.post, ApiEnvelope>(
- '/platform/v1/last-leg/dispatches/pre-arrange',
- payload,
- )
- return unwrapApiResponse(response)
-}
-
-export async function preArrangeDraftDispatch(payload: PreArrangeDispatchDraftPayload) {
- const response = await http.post, ApiEnvelope>(
- '/platform/v1/last-leg/dispatches/drafts/pre-arrange', payload,
- )
- return unwrapApiResponse(response)
-}
-
-function draftSource(selection: string) {
- return selection.startsWith('RETURN:')
- ? { sourceType: 'RETURN_RECEIPT', returnReceiptId: selection.slice(7) }
- : { planPalletId: selection }
-}
-export async function lockDispatchDraftPallet(selection: string, draftToken: string, draftContainerId?: string) {
- const response = await http.post, ApiEnvelope>(
- '/platform/v1/last-leg/dispatches/draft-locks/lock', { ...draftSource(selection), draftToken, ...(draftContainerId ? { draftContainerId } : {}) },
- )
- return unwrapApiResponse(response)
-}
-
-export async function listDispatchDrafts() {
- const response = await http.get, ApiEnvelope>('/platform/v1/last-leg/dispatches/drafts')
- return unwrapApiResponse(response)
-}
-export async function createDispatchDraftContainer(draftToken: string, capacity: number) {
- const response = await http.post, ApiEnvelope>('/platform/v1/last-leg/dispatches/drafts/containers', { draftToken, capacity })
- return unwrapApiResponse(response)
-}
-export async function deleteDispatchDraftContainer(draftToken: string, containerId: string) {
- const response = await http.delete, ApiEnvelope>(`/platform/v1/last-leg/dispatches/drafts/${encodeURIComponent(draftToken)}/containers/${encodeURIComponent(containerId)}`)
- return unwrapApiResponse(response)
-}
-
-export async function unlockDispatchDraftPallet(selection: string, draftToken: string) {
- const response = await http.post, ApiEnvelope>(
- '/platform/v1/last-leg/dispatches/draft-locks/unlock', { ...draftSource(selection), draftToken },
- )
- return unwrapApiResponse(response)
-}
-
-export async function releaseDispatchDraft(draftToken: string) {
- const response = await http.post, ApiEnvelope>(
- '/platform/v1/last-leg/dispatches/draft-locks/release', { draftToken },
- )
- return unwrapApiResponse(response)
-}
-
-export interface TransferDeliveryItem { deliveryNo: string; deliveryStatus: string; destinationWarehouseId?: string | null; destinationWarehouseCode?: string | null; destinationWarehouseName?: string | null; palletCount: number; packageCount: number }
-export interface TransferDeliveryResult { dispatchNo: string; outboundNo: string; outboundStatus: string; palletCount: number; packageCount: number; deliveries: TransferDeliveryItem[]; replayed: boolean }
-export interface TransferDeliveryPayload {
- feeCoverageCargoHash?: string
- feeCoverage?: import('../feeCoverageTypes').FeeCoverageSelection[]
- externalFreightHash?: string
- idempotencyKey: string
- plannedDeliveryAt: string
- deliveryMethod: string
- waybillNo?: string
- carrierName?: string
- pickupMethod: string
- loadingType: string
- vehicleType?: string
- vehicleSize?: string
- driverName?: string
- driverPhone?: string
- vehiclePlate?: string
- appointmentAccount?: string
- sellerName?: string
- remark?: string
- fees: Array<{ feeType: string; feeName: string; direction: 'RECEIVABLE' | 'PAYABLE'; currency: 'CNY' | 'USD'; amount: number
- supplierId?: string; supplierName?: string; supplierCategory?: string; settlementMode?: 'ON_ACCOUNT' | 'DIRECT_PAID'
- paidAt?: string; paymentMethod?: 'ONLINE' | 'BANK_TRANSFER' | 'CASH' | 'OTHER'; paymentReferenceNo?: string
- paymentVoucherFileIds?: string[]; chargeableQuantity?: number; quantityUnit?: string; unitPrice?: number
- periodStart?: string; periodEnd?: string; remark?: string }>
-}
-export async function transferDispatchToDelivery(dispatchNo: string, payload: TransferDeliveryPayload) {
- const response = await http.post, ApiEnvelope>(
- `/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/transfer-delivery`, payload,
- )
- return unwrapApiResponse(response)
-}
-
-export interface ExternalFreightPreview {
- snapshotHash: string
- requiresDeliveryFee: boolean
- quotes: Array<{ bookingNo: string; recordVersion: string | number; incomingNo: string; inboundCode: string;
- customerName: string; destinationKey: string; currency: string; amount: number; quantity: number; quantityUnit: string; unitPrice: number }>
-}
-export async function getDispatchExternalFreight(dispatchNo: string): Promise {
- const response = await http.get, ApiEnvelope>(
- `/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/external-freight`,
- )
- return unwrapApiResponse(response)
-}
-
-export async function getDispatchFeeCoverage(dispatchNo: string): Promise {
- const response = await http.get, ApiEnvelope>(
- `/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}/fee-coverage`,
- )
- const data = unwrapApiResponse(response)
- return { ...data, cargo: data.cargo.map(row => ({ ...row, dispatchItemId: String(row.dispatchItemId), warehousePalletId: String(row.warehousePalletId), orderId: row.orderId == null ? null : String(row.orderId), sourceInboundId: row.sourceInboundId == null ? null : String(row.sourceInboundId), packages: Number(row.packages) })), feeSources: data.feeSources.map(row => ({ ...row, financeFeeId: String(row.financeFeeId), sourceFeeId: String(row.sourceFeeId), customerId: String(row.customerId), sourceInboundId: String(row.sourceInboundId), sourceVersion: Number(row.sourceVersion), amount: Number(row.amount) })) }
-}
+// Existing appointment-domain provider; its consumer handoff remains with the coordinator.
+export interface BatchDispatchCandidate { planPalletId: string; palletNo: string; planId: string; planNo: string; customerId: string; sourceStage: string; packageCount: number | null; allocations: DispatchBatchAllocation[]; blockReasons: string[] }
+export interface BatchDispatchCandidates { batchKey: string; version: number; candidates: BatchDispatchCandidate[]; blockReasons: string[] }
+export const getBatchDispatchCandidates = (key: string) => get(`/platform/v1/last-leg/appointment-planning/batches/${encodeURIComponent(key)}/dispatch-candidates`).then(value => { requireBatchVersions(value.candidates.flatMap(row => row.allocations)); return value })
diff --git a/src/modules/overseas-warehouse/components/DispatchArrangeFields.test.ts b/src/modules/overseas-warehouse/components/DispatchArrangeFields.test.ts
new file mode 100644
index 00000000..be1c16b7
--- /dev/null
+++ b/src/modules/overseas-warehouse/components/DispatchArrangeFields.test.ts
@@ -0,0 +1,35 @@
+import ElementPlus from 'element-plus'
+import { mount } from '@vue/test-utils'
+import { expect, it } from 'vitest'
+import DispatchArrangeFields from './DispatchArrangeFields.vue'
+import type { ArrangeGroup, CargoLine, PreviewGroup } from '../dispatchContracts'
+import { destination } from '../dispatchTestFixtures'
+const cargo = (sourceKey: string, customerId = 'C1'): CargoLine => ({ sourceKey, customerId, inboundCode: `IN-${sourceKey}` }) as CargoLine
+function values() {
+ const preview: PreviewGroup = { groupKey: 'group-1', customerId: 'C1', customerName: '客户甲', destination: destination(), groupCargoHash: 'group-cargo', cargo: [cargo('A'), cargo('B')], externalFreightHash: 'external-hash', externalQuotes: [], feeCoverageCargoHash: 'coverage-hash', feeSources: [{ financeFeeId: 'fee-1', customerId: 'C1', currency: 'USD', amount: 100, sourceVersion: 2, sourceHash: 'fee-hash', status: 'ACTIVE', services: ['DELIVERY_FEE', 'LOADING_FEE'], sourceKeys: ['A'] }], requiredServices: ['DELIVERY_FEE'], blockReasons: [] }
+ const group: ArrangeGroup = { groupKey: 'group-1', groupCargoHash: 'group-cargo', plannedDeliveryAt: '', deliveryMethod: '', waybillNo: null, carrierName: null, pickupMethod: '', loadingType: 'PALLET', vehicleType: null, vehicleSize: null, appointmentAccount: null, sellerName: null, remark: null, externalFreightHash: 'external-hash', feeCoverageCargoHash: 'coverage-hash', feeCoverage: [], fees: ['DELIVERY_FEE', 'LOADING_FEE', 'APPOINTMENT_FEE'].map(feeType => ({ feeType, feeName: feeType, direction: 'RECEIVABLE', currency: 'USD', amount: null })) }
+ return { preview, group }
+}
+it('requires explicit per-service coverage and preserves uncovered cargo instead of treating a partial fee as full coverage', async () => {
+ const { preview, group } = values()
+ const wrapper = mount(DispatchArrangeFields, { props: { modelValue: [group], preview: [preview], disabled: false }, global: { plugins: [ElementPlus] } })
+ const covered = (wrapper.vm as unknown as { covered(index: number, preview: PreviewGroup, service: string): boolean }).covered
+ expect(covered(0, preview, 'DELIVERY_FEE')).toBe(false)
+ await wrapper.get('input[type="checkbox"]').setValue(true)
+ expect(group.feeCoverage).toEqual([{ financeFeeId: 'fee-1', sourceVersion: 2, sourceHash: 'fee-hash', services: ['DELIVERY_FEE'] }])
+ expect(covered(0, preview, 'DELIVERY_FEE')).toBe(false)
+ preview.feeSources[0]!.sourceKeys.push('B'); expect(covered(0, preview, 'DELIVERY_FEE')).toBe(true)
+ expect(covered(0, preview, 'LOADING_FEE')).toBe(false)
+ preview.feeSources[0]!.customerId = 'C2'; expect(covered(0, preview, 'DELIVERY_FEE')).toBe(false); wrapper.unmount()
+})
+it('matches external quotes to the original customer and inbound source and shows only applicable additional fees', async () => {
+ const { preview, group } = values(); preview.cargo = [cargo('A')]
+ preview.externalQuotes = [{ customerId: 'C2', groupKey: 'group-1', bookingNo: 'BOOK-1', inboundCode: 'IN-A', currency: 'USD', amount: 100, sourceHash: 'external-source' }]
+ const wrapper = mount(DispatchArrangeFields, { props: { modelValue: [group], preview: [preview], disabled: false }, global: { plugins: [ElementPlus] } })
+ const state = wrapper.vm as unknown as { covered(index: number, preview: PreviewGroup, service: string): boolean }
+ expect(state.covered(0, preview, 'DELIVERY_FEE')).toBe(false)
+ expect(wrapper.findAll('.el-input-number')).toHaveLength(1)
+ preview.externalQuotes[0]!.customerId = 'C1'; expect(state.covered(0, preview, 'DELIVERY_FEE')).toBe(true)
+ group.loadingType = 'FLOOR'; group.appointmentAccount = 'BUY_APPOINTMENT'; await wrapper.setProps({ modelValue: [{ ...group }] })
+ expect(wrapper.findAll('.el-input-number')).toHaveLength(2); expect(wrapper.text()).toContain('卖家姓名'); wrapper.unmount()
+})
diff --git a/src/modules/overseas-warehouse/components/DispatchArrangeFields.vue b/src/modules/overseas-warehouse/components/DispatchArrangeFields.vue
new file mode 100644
index 00000000..4c80fe37
--- /dev/null
+++ b/src/modules/overseas-warehouse/components/DispatchArrangeFields.vue
@@ -0,0 +1,54 @@
+
+
+
+
+ {{ previewGroup.customerName || '—' }} · {{ previewGroup.destination.warehouseCode || previewGroup.destination.warehouseName || previewGroup.destination.address || '—' }}
+ {{ reason }}
+
+
+
+
+
+
+
+
+
+
+ {{ quote.bookingNo }} · {{ quote.inboundCode }}:{{ quote.currency }} {{ quote.amount }}(外接约定运费)
+ 原费用覆盖
暂无可引用原费用。
+ {{ fee.currency }} {{ fee.amount }} · {{ fee.status }}已包含{{ serviceLabels[service] || '服务费用' }}
+ 本组新增费用
本组{{ serviceLabels[fee.feeType] || fee.feeName }}已全部覆盖,不重复登记。
+
+
+
+
diff --git a/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.test.ts b/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.test.ts
new file mode 100644
index 00000000..02d13fe8
--- /dev/null
+++ b/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.test.ts
@@ -0,0 +1,70 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { beforeEach, expect, it, vi } from 'vitest'
+import { ApiResponseError, http } from '@/shared/api/http'
+import { parseApiJsonResponse } from '@chongchong/shared'
+import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
+import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
+import * as api from '../api/dispatches'
+import type { DispatchEvidenceFile } from '../dispatchContracts'
+vi.mock('../api/dispatches', () => ({ getDispatchEvidence: vi.fn(), getDispatchEvidenceContent: vi.fn(), uploadDispatchEvidence: vi.fn() }))
+const metadata = (fileId = '301', contentType = 'image/png'): DispatchEvidenceFile => ({ fileId, fileName: contentType === 'application/pdf' ? '本车证照.pdf' : '本车证据.png', contentType, sizeBytes: 3 })
+const options = { props: { modelValue: ['301'], dispatchNo: 'PC/1', kind: 'VEHICLE' as const }, global: { stubs: { ElButton: { props: ['disabled'], template: '' }, AppDialog: { name: 'AppDialog', props: ['modelValue', 'title'], template: '' } } } }
+const button = (wrapper: ReturnType, label: string) => wrapper.findAll('button').find(value => value.text() === label)!
+async function selectFile(wrapper: ReturnType, file: File) { const input = wrapper.get('input[type="file"]'); Object.defineProperty(input.element, 'files', { configurable: true, value: [file] }); await input.trigger('change'); await flushPromises() }
+beforeEach(() => { vi.resetAllMocks(); vi.mocked(api.getDispatchEvidence).mockResolvedValue(metadata()); vi.mocked(api.uploadDispatchEvidence).mockResolvedValue(metadata('302')); vi.mocked(api.getDispatchEvidenceContent).mockResolvedValue(new Blob(['png'], { type: 'image/png' })); URL.createObjectURL = vi.fn().mockReturnValue('blob:authenticated'); URL.revokeObjectURL = vi.fn() })
+it('previews and replaces real string-size metadata through the JSON parser and API boundary', async () => {
+ const actual = await vi.importActual('../api/dispatches')
+ const envelope = (fileId: string) => parseApiJsonResponse(JSON.stringify({ code: '200', data: { ...metadata(fileId), sizeBytes: '3' } }))
+ const get = vi.spyOn(http, 'get').mockResolvedValue(envelope('9007199254740993'))
+ const post = vi.spyOn(http, 'post').mockResolvedValue(envelope('9007199254740994'))
+ vi.mocked(api.getDispatchEvidence).mockImplementation(actual.getDispatchEvidence); vi.mocked(api.uploadDispatchEvidence).mockImplementation(actual.uploadDispatchEvidence)
+ const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, modelValue: ['9007199254740993'] } })
+ try {
+ await flushPromises(); expect(wrapper.text()).toContain('本车证据.png')
+ await button(wrapper, '预览').trigger('click'); await flushPromises(); expect(wrapper.get('img').attributes('src')).toBe('blob:authenticated')
+ await button(wrapper, '关闭预览').trigger('click'); await button(wrapper, '替换').trigger('click'); await selectFile(wrapper, new File(['png'], 'new.png', { type: 'image/png' }))
+ expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([['9007199254740994']]); expect(post).toHaveBeenCalledOnce()
+ } finally { wrapper.unmount(); get.mockRestore(); post.mockRestore() }
+})
+it('reads owned metadata without displaying IDs or downloading content until explicitly previewed, including read-only mode', async () => {
+ const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, readonly: true } }); await flushPromises()
+ expect(api.getDispatchEvidence).toHaveBeenCalledWith('PC/1', 'VEHICLE', '301'); expect(api.getDispatchEvidenceContent).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('本车证据.png'); expect(wrapper.text()).not.toContain('301'); expect(wrapper.find('input').exists()).toBe(false)
+ await button(wrapper, '预览').trigger('click'); await flushPromises(); expect(api.getDispatchEvidenceContent).toHaveBeenCalledWith('PC/1', 'VEHICLE', '301'); expect(wrapper.get('img').attributes('src')).toBe('blob:authenticated')
+ await button(wrapper, '关闭预览').trigger('click'); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:authenticated'); wrapper.unmount()
+})
+it('retains original references when metadata or replacement upload fails and preserves request diagnostics', async () => {
+ vi.mocked(api.getDispatchEvidence).mockRejectedValueOnce(new ApiResponseError('Forbidden', 'A0403', 403, undefined, 'request-1', 'trace-1'))
+ const failed = mount(DispatchEvidenceFiles, options); await flushPromises(); expect(failed.emitted('update:modelValue')).toBeUndefined(); expect(failed.getComponent(ApiErrorMeta).props('details')).toMatchObject({ requestId: 'request-1', traceId: 'trace-1' }); failed.unmount()
+ const wrapper = mount(DispatchEvidenceFiles, options); await flushPromises(); vi.mocked(api.uploadDispatchEvidence).mockRejectedValueOnce(new Error('unknown upload'))
+ await button(wrapper, '替换').trigger('click'); await selectFile(wrapper, new File(['png'], 'new.png', { type: 'image/png' })); expect(wrapper.emitted('update:modelValue')).toBeUndefined(); expect(wrapper.text()).toContain('原附件引用保留'); wrapper.unmount()
+})
+it('replaces the selected reference only after a valid successful upload and rejects unsupported types locally', async () => {
+ const wrapper = mount(DispatchEvidenceFiles, options); await flushPromises(); await selectFile(wrapper, new File(['exe'], 'file.exe', { type: 'application/octet-stream' })); expect(api.uploadDispatchEvidence).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('10MiB以内')
+ await button(wrapper, '替换').trigger('click'); const file = new File(['png'], 'new.png', { type: 'image/png' }); await selectFile(wrapper, file)
+ expect(api.uploadDispatchEvidence).toHaveBeenCalledWith('PC/1', 'VEHICLE', file); expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([['302']]); expect(wrapper.emitted('busy')?.slice(-2)).toEqual([[true], [false]]); wrapper.unmount()
+})
+it('ignores a late upload after switching dispatches instead of binding the file to the new vehicle', async () => {
+ let resolve!: (value: DispatchEvidenceFile) => void; vi.mocked(api.uploadDispatchEvidence).mockImplementationOnce(() => new Promise(done => { resolve = done }))
+ const wrapper = mount(DispatchEvidenceFiles, options); await flushPromises(); await button(wrapper, '替换').trigger('click'); await selectFile(wrapper, new File(['png'], 'new.png', { type: 'image/png' }))
+ await wrapper.setProps({ dispatchNo: 'PC/2' }); resolve(metadata('302')); await flushPromises(); expect(wrapper.emitted('update:modelValue')).toBeUndefined(); wrapper.unmount()
+})
+it('does not turn business-error JSON into an image and retains diagnostic evidence', async () => {
+ vi.mocked(api.getDispatchEvidenceContent).mockResolvedValueOnce({ type: 'application/json', size: 100, text: async () => JSON.stringify({ code: 'A0403', message: 'Forbidden', requestId: 'request-content', traceId: 'trace-content' }) } as Blob)
+ const wrapper = mount(DispatchEvidenceFiles, options); await flushPromises(); await button(wrapper, '预览').trigger('click'); await flushPromises(); expect(URL.createObjectURL).not.toHaveBeenCalled(); expect(wrapper.find('img').exists()).toBe(false); expect(wrapper.getComponent(ApiErrorMeta).props('details')).toMatchObject({ requestId: 'request-content', traceId: 'trace-content' }); wrapper.unmount()
+})
+it('ignores content arriving after preview close and cleans up object URLs on unmount', async () => {
+ let resolve!: (value: Blob) => void; vi.mocked(api.getDispatchEvidenceContent).mockImplementationOnce(() => new Promise(done => { resolve = done }))
+ const wrapper = mount(DispatchEvidenceFiles, options); await flushPromises(); await button(wrapper, '预览').trigger('click'); await button(wrapper, '关闭预览').trigger('click'); resolve(new Blob(['png'], { type: 'image/png' })); await flushPromises(); expect(URL.createObjectURL).not.toHaveBeenCalled()
+ await button(wrapper, '预览').trigger('click'); await flushPromises(); expect(URL.createObjectURL).toHaveBeenCalledTimes(1); wrapper.unmount(); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:authenticated')
+})
+it('opens PDF in a user-requested window using owned blob content and never an anonymous URL', async () => {
+ vi.mocked(api.getDispatchEvidence).mockResolvedValueOnce(metadata('301', 'application/pdf')); vi.mocked(api.getDispatchEvidenceContent).mockResolvedValueOnce(new Blob(['pdf'], { type: 'application/pdf' }))
+ const popup = { location: { href: '' }, close: vi.fn() }; const open = vi.spyOn(window, 'open').mockReturnValueOnce(popup as unknown as Window)
+ const wrapper = mount(DispatchEvidenceFiles, options); await flushPromises(); await button(wrapper, '预览').trigger('click'); await flushPromises(); expect(open).toHaveBeenCalledWith('', '_blank'); expect(popup.location.href).toBe('blob:authenticated'); expect(wrapper.find('img').exists()).toBe(false); wrapper.unmount(); expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:authenticated'); open.mockRestore()
+})
+it('rejects mismatched file identity and a blocked PDF popup without fetching content', async () => {
+ vi.mocked(api.getDispatchEvidence).mockResolvedValueOnce(metadata('wrong-file'))
+ const mismatch = mount(DispatchEvidenceFiles, options); await flushPromises(); expect(button(mismatch, '预览').attributes('disabled')).toBeDefined(); expect(api.getDispatchEvidenceContent).not.toHaveBeenCalled(); mismatch.unmount()
+ vi.mocked(api.getDispatchEvidence).mockResolvedValueOnce(metadata('301', 'application/pdf')); const open = vi.spyOn(window, 'open').mockReturnValueOnce(null)
+ const blocked = mount(DispatchEvidenceFiles, options); await flushPromises(); await button(blocked, '预览').trigger('click'); await flushPromises(); expect(blocked.text()).toContain('请允许本站打开PDF预览窗口'); expect(api.getDispatchEvidenceContent).not.toHaveBeenCalled(); blocked.unmount(); open.mockRestore()
+})
diff --git a/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.vue b/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.vue
new file mode 100644
index 00000000..2411f091
--- /dev/null
+++ b/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.vue
@@ -0,0 +1,85 @@
+
+
+ {{ label }}
+ {{ error.message }} 重新读取资料
+ - {{ metadata[id]?.fileName || '附件资料待核实' }} 预览替换移除引用
+ 暂无本趟附件。
上传{{ label }}PDF、JPG、PNG,单文件最大10MiB;上传后保存车辆信息才完成本车引用。
+ 正在读取本车附件…
+
+
+
diff --git a/src/modules/overseas-warehouse/components/DispatchResourceFields.test.ts b/src/modules/overseas-warehouse/components/DispatchResourceFields.test.ts
new file mode 100644
index 00000000..dd1bf87c
--- /dev/null
+++ b/src/modules/overseas-warehouse/components/DispatchResourceFields.test.ts
@@ -0,0 +1,28 @@
+import ElementPlus from 'element-plus'
+import { flushPromises, mount } from '@vue/test-utils'
+import { beforeEach, expect, it, vi } from 'vitest'
+import DispatchResourceFields from './DispatchResourceFields.vue'
+import { resources } from '../dispatchTestFixtures'
+import { listDrivers, listVehicles } from '@/modules/overseas-master-data'
+const can = vi.hoisted(() => vi.fn(() => true))
+vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
+vi.mock('@/modules/overseas-master-data', () => ({ listDrivers: vi.fn(), listVehicles: vi.fn(), overseasMasterDataPermissions: { vehicleList: 'vehicle:list', driverList: 'driver:list' } }))
+beforeEach(() => { vi.clearAllMocks(); can.mockReturnValue(true) })
+it('renders known business labels for existing master resources without falling back to their internal IDs', async () => {
+ const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = '900719925474099301'; value.driver.mode = 'MASTER'; value.driver.driverId = '900719925474099302'
+ const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false }, global: { plugins: [ElementPlus] } }); await flushPromises()
+ expect(wrapper.text()).toContain('CA-100'); expect(wrapper.text()).toContain('司机甲'); expect(wrapper.text()).not.toContain('90071992547409930'); wrapper.unmount()
+})
+it('resets only the resource being switched and keeps external resource choices independent', async () => {
+ const value = resources(); value.vehicle.mode = 'MASTER'; value.vehicle.vehicleId = 'vehicle-1'
+ const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false }, global: { plugins: [ElementPlus] } })
+ const state = wrapper.vm as unknown as { changeDriver(): void }; state.changeDriver(); await flushPromises()
+ expect(wrapper.emitted('update:driver')![0]![0]).toMatchObject({ mode: 'EXTERNAL', driverId: null, name: null, evidence: { fileIds: [], licenseNo: null } })
+ expect(wrapper.emitted('update:vehicle')).toBeUndefined(); expect(value.vehicle.vehicleId).toBe('vehicle-1'); wrapper.unmount()
+})
+it('requires each real directory permission before reading candidates', async () => {
+ can.mockReturnValue(false); const value = resources()
+ const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false }, global: { plugins: [ElementPlus] } })
+ const state = wrapper.vm as unknown as { searchVehicles(): Promise; searchDrivers(): Promise }; await state.searchVehicles(); await state.searchDrivers()
+ expect(listVehicles).not.toHaveBeenCalled(); expect(listDrivers).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('缺少司机档案读取权限'); wrapper.unmount()
+})
diff --git a/src/modules/overseas-warehouse/components/DispatchResourceFields.vue b/src/modules/overseas-warehouse/components/DispatchResourceFields.vue
new file mode 100644
index 00000000..6ce8a6c2
--- /dev/null
+++ b/src/modules/overseas-warehouse/components/DispatchResourceFields.vue
@@ -0,0 +1,53 @@
+
+
+ {{ error }}
+
+
+
+
+
+
+
+
+
+
+
+
+ 临时资料仅用于本趟,不创建档案。先保存排车单,再在本车维护中上传本趟证据;资料齐备后由调度核实审核。
+
+
diff --git a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts
new file mode 100644
index 00000000..599aa8d5
--- /dev/null
+++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts
@@ -0,0 +1,141 @@
+import ElementPlus, { ElMessage } from 'element-plus'
+import { ApiResponseError, http } from '@/shared/api/http'
+import { parseApiJsonResponse } from '@chongchong/shared'
+import { flushPromises, mount } from '@vue/test-utils'
+import { beforeEach, expect, it, vi } from 'vitest'
+import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue'
+import * as api from '../api/dispatches'
+import { candidate, command, detail, destination, loadingPreview, page } from '../dispatchTestFixtures'
+import type { ArrangeGroup, CargoLine, CommandResult, DispatchAction, DispatchDetail, PreviewGroup } from '../dispatchContracts'
+const identity = vi.hoisted(() => ({ id: '' }))
+vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
+vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
+vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '' } }))
+vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchArrangement: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), departDispatch: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn(), transferDispatchToDelivery: vi.fn() }))
+interface State { submit(): Promise; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string }; detail: DispatchDetail }
+it.each([0, '0', '9007199254740993'])('adds cargo with original batch version %s while keeping the parent dispatch version independent', async recordVersion => {
+ const value = candidate({ appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2 }] }] })
+ vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [value], complete: true, snapshotAt: '' })
+ const { wrapper, state } = await open('ADD_CARGO')
+ try {
+ (wrapper.vm as unknown as { selected: string[] }).selected = [value.sourceKey]
+ vi.mocked(api.addDispatchCargo).mockImplementation(async (_, request) => command('ADD_CARGO', request.idempotencyKey)); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
+ await state.submit()
+ expect(api.addDispatchCargo).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, batchAllocations: [{ planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion }] }))
+ } finally { wrapper.unmount() }
+})
+it('blocks cargo addition when the authoritative batch version is absent', async () => {
+ const value = candidate({ appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion: undefined as unknown as number, sources: [] }] })
+ vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [value], complete: true, snapshotAt: '' })
+ const { wrapper, state } = await open('ADD_CARGO')
+ try {
+ (wrapper.vm as unknown as { selected: string[] }).selected = [value.sourceKey]; await state.submit()
+ expect(api.addDispatchCargo).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('预约批次版本缺失或无效,请重新读取候选')
+ } finally { wrapper.unmount() }
+})
+it('removes an unpicked authoritative item after real JSON and API normalization of pickedPackages string zero', async () => {
+ const actual = await vi.importActual('../api/dispatches')
+ const source = candidate()
+ const data = { dispatchNo: 'PC/1', recordVersion: '0', status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [{ ...source.selection, dispatchItemId: '9007199254740993', sourceKey: source.sourceKey, sourceVersion: source.sourceVersion, customerId: 'C1', customerName: '客户甲', palletNo: 'PALLET-1', orderNo: 'LM-1', inboundCode: 'IN-1', destination: source.destination, scheduledPackages: '2', pickedPackages: '0', remove: { allowed: true, blockCode: null, blockReason: null } }] }
+ const get = vi.spyOn(http, 'get').mockResolvedValue(parseApiJsonResponse(JSON.stringify({ code: '200', data })))
+ vi.mocked(api.getDispatchItems).mockImplementation(actual.getDispatchItems)
+ const { wrapper, state } = await open('REMOVE_CARGO')
+ try {
+ (wrapper.vm as unknown as { itemId: string }).itemId = '9007199254740993'; state.form.reason = '本车计划调整'
+ vi.mocked(api.removeDispatchItem).mockImplementation(async (_, __, request) => command('REMOVE_CARGO', request.idempotencyKey)); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
+ await state.submit()
+ expect(api.removeDispatchItem).toHaveBeenCalledWith('PC/1', '9007199254740993', expect.objectContaining({ recordVersion: '0', reason: '本车计划调整' })); expect(wrapper.emitted('saved')).toHaveLength(1)
+ } finally { wrapper.unmount(); get.mockRestore() }
+})
+async function open(action: DispatchAction, allowed = true) {
+ vi.mocked(api.getDispatch).mockResolvedValue(detail({ actions: { [action]: { allowed, blockCode: null, blockReason: allowed ? null : '前置审核未通过' } } }))
+ const wrapper = mount(DispatchWorkflowDialog, { props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises()
+ return { wrapper, state: wrapper.vm as unknown as State }
+}
+beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(api.getDispatchLoading).mockResolvedValue(loadingPreview()) })
+it('keeps order allocation references out of actual measurements and submits native version zero only after real input', async () => {
+ const value = loadingPreview(); value.pallets[0]!.weightKg = 100; value.pallets[0]!.volumeCbm = 2
+ vi.mocked(api.getDispatchLoading).mockResolvedValue(value)
+ const { wrapper, state } = await open('LOADING_COMPLETE')
+ expect(state.measurements[0]).toMatchObject({ weightKg: null, volumeCbm: null })
+ await state.submit(); expect(api.completeDispatchLoading).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('真实托位')
+ state.measurements[0]!.weightKg = 92; state.measurements[0]!.volumeCbm = 1.5
+ vi.mocked(api.completeDispatchLoading).mockImplementation(async (_, body) => command('LOADING_COMPLETE', body.idempotencyKey))
+ vi.mocked(api.getDispatch).mockResolvedValue(detail({ stage: 'AFTER_LOADING', recordVersion: 1 }))
+ await state.submit()
+ expect(api.completeDispatchLoading).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, manifestHash: 'manifest', measurements: [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 92, volumeCbm: 1.5, deliveryIndex: 1 }] }))
+ expect(api.departDispatch).not.toHaveBeenCalled(); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
+})
+it('accepts actual evidence and a matching confirmed manifest while refusing an unknown unloading index', async () => {
+ const value = loadingPreview(); value.pallets[0]!.weightKg = 92; value.pallets[0]!.volumeCbm = 1.5; value.pallets[0]!.deliveryIndex = null
+ value.pallets[0]!.evidence.push({ field: 'weightKg', actual: true, value: 92, sourceType: 'MEASURED', sourceBusinessNo: 'PALLET-1', observedAt: null })
+ value.confirmedLoad = { manifestHash: 'manifest', operatorId: 'operator', confirmedAt: '2026-10-19T00:00:00Z', pallets: [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 92, volumeCbm: 1.5, deliveryIndex: 1 }] }
+ vi.mocked(api.getDispatchLoading).mockResolvedValue(value)
+ const { wrapper, state } = await open('LOADING_COMPLETE'); expect(state.measurements[0]).toMatchObject({ weightKg: 92, volumeCbm: 1.5, deliveryIndex: null })
+ await state.submit(); expect(api.completeDispatchLoading).not.toHaveBeenCalled(); wrapper.unmount()
+})
+it('requires a rejection reason and retains the full original command after an unknown review result', async () => {
+ const { wrapper, state } = await open('DISPATCH_REVIEW'); state.form.decision = 'REJECTED'
+ await state.submit(); expect(api.reviewDispatch).not.toHaveBeenCalled()
+ state.form.reason = '预约未核实'; vi.mocked(api.reviewDispatch).mockRejectedValue(new Error('unknown result'))
+ await state.submit(); state.form.reason = 'later changed'
+ vi.mocked(api.getDispatchCommand).mockImplementation(async (_, key) => command('DISPATCH_REVIEW', key))
+ vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
+ await state.submit(); expect(api.reviewDispatch).toHaveBeenCalledTimes(1)
+ expect(vi.mocked(api.reviewDispatch).mock.calls[0]![1]).toMatchObject({ recordVersion: 0, decision: 'REJECTED', reason: '预约未核实' })
+ expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
+})
+it('blocks an action without a literal service allowance', async () => {
+ const { wrapper, state } = await open('DEPART', false); await state.submit(); expect(api.departDispatch).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('前置审核未通过'); wrapper.unmount()
+})
+it('submits every customer destination group in one arrangement and does not waive another customer fee', async () => {
+ const previews: PreviewGroup[] = ['A', 'B'].map(customerId => ({ groupKey: `group-${customerId}`, customerId, customerName: `客户${customerId}`, destination: destination(), groupCargoHash: `cargo-${customerId}`, cargo: [{ customerId, sourceKey: `source-${customerId}`, inboundCode: `IN-${customerId}`, orderNo: `LM-${customerId}`, packages: 2 } as CargoLine], externalFreightHash: `external-${customerId}`, externalQuotes: customerId === 'A' ? [{ customerId: 'A', groupKey: 'group-A', bookingNo: 'BOOK-A', inboundCode: 'IN-A', currency: 'USD', amount: 100, sourceHash: 'quote-A' }] : [], feeCoverageCargoHash: `coverage-${customerId}`, feeSources: [], requiredServices: ['DELIVERY_FEE'], blockReasons: [] }))
+ vi.mocked(api.getDispatchArrangement).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'whole-cargo', loadingCompletionHash: 'load-hash', arrange: { allowed: true, blockCode: null, blockReason: null }, groups: previews, arrangement: null })
+ const { wrapper, state } = await open('ARRANGE_DELIVERY')
+ const groups = (wrapper.vm as unknown as { groups: ArrangeGroup[] }).groups
+ groups.forEach(group => Object.assign(group, { plannedDeliveryAt: '2026-10-20T00:00:00Z', deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'ONE_PICKUP_MULTI_DROP', loadingType: 'PALLET' }))
+ await state.submit(); expect(api.transferDispatchToDelivery).not.toHaveBeenCalled()
+ groups[1]!.fees.find(fee => fee.feeType === 'DELIVERY_FEE')!.amount = 77
+ vi.mocked(api.transferDispatchToDelivery).mockImplementation(async (_, body): Promise => ({ ...command('ARRANGE_DELIVERY', body.idempotencyKey), action: 'ARRANGE_DELIVERY', result: { dispatchNo: 'PC/1', recordVersion: 1, arrangementKey: 'arrangement', cargoHash: 'whole-cargo', arrangedAt: '2026-10-19T00:00:00Z', outbounds: [], deliveries: [] } }))
+ vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1, stage: 'AFTER_LOADING' })); await state.submit()
+ const sent = vi.mocked(api.transferDispatchToDelivery).mock.calls[0]![1]
+ expect(sent).toMatchObject({ recordVersion: 0, cargoHash: 'whole-cargo', loadingCompletionHash: 'load-hash' })
+ expect(sent.groups).toHaveLength(2); expect(sent.groups[0]!.fees).toEqual([]); expect(sent.groups[1]!.fees).toEqual([expect.objectContaining({ feeType: 'DELIVERY_FEE', amount: 77 })])
+ expect(api.getDispatchArrangement).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
+})
+it('does not reuse another dispatch detail when the target changes while its dialog remains open', async () => {
+ const { wrapper, state } = await open('DEPART')
+ let resolve!: (value: DispatchDetail) => void
+ vi.mocked(api.getDispatch).mockImplementationOnce(() => new Promise(done => { resolve = done }))
+ await wrapper.setProps({ dispatchNo: 'PC/2' }); await state.submit(); expect(api.departDispatch).not.toHaveBeenCalled()
+ resolve(detail({ dispatchNo: 'PC/2', actions: { DEPART: { allowed: false, blockCode: 'NOT_READY', blockReason: '另一车尚未就绪' } } })); await flushPromises()
+ await state.submit(); expect(api.departDispatch).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('另一车尚未就绪'); wrapper.unmount()
+})
+it('removes only the authoritative unpicked item using its item ID as path identity and retains that original target on recovery', async () => {
+ const source = candidate()
+ vi.mocked(api.getDispatchItems).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [{ ...source.selection, dispatchItemId: '9007199254740993', sourceKey: source.sourceKey, sourceVersion: source.sourceVersion, customerId: 'C1', customerName: '客户甲', palletNo: 'PALLET-1', orderNo: 'LM-1', inboundCode: 'IN-1', destination: source.destination, scheduledPackages: 2, pickedPackages: 0, remove: { allowed: true, blockCode: null, blockReason: null } }] })
+ const { wrapper, state } = await open('REMOVE_CARGO'); const target = wrapper.vm as unknown as { itemId: string }; target.itemId = '9007199254740993'
+ await state.submit(); expect(api.removeDispatchItem).not.toHaveBeenCalled()
+ state.form.reason = '本车计划调整'; vi.mocked(api.removeDispatchItem).mockRejectedValueOnce(new Error('unknown')); await state.submit()
+ target.itemId = 'another-item'; state.form.reason = 'later changed'
+ vi.mocked(api.getDispatchCommand).mockImplementation(async (_, key) => command('REMOVE_CARGO', key)); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
+ await state.submit(); expect(api.removeDispatchItem).toHaveBeenCalledExactlyOnceWith('PC/1', '9007199254740993', expect.objectContaining({ recordVersion: 0, reason: '本车计划调整' })); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
+})
+it('preserves external destinations and retries route EDIT through its original route endpoint instead of a resource update', async () => {
+ const toast = vi.spyOn(ElMessage, 'success')
+ vi.mocked(api.getDispatchRoute).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, edit: { allowed: true, blockCode: null, blockReason: null }, stops: [{ index: 0, name: '起运仓', address: '实际起点', destinationKey: null }, { index: 1, name: 'ONT8', address: '实际地址A', destinationKey: 'CODE:ONT8' }, { index: 2, name: '外接卸货点', address: '实际地址B', destinationKey: 'PRIVATE:actual' }] })
+ const { wrapper, state } = await open('EDIT'); await wrapper.setProps({ operation: 'ROUTE' }); await flushPromises()
+ const routeState = wrapper.vm as unknown as { moveStop(index: number, offset: number): void }; routeState.moveStop(1, -1); state.form.reason = '按本车现场顺序卸货'
+ vi.mocked(api.reorderDispatchRoute).mockRejectedValueOnce(new Error('unknown route')).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
+ await state.submit(); vi.mocked(api.getDispatchCommand).mockRejectedValueOnce(new ApiResponseError('not found', 'HTTP_404'))
+ vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 })); await state.submit()
+ const first = vi.mocked(api.reorderDispatchRoute).mock.calls[0]![1]
+ expect(first).toMatchObject({ recordVersion: 0, destinationKeys: ['PRIVATE:actual', 'CODE:ONT8'], reason: '按本车现场顺序卸货' }); expect(api.reorderDispatchRoute).toHaveBeenNthCalledWith(2, 'PC/1', first); expect(api.updateDispatch).not.toHaveBeenCalled(); expect(toast).toHaveBeenLastCalledWith('卸货顺序调整已确认,请重新核对两级审核'); toast.mockRestore(); wrapper.unmount()
+})
+it('refuses already picked item removal and a route that drops an authoritative destination', async () => {
+ const source = candidate()
+ vi.mocked(api.getDispatchItems).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [{ ...source.selection, dispatchItemId: 'item-picked', sourceKey: source.sourceKey, sourceVersion: source.sourceVersion, customerId: 'C1', customerName: '客户甲', palletNo: 'PALLET-1', orderNo: 'LM-1', inboundCode: 'IN-1', destination: source.destination, scheduledPackages: 2, pickedPackages: 1, remove: { allowed: false, blockCode: 'PICKED', blockReason: '本来源已经真实扫码' } }] })
+ const removed = await open('REMOVE_CARGO'); (removed.wrapper.vm as unknown as { itemId: string }).itemId = 'item-picked'; removed.state.form.reason = '调整'; await removed.state.submit(); expect(api.removeDispatchItem).not.toHaveBeenCalled(); expect(removed.wrapper.text()).toContain('已经真实扫码'); removed.wrapper.unmount()
+ vi.mocked(api.getDispatchRoute).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, edit: { allowed: true, blockCode: null, blockReason: null }, stops: [{ index: 0, name: '起运仓', address: null, destinationKey: null }, { index: 1, name: 'ONT8', address: null, destinationKey: 'CODE:ONT8' }, { index: 2, name: '外接点', address: null, destinationKey: 'EXTERNAL:destination' }] })
+ const route = await open('EDIT'); await route.wrapper.setProps({ operation: 'ROUTE' }); await flushPromises(); (route.wrapper.vm as unknown as { stops: unknown[] }).stops.pop(); route.state.form.reason = '调整'; await route.state.submit(); expect(api.reorderDispatchRoute).not.toHaveBeenCalled(); expect(route.wrapper.text()).toContain('完整保留'); route.wrapper.unmount()
+})
diff --git a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue
new file mode 100644
index 00000000..307739b2
--- /dev/null
+++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue
@@ -0,0 +1,216 @@
+
+
+
+ 排车单号 {{ currentNo || '—' }}
原操作结果尚待核对,当前使用原版本和原请求内容。
+
+ 按明确驳回结果重新核对
+ {{ detail?.actions[currentAction]?.blockReason || '当前操作资格未返回,请刷新核对' }}
+
+ 起运仓:{{ route.stops[0]?.name || '—' }} · {{ route.stops[0]?.address || '—' }}
{{ route.edit.blockReason || '仅调整本车既有卸货点,外接卸货点一并保留。调整后须重新审核。' }}
- {{ stop.name }} · {{ stop.address || '—' }} 上移下移
移除此项{{ row.remove.blockReason || '可移除' }}
+ {{ detail.vehicleInfo.vehicle?.plate || '—' }} · {{ detail.vehicleInfo.driver?.name || '—' }} · {{ detail.counts.palletCount }}托/{{ detail.counts.packageCount }}箱
{{ appointment.destinationWarehouseCode }} · {{ appointment.appointmentNo || '—' }} · {{ appointment.status }}
请核对本车资源与本趟证据;审核通过不代替后续现场扫码。
审核通过驳回
+ 查询
key !== row.sourceKey)" :disabled="frozen || row.blockReasons.length > 0" :aria-label="`选择货物 ${row.palletNo || row.orderNo || '待核实'}`" />{{ row.destination.warehouseCode || row.destination.address || '—' }}
+ {{ preview.loadingComplete.blockReason || 'PDA真实拣货已核平,请核对每托实际装载。' }}
{{ row.deliveryIndex ?? '尚未匹配,请调度核对' }}{{ pallet.palletNo }}:{{ evidence.actual ? '实测依据' : '仅供参考' }} {{ evidence.sourceBusinessNo || '—' }} {{ evidence.value ?? '—' }};
+
+ 确认本车实际发车。装车完成及全部客户派送安排由服务端再次核验。
仅零真实扫码允许退回装车前,保留原排车号,修改后两级审核重新提交。
确认取消本排车单;服务端将核对真实作业和来源占用,不自动回库。
+
+
+
+
+
+
diff --git a/src/modules/overseas-warehouse/components/SmartDispatchDialog.test.ts b/src/modules/overseas-warehouse/components/SmartDispatchDialog.test.ts
index 37874b72..f0adfbe6 100644
--- a/src/modules/overseas-warehouse/components/SmartDispatchDialog.test.ts
+++ b/src/modules/overseas-warehouse/components/SmartDispatchDialog.test.ts
@@ -1,338 +1,107 @@
-import { flushPromises, shallowMount } from '@vue/test-utils'
-import { defineComponent, h } from 'vue'
+import ElementPlus from 'element-plus'
+import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
-import { ElMessage, ElMessageBox } from 'element-plus'
import SmartDispatchDialog from './SmartDispatchDialog.vue'
-import { listDispatchCandidates, listDispatchDrafts, preArrangeDispatch, preArrangeDraftDispatch, lockDispatchDraftPallet, unlockDispatchDraftPallet, releaseDispatchDraft, deleteDispatchDraftContainer, type DispatchPalletCandidateDto, type DispatchDraftDto } from '../api/dispatches'
-
-vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
-vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn(), error: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
-vi.mock('../api/dispatches', () => ({
- listDispatchCandidates: vi.fn(), listDispatchDrafts: vi.fn(), preArrangeDispatch: vi.fn(), preArrangeDraftDispatch: vi.fn(),
- createDispatchDraftContainer: vi.fn(), deleteDispatchDraftContainer: vi.fn(),
- lockDispatchDraftPallet: vi.fn(), unlockDispatchDraftPallet: vi.fn(),
- releaseDispatchDraft: vi.fn(),
-}))
-
-const shell = defineComponent({ setup(_, { slots }) { return () => h('section', [slots.default?.(), slots.footer?.()]) } })
-const button = defineComponent({ props: { disabled: Boolean }, emits: ['click'], setup(props, { slots, emit }) {
- return () => h('button', { disabled: props.disabled, onClick: () => emit('click') }, slots.default?.())
-} })
-const retry = defineComponent({ emits: ['retry'], setup(_, { emit }) {
- return () => h('button', { 'data-test': 'retry', onClick: () => emit('retry') }, '重试加载')
-} })
-const plan: DispatchPalletCandidateDto = { orderId: '1', orderNo: 'LM-A', planPalletId: '11', sourceType: 'INBOUND_PLAN',
- warehousePalletId: '101', overseasWarehouseId: '92', customerId: 'A', palletNo: 'PAL-A', packageCount: 2, sourceStage: 'RECEIVED' }
-const returned: DispatchPalletCandidateDto = { orderId: '2', orderNo: 'LM-B', returnReceiptId: '22', returnReceiptNo: 'RSR-B', sourceType: 'RETURN_RECEIPT',
- warehousePalletId: '102', overseasWarehouseId: '92', customerId: 'A', palletNo: 'PAL-B', packageCount: 2, sourceStage: 'RECEIVED' }
-
-beforeEach(() => vi.resetAllMocks())
-
-function mountDraft() {
- return shallowMount(SmartDispatchDialog, { props: { modelValue: true, orders: [] }, global: {
- stubs: { AppDialog: shell, ElButton: button, LoadErrorState: retry, ElDialog: true,
- ElInput: true, ElOption: true, ElSelect: true, ElTable: true, ElTableColumn: true,
- ElTag: shell, ElDropdown: true, ElDropdownMenu: true, ElDropdownItem: true,
- ElEmpty: true, ElRadio: true, ElRadioGroup: true },
- } })
-}
-const secondPlan = { ...plan, orderId: '3', orderNo: 'LM-C', planPalletId: '13', warehousePalletId: '103', palletNo: 'PAL-C' }
-const thirdPlan = { ...plan, orderId: '4', orderNo: 'LM-D', planPalletId: '14', warehousePalletId: '104', palletNo: 'PAL-D' }
-const twoGroups: DispatchDraftDto = { draftToken: 'original-draft', containers: [
- { id: 'container-1', capacity: 30, palletIds: ['11', '13'] },
- { id: 'container-2', capacity: 30, palletIds: ['14', 'RETURN:22'] },
- { id: 'empty-container', capacity: 15, palletIds: [] },
-] }
-const expectedBatch = { draftToken: 'original-draft', groups: [
- { draftContainerId: 'container-1', planPalletIds: ['11', '13'], returnReceiptIds: [] },
- { draftContainerId: 'container-2', planPalletIds: ['14'], returnReceiptIds: ['22'] },
-] }
-const goodResult = [{ draftContainerId: 'container-2', dispatch: { id: '102', dispatchNo: 'DS-2', appointmentBatches: [] } },
- { draftContainerId: 'container-1', dispatch: { id: '101', dispatchNo: 'DS-1', appointmentBatches: [] } }]
-async function submitDraft(wrapper: ReturnType) {
- await wrapper.findAll('button').find(node => node.text() === '预排车')!.trigger('click')
- await flushPromises()
-}
-
-it('keeps two explicit groups in one atomic request, omits empty containers and accepts reordered matching results', async () => {
- vi.mocked(listDispatchDrafts).mockResolvedValue([twoGroups])
- vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
- vi.mocked(preArrangeDraftDispatch).mockResolvedValue(goodResult)
- const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
- expect(preArrangeDraftDispatch).toHaveBeenCalledExactlyOnceWith(expectedBatch)
- expect(preArrangeDispatch).not.toHaveBeenCalled()
- expect(wrapper.emitted('created')).toHaveLength(1)
- expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
- wrapper.unmount()
+import AppDialog from '@/shared/components/AppDialog.vue'
+import { listAllPlatformWarehouses } from '@/modules/warehouses'
+import * as api from '../api/dispatches'
+import { candidate, command, detail } from '../dispatchTestFixtures'
+import type { ConvertDraft, DispatchDraft, Version } from '../dispatchContracts'
+import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
+const identity = vi.hoisted(() => ({ id: 'smart-operator' }))
+const push = vi.hoisted(() => vi.fn())
+vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
+vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
+vi.mock('vue-router', () => ({ useRouter: () => ({ push }) }))
+vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() }))
+vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '' } }))
+vi.mock('../api/dispatches', () => ({ listDispatchDrafts: vi.fn(), listDispatchCandidates: vi.fn(), createDispatchDraft: vi.fn(), createDispatchDraftContainer: vi.fn(), lockDispatchDraftPallet: vi.fn(), unlockDispatchDraftPallet: vi.fn(), deleteDispatchDraftContainer: vi.fn(), releaseDispatchDraft: vi.fn(), convertDispatchDraft: vi.fn(), createDispatch: vi.fn(), getDispatch: vi.fn(), getDispatchCommand: vi.fn() }))
+const source = () => candidate(), draft = (): DispatchDraft => ({ draftToken: 'server-token', recordVersion: 0, status: 'ACTIVE', warehouseId: '321', containers: [{ draftContainerId: '100000000000000001', sequenceNo: 1, capacity: 30, sourceKeys: ['source-1'] }], sources: [source()], expiresAt: '2026-12-01T00:00:00Z' })
+const options = { props: { modelValue: true, orders: [] }, global: { plugins: [ElementPlus] } }
+beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(listAllPlatformWarehouses).mockResolvedValue([{ id: '321', warehouseName: '洛杉矶仓' }] as Awaited>); vi.mocked(api.listDispatchDrafts).mockResolvedValue([draft()]); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [source()], complete: true, snapshotAt: '2026-10-19T00:00:00Z' }); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 })) })
+it.each([0, '0', '9007199254740993'])('transmits original authoritative batch version %s in complete draft conversion and exact retries', async recordVersion => {
+ const original = draft()
+ original.sources[0]!.appointmentBatches = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2 }] }]
+ vi.mocked(api.listDispatchDrafts).mockResolvedValue([original])
+ vi.mocked(api.convertDispatchDraft).mockRejectedValueOnce(new Error('unknown')).mockImplementationOnce(async request => ({ draftToken: request.draftToken, replayed: true, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey) })) }))
+ const wrapper = mount(SmartDispatchDialog, options)
+ try {
+ await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise }
+ state.restoreDraft('server-token'); await flushPromises(); await state.submit()
+ const request = JSON.parse(JSON.stringify(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0])) as ConvertDraft
+ expect(request.groups[0]!.dispatch.batchAllocations).toEqual([{ planPalletId: '11', batchKey: 'FBA_APPOINTMENT:1', customerId: 'C1', sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2, recordVersion }])
+ original.sources[0]!.appointmentBatches[0]!.recordVersion = '9007199254740994'
+ await state.submit(); expect(api.convertDispatchDraft).toHaveBeenNthCalledWith(2, request)
+ } finally { wrapper.unmount() }
})
-
-it.each([
- ['one collapsed result', goodResult.slice(0, 1)],
- ['foreign container', [{ ...goodResult[0], draftContainerId: 'foreign' }, goodResult[1]]],
- ['duplicate container', [goodResult[0], goodResult[0]]],
- ['duplicate dispatch', [goodResult[0], { ...goodResult[1], dispatch: goodResult[0].dispatch }]],
- ['missing business number', [goodResult[0], { ...goodResult[1], dispatch: { id: '101', dispatchNo: '', appointmentBatches: [] } }]],
-])('retains the original groups and token after %s', async (_label, result) => {
- vi.mocked(listDispatchDrafts).mockResolvedValue([twoGroups])
- vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
- vi.mocked(preArrangeDraftDispatch).mockResolvedValue(result)
- const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
- expect(wrapper.emitted('created')).toBeUndefined()
- expect(wrapper.emitted('update:modelValue')).toBeUndefined()
- expect(wrapper.text()).toContain('托盘 4 · 箱数 8')
- await submitDraft(wrapper)
- expect(preArrangeDraftDispatch).toHaveBeenNthCalledWith(2, expectedBatch)
- expect(preArrangeDispatch).not.toHaveBeenCalled()
- wrapper.unmount()
+it.each([undefined, null, 'bad', 9007199254740992])('requires rereading a restored candidate with missing or invalid batch version %s', async recordVersion => {
+ const original = draft()
+ original.sources[0]!.appointmentBatches = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion: recordVersion as Version, sources: [] }]
+ vi.mocked(api.listDispatchDrafts).mockResolvedValue([original])
+ const wrapper = mount(SmartDispatchDialog, options)
+ try {
+ await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise }
+ state.restoreDraft('server-token'); await flushPromises(); await state.submit()
+ expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('预约批次版本缺失或无效,请重新读取候选')
+ } finally { wrapper.unmount() }
})
-
-it('retains all groups on batch rejection without issuing a flat request', async () => {
- vi.mocked(listDispatchDrafts).mockResolvedValue([twoGroups])
- vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
- vi.mocked(preArrangeDraftDispatch).mockRejectedValue(new Error('草稿来源已变化'))
- const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
- expect(preArrangeDraftDispatch).toHaveBeenCalledTimes(1)
- expect(preArrangeDispatch).not.toHaveBeenCalled()
- expect(wrapper.emitted('created')).toBeUndefined()
- expect(wrapper.text()).toContain('托盘 4 · 箱数 8')
- wrapper.unmount()
+it('requires an explicit warehouse and allows saving a true empty dispatch with no fake cargo', async () => {
+ const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
+ const state = wrapper.vm as unknown as { mode: string; warehouseId: string; submit: () => Promise }
+ state.mode = 'empty'; await wrapper.getComponent(AppDialog).vm.$emit('confirm'); await flushPromises(); expect(api.createDispatch).not.toHaveBeenCalled()
+ state.warehouseId = '321'; vi.mocked(api.createDispatch).mockImplementation(async body => command('CREATE', body.idempotencyKey))
+ await state.submit(); await flushPromises()
+ expect(api.createDispatch).toHaveBeenCalledWith(expect.objectContaining({ warehouseId: '321', cargo: [], vehicle: null, driver: null }))
+ expect(wrapper.emitted('created')).toHaveLength(1); wrapper.unmount()
})
-
-it('does not submit a physical pallet fallback when its actual plan source identity is missing', async () => {
- vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'original-draft', containers: [
- { id: 'container', capacity: 30, palletIds: ['101'] },
- ] }])
- vi.mocked(listDispatchCandidates).mockResolvedValue([{ ...plan, planPalletId: undefined }])
- const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
- expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
- expect(preArrangeDispatch).not.toHaveBeenCalled()
- expect(ElMessage.warning).toHaveBeenCalledWith(expect.stringContaining('来源标识缺失'))
- expect(wrapper.emitted('update:modelValue')).toBeUndefined()
- wrapper.unmount()
+it('restores an explicit draft and submits complete server source versions in each container', async () => {
+ const original = draft(); original.sources.push(candidate({ sourceKey: 'source-B', customerId: 'B', customerName: '客户乙' })); original.containers[0]!.sourceKeys.push('source-B')
+ vi.mocked(api.listDispatchDrafts).mockResolvedValue([original])
+ vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, 'PC/1') })) }))
+ const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft: (token: string) => void; submit: () => Promise }
+ state.restoreDraft('server-token'); await flushPromises(); await state.submit(); await flushPromises()
+ expect(api.convertDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ recordVersion: 0, draftToken: 'server-token', groups: [expect.objectContaining({ draftContainerId: '100000000000000001', dispatch: expect.objectContaining({ cargo: [source().selection, source().selection] }) })] }))
+ expect(wrapper.emitted('created')).toHaveLength(1); wrapper.unmount()
})
-
-it('submits relabel physical pallet identity without requiring an inbound plan pallet', async () => {
- vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'relabel-draft', containers: [
- { id: 'container', capacity: 30, palletIds: ['101'] },
- ] }])
- vi.mocked(listDispatchCandidates).mockResolvedValue([{ ...plan, planPalletId: null, sourceStage: 'RELABEL', orderRefs: 'RL-1' }])
- vi.mocked(preArrangeDispatch).mockResolvedValue({ id: '1', dispatchNo: 'DS-1', appointmentBatches: [] })
- const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
- expect(preArrangeDispatch).toHaveBeenCalledExactlyOnceWith({ planPalletIds: ['101'], returnReceiptIds: [], relabelNo: 'RL-1' })
- expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
- expect(wrapper.emitted('created')).toHaveLength(1)
- wrapper.unmount()
+it('retains conversion payload and key after an unknown result instead of rebuilding from fresh versions', async () => {
+ vi.mocked(api.convertDispatchDraft).mockRejectedValueOnce(new Error('network unknown')).mockImplementationOnce(async request => ({ draftToken: request.draftToken, replayed: true, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey) })) }))
+ const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft: (token: string) => void; submit: () => Promise }
+ state.restoreDraft('server-token'); await flushPromises(); await state.submit(); await state.submit()
+ expect(api.convertDispatchDraft).toHaveBeenCalledTimes(2); expect(vi.mocked(api.convertDispatchDraft).mock.calls[1]![0]).toEqual(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0]); wrapper.unmount()
})
-
-it('does not send a second batch while the original command is pending', async () => {
- vi.mocked(listDispatchDrafts).mockResolvedValue([twoGroups])
- vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
- let resolveCommand!: (result: typeof goodResult) => void
- vi.mocked(preArrangeDraftDispatch).mockReturnValue(new Promise(resolve => { resolveCommand = resolve }))
- const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper); await submitDraft(wrapper)
- expect(preArrangeDraftDispatch).toHaveBeenCalledExactlyOnceWith(expectedBatch)
- expect(wrapper.emitted('created')).toBeUndefined()
- resolveCommand(goodResult); await flushPromises()
- expect(wrapper.emitted('created')).toHaveLength(1)
- wrapper.unmount()
+it('does not discard draft selections when recovery fails and never submits an incomplete source list', async () => {
+ vi.mocked(api.listDispatchDrafts).mockRejectedValue(new Error('draft unavailable'))
+ const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); expect(wrapper.text()).toContain('排车候选或草稿恢复失败'); expect(api.convertDispatchDraft).not.toHaveBeenCalled(); wrapper.unmount()
})
-
-it('keeps group membership frozen while the submitted batch is awaiting its response', async () => {
- vi.mocked(listDispatchDrafts).mockResolvedValue([twoGroups])
- vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
- let resolveCommand!: (result: typeof goodResult) => void
- vi.mocked(preArrangeDraftDispatch).mockReturnValue(new Promise(resolve => { resolveCommand = resolve }))
- const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
- await wrapper.findAll('button').find(node => node.text() === '移除')!.trigger('click')
- await wrapper.findAll('button').find(node => node.attributes('aria-label') === '删除装车车型1')!.trigger('click')
- await flushPromises()
- expect(unlockDispatchDraftPallet).not.toHaveBeenCalled()
- expect(deleteDispatchDraftContainer).not.toHaveBeenCalled()
- expect(wrapper.text()).toContain('托盘 4 · 箱数 8')
- resolveCommand(goodResult); await flushPromises()
- expect(wrapper.emitted('created')).toHaveLength(1)
- wrapper.unmount()
+it('refuses an incomplete candidate page without pretending its filtered rows are all available cargo', async () => {
+ vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [], complete: false, snapshotAt: '' })
+ const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft: (token: string) => void }; state.restoreDraft('server-token'); await flushPromises(); expect(wrapper.text()).toContain('尚未完整返回'); wrapper.unmount()
})
-
-it('retains unresolved sources when retry hydration still cannot recover the complete original draft', async () => {
- vi.mocked(listDispatchDrafts).mockResolvedValue([twoGroups])
- vi.mocked(listDispatchCandidates).mockResolvedValue([plan, returned])
- const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
- expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
- expect(preArrangeDispatch).not.toHaveBeenCalled()
- expect(ElMessage.warning).toHaveBeenCalledWith(expect.stringContaining('原选择已保留'))
- expect(wrapper.emitted('update:modelValue')).toBeUndefined()
- wrapper.unmount()
+it('keeps vehicle assignments independent between containers and reuses a proven conversion after a detail read fails', async () => {
+ const original = draft(); original.containers.push({ draftContainerId: 'container-B', sequenceNo: 2, capacity: 15, sourceKeys: ['source-B'] }); original.sources.push(candidate({ sourceKey: 'source-B', customerId: 'B' }))
+ vi.mocked(api.listDispatchDrafts).mockResolvedValue([original])
+ vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC/${index + 1}`) })) }))
+ vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('detail unavailable')).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 }))
+ const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
+ const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise; activeContainerId: string; vehicle: { vehicleId: string | null }; form: { remark: string } }
+ state.restoreDraft('server-token'); await flushPromises(); state.vehicle.vehicleId = 'vehicle-A'; state.form.remark = '车甲'
+ state.activeContainerId = 'container-B'; state.vehicle.vehicleId = 'vehicle-B'; state.form.remark = '车乙'
+ await state.submit(); expect(wrapper.emitted('created')).toBeUndefined()
+ await state.submit(); expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
+ const groups = vi.mocked(api.convertDispatchDraft).mock.calls[0]![0].groups
+ expect(groups.map(group => [group.dispatch.vehicle?.vehicleId, group.dispatch.remark])).toEqual([['vehicle-A', '车甲'], ['vehicle-B', '车乙']])
+ expect(wrapper.emitted('created')).toHaveLength(1); wrapper.unmount()
})
-
-it.each([
- { ...twoGroups, containers: [{ id: 'c1', capacity: 30, palletIds: ['11'] }, { id: 'c2', capacity: 30, palletIds: ['11'] }] },
- { ...twoGroups, containers: [{ id: 'c1', capacity: 30, palletIds: ['11'] }, { id: 'c1', capacity: 30, palletIds: ['13'] }] },
- { ...twoGroups, containers: [{ id: '', capacity: 30, palletIds: ['11'] }] },
-])('rejects duplicate sources or invalid container identities before writing', async draft => {
- vi.mocked(listDispatchDrafts).mockResolvedValue([draft])
- vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan])
- const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
- expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
- expect(preArrangeDispatch).not.toHaveBeenCalled()
- expect(wrapper.emitted('update:modelValue')).toBeUndefined()
- wrapper.unmount()
+it('never sends an existing departure command through the empty creation transport', async () => {
+ useDispatchCommandRecovery(() => identity.id).begin('PC/1', 'DEPART', { recordVersion: 0, idempotencyKey: 'original-key' })
+ const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
+ await (wrapper.vm as unknown as { submit(): Promise }).submit()
+ expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('从列表的原请求恢复入口'); wrapper.unmount()
})
-
-it('rejects a relabel order split across multiple containers before either API command', async () => {
- const relabel: DispatchPalletCandidateDto = { ...plan, sourceStage: 'RELABEL', orderRefs: 'RL-1' }
- vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'original-draft', containers: [
- { id: 'c1', capacity: 30, palletIds: ['101'] }, { id: 'c2', capacity: 30, palletIds: ['103'] },
- ] }])
- vi.mocked(listDispatchCandidates).mockResolvedValue([relabel, { ...secondPlan, sourceStage: 'RELABEL', orderRefs: 'RL-1' }])
- const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
- expect(preArrangeDispatch).not.toHaveBeenCalled()
- expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
- expect(ElMessage.warning).toHaveBeenCalledWith(expect.stringContaining('一组排车'))
- expect(wrapper.emitted('update:modelValue')).toBeUndefined()
- wrapper.unmount()
-})
-
-it('keeps one complete relabel order on the single-dispatch endpoint', async () => {
- vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'original-draft', containers: [
- { id: 'c1', capacity: 30, palletIds: ['101', '103'] },
- ] }])
- vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan].map(p => ({ ...p, sourceStage: 'RELABEL', orderRefs: 'RL-1' })))
- vi.mocked(preArrangeDispatch).mockResolvedValue({ id: '1', dispatchNo: 'DS-RL', appointmentBatches: [] })
- const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
- expect(preArrangeDispatch).toHaveBeenCalledExactlyOnceWith({ planPalletIds: ['101', '103'], returnReceiptIds: [], relabelNo: 'RL-1' })
- expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
- expect(wrapper.emitted('created')).toHaveLength(1)
- wrapper.unmount()
-})
-
-it('retains every restored selection on candidate failure and submits both sources only after retry hydration', async () => {
- vi.mocked(preArrangeDraftDispatch).mockResolvedValue([{ draftContainerId: 'container', dispatch: { id: '1', dispatchNo: 'DS-1', appointmentBatches: [] } }])
- vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'original-draft', containers: [{ id: 'container', capacity: 30, palletIds: ['11', 'RETURN:22'] }] }])
- vi.mocked(listDispatchCandidates)
- .mockResolvedValueOnce([plan])
- .mockRejectedValueOnce(new Error('恢复来源失败'))
- .mockResolvedValueOnce([plan])
- .mockResolvedValueOnce([plan, returned])
- const wrapper = shallowMount(SmartDispatchDialog, { props: { modelValue: true, orders: [] }, global: {
- stubs: { AppDialog: shell, ElButton: button, LoadErrorState: retry, ElDialog: true,
- ElInput: true, ElOption: true, ElSelect: true, ElTable: true, ElTableColumn: true,
- ElTag: true, ElDropdown: true, ElDropdownMenu: true, ElDropdownItem: true,
- ElEmpty: true, ElRadio: true, ElRadioGroup: true },
- } })
- await flushPromises()
- const submit = () => wrapper.findAll('button').find(node => node.text() === '预排车')!
- await submit().trigger('click')
- await flushPromises()
- expect(preArrangeDispatch).not.toHaveBeenCalled()
- expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
- expect(ElMessage.warning).toHaveBeenCalledWith(expect.stringContaining('原选择已保留'))
- expect(wrapper.emitted('update:modelValue')).toBeUndefined()
- await wrapper.get('[data-test="retry"]').trigger('click')
- await flushPromises()
- await submit().trigger('click')
- await flushPromises()
- expect(preArrangeDraftDispatch).toHaveBeenCalledExactlyOnceWith({ draftToken: 'original-draft', groups: [{ draftContainerId: 'container', planPalletIds: ['11'], returnReceiptIds: ['22'] }] })
- expect(preArrangeDispatch).not.toHaveBeenCalled()
- expect(listDispatchCandidates).toHaveBeenLastCalledWith({ draftToken: 'original-draft' })
- expect(wrapper.emitted('created')).toHaveLength(1)
- wrapper.unmount()
-})
-
-it('shows a draft recovery error and only loads candidates after a successful retry with the original token', async () => {
- vi.mocked(listDispatchDrafts).mockRejectedValueOnce(new Error('草稿查询超时')).mockResolvedValueOnce([twoGroups])
- vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
- vi.mocked(preArrangeDraftDispatch).mockResolvedValue(goodResult)
- const wrapper = mountDraft(); await flushPromises()
- expect(wrapper.get('[data-test="retry"]').attributes('title')).toBe('排车草稿恢复失败')
- expect(listDispatchCandidates).not.toHaveBeenCalled()
- expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
- await wrapper.get('[data-test="retry"]').trigger('click'); await flushPromises()
- expect(wrapper.text()).toContain('托盘 4 · 箱数 8')
- await submitDraft(wrapper)
- expect(preArrangeDraftDispatch).toHaveBeenCalledExactlyOnceWith(expectedBatch)
- wrapper.unmount()
-})
-
-it('keeps local relabel choices on close and reopen while describing their actual unlocked state', async () => {
- const relabel: DispatchPalletCandidateDto = { ...plan, sourceStage: 'RELABEL', orderRefs: 'RL-1' }
- vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'local-relabel', containers: [{ id: 'c1', capacity: 30, palletIds: [] }] }])
- vi.mocked(listDispatchCandidates).mockResolvedValue([relabel])
- const wrapper = mountDraft(); await flushPromises()
- wrapper.getComponent({ name: 'ElDropdown' }).vm.$emit('command', 'c1'); await flushPromises()
- expect(lockDispatchDraftPallet).not.toHaveBeenCalled()
- expect(wrapper.text()).toContain('已选择,尚未锁定')
- await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
- expect(wrapper.text()).toContain('托盘 1 · 箱数 2')
- expect(wrapper.text()).toContain('已选择,尚未锁定')
- expect(listDispatchDrafts).toHaveBeenCalledTimes(2)
- await wrapper.findAll('button').find(node => node.text() === '移除')!.trigger('click'); await flushPromises()
- expect(unlockDispatchDraftPallet).not.toHaveBeenCalled()
- expect(wrapper.text()).toContain('托盘 0 · 箱数 0')
- wrapper.unmount()
-})
-
-it.each(['RELABEL_APPLICATION', 'RETURN_RELABEL'])('rejects a partial %s application after refreshing its complete candidate set', async orderType => {
- const relabel = { ...plan, sourceStage: 'RELABEL' as const, orderRefs: 'RL-1', orderType }
- vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'local-relabel', containers: [{ id: 'c1', capacity: 30, palletIds: ['101'] }] }])
- vi.mocked(listDispatchCandidates).mockResolvedValue([relabel, { ...relabel, planPalletId: '13', warehousePalletId: '103' }])
- const wrapper = mountDraft(); await flushPromises(); await submitDraft(wrapper)
- expect(listDispatchCandidates).toHaveBeenLastCalledWith({ sourceStage: 'RELABEL', orderNo: 'RL-1', draftToken: 'local-relabel' })
- expect(preArrangeDispatch).not.toHaveBeenCalled()
- expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
- expect(wrapper.getComponent({ name: 'OperationErrorToast' }).props('details')?.message).toContain('全部应排托盘')
- expect(wrapper.text()).toContain('托盘 1 · 箱数 2')
- expect(wrapper.emitted('created')).toBeUndefined()
- wrapper.unmount()
-})
-
-it('preserves all previous selections when reopening fails to read drafts, then recovers the same groups on retry', async () => {
- vi.mocked(listDispatchDrafts).mockResolvedValueOnce([twoGroups]).mockRejectedValueOnce(new Error('查询超时')).mockResolvedValueOnce([twoGroups])
- vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
- vi.mocked(preArrangeDraftDispatch).mockResolvedValue(goodResult)
- const wrapper = mountDraft(); await flushPromises()
- await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
- expect(wrapper.get('[data-test="retry"]').attributes('title')).toBe('排车草稿恢复失败')
- expect(listDispatchCandidates).toHaveBeenCalledTimes(1)
- expect(wrapper.findAll('button').find(button => button.text() === '预排车')!.attributes('disabled')).toBeDefined()
- await wrapper.get('[data-test="retry"]').trigger('click'); await flushPromises()
- expect(wrapper.text()).toContain('托盘 4 · 箱数 8')
- await submitDraft(wrapper)
- expect(preArrangeDraftDispatch).toHaveBeenCalledExactlyOnceWith(expectedBatch)
- wrapper.unmount()
-})
-
-it('requires explicit release to restart an expired draft and keeps choices when release fails', async () => {
- vi.mocked(listDispatchDrafts).mockResolvedValueOnce([twoGroups]).mockResolvedValue([])
- vi.mocked(listDispatchCandidates).mockResolvedValue([plan, secondPlan, thirdPlan, returned])
- vi.mocked(ElMessageBox.confirm).mockResolvedValue('confirm' as Awaited>)
- vi.mocked(releaseDispatchDraft).mockRejectedValueOnce(new Error('释放超时')).mockResolvedValueOnce(null)
- const wrapper = mountDraft(); await flushPromises()
- await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
- expect(wrapper.get('[data-test="retry"]').attributes('message')).toContain('原草稿已失效')
- expect(releaseDispatchDraft).not.toHaveBeenCalled()
- const restart = () => wrapper.findAll('button').find(button => button.text() === '释放草稿并重新选择')!
- await restart().trigger('click'); await flushPromises()
- expect(releaseDispatchDraft).toHaveBeenLastCalledWith('original-draft')
- expect(wrapper.get('[data-test="retry"]').attributes('message')).toContain('原选择已保留')
- await restart().trigger('click'); await flushPromises()
- expect(releaseDispatchDraft).toHaveBeenNthCalledWith(2, 'original-draft')
- expect(wrapper.find('[data-test="retry"]').exists()).toBe(false)
- expect(wrapper.text()).toContain('托盘 0 · 箱数 0')
- wrapper.unmount()
-})
-
-it.each([plan, returned])('does not claim a failed source lock succeeded for $sourceType', async candidate => {
- vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: 'lock-draft', containers: [{ id: 'c1', capacity: 30, palletIds: [] }] }])
- vi.mocked(listDispatchCandidates).mockResolvedValue([candidate])
- vi.mocked(lockDispatchDraftPallet).mockRejectedValueOnce({ isAxiosError: true, response: { status: 409, data: { code: 'A0409', message: '来源已被其他草稿锁定', requestId: 'lock-request', traceId: 'lock-trace' } } }).mockResolvedValueOnce(null)
- const wrapper = mountDraft(); await flushPromises()
- wrapper.getComponent({ name: 'ElDropdown' }).vm.$emit('command', 'c1'); await flushPromises()
- expect(wrapper.text()).toContain('托盘 0 · 箱数 0')
- expect(wrapper.getComponent({ name: 'OperationErrorToast' }).props('details')).toMatchObject({ message: '来源已被其他草稿锁定', requestId: 'lock-request', traceId: 'lock-trace' })
- wrapper.getComponent({ name: 'ElDropdown' }).vm.$emit('command', 'c1'); await flushPromises()
- expect(wrapper.text()).toContain('托盘 1 · 箱数 2')
- expect(wrapper.text()).toContain('已锁定')
- expect(lockDispatchDraftPallet).toHaveBeenLastCalledWith(candidate.sourceType === 'RETURN_RECEIPT' ? 'RETURN:22' : '11', 'lock-draft', 'c1')
- wrapper.unmount()
+it('rejects a restored source whose batch customer differs from its real cargo owner', async () => {
+ const original = draft(); original.sources[0]!.appointmentBatches = [{ batchKey: 'batch-1', batchNo: 'BATCH-1', recordVersion: 0, customerId: 'other-customer', sources: [{ sourceKey: 'source-1', destinationKey: 'CODE:ONT8', packageCount: 2 }] }]
+ vi.mocked(api.listDispatchDrafts).mockResolvedValue([original])
+ const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
+ const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise }; state.restoreDraft('server-token'); await flushPromises(); await state.submit()
+ expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('预约客户不一致'); wrapper.unmount()
})
diff --git a/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue b/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue
index ad34824b..300670c3 100644
--- a/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue
+++ b/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue
@@ -1,915 +1,185 @@
-
-
-
-
-
- 仅展示客户已确认、卡车派送且尚未排车的换标单。必须一次选择本申请全部应排托盘并放入同一装车容器。换标选择仅保留在当前页面,尚未锁定;刷新页面后需重新选择,提交时由服务端重新校验。
-
-
-
-
- 正在恢复排车草稿,请稍候…
-
-
-
-
-
-
-
- {{ row.first.sourceStage === 'RELABEL' ? text(row.first.orderRefs) : '—' }}
- {{ text(row.first.inboundCode) }}
- {{ orderType(row.first.orderType) }}
- {{ formatWeightLb(orderWeightLb(row.pallets)) }}
- {{ row.pallets.length }}
- {{ text(row.first.fbaWarehouseCode) }}
- {{ privateWarehouse(row.first) }}
- {{
- number(
- row.pallets.reduce((sum: number, item: DispatchPalletCandidateDto) => sum + Number(item.volumeCbm || 0), 0),
- 2,
- )
- }}
- {{ text(row.first.containerNo) }}
- {{ dateTime(row.first.appointmentAt) }}
- {{ dateTime(row.first.expectedLoadingAt) }}
-
-
-
-
-
-
-
- 托盘明细
- {{ activePallets.length ? `(${activePallets.length}托)` : '' }}
-
- 订单号:
-
-
-
-
-
-
{{ text(item.palletNo) }}{{ Number(item.packageCount || 0) }}箱 · {{ formatWeightLb(weightLb(item)) }}
-
退回实收:{{ text(item.returnReceiptNo) }}
-
-
-
{{ item.sourceStage === 'RELABEL' ? '已选择,尚未锁定' : '已锁定' }}
-
- 移入
- 装车托盘 {{ index + 1 }}({{ container.palletIds.length }}/{{ container.capacity }})
-
-
-
-
-
-
-
-
-
-
+
+
+ 原请求结果尚待核对,请勿更改选择、版本或重新建单。
按明确驳回结果重新核对
+ 先选货建单先建空单后加货
+ 当前编辑:{{ draft?.containers.find(row => row.draftContainerId === activeContainerId)?.sequenceNo ? `装车组 ${draft.containers.find(row => row.draftContainerId === activeContainerId)!.sequenceNo}` : '请先选择装车组' }};每组车辆、司机和时间独立保存。
+
+ 空单可先保存,之后添加同仓各客户货物;空单不能核平、装车或发车。
+ 新建草稿添加装车组释放本草稿
+ 查询
+ 加入本组{{ ({ INBOUND_PLAN: '计划托盘', RETURN_RECEIPT: '退回实收', RELABEL: '换标' } as Record)[row.selection.sourceType] || '—' }}
{{ row.selection.relabelNo || row.planNo || '—' }}{{ [row.destination.warehouseCode || row.destination.address, row.destination.postalCode].filter(Boolean).join(' · ') || '—' }}{{ row.suggestedWeightKg ?? '—' }}kg/{{ row.suggestedVolumeCbm ?? '—' }}m³
仅供预排参考{{ row.blockReasons.join(';') || '可排' }}
+
装车组 {{ container.sequenceNo }} · {{ container.capacity }}托位移除本组- {{ draft?.sources.find(row => row.sourceKey === key)?.palletNo || '来源待恢复' }} row.sourceKey === key), container.draftContainerId)">移除货物
新建或恢复草稿后添加装车组。同仓可混装多个客户与目的仓。
+
+
-
-
- {{ spec.label }} · 限重 {{ spec.maxWeightLb }}LB
-
- 取消确定
-
-
diff --git a/src/modules/overseas-warehouse/dispatchCommandRecovery.test.ts b/src/modules/overseas-warehouse/dispatchCommandRecovery.test.ts
new file mode 100644
index 00000000..e4de207d
--- /dev/null
+++ b/src/modules/overseas-warehouse/dispatchCommandRecovery.test.ts
@@ -0,0 +1,63 @@
+import { beforeEach, expect, it, vi } from 'vitest'
+import { ApiResponseError } from '@/shared/api/http'
+import { useDispatchCommandRecovery } from './dispatchCommandRecovery'
+import { command } from './dispatchTestFixtures'
+beforeEach(() => sessionStorage.clear())
+it('retains the exact original version and body when POST outcome is unknown and a newer detail was read', async () => {
+ const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
+ recovery.begin('PC/1', 'DEPART', { recordVersion: 0, idempotencyKey: 'key', remark: 'original' })
+ const send = vi.fn().mockRejectedValueOnce(new Error('network unknown')).mockResolvedValueOnce(command('DEPART'))
+ const read = vi.fn().mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 50 }), find = vi.fn().mockRejectedValue(new ApiResponseError('not found', 'HTTP_404'))
+ await expect(recovery.execute(send, read, find)).rejects.toThrow('network unknown')
+ await recovery.execute(send, read, find)
+ expect(send.mock.calls[1]![0].body).toEqual({ recordVersion: 0, idempotencyKey: 'key', remark: 'original' })
+ expect(recovery.pending.value).toBeNull()
+})
+it('keeps a proven result across GET failure and does not send the command a second time', async () => {
+ const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
+ recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'key' })
+ const send = vi.fn().mockResolvedValue(command('LOADING_COMPLETE')), read = vi.fn().mockRejectedValueOnce(new Error('read failed')).mockResolvedValueOnce({ dispatchNo: 'PC/1', recordVersion: 1 }), find = vi.fn()
+ await expect(recovery.execute(send, read, find)).rejects.toThrow('read failed')
+ expect(recovery.pending.value?.result?.stage).toBe('AFTER_LOADING')
+ await recovery.execute(send, read, find)
+ expect(send).toHaveBeenCalledTimes(1); expect(find).not.toHaveBeenCalled()
+})
+it('shares one pending command between empty-create and workflow dialogs and refuses a new command', () => {
+ const owner = crypto.randomUUID(), first = useDispatchCommandRecovery(() => owner), second = useDispatchCommandRecovery(() => owner)
+ first.begin('PC/1', 'EDIT', { recordVersion: 0, idempotencyKey: 'key' })
+ expect(second.pending.value?.dispatchNo).toBe('PC/1')
+ expect(() => second.begin('', 'CREATE', { idempotencyKey: 'new' })).toThrow('原请求')
+})
+it('does not discard an earlier unknown write on a later business rejection', async () => {
+ const recovery = useDispatchCommandRecovery(() => crypto.randomUUID())
+ expect(() => recovery.begin('PC/1', 'EDIT', { recordVersion: 0, idempotencyKey: 'key' })).toThrow('账号')
+ const owner = crypto.randomUUID(), stable = useDispatchCommandRecovery(() => owner)
+ stable.begin('PC/1', 'EDIT', { recordVersion: 0, idempotencyKey: 'key' })
+ const send = vi.fn().mockRejectedValueOnce(new Error('unknown')).mockRejectedValueOnce(new ApiResponseError('version conflict', 'A0409'))
+ const find = vi.fn().mockRejectedValue(new ApiResponseError('not found', 'HTTP_404'))
+ await expect(stable.execute(send, vi.fn(), find)).rejects.toThrow('unknown')
+ await expect(stable.execute(send, vi.fn(), find)).rejects.toThrow('version conflict')
+ stable.discardRejected(); expect(stable.pending.value).not.toBeNull()
+})
+it('rejects a mismatched business number/action/key instead of treating it as successful', async () => {
+ const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
+ recovery.begin('PC/1', 'DEPART', { recordVersion: 0, idempotencyKey: 'key' })
+ const read = vi.fn()
+ await expect(recovery.execute(vi.fn().mockResolvedValue(command('DEPART', 'different')), read, vi.fn())).rejects.toThrow('不一致')
+ expect(read).not.toHaveBeenCalled(); expect(recovery.pending.value).not.toBeNull()
+})
+it('does not release a proven result for an older detail version, including versions beyond safe integer precision', async () => {
+ const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
+ recovery.begin('PC/1', 'DEPART', { recordVersion: '9007199254740992', idempotencyKey: 'key' })
+ const send = vi.fn().mockResolvedValue({ ...command('DEPART'), recordVersion: '9007199254740993' }), find = vi.fn()
+ await expect(recovery.execute(send, vi.fn().mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: '9007199254740992' }), find)).rejects.toThrow('详情版本早于')
+ expect(recovery.pending.value?.result).not.toBeNull()
+ await recovery.execute(send, vi.fn().mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: '9007199254740994' }), find)
+ expect(send).toHaveBeenCalledTimes(1); expect(recovery.pending.value).toBeNull()
+})
+it('treats an internal server error as unknown instead of authorizing a new command', async () => {
+ const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner)
+ recovery.begin('PC/1', 'EDIT', { recordVersion: 0, idempotencyKey: 'key' })
+ await expect(recovery.execute(vi.fn().mockRejectedValue(new ApiResponseError('server failure', 'A0500', 500)), vi.fn(), vi.fn())).rejects.toThrow('server failure')
+ recovery.discardRejected(); expect(recovery.pending.value?.rejected).toBe(false)
+})
diff --git a/src/modules/overseas-warehouse/dispatchCommandRecovery.ts b/src/modules/overseas-warehouse/dispatchCommandRecovery.ts
new file mode 100644
index 00000000..2d3bed5a
--- /dev/null
+++ b/src/modules/overseas-warehouse/dispatchCommandRecovery.ts
@@ -0,0 +1,82 @@
+import { ref } from 'vue'
+import { ApiResponseError } from '@/shared/api/http'
+import type { CommandResult, DispatchAction } from './dispatchContracts'
+import { requireVersion } from './dispatchContracts'
+
+export interface PendingDispatchCommand { dispatchNo: string; action: DispatchAction; body: Record; attempted: boolean; result: CommandResult | null; rejected: boolean; operation?: 'ROUTE'; itemId?: string }
+const storageKey = (scope: string) => `dispatch-command:${scope}`
+const snapshot = (value: T): T => JSON.parse(JSON.stringify(value)) as T
+const sessions = new Map>; busy: ReturnType> }>()
+export function isDefiniteDispatchRejection(error: unknown) {
+ return error instanceof ApiResponseError && /^(?:A0|HTTP_)?(?:400|401|403|404|409|410|422)$/.test(String(error.code))
+}
+export function validateCommandResult(result: CommandResult, pending: PendingDispatchCommand) {
+ if (!result || result.dispatchNo !== pending.dispatchNo && pending.action !== 'CREATE'
+ || result.action !== pending.action || result.idempotencyKey !== pending.body.idempotencyKey
+ || !result.dispatchNo || !result.result) throw new Error('操作结果与原排车请求不一致,请保留原请求核对')
+ requireVersion(result.recordVersion)
+ if (pending.action === 'DEPART' && result.stage !== 'DELIVERING') throw new Error('发车结果未确认派送中,请核对原请求')
+ if (pending.action === 'LOADING_COMPLETE' && result.stage !== 'AFTER_LOADING') throw new Error('装车结果未确认装车后,请核对原请求')
+}
+export function validateCurrentDispatch(value: unknown, result: CommandResult) {
+ const current = value as { dispatchNo?: string; recordVersion?: unknown } | null
+ if (current?.dispatchNo !== result.dispatchNo) throw new Error('恢复详情与原排车单不一致')
+ const latest = String(requireVersion(current.recordVersion)).replace(/^0+(?=\d)/, '')
+ const confirmed = String(requireVersion(result.recordVersion)).replace(/^0+(?=\d)/, '')
+ if (latest.length < confirmed.length || latest.length === confirmed.length && latest < confirmed) throw new Error('详情版本早于已确认操作,请保留原请求重读')
+}
+
+/** A response proves the original command; a subsequent GET supplies current UI facts. */
+export function useDispatchCommandRecovery(scope: () => string) {
+ const owner = scope()
+ const saved = owner ? sessionStorage.getItem(storageKey(owner)) : null
+ const existing = sessions.get(owner)
+ const pending = existing?.pending || ref(null)
+ const busy = existing?.busy || ref(false)
+ if (!existing && saved) {
+ try { pending.value = JSON.parse(saved) as PendingDispatchCommand }
+ catch { throw new Error('本次排车请求记录损坏,请保留现场核对') }
+ }
+ if (!existing) sessions.set(owner, { pending, busy })
+ function persist() {
+ if (!owner || owner !== scope()) throw new Error('账号已变化,请重新进入排车列表')
+ if (pending.value) sessionStorage.setItem(storageKey(owner), JSON.stringify(pending.value))
+ else sessionStorage.removeItem(storageKey(owner))
+ }
+ function begin(dispatchNo: string, action: DispatchAction, body: object, target: Pick = {}) {
+ if (pending.value) throw new Error('上次排车操作结果尚未核对,请先恢复原请求')
+ pending.value = { dispatchNo, action, body: snapshot(body) as Record, attempted: false, result: null, rejected: false, ...target }
+ persist()
+ }
+ async function execute(send: (command: PendingDispatchCommand) => Promise, read: (dispatchNo: string) => Promise, find: (dispatchNo: string, key: string) => Promise) {
+ if (busy.value || !pending.value) return
+ const command = pending.value
+ const previouslyAttempted = command.attempted
+ busy.value = true
+ try {
+ persist()
+ if (!command.result && previouslyAttempted && command.dispatchNo) {
+ try { const found = await find(command.dispatchNo, String(command.body.idempotencyKey)); validateCommandResult(found, command); command.result = found }
+ catch (error) { if (!(error instanceof ApiResponseError) || !['404', 'A0404', 'HTTP_404'].includes(String(error.code))) throw error }
+ }
+ if (!command.result) {
+ command.attempted = true; persist()
+ try { const sent = await send(snapshot(command)); validateCommandResult(sent, command); command.result = sent }
+ catch (error) {
+ // A later rejection cannot erase an earlier unknown write result.
+ if (!previouslyAttempted && isDefiniteDispatchRejection(error)) command.rejected = true
+ persist(); throw error
+ }
+ }
+ validateCommandResult(command.result, command); persist()
+ validateCurrentDispatch(await read(command.result.dispatchNo), command.result)
+ pending.value = null; persist()
+ return command.result
+ } finally { busy.value = false }
+ }
+ function discardRejected() {
+ if (busy.value || !pending.value?.rejected) return
+ pending.value = null; persist()
+ }
+ return { pending, busy, begin, execute, discardRejected }
+}
diff --git a/src/modules/overseas-warehouse/dispatchContracts.ts b/src/modules/overseas-warehouse/dispatchContracts.ts
new file mode 100644
index 00000000..0d28d2ab
--- /dev/null
+++ b/src/modules/overseas-warehouse/dispatchContracts.ts
@@ -0,0 +1,100 @@
+// Models after the dispatch API boundary: Long quantities are validated safe numbers.
+// dispatchWire.ts declares their HTTP string/number representation; IDs/versions remain lossless.
+export type Version = string | number
+export class DispatchInputError extends Error {}
+export type DispatchStatus = 'ACTIVE' | 'CANCELLED'
+export type DispatchStage = 'BEFORE_LOADING' | 'LOADING' | 'AFTER_LOADING' | 'DELIVERING' | 'DELIVERED'
+export type DispatchAction = 'CREATE' | 'EDIT' | 'ADD_CARGO' | 'REMOVE_CARGO' | 'CANCEL' | 'SUBMIT_REVIEW' | 'DISPATCH_REVIEW' | 'LOADING_REVIEW' | 'RETURN_TO_REVIEW' | 'PICK' | 'LOADING_COMPLETE' | 'ARRANGE_DELIVERY' | 'DEPART'
+export type ReviewStatus = 'NOT_SUBMITTED' | 'PENDING' | 'APPROVED' | 'REJECTED'
+export type BeforeLoadingSection = 'VEHICLE_INFO' | 'DISPATCH_REVIEW' | 'LOADING_REVIEW'
+export interface Gate { allowed: boolean; blockCode: string | null; blockReason: string | null }
+export type DispatchActions = Partial>
+export interface Counts { itemCount: number; palletCount: number; packageCount: number; pickedPackageCount: number; shippedPackageCount: number }
+export interface Capacity { palletSpaces: number | null; maxLoadKg: number | null; maxVolumeCbm: number | null }
+export interface DriverEvidence { licenseNo: string | null; licenseExpiresOn: string | null; fileIds: string[]; expectedTaskEndAt: string | null }
+export interface Vehicle { mode: 'MASTER' | 'EXTERNAL'; vehicleId: string | null; plate: string | null; carrierName: string | null; capacity: Capacity | null; evidenceFileIds: string[] }
+export interface Driver { mode: 'MASTER' | 'EXTERNAL'; driverId: string | null; name: string | null; phone: string | null; evidence: DriverEvidence | null }
+export interface VehicleSnapshot extends Vehicle { vehicleStatus: string | null }
+export interface Resources { vehicle: VehicleSnapshot | null; driver: Driver | null }
+export interface Destination { key: string; type: string; warehouseId: string | null; warehouseCode: string | null; warehouseName: string | null; address: string | null; postalCode: string | null; contactName: string | null; contactPhone: string | null }
+export interface Review { status: ReviewStatus; reason: string | null; reviewerId: string | null; reviewedAt: string | null; reviewRound: Version; basisHash: string | null; invalidatedReason: string | null }
+export interface Reviews { dispatchReview: Review; loadingReview: Review }
+export interface DispatchBatchReference { batchKey: string; batchNo: string | null; customerId: string; sources: Array<{ sourceKey: string; destinationKey: string; packageCount: number }>; recordVersion: Version }
+export interface DispatchBatchAllocation { planPalletId: string; batchKey: string; customerId: string; sourceKey: string; destinationKey: string; packageCount: number; recordVersion: Version }
+export interface DispatchQuery { page: number; size: number; dispatchNo?: string; vehiclePlate?: string; warehouseId?: string; customerId?: string; fbaAppointmentNo?: string; status?: DispatchStatus | 'ALL'; stage?: DispatchStage; beforeLoadingSection?: BeforeLoadingSection; reviewStatus?: ReviewStatus }
+export interface ReviewCounts { notSubmitted: number; pending: number; approved: number; rejected: number }
+export interface DispatchStatistics { matchedTotal: number; activeTotal: number; cancelledTotal: number; stageCounts: Record; beforeLoadingReviewCounts: { vehicleInfoTotal: number; dispatchReview: ReviewCounts; loadingReview: ReviewCounts } }
+export interface DispatchListRow { dispatchNo: string; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; resources: Resources; warehouseId: string; warehouseCode: string; warehouseName: string; plannedDepartureAt: string | null; departedAt: string | null; counts: Counts; pickingReconciled: boolean; arranged: boolean; actions: DispatchActions }
+export interface DispatchPage { records: DispatchListRow[]; total: number; page: number; size: number; pages: number; statistics: DispatchStatistics; snapshotAt: string }
+export interface OutboundReference { customerId: string; customerName: string; outboundNo: string; status: string; packageCount: number; palletCount: number }
+export interface DeliveryReference { groupKey: string; customerId: string; customerName: string; outboundNo: string; deliveryNo: string; destination: Destination; status: string; packageCount: number; palletCount: number }
+export interface VehicleAppointment { referenceNo: string; status: string; appointmentAt: string | null }
+export interface FbaAppointment { batchKey: string; batchNo: string | null; appointmentNo: string | null; destinationKey: string; destinationWarehouseCode: string | null; status: string; confirmedAt: string | null; blockReason: string | null }
+export interface OrderDetail { customerId: string; customerName: string; orderNo: string; inboundCode: string | null; grossWeightKg: number | null; palletCount: number | null; destinationFbaWarehouse: string | null; volumeCbm: number | null; containerNo: string | null; sourceKey: string }
+export interface DispatchDetail { dispatchNo: string; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; counts: Counts; pickingReconciled: boolean; actions: DispatchActions; vehicleInfo: Resources; dispatchInfo: { route: string | null; plannedDepartureAt: string | null; departedAt: string | null; estimatedArrivalAt: string | null; fbaAppointments: FbaAppointment[]; vehicleAppointment: VehicleAppointment | null; appointmentBatches: DispatchBatchReference[] }; documents: { dispatchNo: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[]; createdAt: string; updatedAt: string; remark: string | null }; orderDetails: OrderDetail[] }
+export interface CargoSelection { sourceType: 'INBOUND_PLAN' | 'RETURN_RECEIPT' | 'RELABEL'; planPalletId: string | null; returnReceiptId: string | null; relabelNo: string | null; warehousePalletId: string | null; sourceVersion: string }
+export interface CreateDispatch { idempotencyKey: string; warehouseId: string; vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null; cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] }
+export interface VersionCommand { recordVersion: Version; idempotencyKey: string }
+export interface UpdateDispatch extends VersionCommand { vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; vehicleAppointment?: { referenceNo: string; recordVersion: Version } | null; remark: string | null }
+export interface AddCargo extends VersionCommand { cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] }
+export interface ReasonCommand extends VersionCommand { reason: string }
+export interface ReviewCommand extends VersionCommand { decision: 'APPROVED' | 'REJECTED'; reason: string | null }
+export interface LoadingMeasurement { warehousePalletId: string; palletSpaces: number; weightKg: number; volumeCbm: number; deliveryIndex: number }
+export interface ConfirmedLoad { manifestHash: string; pallets: LoadingMeasurement[]; operatorId: string; confirmedAt: string }
+export interface LoadingComplete extends VersionCommand { manifestHash: string; measurements: LoadingMeasurement[]; remark: string | null }
+export interface LoadingPreview { dispatchNo: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; manifestHash: string; pickingReconciled: boolean; resources: Resources; pallets: Array<{ warehousePalletId: string; palletNo: string; scheduledPackages: number; pickedPackages: number; palletSpaces: number | null; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; missingFields: string[]; evidence: Array<{ field: string; sourceType: string; sourceBusinessNo: string | null; observedAt: string | null; value: number | null; actual: boolean }> }>; confirmedLoad: ConfirmedLoad | null; loadingComplete: Gate; missingFields: string[] }
+export interface Candidate { selection: CargoSelection; sourceKey: string; sourceVersion: string; customerId: string; customerName: string; warehouseId: string; warehouseCode: string; warehouseName: string; orderNo: string | null; inboundCode: string | null; planNo: string | null; palletNo: string | null; containerNo: string | null; destination: Destination; availablePackages: number; suggestedWeightKg: number | null; suggestedVolumeCbm: number | null; measurementSource: string | null; appointmentBatches: DispatchBatchReference[]; blockReasons: string[] }
+export interface CandidateQuery { warehouseId?: string; planIds?: string[]; orderNo?: string; destinationWarehouseCode?: string; sourceStage?: string; draftToken?: string }
+export interface DispatchCargoItem { dispatchItemId: string; customerId: string; customerName: string; sourceKey: string; sourceVersion: string; sourceType: CargoSelection['sourceType']; planPalletId: string | null; returnReceiptId: string | null; relabelNo: string | null; warehousePalletId: string | null; palletNo: string | null; orderNo: string | null; inboundCode: string | null; destination: Destination; scheduledPackages: number; pickedPackages: number; remove: Gate }
+export interface DispatchItems { dispatchNo: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; items: DispatchCargoItem[] }
+export interface DispatchRouteStop { index: number; name: string; address: string | null; destinationKey: string | null }
+export interface DispatchRoute { dispatchNo: string; recordVersion: Version; stops: DispatchRouteStop[]; edit: Gate }
+export interface DispatchRouteCommand extends VersionCommand { destinationKeys: string[]; reason: string }
+export type DispatchEvidenceKind = 'VEHICLE' | 'DRIVER'
+export interface DispatchEvidenceFile { fileId: string; fileName: string; contentType: string; sizeBytes: number }
+export interface CandidateList { candidates: Candidate[]; snapshotAt: string; complete: boolean }
+export interface DraftContainer { draftContainerId: string; sequenceNo: number; capacity: number; sourceKeys: string[] }
+export interface DispatchDraft { draftToken: string; recordVersion: Version; status: string; warehouseId: string; containers: DraftContainer[]; sources: Candidate[]; expiresAt: string }
+export interface DraftSelection extends VersionCommand { draftToken: string; draftContainerId: string; source: CargoSelection }
+export interface ConvertDraft extends VersionCommand { draftToken: string; groups: Array<{ draftContainerId: string; dispatch: CreateDispatch }> }
+export interface ConvertedDraft { draftToken: string; vehicles: Array<{ draftContainerId: string; dispatch: CommandResult }>; replayed: boolean }
+export interface CoverageSelection { financeFeeId: string; sourceVersion: number; sourceHash: string; services: string[] }
+export interface FeeItem { feeType: string; feeName: string; direction: string; currency: string; amount: number | null; supplierId?: string | null; supplierName?: string | null; supplierCategory?: string | null; settlementMode?: string | null; paidAt?: string | null; paymentMethod?: string | null; paymentReferenceNo?: string | null; paymentVoucherFileIds?: string[]; chargeableQuantity?: number | null; quantityUnit?: string | null; unitPrice?: number | null; periodStart?: string | null; periodEnd?: string | null; remark?: string | null }
+export interface ArrangeGroup { groupKey: string; groupCargoHash: string; plannedDeliveryAt: string; deliveryMethod: string; waybillNo: string | null; carrierName: string | null; pickupMethod: string; loadingType: string; vehicleType: string | null; vehicleSize: string | null; appointmentAccount: string | null; sellerName: string | null; remark: string | null; externalFreightHash: string; feeCoverageCargoHash: string; feeCoverage: CoverageSelection[]; fees: FeeItem[] }
+export interface ArrangeDelivery extends VersionCommand { cargoHash: string; loadingCompletionHash: string; groups: ArrangeGroup[] }
+export interface ArrangementResult { dispatchNo: string; recordVersion: Version; arrangementKey: string; cargoHash: string; arrangedAt: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[] }
+export interface CargoLine { dispatchItemId: string; customerId: string; customerName: string; orderId: string | null; orderNo: string | null; sourceKey: string; sourceStage: string; sourceRegion: string | null; sourceInboundId: string | null; inboundCode: string | null; planPalletId: string | null; warehousePalletId: string | null; palletNo: string | null; pickId: string | null; attributionId: string | null; inventoryPositionId: string | null; receiptId: string | null; returnReceiptId: string | null; returnCartonId: string | null; cartonMark: string | null; relabelNo: string | null; destination: Destination; packages: number; productQuantity: number; grossWeightKg: number | null; volumeCbm: number | null; batchAllocations: DispatchBatchAllocation[] }
+export interface PreviewGroup { groupKey: string; customerId: string; customerName: string; destination: Destination; groupCargoHash: string; cargo: CargoLine[]; externalFreightHash: string; externalQuotes: Array<{ customerId: string; groupKey: string; bookingNo: string; inboundCode: string; currency: string; amount: number; sourceHash: string }>; feeCoverageCargoHash: string; feeSources: Array<{ financeFeeId: string; customerId: string; currency: string; amount: number; sourceVersion: number; sourceHash: string; status: string; services: string[]; sourceKeys: string[] }>; requiredServices: string[]; blockReasons: string[] }
+export interface ArrangementPreview { dispatchNo: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; cargoHash: string; loadingCompletionHash: string; arrange: Gate; groups: PreviewGroup[]; arrangement: ArrangementResult | null }
+interface CommandBase { dispatchNo: string; idempotencyKey: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; occurredAt: string; effect: 'APPLIED' | 'EXISTING'; counts: Counts; replayed: boolean; alreadyArranged: boolean }
+export type CommandResult = CommandBase & (
+ { action: 'PICK'; result: { dispatchItemId: string; positionId: string | null; returnCartonId: string | null; packages: number; pickedPackages: number; pickingReconciled: boolean } }
+ | { action: 'LOADING_COMPLETE'; result: { loadingCompletionHash: string; loadingCompletedAt: string; confirmedLoad: ConfirmedLoad } }
+ | { action: 'DEPART'; result: { departedAt: string; shippedPackages: number; outbounds: OutboundReference[]; deliveries: DeliveryReference[] } }
+ | { action: 'ARRANGE_DELIVERY'; result: ArrangementResult }
+ | { action: Exclude; result: { dispatchNo: string; changedItemIds: string[] } }
+)
+
+export const dispatchStages: Array<{ value: DispatchStage; label: string }> = [
+ { value: 'BEFORE_LOADING', label: '装车前' }, { value: 'LOADING', label: '装车中' },
+ { value: 'AFTER_LOADING', label: '装车后' }, { value: 'DELIVERING', label: '派送中' }, { value: 'DELIVERED', label: '派送完成' },
+]
+export const stageLabel = (stage: DispatchStage) => dispatchStages.find(row => row.value === stage)?.label || '—'
+export const reviewLabels: Record = { NOT_SUBMITTED: '未提交', PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' }
+export function requireVersion(value: unknown): Version {
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) return value
+ if (typeof value === 'string' && /^\d+$/.test(value)) return value
+ throw new DispatchInputError('操作版本缺失或无效,请刷新单据')
+}
+
+export function requireBatchVersions(values: readonly { batchKey: string; recordVersion: Version }[]): void {
+ const versions = new Map()
+ for (const value of values) {
+ let version: Version
+ try { version = requireVersion(value.recordVersion) }
+ catch { throw new DispatchInputError('预约批次版本缺失或无效,请重新读取候选') }
+ const canonical = String(version).replace(/^0+(?=\d)/, '')
+ if (versions.has(value.batchKey) && versions.get(value.batchKey) !== canonical) throw new DispatchInputError('同一预约批次的版本不一致,请重新读取候选')
+ versions.set(value.batchKey, canonical)
+ }
+}
diff --git a/src/modules/overseas-warehouse/dispatchPresentation.ts b/src/modules/overseas-warehouse/dispatchPresentation.ts
new file mode 100644
index 00000000..674909f2
--- /dev/null
+++ b/src/modules/overseas-warehouse/dispatchPresentation.ts
@@ -0,0 +1,16 @@
+import type { DispatchAction, DispatchActions } from './dispatchContracts'
+export const dispatchActionLabels: Record = {
+ CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', CANCEL: '取消排车',
+ SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核',
+ RETURN_TO_REVIEW: '退回装车前重审', PICK: '拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '确认发车',
+}
+export const dispatchActionPermissions: Record = {
+ CREATE: 'platform:overseas-warehouse:dispatch:create', EDIT: 'platform:overseas-warehouse:dispatch:edit',
+ ADD_CARGO: 'platform:overseas-warehouse:dispatch:edit', REMOVE_CARGO: 'platform:overseas-warehouse:dispatch:edit',
+ CANCEL: 'platform:overseas-warehouse:dispatch:cancel', SUBMIT_REVIEW: 'platform:overseas-warehouse:dispatch:edit',
+ DISPATCH_REVIEW: 'platform:fleet:trip:dispatch-review', LOADING_REVIEW: 'platform:fleet:trip:loading-review',
+ RETURN_TO_REVIEW: 'platform:overseas-warehouse:dispatch:edit', PICK: 'lastleg:pda:operate',
+ LOADING_COMPLETE: 'platform:overseas-warehouse:dispatch:loading-complete', ARRANGE_DELIVERY: 'platform:overseas-warehouse:dispatch:transfer-delivery', DEPART: 'platform:overseas-warehouse:dispatch:depart',
+}
+export const canDispatchAction = (actions: DispatchActions | undefined, action: DispatchAction, can: (code: string) => boolean) => actions?.[action]?.allowed === true && can(dispatchActionPermissions[action])
+export const displayDispatchValue = (value: unknown) => value === null || value === undefined || value === '' ? '—' : String(value)
diff --git a/src/modules/overseas-warehouse/dispatchTestFixtures.ts b/src/modules/overseas-warehouse/dispatchTestFixtures.ts
new file mode 100644
index 00000000..497a01c9
--- /dev/null
+++ b/src/modules/overseas-warehouse/dispatchTestFixtures.ts
@@ -0,0 +1,14 @@
+import type { Candidate, CommandResult, DispatchDetail, DispatchListRow, DispatchPage, Driver, LoadingPreview, VehicleSnapshot } from './dispatchContracts'
+export const counts = () => ({ itemCount: 1, palletCount: 1, packageCount: 2, pickedPackageCount: 0, shippedPackageCount: 0 })
+export const reviews = () => ({ dispatchReview: { status: 'NOT_SUBMITTED' as const, reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }, loadingReview: { status: 'NOT_SUBMITTED' as const, reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null } })
+export const resources = (): { vehicle: VehicleSnapshot; driver: Driver } => ({ vehicle: { mode: 'EXTERNAL', vehicleId: null, plate: 'CA-100', carrierName: 'Carrier', capacity: { palletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 70 }, evidenceFileIds: [], vehicleStatus: null }, driver: { mode: 'EXTERNAL', driverId: null, name: '司机甲', phone: '555-0100', evidence: { licenseNo: 'TEST-LICENSE', licenseExpiresOn: '2030-01-01', fileIds: [], expectedTaskEndAt: '2026-11-01T10:00:00Z' } } })
+export const destination = () => ({ key: 'CODE:ONT8', type: 'FBA', warehouseId: null, warehouseCode: 'ONT8', warehouseName: 'ONT8', address: null, postalCode: null, contactName: null, contactPhone: null })
+export function detail(overrides: Partial = {}): DispatchDetail { return { dispatchNo: 'PC/1', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: reviews(), counts: counts(), pickingReconciled: false, actions: {}, vehicleInfo: resources(), dispatchInfo: { route: 'LAX → ONT8', plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], vehicleAppointment: null, appointmentBatches: [] }, documents: { dispatchNo: 'PC/1', createdAt: '2026-10-19T10:00:00Z', updatedAt: '2026-10-19T11:00:00Z', remark: '真实备注', outbounds: [], deliveries: [] }, orderDetails: [{ customerId: 'C1', customerName: '客户甲', orderNo: 'LM-1', inboundCode: 'IN-1', grossWeightKg: null, palletCount: 1, destinationFbaWarehouse: 'ONT8', volumeCbm: null, containerNo: 'CSLU1234567', sourceKey: 'source-1' }], ...overrides } }
+export function listRow(overrides: Partial = {}): DispatchListRow { return { dispatchNo: 'PC/1', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: reviews(), resources: resources(), warehouseId: '321', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', plannedDepartureAt: null, departedAt: null, counts: counts(), pickingReconciled: false, arranged: false, actions: {}, ...overrides } }
+export function page(records = [listRow()]): DispatchPage { return { records, total: records.length, page: 1, size: 10, pages: 1, snapshotAt: '2026-10-19T10:00:00Z', statistics: { matchedTotal: 8, activeTotal: 7, cancelledTotal: 1, stageCounts: { BEFORE_LOADING: 3, LOADING: 1, AFTER_LOADING: 1, DELIVERING: 1, DELIVERED: 1 }, beforeLoadingReviewCounts: { vehicleInfoTotal: 3, dispatchReview: { notSubmitted: 1, pending: 1, approved: 1, rejected: 0 }, loadingReview: { notSubmitted: 2, pending: 0, approved: 1, rejected: 0 } } } } }
+export function candidate(overrides: Partial = {}): Candidate { return { selection: { sourceType: 'INBOUND_PLAN', planPalletId: '11', returnReceiptId: null, relabelNo: null, warehousePalletId: '21', sourceVersion: 'source-hash' }, sourceKey: 'source-1', sourceVersion: 'source-hash', customerId: 'C1', customerName: '客户甲', warehouseId: '321', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', orderNo: 'LM-1', inboundCode: 'IN-1', planNo: 'PLAN-1', palletNo: 'PALLET-1', containerNo: 'CSLU1234567', destination: destination(), availablePackages: 2, suggestedWeightKg: 100, suggestedVolumeCbm: 1, measurementSource: 'ORDER_SNAPSHOT_ALLOCATION', appointmentBatches: [], blockReasons: [], ...overrides } }
+export function command(action: CommandResult['action'], key = 'key', no = 'PC/1'): CommandResult {
+ const base = { dispatchNo: no, action, idempotencyKey: key, recordVersion: 1, status: 'ACTIVE' as const, stage: action === 'DEPART' ? 'DELIVERING' as const : action === 'LOADING_COMPLETE' ? 'AFTER_LOADING' as const : 'BEFORE_LOADING' as const, occurredAt: '2026-10-19T10:00:00Z', effect: 'APPLIED' as const, counts: counts(), replayed: false, alreadyArranged: false }
+ return { ...base, result: action === 'DEPART' ? { departedAt: '2026-10-19T10:00:00Z', shippedPackages: 2, outbounds: [], deliveries: [] } : action === 'LOADING_COMPLETE' ? { loadingCompletionHash: 'load-hash', loadingCompletedAt: '2026-10-19T10:00:00Z', confirmedLoad: { manifestHash: 'manifest', pallets: [], operatorId: 'operator', confirmedAt: '2026-10-19T10:00:00Z' } } : { dispatchNo: no, changedItemIds: [] } } as CommandResult
+}
+export function loadingPreview(): LoadingPreview { return { dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'LOADING', manifestHash: 'manifest', pickingReconciled: true, resources: resources(), pallets: [{ warehousePalletId: '21', palletNo: 'PALLET-1', scheduledPackages: 2, pickedPackages: 2, palletSpaces: 1, weightKg: null, volumeCbm: null, deliveryIndex: 1, missingFields: ['weightKg', 'volumeCbm'], evidence: [{ field: 'weightKg', sourceType: 'ORDER_SNAPSHOT_ALLOCATION', sourceBusinessNo: 'IN-1', observedAt: null, value: 100, actual: false }] }], confirmedLoad: null, loadingComplete: { allowed: true, blockCode: null, blockReason: null }, missingFields: ['weightKg', 'volumeCbm'] } }
diff --git a/src/modules/overseas-warehouse/dispatchWire.test.ts b/src/modules/overseas-warehouse/dispatchWire.test.ts
new file mode 100644
index 00000000..649883fd
--- /dev/null
+++ b/src/modules/overseas-warehouse/dispatchWire.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from 'vitest'
+import { parseApiJsonResponse } from '@chongchong/shared'
+import { candidate, command, detail, destination, loadingPreview, page } from './dispatchTestFixtures'
+import * as wire from './dispatchWire'
+
+// Exercise the same JSON parser as Axios; quoted Long values must reach the boundary intact.
+const parsed = (value: T): T => parseApiJsonResponse(JSON.stringify(value)) as T
+const id = '9007199254740993'
+const counts = { itemCount: '1', palletCount: '2', packageCount: '12', pickedPackageCount: '0', shippedPackageCount: '0' }
+const gate = { allowed: true, blockCode: null, blockReason: null }
+const source = () => ({ ...candidate(), customerId: id, availablePackages: '12' })
+const draft = (): wire.DispatchDraftWire => ({ draftToken: 'draft', recordVersion: id, warehouseId: id, status: 'ACTIVE', sources: [source()], containers: [{ draftContainerId: id, sequenceNo: 1, capacity: 30, sourceKeys: ['source-1'] }], expiresAt: '' })
+const references = () => ({ outbounds: [{ customerId: id, customerName: '客户甲', outboundNo: 'OUT-1', status: 'WAIT_OUTBOUND', packageCount: '12', palletCount: '2' }], deliveries: [{ groupKey: 'group-1', customerId: id, customerName: '客户甲', outboundNo: 'OUT-1', deliveryNo: 'DEL-1', destination: destination(), status: 'WAIT_DELIVERY', packageCount: '12', palletCount: '2' }] })
+const arrangement = () => ({ dispatchNo: 'PC/1', recordVersion: id, arrangementKey: 'key', cargoHash: 'hash', arrangedAt: '', ...references() })
+
+describe('native dispatch Long quantity boundary', () => {
+ it.each([0, '0', id])('keeps actual batch recordVersion %s lossless in candidates, drafts and existing dispatch references', version => {
+ const reference = { batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: id, recordVersion: version, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }
+ const value = { ...source(), appointmentBatches: [reference] }
+ expect(wire.normalizeCandidateList(parsed({ candidates: [value], complete: true, snapshotAt: '' })).candidates[0]!.appointmentBatches[0]!.recordVersion).toBe(version)
+ expect(wire.normalizeDispatchDraft(parsed({ ...draft(), sources: [value] })).sources[0]!.appointmentBatches[0]!.recordVersion).toBe(version)
+ const existing = detail()
+ expect(wire.normalizeDispatchDetail(parsed({ ...existing, dispatchInfo: { ...existing.dispatchInfo, appointmentBatches: [reference] } })).dispatchInfo.appointmentBatches[0]!.recordVersion).toBe(version)
+ })
+ it.each([undefined, null, -1, '', 'bad'])('rejects missing or invalid authoritative candidate batch version %s', version => {
+ const reference = { batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: id, recordVersion: version, sources: [] }
+ const value = { ...source(), appointmentBatches: [reference] } as unknown as wire.CandidateWire
+ expect(() => wire.normalizeCandidateList(parsed({ candidates: [value], complete: true, snapshotAt: '' }))).toThrow('预约批次版本缺失或无效,请重新读取候选')
+ })
+ it('preserves a raw large JSON integer that the shared parser safely converts into a version string', () => {
+ const value = { ...source(), appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: id, recordVersion: 9007199254740992, sources: [] }] }
+ expect(wire.normalizeCandidateList(parsed({ candidates: [value], complete: true, snapshotAt: '' })).candidates[0]!.appointmentBatches[0]!.recordVersion).toBe('9007199254740992')
+ })
+ it.each([undefined, null, true, false, '', ' ', ' 1', '1 ', '01', '+1', '-1', '1.0', '1e2', -1, 1.5, NaN, Infinity, '9007199254740992', 9007199254740992])('rejects invalid or unsafe quantity %s instead of silently changing it', value => {
+ expect(() => wire.dispatchQuantity(value, '箱数')).toThrow('排车响应')
+ })
+ it('accepts zero and the safe integer limit in numeric and string form', () => {
+ for (const value of [0, '0', Number.MAX_SAFE_INTEGER, String(Number.MAX_SAFE_INTEGER)]) expect(wire.dispatchQuantity(value, '箱数')).toBe(Number(value))
+ })
+ it('normalizes all list, statistics and review counts while preserving versions and IDs', () => {
+ const value = page()
+ const result = wire.normalizeDispatchPage(parsed({ ...value, total: '8', page: '1', size: '10', pages: '1', records: [{ ...value.records[0]!, warehouseId: id, recordVersion: id, counts, reviews: { ...value.records[0]!.reviews, dispatchReview: { ...value.records[0]!.reviews.dispatchReview, reviewRound: id } } }], statistics: { matchedTotal: '8', activeTotal: '7', cancelledTotal: '1', stageCounts: { BEFORE_LOADING: '3', LOADING: '1', AFTER_LOADING: '1', DELIVERING: '1', DELIVERED: '1' }, beforeLoadingReviewCounts: { vehicleInfoTotal: '3', dispatchReview: { notSubmitted: '1', pending: '1', approved: '1', rejected: '0' }, loadingReview: { notSubmitted: '2', pending: '0', approved: '1', rejected: '0' } } } }))
+ expect(result).toMatchObject({ total: 8, page: 1, size: 10, pages: 1, statistics: { matchedTotal: 8, activeTotal: 7, cancelledTotal: 1, stageCounts: value.statistics.stageCounts, beforeLoadingReviewCounts: value.statistics.beforeLoadingReviewCounts } })
+ expect(result.records[0]).toMatchObject({ warehouseId: id, recordVersion: id, counts: { itemCount: 1, palletCount: 2, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, reviews: { dispatchReview: { reviewRound: id } } })
+ })
+ it('normalizes detail document quantities without changing nullable Integer measurements or versions', () => {
+ const value = detail()
+ const result = wire.normalizeDispatchDetail(parsed({ ...value, recordVersion: id, counts, documents: { ...value.documents, ...references() }, orderDetails: [{ ...value.orderDetails[0]!, palletCount: null }] }))
+ expect(result.recordVersion).toBe(id); expect(result.counts.packageCount).toBe(12); expect(result.orderDetails[0]!.palletCount).toBeNull()
+ expect(result.documents.outbounds[0]).toMatchObject({ customerId: id, packageCount: 12, palletCount: 2 }); expect(result.documents.deliveries[0]!.packageCount).toBe(12)
+ })
+ it('normalizes candidates and draft sources and keeps source versions and container IDs', () => {
+ const candidates = wire.normalizeCandidateList(parsed({ candidates: [source()], snapshotAt: '', complete: true }))
+ expect(candidates.candidates[0]).toMatchObject({ availablePackages: 12, customerId: id, sourceVersion: 'source-hash' })
+ const result = wire.normalizeDispatchDraft(parsed(draft()))
+ expect(result.sources[0]!.availablePackages).toBe(12); expect(result.recordVersion).toBe(id); expect(result.containers[0]).toMatchObject({ draftContainerId: id, sequenceNo: 1, capacity: 30 })
+ })
+ it('normalizes actual item and loading quantities while keeping decimal measurements and integer positions', () => {
+ const value = source()
+ const items = wire.normalizeDispatchItems(parsed({ dispatchNo: 'PC/1', recordVersion: id, status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [{ ...value.selection, dispatchItemId: id, sourceKey: value.sourceKey, sourceVersion: value.sourceVersion, customerId: id, customerName: value.customerName, palletNo: value.palletNo, orderNo: value.orderNo, inboundCode: value.inboundCode, destination: value.destination, scheduledPackages: '12', pickedPackages: '0', remove: gate }] } satisfies wire.DispatchItemsWire))
+ expect(items.items[0]).toMatchObject({ dispatchItemId: id, scheduledPackages: 12, pickedPackages: 0 }); expect(items.recordVersion).toBe(id)
+ const preview = loadingPreview()
+ const loading = wire.normalizeLoadingPreview(parsed({ ...preview, recordVersion: id, pallets: [{ ...preview.pallets[0]!, warehousePalletId: id, scheduledPackages: '12', pickedPackages: '12', weightKg: 12.25, volumeCbm: 0.5, deliveryIndex: 1, palletSpaces: 2 }] }))
+ expect(loading.pallets[0]).toMatchObject({ warehousePalletId: id, scheduledPackages: 12, pickedPackages: 12, weightKg: 12.25, volumeCbm: 0.5, deliveryIndex: 1, palletSpaces: 2 })
+ })
+ it('normalizes file size before metadata and Blob validation without rounding file identity', () => {
+ const result = wire.normalizeEvidenceFile(parsed({ fileId: id, fileName: '证据.png', contentType: 'image/png', sizeBytes: '3' }))
+ expect(result).toMatchObject({ fileId: id, sizeBytes: 3 }); expect(Number.isSafeInteger(result.sizeBytes)).toBe(true); expect(new Blob(['png']).size).toBe(result.sizeBytes)
+ })
+ it('normalizes every arrangement cargo and existing outbound/delivery quantity', () => {
+ const result = wire.normalizeArrangementPreview(parsed({ dispatchNo: 'PC/1', recordVersion: id, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'hash', loadingCompletionHash: 'loaded', arrange: gate, groups: [{ groupKey: 'group-1', customerId: id, customerName: '客户甲', destination: destination(), groupCargoHash: 'group-hash', cargo: [{ dispatchItemId: id, customerId: id, customerName: '客户甲', orderId: id, orderNo: 'LM-1', sourceKey: 'source-1', sourceStage: 'RECEIVED', sourceRegion: null, sourceInboundId: id, inboundCode: 'IN-1', planPalletId: id, warehousePalletId: id, palletNo: 'PALLET-1', pickId: id, attributionId: id, inventoryPositionId: id, receiptId: id, returnReceiptId: null, returnCartonId: null, cartonMark: null, relabelNo: null, destination: destination(), packages: '12', productQuantity: '24', grossWeightKg: 12.25, volumeCbm: null, batchAllocations: [] }], externalFreightHash: '', externalQuotes: [], feeCoverageCargoHash: '', feeSources: [], requiredServices: [], blockReasons: [] }], arrangement: arrangement() } satisfies wire.ArrangementPreviewWire))
+ expect(result.groups[0]!.cargo[0]).toMatchObject({ dispatchItemId: id, packages: 12, productQuantity: 24, grossWeightKg: 12.25 }); expect(result.arrangement).toMatchObject({ recordVersion: id, outbounds: [{ packageCount: 12, palletCount: 2 }], deliveries: [{ packageCount: 12, palletCount: 2 }] })
+ })
+ it('normalizes typed command payloads and converted drafts and preserves immutable proof versions', () => {
+ const base = { ...command('EDIT'), counts, recordVersion: id }
+ const pick = wire.normalizeDispatchCommand(parsed({ ...base, action: 'PICK', result: { dispatchItemId: id, positionId: id, returnCartonId: null, packages: '2', pickedPackages: '12', pickingReconciled: true } } satisfies wire.CommandResultWire))
+ expect(pick).toMatchObject({ recordVersion: id, counts: { packageCount: 12 }, result: { dispatchItemId: id, packages: 2, pickedPackages: 12 } })
+ const depart: wire.CommandResultWire = { ...base, action: 'DEPART', result: { departedAt: '', shippedPackages: '12', ...references() } }
+ expect(wire.normalizeDispatchCommand(parsed(depart))).toMatchObject({ result: { shippedPackages: 12, outbounds: [{ packageCount: 12 }], deliveries: [{ palletCount: 2 }] } })
+ const arranged: wire.CommandResultWire = { ...base, action: 'ARRANGE_DELIVERY', result: arrangement() }
+ expect(wire.normalizeDispatchCommand(parsed(arranged))).toMatchObject({ result: { recordVersion: id, deliveries: [{ packageCount: 12 }] } })
+ expect(wire.normalizeConvertedDraft(parsed({ draftToken: 'draft', vehicles: [{ draftContainerId: id, dispatch: depart }], replayed: true }))).toMatchObject({ vehicles: [{ draftContainerId: id, dispatch: { recordVersion: id, counts: { pickedPackageCount: 0 }, result: { shippedPackages: 12 } } }] })
+ })
+ it('rejects malformed nested quantities at the boundary', () => {
+ expect(() => wire.normalizeDispatchDraft(parsed({ ...draft(), sources: [{ ...source(), availablePackages: '9007199254740992' }] }))).toThrow('安全整数')
+ expect(() => wire.normalizeEvidenceFile({ fileId: id, fileName: '', contentType: '', sizeBytes: null } as unknown as wire.DispatchEvidenceFileWire)).toThrow('有效非负整数')
+ expect(() => wire.normalizeDispatchCommand({ ...command('EDIT'), counts: { ...counts, pickedPackageCount: undefined } } as unknown as wire.CommandResultWire)).toThrow('有效非负整数')
+ })
+})
diff --git a/src/modules/overseas-warehouse/dispatchWire.ts b/src/modules/overseas-warehouse/dispatchWire.ts
new file mode 100644
index 00000000..2417fbe0
--- /dev/null
+++ b/src/modules/overseas-warehouse/dispatchWire.ts
@@ -0,0 +1,71 @@
+import { DispatchInputError, requireBatchVersions, type ArrangementPreview, type ArrangementResult, type Candidate, type CandidateList, type CargoLine, type CommandResult, type ConvertedDraft, type Counts, type DeliveryReference, type DispatchDetail, type DispatchDraft, type DispatchEvidenceFile, type DispatchItems, type DispatchListRow, type DispatchPage, type DispatchStage, type DispatchStatistics, type LoadingPreview, type OutboundReference, type PreviewGroup, type ReviewCounts } from './dispatchContracts'
+
+// common-web serializes both Long and long as decimal strings. Only quantity
+// fields are normalized here; IDs, recordVersion and reviewRound stay lossless.
+export type LongQuantityWire = string | number
+type LongFields = Omit & { [P in K]: LongQuantityWire }
+type CountsWire = LongFields
+type ReviewCountsWire = LongFields
+type OutboundWire = LongFields
+type DeliveryWire = LongFields
+type ReferencesWire = { outbounds: OutboundWire[]; deliveries: DeliveryWire[] }
+type StatisticsWire = Omit & {
+ matchedTotal: LongQuantityWire; activeTotal: LongQuantityWire; cancelledTotal: LongQuantityWire
+ stageCounts: Record
+ beforeLoadingReviewCounts: { vehicleInfoTotal: LongQuantityWire; dispatchReview: ReviewCountsWire; loadingReview: ReviewCountsWire }
+}
+type ListRowWire = Omit & { counts: CountsWire }
+export type DispatchPageWire = Omit & { records: ListRowWire[]; total: LongQuantityWire; page: LongQuantityWire; size: LongQuantityWire; pages: LongQuantityWire; statistics: StatisticsWire }
+export type DispatchDetailWire = Omit & { counts: CountsWire; documents: Omit & ReferencesWire }
+export type CandidateWire = LongFields
+export type CandidateListWire = Omit & { candidates: CandidateWire[] }
+export type DispatchDraftWire = Omit & { sources: CandidateWire[] }
+export type DispatchItemsWire = Omit & { items: Array> }
+export type LoadingPreviewWire = Omit & { pallets: Array> }
+export type DispatchEvidenceFileWire = LongFields
+type ArrangementResultWire = Omit & ReferencesWire
+type PreviewGroupWire = Omit & { cargo: Array> }
+export type ArrangementPreviewWire = Omit & { groups: PreviewGroupWire[]; arrangement: ArrangementResultWire | null }
+type PickedWire = LongFields['result'], 'packages' | 'pickedPackages'>
+type DepartedWire = Omit['result'], 'shippedPackages' | keyof ReferencesWire> & ReferencesWire & { shippedPackages: LongQuantityWire }
+type WireCommand = C extends CommandResult ? Omit & { counts: CountsWire; result: C extends { action: 'PICK' } ? PickedWire : C extends { action: 'DEPART' } ? DepartedWire : C extends { action: 'ARRANGE_DELIVERY' } ? ArrangementResultWire : C['result'] } : never
+export type CommandResultWire = WireCommand
+export type ConvertedDraftWire = Omit & { vehicles: Array<{ draftContainerId: string; dispatch: CommandResultWire }> }
+
+export function dispatchQuantity(value: unknown, field: string): number {
+ if (typeof value !== 'number' && (typeof value !== 'string' || !/^(?:0|[1-9]\d*)$/.test(value))) throw new DispatchInputError(`排车响应${field}不是有效非负整数,请重新读取`)
+ const number = typeof value === 'number' ? value : Number(value)
+ if (!Number.isSafeInteger(number) || number < 0) throw new DispatchInputError(`排车响应${field}超出安全整数范围或无效,请重新读取`)
+ return number
+}
+function numbers(value: LongFields, fields: readonly K[]): T {
+ const result = { ...value }
+ for (const field of fields) Object.assign(result, { [field]: dispatchQuantity(value[field], String(field)) })
+ return result as T
+}
+const counts = (value: CountsWire) => numbers(value, ['itemCount', 'palletCount', 'packageCount', 'pickedPackageCount', 'shippedPackageCount'])
+const reviewCounts = (value: ReviewCountsWire) => numbers(value, ['notSubmitted', 'pending', 'approved', 'rejected'])
+const references = (value: ReferencesWire) => ({ outbounds: value.outbounds.map(row => numbers(row, ['packageCount', 'palletCount'])), deliveries: value.deliveries.map(row => numbers(row, ['packageCount', 'palletCount'])) })
+const candidate = (value: CandidateWire): Candidate => { requireBatchVersions(value.appointmentBatches); return { ...value, availablePackages: dispatchQuantity(value.availablePackages, 'availablePackages') } }
+function statistics(value: StatisticsWire): DispatchStatistics {
+ return { ...value, matchedTotal: dispatchQuantity(value.matchedTotal, 'matchedTotal'), activeTotal: dispatchQuantity(value.activeTotal, 'activeTotal'), cancelledTotal: dispatchQuantity(value.cancelledTotal, 'cancelledTotal'), stageCounts: Object.fromEntries(Object.entries(value.stageCounts).map(([stage, count]) => [stage, dispatchQuantity(count, `stageCounts.${stage}`)])) as Record, beforeLoadingReviewCounts: { vehicleInfoTotal: dispatchQuantity(value.beforeLoadingReviewCounts.vehicleInfoTotal, 'vehicleInfoTotal'), dispatchReview: reviewCounts(value.beforeLoadingReviewCounts.dispatchReview), loadingReview: reviewCounts(value.beforeLoadingReviewCounts.loadingReview) } }
+}
+export function normalizeDispatchPage(value: DispatchPageWire): DispatchPage { return { ...value, total: dispatchQuantity(value.total, 'total'), page: dispatchQuantity(value.page, 'page'), size: dispatchQuantity(value.size, 'size'), pages: dispatchQuantity(value.pages, 'pages'), records: value.records.map(row => ({ ...row, counts: counts(row.counts) })), statistics: statistics(value.statistics) } }
+export function normalizeDispatchDetail(value: DispatchDetailWire): DispatchDetail { requireBatchVersions(value.dispatchInfo.appointmentBatches); return { ...value, counts: counts(value.counts), documents: { ...value.documents, ...references(value.documents) } } }
+export function normalizeCandidateList(value: CandidateListWire): CandidateList { return { ...value, candidates: value.candidates.map(candidate) } }
+export function normalizeDispatchDraft(value: DispatchDraftWire): DispatchDraft { return { ...value, sources: value.sources.map(candidate) } }
+export function normalizeDispatchItems(value: DispatchItemsWire): DispatchItems { return { ...value, items: value.items.map(row => ({ ...row, scheduledPackages: dispatchQuantity(row.scheduledPackages, 'scheduledPackages'), pickedPackages: dispatchQuantity(row.pickedPackages, 'pickedPackages') })) } }
+export function normalizeLoadingPreview(value: LoadingPreviewWire): LoadingPreview { return { ...value, pallets: value.pallets.map(row => ({ ...row, scheduledPackages: dispatchQuantity(row.scheduledPackages, 'scheduledPackages'), pickedPackages: dispatchQuantity(row.pickedPackages, 'pickedPackages') })) } }
+export function normalizeEvidenceFile(value: DispatchEvidenceFileWire): DispatchEvidenceFile { return { ...value, sizeBytes: dispatchQuantity(value.sizeBytes, 'sizeBytes') } }
+function arrangementResult(value: ArrangementResultWire): ArrangementResult { return { ...value, ...references(value) } }
+export function normalizeArrangementPreview(value: ArrangementPreviewWire): ArrangementPreview { return { ...value, groups: value.groups.map(group => ({ ...group, cargo: group.cargo.map(row => ({ ...row, packages: dispatchQuantity(row.packages, 'packages'), productQuantity: dispatchQuantity(row.productQuantity, 'productQuantity') })) })), arrangement: value.arrangement ? arrangementResult(value.arrangement) : null } }
+export function normalizeDispatchCommand(value: CommandResultWire): CommandResult {
+ const normalizedCounts = counts(value.counts)
+ switch (value.action) {
+ case 'PICK': return { ...value, counts: normalizedCounts, result: { ...value.result, packages: dispatchQuantity(value.result.packages, 'packages'), pickedPackages: dispatchQuantity(value.result.pickedPackages, 'pickedPackages') } }
+ case 'DEPART': return { ...value, counts: normalizedCounts, result: { ...value.result, shippedPackages: dispatchQuantity(value.result.shippedPackages, 'shippedPackages'), ...references(value.result) } }
+ case 'ARRANGE_DELIVERY': return { ...value, counts: normalizedCounts, result: arrangementResult(value.result) }
+ default: return { ...value, counts: normalizedCounts }
+ }
+}
+export function normalizeConvertedDraft(value: ConvertedDraftWire): ConvertedDraft { return { ...value, vehicles: value.vehicles.map(row => ({ ...row, dispatch: normalizeDispatchCommand(row.dispatch) })) } }
diff --git a/src/modules/overseas-warehouse/index.ts b/src/modules/overseas-warehouse/index.ts
index 2c747516..f7363bec 100644
--- a/src/modules/overseas-warehouse/index.ts
+++ b/src/modules/overseas-warehouse/index.ts
@@ -6,8 +6,12 @@ export type { FbaAppointmentDto, BatchFbaAppointmentPayload, UpdateFbaAppointmen
export { default as FbaAppointmentFields } from './components/FbaAppointmentFields.vue'
export { createAppointmentForm, appointmentPayload, appointmentFormError } from './appointmentForm'
export { overseasWarehousePermissions } from './permissions'
-export { getDispatch, getBatchDispatchCandidates, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDraftDispatch, releaseDispatchDraft } from './api/dispatches'
-export type { DispatchBatchReference, BatchDispatchCandidate, BatchDispatchCandidates, DispatchBatchAllocation, DispatchSummaryDto, PreArrangeDispatchDraftPayload } from './api/dispatches'
+export { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, addDispatchCargo } from './api/dispatches'
+export type { BatchDispatchCandidate, BatchDispatchCandidates } from './api/dispatches'
+export type { DispatchBatchReference, DispatchBatchAllocation, DispatchDetail, DispatchDraft, ConvertDraft, ConvertedDraft, CommandResult, Candidate, DraftSelection, VersionCommand } from './dispatchContracts'
+export { DispatchInputError, requireVersion, requireBatchVersions, stageLabel } from './dispatchContracts'
+export { useDispatchCommandRecovery, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection } from './dispatchCommandRecovery'
+export { default as DispatchWorkflowDialog } from './components/DispatchWorkflowDialog.vue'
export { dispatchVehicleSpecs } from './dispatchVehicleSpecs'
export { getContainerPlanProgress, getContainerInboundPlanPreview, convertLastMileContainerToInboundPlans } from './api/lastMileOrders'
export type { ContainerPlanProgressDetailDto, ContainerInboundPlanPreviewDto, ConvertContainerInboundPlanPayload } from './api/lastMileOrders'
diff --git a/src/modules/overseas-warehouse/layoutAcceptance.test.ts b/src/modules/overseas-warehouse/layoutAcceptance.test.ts
index 2f86a735..655e1814 100644
--- a/src/modules/overseas-warehouse/layoutAcceptance.test.ts
+++ b/src/modules/overseas-warehouse/layoutAcceptance.test.ts
@@ -58,7 +58,8 @@ describe('overseas warehouse 1920px layout acceptance', () => {
expect(sharedDetail).toContain('grid-template-columns: repeat(2, minmax(0, 1fr))')
expect(page('LastMileOrderDetailView.vue')).toContain('density-fields')
expect(page('InboundOrderDetailView.vue')).toContain('density-fields')
- expect(page('DispatchDetailView.vue')).toContain('.document-section { grid-column: 1 / -1; }')
+ expect(page('DispatchDetailView.vue')).toContain('class="density-element-tabs density-contained-tabs"')
+ expect(page('DispatchDetailView.vue').match(/ ({} as Record))
-const routerPush = vi.hoisted(() => vi.fn())
-const canPermission = vi.hoisted(() => vi.fn(() => false))
-vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
-vi.mock('vue-router', () => ({ useRoute: () => ({ params: { dispatchNo: 'PC/1' }, query: sourceQuery }), useRouter: () => ({ push: routerPush }) }))
+const query = vi.hoisted(() => ({} as Record))
+const push = vi.hoisted(() => vi.fn())
+const can = vi.hoisted(() => vi.fn(() => false))
+vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
+vi.mock('vue-router', () => ({ useRoute: () => ({ params: { dispatchNo: 'PC/1' }, query }), useRouter: () => ({ push }) }))
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn() }))
-
-describe('DispatchDetailView', () => {
- beforeEach(() => {
- routerPush.mockReset()
- canPermission.mockReset().mockReturnValue(false)
- Object.keys(sourceQuery).forEach(key => delete sourceQuery[key])
- vi.mocked(getDispatch).mockReset().mockResolvedValue({
- id: '1', dispatchNo: 'PC/1', appointmentBatches: [], vehiclePlate: 'CA-100', driverName: '张师傅', status: 'PENDING_DEPARTURE',
- route: 'LAX → ONT8', items: [{ id: '11', planPalletId: '20', orderNo: 'LM-1', warehousePalletId: '21', fbaWarehouseCode: 'ONT8', grossWeightKg: 88.5, volumeCbm: 1.25 }],
- })
- })
-
- it('loads the business dispatch number and renders summary and related orders', async () => {
- const wrapper = mount(DispatchDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub } } })
- await flushPromises()
+vi.mock('@/modules/overseas-master-data', () => ({ inquiryReturnTarget: (value: Record) => value.fleetOfferNo ? { name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: value.fleetOfferNo }, query: { inquiry: value.fleetBookingNo } } : null }))
+const options = { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub, DispatchWorkflowDialog: true } } }
+beforeEach(() => { vi.mocked(getDispatch).mockResolvedValue(detail()); can.mockReturnValue(false); push.mockReset(); Object.keys(query).forEach(key => delete query[key]) })
+describe('whole-dispatch four-tab detail', () => {
+ it('uses only four accessible colored tabs and switches without another network read', async () => {
+ const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(getDispatch).toHaveBeenCalledWith('PC/1')
- expect(wrapper.text()).toContain('PC/1')
- expect(wrapper.text()).toContain('CA-100')
- expect(wrapper.text()).toContain('待发车')
- const table = wrapper.findComponent(DataTableStub)
- expect(table.props('data')).toEqual([{ orderNo: 'LM-1', weight: 88.5, palletCount: 1, fbaWarehouse: 'ONT8', volume: 1.25, containerNo: '—' }])
- wrapper.unmount()
+ const tabs = wrapper.findAll('[role="tab"]')
+ expect(tabs.map(tab => tab.text())).toEqual(['🚚车辆信息', '📋排车信息', '📝单据信息', '📦关联订单明细'])
+ expect(wrapper.findAll('.density-tab-icon')).toHaveLength(4)
+ await tabs[1]!.trigger('click'); await flushPromises()
+ expect(tabs[1]!.attributes('aria-selected')).toBe('true')
+ expect(wrapper.get(`#${tabs[1]!.attributes('aria-controls')}`).text()).toContain('LAX → ONT8')
+ expect(getDispatch).toHaveBeenCalledTimes(1); expect(wrapper.text()).toContain('装车前'); wrapper.unmount()
})
-
- it('keeps related order weight unknown when one pallet lacks physical weight', async () => {
- vi.mocked(getDispatch).mockResolvedValueOnce({ id: '1', dispatchNo: 'PC/1', appointmentBatches: [], status: 'PENDING_DEPARTURE', items: [
- { id: '11', planPalletId: '31', orderNo: 'LM-1', warehousePalletId: '21', grossWeightKg: 88.5, volumeCbm: 1 },
- { id: '12', planPalletId: '32', orderNo: 'LM-1', warehousePalletId: '22', grossWeightKg: null, volumeWeightKg: 999, volumeCbm: 1 },
- ] })
- const wrapper = mount(DispatchDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub } } })
- await flushPromises()
- expect(wrapper.findComponent(DataTableStub).props('data')).toEqual([expect.objectContaining({ weight: null })])
- wrapper.unmount()
+ it('uses exact server order attribution including unknown measurements and real container numbers', async () => {
+ const value = detail(); value.orderDetails.push({ ...value.orderDetails[0]!, customerId: 'C2', customerName: '客户乙', sourceKey: 'source-2', orderNo: 'LM-2', grossWeightKg: 12.25 })
+ vi.mocked(getDispatch).mockResolvedValueOnce(value)
+ const wrapper = mount(DispatchDetailView, options); await flushPromises()
+ expect(wrapper.getComponent(DataTableStub).props('data')).toEqual(value.orderDetails)
+ expect(value.orderDetails[0]!.grossWeightKg).toBeNull(); wrapper.unmount()
})
-
- it('shows server blocking reasons and opens only authorized original documents', async () => {
- const data = await getDispatch('PC/1')
- data.actions = { canEdit: false, editBlockReason: '请先解除车次关联', canCancel: false, cancelBlockReason: '请先解除车次关联', canRemovePallet: false, removePalletBlockReason: '请先解除车次关联', canTransfer: false, transferBlockReason: '原出库已取消,请取消原排车后重新预排', outboundNo: 'OUT-1', outboundStatus: 'CANCELLED' }
- data.workflow = { tripNo: 'TR-1', dispatchReview: { status: 'PENDING', reason: null, reviewedAt: null }, loadingReview: { status: 'PENDING', reason: null, reviewedAt: null } }
- vi.mocked(getDispatch).mockResolvedValue(data)
- canPermission.mockReturnValue(true)
- const wrapper = mount(DispatchDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub } } }); await flushPromises()
- expect(wrapper.text()).toContain('原出库已取消')
- await wrapper.findAll('button').find(button => button.text().includes('处理原车次'))!.trigger('click')
- expect(routerPush).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-1' }, query: { tab: 'overview' } })
- await wrapper.findAll('button').find(button => button.text().includes('查看原出库'))!.trigger('click')
- expect(routerPush).toHaveBeenLastCalledWith({ name: 'overseas-outbounds', query: { documentNo: 'OUT-1' } })
- wrapper.unmount()
- canPermission.mockReturnValue(false)
- const readonly = mount(DispatchDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub } } }); await flushPromises()
- expect(readonly.findAll('button').some(button => ['处理原车次', '查看原出库'].some(label => button.text().includes(label)))).toBe(false)
- expect(readonly.text()).toContain('原出库已取消')
- readonly.unmount()
+ it('renders all customer delivery references and real update time/remark without leaking internal IDs', async () => {
+ const value = detail(); value.documents.deliveries = ['DEL-A', 'DEL-B'].map((deliveryNo, index) => ({ deliveryNo, customerId: `C${index}`, customerName: `客户${index}`, outboundNo: `OUT-${index}`, groupKey: `group-${index}`, destination: destination(), status: 'WAIT_DELIVERY', packageCount: 1, palletCount: 1 }))
+ vi.mocked(getDispatch).mockResolvedValueOnce(value)
+ const wrapper = mount(DispatchDetailView, options); await flushPromises(); await wrapper.findAll('[role="tab"]')[2]!.trigger('click')
+ expect(wrapper.text()).toContain('DEL-A'); expect(wrapper.text()).toContain('DEL-B'); expect(wrapper.text()).toContain('真实备注')
+ expect(wrapper.findAll('button').some(button => button.text() === 'DEL-A')).toBe(false); wrapper.unmount()
})
- it.each([false, true])('returns to the original inquiry only for a valid source: %s', async fromInquiry => {
- if (fromInquiry) Object.assign(sourceQuery, { fleetSource: 'inquiry', fleetOfferNo: 'FO-1', fleetBookingNo: 'FB-2' })
- const wrapper = mount(DispatchDetailView, { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub } } })
- await flushPromises()
- const backButton = wrapper.get('button.density-back')
- expect(backButton.text()).toBe(fromInquiry ? '返回原拼车登记' : '返回列表')
- await backButton.trigger('click')
- expect(routerPush).toHaveBeenCalledWith(fromInquiry ? { name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'FO-1' }, query: { inquiry: 'FB-2' } } : { name: 'overseas-dispatches' })
- wrapper.unmount()
+ it('requires a literal service allowance and permission for a write action', async () => {
+ can.mockReturnValue(true); vi.mocked(getDispatch).mockResolvedValueOnce(detail({ actions: { DEPART: { allowed: false, blockCode: 'NOT_ARRANGED', blockReason: '尚未安排派送' } } }))
+ const wrapper = mount(DispatchDetailView, options); await flushPromises()
+ expect(wrapper.text()).toContain('尚未安排派送'); expect(wrapper.findAll('button').some(button => button.text() === '确认发车')).toBe(false); wrapper.unmount()
+ })
+ it('retains a real API failure and exposes retry rather than rendering a successful empty detail', async () => {
+ vi.mocked(getDispatch).mockRejectedValueOnce(new Error('permission denied'))
+ const wrapper = mount(DispatchDetailView, options); await flushPromises(); expect(wrapper.text()).toContain('排车详情加载失败,请重试'); expect(wrapper.findAll('[role="tab"]')).toHaveLength(0); wrapper.unmount()
+ })
+ it('keeps the original inquiry return target', async () => {
+ Object.assign(query, { fleetOfferNo: 'FO-1', fleetBookingNo: 'FB-2' }); const wrapper = mount(DispatchDetailView, options); await flushPromises(); await wrapper.get('button.density-back').trigger('click')
+ expect(push).toHaveBeenCalledWith({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'FO-1' }, query: { inquiry: 'FB-2' } }); wrapper.unmount()
})
-
})
diff --git a/src/modules/overseas-warehouse/pages/DispatchDetailView.vue b/src/modules/overseas-warehouse/pages/DispatchDetailView.vue
index 0252ad22..10f9f2ac 100644
--- a/src/modules/overseas-warehouse/pages/DispatchDetailView.vue
+++ b/src/modules/overseas-warehouse/pages/DispatchDetailView.vue
@@ -1,187 +1,95 @@
-
+
-
{{ reason }}
处理原车次 {{ detail.workflow.tripNo }}查看原出库 {{ detail.actions.outboundNo }}
-
-
车辆信息
- {{ field.label }}
- {{ field.value }}
-
排车信息
- {{ field.label }}
- {{ field.value }}
-
单据信息
- {{ field.label }}
- {{ field.value }}
-
-
+
{{ reason }}
{{ dispatchActionLabels[value] }}调整卸货顺序
+
+ 🚚车辆信息- {{ field[0] }}
- {{ field[1] }}
+ 📋排车信息- {{ field[0] }}
- {{ field[1] }}
+ 📝单据信息
+ - {{ field[0] }}
- {{ field[1] }}
- 关联派送单
- {{ delivery.deliveryNo }}{{ text(delivery.deliveryNo) }}—
+ 查看出库 {{ outbound.outboundNo }}
+
+ 📦关联订单明细
+
+
diff --git a/src/modules/overseas-warehouse/pages/DispatchesView.test.ts b/src/modules/overseas-warehouse/pages/DispatchesView.test.ts
index 0ca2c1bc..f650e5b5 100644
--- a/src/modules/overseas-warehouse/pages/DispatchesView.test.ts
+++ b/src/modules/overseas-warehouse/pages/DispatchesView.test.ts
@@ -1,382 +1,71 @@
import { createPinia } from 'pinia'
-import ElementPlus, { ElMessage, ElFormItem, ElDatePicker, ElSelect, ElCheckbox } from 'element-plus'
-import AppDialog from '@/shared/components/AppDialog.vue'
-import { readFileSync } from 'node:fs'
-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 LoadErrorState from '@/shared/components/LoadErrorState.vue'
-import RowActions from '@/shared/components/RowActions.vue'
-import { cancelDispatch, getDispatchBolPdf, getDispatchExternalFreight, getDispatchFeeCoverage, listDispatches, removeDispatchItem, transferDispatchToDelivery, updateDispatch } from '../api/dispatches'
-import DeliveryFeeCoveragePanel from '../components/DeliveryFeeCoveragePanel.vue'
+import ElementPlus from 'element-plus'
+import { flushPromises, mount } from '@vue/test-utils'
+import { beforeEach, expect, it, vi } from 'vitest'
import DispatchesView from './DispatchesView.vue'
-vi.mock('@/modules/warehouses', () => ({
- listAllPlatformWarehouses: vi.fn(async (query: { keyword: string }) => [{ id: query.keyword, warehouseCode: query.keyword, warehouseType: 'FBA' }]),
- getPlatformWarehouse: vi.fn(async (id: string) => ({ warehouse: { warehouseCode: id, warehouseType: id === '321' ? 'OVERSEAS' : 'FBA' }, addresses: [{ addressType: 'OPERATING', addressLine1: '100 Example Street', city: 'City', stateProvince: 'CA', countryCode: 'US' }], capabilities: [] })),
-}))
-
+import QueryToolbar from '@/shared/components/QueryToolbar.vue'
+import RowActions from '@/shared/components/RowActions.vue'
+import { getDispatch, getDispatchBolPdf, listDispatches } from '../api/dispatches'
+import { detail, listRow, page } from '../dispatchTestFixtures'
+import type { DispatchPage } from '../dispatchContracts'
+import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
+const identity = vi.hoisted(() => ({ id: '' }))
const push = vi.hoisted(() => vi.fn())
-vi.mock('vue-router', () => ({ useRouter: () => ({ push }), useRoute: () => ({ query: {} }) }))
-const canPermission = vi.hoisted(() => vi.fn<(permissionCode: string) => boolean>(() => true))
-vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
-vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), removeDispatchItem: vi.fn(), getDispatchBolPdf: vi.fn(), getDispatchExternalFreight: vi.fn(), getDispatchFeeCoverage: vi.fn(), updateDispatch: vi.fn(), cancelDispatch: vi.fn(), transferDispatchToDelivery: vi.fn() }))
-
-class ResizeObserverStub implements ResizeObserver {
- observe() {}
- unobserve() {}
- disconnect() {}
-}
-
-function memoryStorage(): Storage {
- const values = new Map()
- return {
- get length() { return values.size },
- clear: () => values.clear(),
- getItem: (key) => values.get(key) ?? null,
- key: (index) => [...values.keys()][index] ?? null,
- removeItem: (key) => values.delete(key),
- setItem: (key, value) => values.set(key, value),
- }
-}
-
-enableAutoUnmount(afterEach)
-afterEach(() => vi.restoreAllMocks())
-afterAll(() => vi.unstubAllGlobals())
-
-const allowedActions = { canEdit: true, editBlockReason: null, canCancel: true, cancelBlockReason: null,
- canRemovePallet: true, removePalletBlockReason: null, canTransfer: true, transferBlockReason: null, outboundNo: null, outboundStatus: null }
-
-describe('DispatchesView', () => {
- beforeEach(() => {
- vi.stubGlobal('ResizeObserver', ResizeObserverStub)
- vi.stubGlobal('localStorage', memoryStorage())
- vi.mocked(listDispatches).mockReset().mockResolvedValue({
- records: [{
- id: '190000000000000099', dispatchNo: 'PC-1', appointmentBatches: [], actions: allowedActions, vehiclePlate: 'CA-100', driverName: '张师傅',
- driverPhone: '555-0100', carrierName: 'Carrier A', orderNos: 'LM-1,LM-2', route: 'ONT8,LAX9',
- overseasWarehouseId: '321', overseasWarehouseName: '洛杉矶仓', departAt: '2026-08-20T09:00:00', fbaAppointmentNo: 'FBA-1',
- fbaAppointmentStatus: 'CONFIRMED', vehicleAppointmentStatus: 'BOOKED', estimatedArrivalAt: '2026-08-20T18:00:00',
- status: 'PENDING_DEPARTURE', palletCount: 12, packageCount: 80, createdAt: '2026-08-19T16:00:00',
- }],
- page: 1, size: 20, total: 1, pages: 1,
- })
- vi.mocked(updateDispatch).mockReset()
- vi.mocked(getDispatchBolPdf).mockReset().mockResolvedValue(new Blob(['%PDF-test'], { type: 'application/pdf' }))
- vi.mocked(removeDispatchItem).mockReset()
- vi.mocked(cancelDispatch).mockReset()
- vi.mocked(transferDispatchToDelivery).mockReset().mockResolvedValue({ dispatchNo: 'PC-1', outboundNo: 'OUT-1', outboundStatus: 'WAIT_OUTBOUND', palletCount: 12, packageCount: 80, deliveries: [], replayed: false })
- vi.mocked(getDispatchExternalFreight).mockReset().mockResolvedValue({ snapshotHash: 'own-hash', requiresDeliveryFee: true, quotes: [] })
- vi.mocked(getDispatchFeeCoverage).mockReset().mockResolvedValue({ dispatchNo: 'PC-1', sourceComplete: true, blockReason: null, cargoHash: 'cargo-hash', cargo: [], feeSources: [] })
- canPermission.mockReset().mockReturnValue(true)
- })
-
-
- it('queries stages on the server and discards an older response after switching', async () => {
- let resolveOld!: (value: Awaited>) => void
- vi.mocked(listDispatches).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
- const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
- await wrapper.get('[role="tablist"]').findAll('button')[2]!.trigger('click')
- await flushPromises()
- expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'LOADING', page: 1, status: undefined }))
- expect(wrapper.text()).toContain('PC-1')
- resolveOld({ records: [], total: 0, page: 1, size: 10, pages: 0 })
- await flushPromises()
- expect(wrapper.text()).toContain('PC-1')
- await wrapper.get('[role="tablist"]').findAll('button')[3]!.trigger('click')
- await flushPromises()
- expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'AFTER_LOADING', page: 1 }))
- })
-
- it('shows the shared route map only in the after-loading vehicle section without another query', async () => {
- const wrapper = mount(DispatchesView, { attachTo: document.body, global: { plugins: [createPinia(), ElementPlus] } })
- await flushPromises()
- expect(wrapper.text()).not.toContain('查看路线')
- await wrapper.get('[aria-label="排车执行阶段"]').findAll('button')[3]!.trigger('click')
- await flushPromises()
- const requests = vi.mocked(listDispatches).mock.calls.length
- await wrapper.findAll('button').find(button => button.text() === '查看路线')!.trigger('click')
- await flushPromises()
- expect(wrapper.findComponent({ name: 'TransportRouteDialog' }).props('modelValue')).toBe(true)
- expect(wrapper.findComponent({ name: 'TransportRouteDialog' }).props('origin')).toEqual({ kind: 'warehouse', label: '洛杉矶仓', warehouseId: '321' })
- expect(wrapper.findComponent({ name: 'TransportRouteDialog' }).props('destinations')).toEqual(['ONT8', 'LAX9'].map(code => ({ kind: 'warehouse', label: code, warehouseCode: code })))
- expect(listDispatches).toHaveBeenCalledTimes(requests)
- await wrapper.get('[aria-label="排车执行阶段"]').findAll('button')[1]!.trigger('click')
- await flushPromises()
- await wrapper.get('[aria-label="排车业务分区"]').findAll('button')[1]!.trigger('click')
- expect(wrapper.findAll('button').some(button => button.text() === '查看路线')).toBe(false)
- })
-
- it('shows review facts and opens the matching trip section with read permission', async () => {
- const result = await listDispatches({ page: 1, size: 10 })
- result.records[0]!.workflow = { tripNo: 'TR-A', dispatchReview: { status: 'REJECTED', reason: '请核对货量', reviewedAt: null }, loadingReview: { status: 'PENDING', reason: null, reviewedAt: null } }
- vi.mocked(listDispatches).mockClear().mockResolvedValue(result)
- canPermission.mockImplementation(code => code === 'platform:fleet:trip:list')
- const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
- await flushPromises()
- const sections = wrapper.get('[aria-label="排车业务分区"]')
- expect(sections.findAll('button').map(button => button.text())).toEqual(['车辆信息'])
- await wrapper.get('[aria-label="排车执行阶段"]').findAll('button')[1]!.trigger('click')
- await flushPromises()
- expect(sections.findAll('button').map(button => button.text())).toEqual(['车辆信息', '调度审核', '理货员审核'])
- expect(wrapper.findAll('[role="tablist"]')).toHaveLength(2)
- expect(wrapper.text()).not.toContain('请核对货量')
- await sections.findAll('button')[1]!.trigger('click')
- await flushPromises()
- expect(wrapper.text()).toContain('请核对货量')
- expect(wrapper.findAll('button').some(button => button.text() === '打印BOL')).toBe(false)
- await wrapper.findAll('button').find(button => button.text() === '查看审核')!.trigger('click')
- expect(push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-A' }, query: { tab: 'overview' } })
- await sections.findAll('button')[2]!.trigger('click')
- await flushPromises()
- expect(wrapper.text()).not.toContain('请核对货量')
- expect(wrapper.text()).toContain('待审核')
- const links = wrapper.findAll('button').filter(button => button.text() === '查看审核')
- await links[0]!.trigger('click')
- expect(push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-A' }, query: { tab: 'loading' } })
- expect(wrapper.text()).not.toContain('前往审核')
- expect(listDispatches).toHaveBeenCalledTimes(2)
- })
-
- it.each([0, 2, 3, 4].flatMap(stageIndex => [1, 2].map(sectionIndex => [stageIndex, sectionIndex])))('leaving a review section for stage %s resets section %s to vehicle information', async (stageIndex, sectionIndex) => {
- const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
- await flushPromises()
- const stages = wrapper.get('[aria-label="排车执行阶段"]').findAll('button')
- const sections = wrapper.get('[aria-label="排车业务分区"]')
- await stages[1]!.trigger('click')
- await flushPromises()
- await sections.findAll('button')[sectionIndex]!.trigger('click')
- expect(wrapper.findAll('button').some(button => button.text() === '打印BOL')).toBe(false)
- await stages[stageIndex]!.trigger('click')
- await flushPromises()
- expect(sections.findAll('button').map(button => button.text())).toEqual(['车辆信息'])
- expect(sections.get('button').attributes('aria-selected')).toBe('true')
- expect(wrapper.findAll('button').some(button => button.text() === '打印BOL')).toBe(true)
- expect(wrapper.findAll('button').some(button => ['前往审核', '查看审核'].includes(button.text()))).toBe(false)
- await stages[1]!.trigger('click')
- await flushPromises()
- expect(sections.findAll('button').map(button => button.text())).toEqual(['车辆信息', '调度审核', '理货员审核'])
- expect(sections.get('button').attributes('aria-selected')).toBe('true')
- })
-
- it.each([
- [[], ['查看', '打印BOL'], false],
- [['edit'], ['查看', '打印BOL', '编辑'], false],
- [['cancel'], ['查看', '打印BOL', '取消'], false],
- [['transfer-delivery'], ['查看', '打印BOL', '转派送'], false],
- [['edit', 'transfer-delivery'], ['查看', '打印BOL', '转派送', '更多'], true],
- [['edit', 'cancel'], ['查看', '打印BOL', '编辑', '更多'], true],
- [['edit', 'cancel', 'transfer-delivery'], ['查看', '打印BOL', '转派送', '更多'], true],
- ])('uses the row action threshold for permissions %j', async (allowed, labels, overflow) => {
- canPermission.mockImplementation(code => allowed.some(action => code === 'platform:overseas-warehouse:dispatch:' + action))
- const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
- await flushPromises()
- const actions = wrapper.get('.el-table__body-wrapper .density-actions')
- expect(actions.findAll('button').map(button => button.text().trim())).toEqual(labels)
- expect(actions.find('.el-dropdown').exists()).toBe(overflow)
- })
-
- it('keeps the integrated header and filters usable through failure and retry', async () => {
- vi.mocked(listDispatches).mockRejectedValueOnce(new Error('网络错误'))
- const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
- await flushPromises()
- const card = wrapper.get('.data-table--integrated')
- expect(card.get('.data-table__toolbar h1').text()).toBe('排车列表')
- expect(card.get('.data-table__toolbar button[aria-label="列设置"]').text()).toBe('字段设置')
- expect(card.get('.data-table__filters').findComponent(QueryToolbar).exists()).toBe(true)
- expect(card.find('.el-table').exists()).toBe(false)
- expect(card.find('.data-table__pagination').exists()).toBe(false)
- wrapper.findComponent(LoadErrorState).vm.$emit('retry')
- await flushPromises()
- expect(card.find('.data-table__state').exists()).toBe(false)
- expect(card.get('.el-table').text()).toContain('PC-1')
- expect(card.get('.data-table__pagination').text()).toContain('共 1 条排车记录')
- })
-
- it('loads immediately and sends only supported page filters', async () => {
- const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
- await flushPromises()
- expect(listDispatches).toHaveBeenCalledWith({ page: 1, size: 10, dispatchNo: undefined, vehiclePlate: undefined, status: undefined, fbaAppointmentNo: undefined })
-
- const model: QueryModel = {
- dispatchNo: 'PC-1', vehiclePlate: 'CA-100',
- status: 'PENDING_DEPARTURE', fbaAppointmentNo: 'FBA-1',
- }
- wrapper.findComponent(QueryToolbar).vm.$emit('search', model)
- await flushPromises()
-
- expect(listDispatches).toHaveBeenCalledWith({
- page: 1, size: 10, dispatchNo: 'PC-1',
- vehiclePlate: 'CA-100', status: 'PENDING_DEPARTURE', fbaAppointmentNo: 'FBA-1',
- })
- expect(wrapper.text()).toContain('PC-1')
- expect(wrapper.text()).toContain('待发车')
- expect(wrapper.text()).toContain('FBA-1 · 已确认')
- expect(wrapper.text()).toContain('已预约')
- expect(wrapper.get('.data-table').classes()).not.toContain('admin-text-actions')
- expect(wrapper.get('.query-toolbar').classes()).not.toContain('admin-text-actions')
- expect(wrapper.get('.data-table__toolbar button[aria-label="列设置"]').find('svg').exists()).toBe(true)
- expect(wrapper.get('.query-toolbar__actions .el-button--primary').text()).toBe('查询')
- expect(wrapper.get('button[aria-label="上一页"]').find('svg').exists()).toBe(true)
- expect(wrapper.findAll('.query-toolbar__actions button svg')).toHaveLength(3)
- expect(wrapper.get('.el-table__body-wrapper .row-actions').classes()).toContain('admin-text-actions')
- })
-
- it('keeps long dispatch identifiers as strings', async () => {
- vi.mocked(listDispatches).mockResolvedValueOnce({
- records: [{ id: '190000000000000099', dispatchNo: '190000000000000099', appointmentBatches: [] }],
- page: 1, size: 20, total: 1, pages: 1,
- })
- const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
- await flushPromises()
- expect(wrapper.html()).toContain('190000000000000099')
- })
-
- it('opens the server-generated BOL PDF with one request', async () => {
- const pdfWindow = { location: { href: '' }, close: vi.fn() }
- vi.spyOn(window, 'open').mockReturnValue(pdfWindow as unknown as Window)
- vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:dispatch-bol'), revokeObjectURL: vi.fn() })
- const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
- await flushPromises()
- expect(getDispatchBolPdf).not.toHaveBeenCalled()
-
- await wrapper.findAll('.el-table__body-wrapper button').find((button) => button.text().trim() === '打印BOL')!.trigger('click')
- await flushPromises()
-
- expect(getDispatchBolPdf).toHaveBeenCalledOnce()
- expect(getDispatchBolPdf).toHaveBeenCalledWith('PC-1')
- expect(pdfWindow.location.href).toBe('blob:dispatch-bol')
- })
-
- it('documents the backend feature flag required by a missing endpoint', () => {
- const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/DispatchesView.vue'), 'utf8')
- expect(source).toContain('请确认海外尾程排车接口已发布')
- })
-
- it('uses server blocks even for a pending row and offers the original trip and outbound paths', async () => {
- const result = await listDispatches({ page: 1, size: 10 })
- Object.assign(result.records[0]!, {
- actions: { ...allowedActions, canEdit: false, canCancel: false, canRemovePallet: false, canTransfer: false,
- editBlockReason: '请先在车次 TR-A 中解除关联', cancelBlockReason: '请先在车次 TR-A 中解除关联',
- removePalletBlockReason: '请先在车次 TR-A 中解除关联', transferBlockReason: '原出库已取消,请取消原排车后通过新预排重新安排',
- outboundNo: 'OUT-A', outboundStatus: 'CANCELLED' },
- workflow: { tripNo: 'TR-A', dispatchReview: { status: 'PENDING', reason: null, reviewedAt: null }, loadingReview: { status: 'PENDING', reason: null, reviewedAt: null } },
- })
- vi.mocked(listDispatches).mockClear().mockResolvedValue(result)
- const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises()
- const actions = wrapper.findAllComponents(RowActions).find(item => item.element.closest('.el-table__body-wrapper'))!
- const visible = actions.props('actions').filter(action => action.visible !== false).map(action => action.key)
- expect(visible.filter(key => ['edit', 'cancel', 'transfer'].includes(key))).toEqual([])
- expect(visible).toEqual(expect.arrayContaining(['trip', 'outbound', 'detail']))
- expect(wrapper.text()).toContain('原出库已取消')
- actions.vm.$emit('action', 'edit'); actions.vm.$emit('action', 'transfer'); await flushPromises()
- expect(wrapper.findAllComponents(AppDialog).filter(dialog => ['编辑排车', '转派送'].includes(String(dialog.props('title')))).every(dialog => !dialog.props('modelValue'))).toBe(true)
- actions.vm.$emit('action', 'trip'); await flushPromises()
- expect(push).toHaveBeenLastCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-A' }, query: { tab: 'overview' } })
- actions.vm.$emit('action', 'outbound'); await flushPromises()
- expect(push).toHaveBeenLastCalledWith({ name: 'overseas-outbounds', query: { documentNo: 'OUT-A' } })
- expect(getDispatchExternalFreight).not.toHaveBeenCalled()
- })
-
- it('keeps write commands closed when action capabilities are missing', async () => {
- const result = await listDispatches({ page: 1, size: 10 })
- delete result.records[0]!.actions
- vi.mocked(listDispatches).mockClear().mockResolvedValue(result)
- const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises()
- expect(wrapper.findComponent(RowActions).props('actions').filter(action => action.visible !== false).map(action => action.key)).toEqual(['detail', 'bol'])
- expect(wrapper.text()).toContain('操作资格暂未返回,请刷新列表')
- })
-
- it.each([
- { replayed: true, outboundStatus: 'WAIT_OUTBOUND', method: 'info' as const, message: '本次未重复生成' },
- { replayed: true, outboundStatus: 'CANCELLED', method: 'warning' as const, message: '请取消原排车后重新预排' },
- ])('reports transfer replay accurately for $outboundStatus', async result => {
- const success = vi.spyOn(ElMessage, 'success'), notice = vi.spyOn(ElMessage, result.method)
- vi.mocked(transferDispatchToDelivery).mockResolvedValue({ dispatchNo: 'PC-1', outboundNo: 'OUT-OLD', palletCount: 1, packageCount: 2, deliveries: [], replayed: result.replayed, outboundStatus: result.outboundStatus })
- vi.mocked(getDispatchExternalFreight).mockResolvedValue({ snapshotHash: 'external-hash', requiresDeliveryFee: false, quotes: [] })
- const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } }); await flushPromises()
- await wrapper.findAll('button').find(button => button.text() === '转派送')!.trigger('click'); await flushPromises()
- const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '转派送')!
- dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-03T12:00:00')
- for (const [label, value] of [['派送方式', 'TRUCK_PALLET'], ['提货方式', 'WAREHOUSE_PICKUP'], ['装车类型', 'PALLET']]) {
- dialog.findAllComponents(ElFormItem).find(item => item.props('label') === label)!.findComponent(ElSelect).vm.$emit('update:modelValue', value)
- }
- await flushPromises(); dialog.vm.$emit('confirm'); await flushPromises()
- expect(notice).toHaveBeenCalledWith(expect.stringContaining(result.message))
- expect(success).not.toHaveBeenCalledWith('已生成出库单和派送单')
- })
-
- it('loads external quotes only when opened and submits their hash without a duplicate delivery charge', async () => {
- vi.mocked(getDispatchExternalFreight).mockResolvedValue({ snapshotHash: 'external-hash', requiresDeliveryFee: false, quotes: [
- { bookingNo: 'FB-1', recordVersion: '4', incomingNo: 'WT-1', inboundCode: 'IN-1', customerName: '客户甲', destinationKey: 'FBA:ONT8', currency: 'CNY', amount: 60, quantity: 2, quantityUnit: '托位', unitPrice: 30 },
- ] })
- const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
- await flushPromises()
- expect(getDispatchExternalFreight).not.toHaveBeenCalled()
- await wrapper.findAll('button').find(button => button.text() === '转派送')!.trigger('click')
- await flushPromises()
- expect(getDispatchExternalFreight).toHaveBeenCalledOnce()
- expect(getDispatchExternalFreight).toHaveBeenCalledWith('PC-1')
- const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '转派送')!
- expect(dialog.text()).toContain('FB-1')
- expect(dialog.text()).not.toContain('请输入派送费用')
- dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-03T12:00:00')
- for (const [label, value] of [['派送方式', 'TRUCK_PALLET'], ['提货方式', 'WAREHOUSE_PICKUP'], ['装车类型', 'PALLET']]) {
- dialog.findAllComponents(ElFormItem).find(item => item.props('label') === label)!.findComponent(ElSelect).vm.$emit('update:modelValue', value)
- }
- await flushPromises()
- dialog.vm.$emit('confirm')
- await flushPromises()
- expect(transferDispatchToDelivery).toHaveBeenCalledWith('PC-1', expect.objectContaining({ fees: [], externalFreightHash: 'external-hash' }))
- })
-
- it('confirms an original delivery fee without submitting a new receivable', async () => {
- vi.mocked(getDispatchFeeCoverage).mockResolvedValue({ dispatchNo: 'PC-1', sourceComplete: true, blockReason: null, cargoHash: 'actual-cargo',
- cargo: [{ dispatchItemId: '201', warehousePalletId: '501', palletNo: 'PLT-1', orderId: '401', sourceRegion: 'FIRSTLEG', sourceInboundId: '11', inboundCode: 'IN-11', packages: 20, dispatchInboundCode: 'IN-11' }],
- feeSources: [{ financeFeeId: '711', sourceFeeId: '811', sourceType: 'INBOUND_FEE', sourceVersion: 3, customerId: '101', sourceInboundId: '11', inboundCode: 'IN-11', feeItemCode: 'FREIGHT', feeItemName: '原运费', currency: 'CNY', amount: 100, sourceHash: 'original-fee-hash', status: 'AVAILABLE' }],
- })
- const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
- await flushPromises()
- expect(getDispatchFeeCoverage).not.toHaveBeenCalled()
- await wrapper.findAll('button').find(button => button.text() === '转派送')!.trigger('click')
- await flushPromises()
- expect(getDispatchFeeCoverage).toHaveBeenCalledTimes(1)
- const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '转派送')!
- await dialog.findComponent(DeliveryFeeCoveragePanel).findAllComponents(ElCheckbox).find(row => row.text() === '已含派送')!.find('input').setValue(true)
- dialog.findComponent(ElDatePicker).vm.$emit('update:modelValue', '2026-10-03T12:00:00')
- for (const [label, value] of [['派送方式', 'TRUCK_PALLET'], ['提货方式', 'WAREHOUSE_PICKUP'], ['装车类型', 'PALLET']]) {
- dialog.findAllComponents(ElFormItem).find(item => item.props('label') === label)!.findComponent(ElSelect).vm.$emit('update:modelValue', value)
- }
- await flushPromises()
- expect(dialog.text()).toContain('本次全部货物已覆盖:派送')
- dialog.vm.$emit('confirm')
- await flushPromises()
- expect(transferDispatchToDelivery).toHaveBeenCalledWith('PC-1', expect.objectContaining({ fees: [], feeCoverageCargoHash: 'actual-cargo', feeCoverage: [{ financeFeeId: '711', sourceVersion: 3, sourceHash: 'original-fee-hash', services: ['DELIVERY_FEE'] }] }))
- })
-
- it('gates edit, cancel, pallet removal and transfer with independent permissions and server capabilities', async () => {
- const source = readFileSync(resolve(process.cwd(), 'src/modules/overseas-warehouse/pages/DispatchesView.vue'), 'utf8')
- expect(source).toContain('permission.dispatchEdit')
- expect(source).toContain('permission.dispatchCancel')
- expect(source).toContain('permission.dispatchTransferDelivery')
- expect(source).toContain("import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'")
- expect(source).toContain("{ key: 'transfer', label: '转派送', priority: 250")
- expect(source).toContain('access.can(permission.dispatchEdit) && row.actions?.canEdit === true')
- expect(source).toContain('access.can(permission.dispatchCancel) && row.actions?.canCancel === true')
- expect(source).toContain('access.can(permission.dispatchTransferDelivery) && row.actions?.canTransfer === true')
- expect(source).toContain('access.can(permission.dispatchEdit) && row.actions?.canRemovePallet === true')
- expect(source).toContain('!canRemovePallet(editRow.value)')
- expect(source).toContain('removeDispatchItem')
- expect(source).not.toContain('托盘箱唛确认')
- expect(source).not.toContain('palletCartonMarks')
- expect(source).toContain('v-model="editForm.route" readonly')
- expect(source).not.toContain('route: editForm.route.trim()')
- expect(source).toContain('dispatchVehicleSizeOptions')
- expect(source).not.toContain('departDispatch')
-
- canPermission.mockReturnValue(false)
- const wrapper = mount(DispatchesView, { global: { plugins: [createPinia(), ElementPlus] } })
- await flushPromises()
- expect(wrapper.text()).not.toContain('转派送')
- })
+const can = vi.hoisted(() => vi.fn(() => true))
+vi.mock('vue-router', () => ({ useRoute: () => ({ query: {} }), useRouter: () => ({ push }) }))
+vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
+vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
+vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null }))
+vi.mock('@/modules/overseas-master-data', () => ({ TransportRouteDialog: { template: '' }, routeLocation: (label: string) => ({ label }) }))
+vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), getDispatch: vi.fn(), getDispatchBolPdf: vi.fn() }))
+const options = { global: { plugins: [createPinia(), ElementPlus], stubs: { SmartDispatchDialog: true, DispatchWorkflowDialog: true } } }
+beforeEach(() => { vi.clearAllMocks(); identity.id = crypto.randomUUID(); sessionStorage.clear(); can.mockReturnValue(true); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); localStorage.clear(); vi.mocked(listDispatches).mockResolvedValue(page()); vi.mocked(getDispatch).mockResolvedValue(detail()) })
+it('offers recovery of the original pending command independently of the filtered list rows', async () => {
+ const pending = useDispatchCommandRecovery(() => identity.id); pending.begin('PC-OTHER', 'DEPART', { recordVersion: 0, idempotencyKey: 'original-key' })
+ const wrapper = mount(DispatchesView, options); await flushPromises()
+ expect(wrapper.text()).toContain('PC-OTHER 的确认发车结果尚待核对')
+ await wrapper.findAll('button').find(button => button.text() === '核对原请求')!.trigger('click')
+ const dialog = wrapper.get('dispatch-workflow-dialog-stub')
+ expect(dialog.attributes('dispatchno')).toBe('PC-OTHER'); expect(dialog.attributes('modelvalue')).toBe('true'); wrapper.unmount()
+})
+it('shows exactly five phases and uses server stage counts instead of current page length', async () => {
+ const wrapper = mount(DispatchesView, options); await flushPromises()
+ const tabs = wrapper.get('[aria-label="排车执行阶段"]').findAll('button')
+ expect(tabs.map(tab => tab.text())).toEqual(['装车前 3', '装车中 1', '装车后 1', '派送中 1', '派送完成 1'])
+ expect(listDispatches).toHaveBeenCalledWith(expect.objectContaining({ stage: 'BEFORE_LOADING', status: 'ACTIVE', beforeLoadingSection: 'VEHICLE_INFO' }))
+ await tabs[3]!.trigger('click'); await flushPromises()
+ expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'DELIVERING' })); expect(wrapper.find('[aria-label="装车前审核分区"]').exists()).toBe(false); wrapper.unmount()
+})
+it('queries each review section on the server and includes only its legal review filter', async () => {
+ const wrapper = mount(DispatchesView, options); await flushPromises()
+ await wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[1]!.trigger('click'); await flushPromises()
+ expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ beforeLoadingSection: 'DISPATCH_REVIEW' }))
+ wrapper.getComponent(QueryToolbar).vm.$emit('search', { status: 'ACTIVE', reviewStatus: 'PENDING', dispatchNo: '', vehiclePlate: '' }); await flushPromises()
+ expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ reviewStatus: 'PENDING' })); wrapper.unmount()
+})
+it('discards a late page response after switching phase', async () => {
+ let old!: (value: DispatchPage) => void
+ vi.mocked(listDispatches).mockImplementationOnce(() => new Promise(resolve => { old = resolve }))
+ const wrapper = mount(DispatchesView, options)
+ await wrapper.get('[aria-label="排车执行阶段"]').findAll('button')[1]!.trigger('click'); await flushPromises()
+ old(page([])); await flushPromises(); expect(wrapper.text()).toContain('PC/1'); wrapper.unmount()
+})
+it('gates every mutation with both its literal service capability and independent permission', async () => {
+ vi.mocked(listDispatches).mockResolvedValue(page([listRow({ actions: { DEPART: { allowed: false, blockReason: '未安排', blockCode: 'NO' }, EDIT: { allowed: true, blockCode: null, blockReason: null } } })]))
+ const wrapper = mount(DispatchesView, options); await flushPromises()
+ const actions = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.props('actions') as Array<{ key: string }>
+ expect(actions.map(row => row.key)).toContain('EDIT'); expect(actions.map(row => row.key)).not.toContain('DEPART')
+ can.mockReturnValue(false); wrapper.unmount()
+ const readonly = mount(DispatchesView, options); await flushPromises(); expect((readonly.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.props('actions') as Array<{ key: string }>).map(row => row.key)).toEqual(['detail', 'bol']); readonly.unmount()
+})
+it('keeps server errors visible with the integrated query and retry context', async () => {
+ vi.mocked(listDispatches).mockRejectedValueOnce(new Error('service unavailable'))
+ const wrapper = mount(DispatchesView, options); await flushPromises(); expect(wrapper.text()).toContain('排车列表加载失败,请重试'); expect(wrapper.findComponent(QueryToolbar).exists()).toBe(true); wrapper.unmount()
+})
+it('navigates by the business number without arranging a separate trip', async () => {
+ const wrapper = mount(DispatchesView, options); await flushPromises(); wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.vm.$emit('action', 'detail'); await flushPromises()
+ expect(push).toHaveBeenCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'PC/1' } }); expect(wrapper.text()).not.toContain('安排车次'); wrapper.unmount()
+})
+it('reads one BOL on demand and preserves popup failure handling', async () => {
+ vi.stubGlobal('open', vi.fn().mockReturnValue(null)); const wrapper = mount(DispatchesView, options); await flushPromises(); wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.vm.$emit('action', 'bol'); await flushPromises(); expect(getDispatchBolPdf).not.toHaveBeenCalled(); wrapper.unmount()
})
diff --git a/src/modules/overseas-warehouse/pages/DispatchesView.vue b/src/modules/overseas-warehouse/pages/DispatchesView.vue
index d9c97939..c2b45952 100644
--- a/src/modules/overseas-warehouse/pages/DispatchesView.vue
+++ b/src/modules/overseas-warehouse/pages/DispatchesView.vue
@@ -1,743 +1,127 @@
-
-
-
- 返回预约工作排车列表
安排车次
-
-
-
-
-
-
-
-
+
+ {{ recovery.pending.value.dispatchNo || '新排车单' }} 的{{ dispatchActionLabels[recovery.pending.value.action] }}结果尚待核对。核对原请求
+
+ 返回预约工作排车列表
+ 新建排车
+
- {{ reason }}
-
- {{ row.workflow.tripNo }}
- {{ row.workflow?.tripNo || '未关联车次' }}
-
-
-
- {{ reviewLabels[(row as DispatchListItem).workflow![review].status] }}
- {{ row.workflow[review].reason || '—' }}
- {{ formatOverseasDateTime(row.workflow[review].reviewedAt) }}
-
- 未关联车次
-
-
- 查看路线
- 共 {{ total }} 条排车记录
+ 操作资格未返回,请刷新核对
+ {{ row.resources.vehicle?.plate || '—' }}{{ row.resources.driver?.name || '—' }}
{{ row.resources.driver?.phone || '—' }}
+ {{ reviewLabels[review.status as ReviewStatus] }}
{{ review.reason }}
{{ review.invalidatedReason }}
{{ time(review.reviewedAt) }}
+ {{ row.counts.palletCount }}托/{{ row.counts.packageCount }}箱/已拣{{ row.counts.pickedPackageCount }}箱{{ time(row.plannedDepartureAt) }}{{ time(row.departedAt) }}{{ row.status === 'CANCELLED' ? '已取消' : stageLabel(row.stage) }}查看路线
-
- 派送信息
本次全部货物已覆盖:{{ coveredServices.map(service => coveredServiceLabels[service]).join('、') }},无需重复登记对应应收。
本次新增费用
正在读取约定运费…
{{ quote.bookingNo }} · {{ quote.incomingNo }}:{{ quote.quantity }} {{ quote.quantityUnit }} × {{ quote.currency }} {{ quote.unitPrice }} = {{ quote.currency }} {{ quote.amount }}(外接约定运费)
-
{{ editRow.actions.removePalletBlockReason }}
已排车托盘
移除
+
+
+
+
-
diff --git a/src/modules/overseas-warehouse/pages/InboundOrdersView.dispatch.test.ts b/src/modules/overseas-warehouse/pages/InboundOrdersView.dispatch.test.ts
new file mode 100644
index 00000000..001fa6ac
--- /dev/null
+++ b/src/modules/overseas-warehouse/pages/InboundOrdersView.dispatch.test.ts
@@ -0,0 +1,53 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { beforeEach, expect, it, vi } from 'vitest'
+import InboundOrdersView from './InboundOrdersView.vue'
+import { overseasWarehousePermissions as permission } from '../permissions'
+
+const access = vi.hoisted(() => ({ codes: [] as string[] }))
+vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (code: string) => access.codes.includes(code) }) }))
+vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }), onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
+vi.mock('../api/inboundOrders', () => ({ listInboundOrders: vi.fn().mockResolvedValue({ records: [], total: 0 }), createFbaAppointment: vi.fn(), getFbaAppointment: vi.fn(), updateFbaAppointment: vi.fn(), updateInboundPlan: vi.fn() }))
+vi.mock('../components/SmartDispatchDialog.vue', () => ({ default: { name: 'SmartDispatchDialog', props: ['modelValue', 'orders'], template: '' } }))
+vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() } }))
+
+function render() {
+ return mount(InboundOrdersView, { global: { directives: { loading: {} }, stubs: {
+ AdminPage: { template: '' }, ListPageLayout: { template: '
' },
+ DataTable: { template: '
' },
+ ElButton: { template: '' },
+ AppDialog: true, FbaAppointmentFields: true, QueryToolbar: true, OperationErrorToast: true,
+ } } })
+}
+
+beforeEach(() => { vi.clearAllMocks(); access.codes = [permission.inboundOrderList] })
+
+it('opens the existing smart-dispatch flow with dispatch list/create and no legacy smart permission or selected rows', async () => {
+ access.codes.push(permission.dispatchList, permission.dispatchCreate)
+ const wrapper = render()
+ try {
+ await flushPromises()
+ const button = wrapper.findAll('button').find(button => button.text() === '智能排车')!
+ expect(button.exists()).toBe(true)
+ await button.trigger('click')
+ expect(wrapper.find('[data-dispatch-dialog]').exists()).toBe(true)
+ expect(wrapper.getComponent({ name: 'SmartDispatchDialog' }).props('orders')).toEqual([])
+ } finally { wrapper.unmount() }
+})
+
+it.each([
+ ['list only', [permission.dispatchList]],
+ ['create only', [permission.dispatchCreate]],
+ ['legacy smart only', ['platform:overseas-warehouse:inbound-order:smart-dispatch']],
+ ['list plus legacy smart', [permission.dispatchList, 'platform:overseas-warehouse:inbound-order:smart-dispatch']],
+])('blocks the button and direct handler for %s', async (_, codes) => {
+ access.codes.push(...codes)
+ const wrapper = render()
+ try {
+ await flushPromises()
+ expect(wrapper.findAll('button').some(button => button.text() === '智能排车')).toBe(false)
+ const state = wrapper.vm as unknown as { openSmartDispatch(): void }
+ state.openSmartDispatch()
+ await flushPromises()
+ expect(wrapper.find('[data-dispatch-dialog]').exists()).toBe(false)
+ } finally { wrapper.unmount() }
+})
diff --git a/src/modules/overseas-warehouse/pages/InboundOrdersView.vue b/src/modules/overseas-warehouse/pages/InboundOrdersView.vue
index a277c918..0f8f2c5b 100644
--- a/src/modules/overseas-warehouse/pages/InboundOrdersView.vue
+++ b/src/modules/overseas-warehouse/pages/InboundOrdersView.vue
@@ -24,6 +24,7 @@ import { formatOverseasDateTime, inboundPlanStatusLabel, lastMileOrderTypeLabel
import type { InboundOrderListItem } from '../types'
const access = useAccessPreviewStore()
+const canCreateDispatch = computed(() => access.can(permission.dispatchList) && access.can(permission.dispatchCreate))
const router = useRouter()
const rows = ref([])
const selectedRows = ref([])
@@ -145,6 +146,7 @@ function openAppointment() {
}
function openSmartDispatch() {
+ if (!canCreateDispatch.value) return
smartDispatchOpen.value = true
}
@@ -234,7 +236,7 @@ onMounted(() => { void load() })
- 预约智能排车
+ 预约智能排车
查看编辑查看预约预约管理多批预约
共 {{ total }} 条入库单 · 已选 {{ selectedRows.length }} 条
diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts
index 25c76e1a..9e2dd673 100644
--- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts
+++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.behavior.test.ts
@@ -307,9 +307,9 @@ describe('LastMileOrdersView pending order and container conversion', () => {
it.each([
[[], ['查看'], false],
- [['inbound-order:create'], ['查看', '生成入库计划'], false],
- [['last-mile-order:edit'], ['查看', '人工修改节点'], false],
- [['inbound-order:create', 'last-mile-order:edit'], ['查看', '生成入库计划', '人工修改节点'], false],
+ [['inbound-order:create'], ['查看', '入库'], false],
+ [['last-mile-order:edit'], ['查看', '修改节点'], false],
+ [['inbound-order:create', 'last-mile-order:edit'], ['查看', '入库', '修改节点'], false],
])('only collapses row actions above three for %j', async (allowed, labels, overflow) => {
canAccess.mockImplementation(code => allowed.some(action => code === 'platform:overseas-warehouse:' + action))
vi.mocked(listLastMileOrders).mockResolvedValue({ records: [{ id: 'order-1', customerId, orderNo: 'LM-1', currentStatus: 'PENDING_ASSIGNMENT', orderType: 'FIRST_LEG' }], page: 1, size: 10, total: 1, pages: 1 })
@@ -469,11 +469,11 @@ describe('LastMileOrdersView pending order and container conversion', () => {
const wrapper = await mountView()
const actions = wrapper.get('.el-table__body-wrapper .row-actions')
expect(actions.text()).toContain('查看')
- expect(actions.text()).not.toContain('生成入库计划')
+ expect(actions.text()).not.toContain('入库')
expect(actions.text()).not.toContain('暂扣订单')
await selectRows(wrapper, [(wrapper.findComponent(DataTable).props('data') as object[])[0]])
- expect((button(wrapper, '生成入库计划')!.element as HTMLButtonElement).disabled).toBe(true)
+ expect((button(wrapper, '入库')!.element as HTMLButtonElement).disabled).toBe(true)
})
it('keeps the original pending order table and does not render a view switch or container table', async () => {
@@ -481,7 +481,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
expect(wrapper.text()).toContain('尾程单号')
expect(wrapper.text()).toContain('海外仓')
expect(wrapper.text()).toContain('转仓')
- expect(wrapper.text()).toContain('生成入库计划')
+ expect(wrapper.text()).toContain('入库')
expect(wrapper.text()).toContain('按柜批量生成')
expect(wrapper.text()).not.toContain('按订单')
expect(wrapper.text()).not.toContain('待分配整柜')
@@ -545,7 +545,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
it('opens the required single-order inbound-plan form without invoking the cabinet conversion', async () => {
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow()])
- await button(wrapper, '生成入库计划')!.trigger('click')
+ await button(wrapper, '入库')!.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('计划入库仓')
expect(document.body.textContent).toContain('预计到仓时间')
@@ -572,7 +572,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
it('shows cabinet-frozen facts but never sends arrival time or per-order warehouse overrides', async () => {
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow()])
- await button(wrapper, '生成入库计划')!.trigger('click')
+ await button(wrapper, '入库')!.trigger('click')
await flushPromises()
const confirm = [...document.body.querySelectorAll('button')]
.find((item) => item.textContent?.includes('保存并生成入库单')) as HTMLButtonElement
@@ -598,7 +598,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
orderTypeCode: 'ONE_CLICK_DROPSHIP',
currentStatusCode: 'PENDING_ASSIGNMENT',
})])
- await button(wrapper, '生成入库计划')!.trigger('click')
+ await button(wrapper, '入库')!.trigger('click')
await flushPromises()
expect(getContainerInboundPlanPreview).not.toHaveBeenCalled()
@@ -610,7 +610,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
it('blocks manual plan creation when the cabinet has no frozen expected arrival time', async () => {
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow({ estimatedWarehouseAt: '', warehouseEta: '—' })])
- await button(wrapper, '生成入库计划')!.trigger('click')
+ await button(wrapper, '入库')!.trigger('click')
await flushPromises()
const confirm = [...document.body.querySelectorAll('button')]
@@ -634,7 +634,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
} as never)
const wrapper = await openPending()
await selectRows(wrapper, [selectedRow({ orderTypeCode: 'ONE_CLICK_DROPSHIP', pallets: 2 })])
- await button(wrapper, '生成入库计划')!.trigger('click')
+ await button(wrapper, '入库')!.trigger('click')
await flushPromises()
const palletInput = wrapper.find('.command-form').findComponent({ name: 'ElInputNumber' })
@@ -944,7 +944,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
it('starts cabinet plan generation directly from the workbench card', async () => {
const wrapper = await mountView()
await openHiddenContainerWorkbench(wrapper)
- await button(wrapper, '生成入库计划')!.trigger('click')
+ await button(wrapper, '入库')!.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('头程已有 4 个托盘码,海外仓补充 1 个,最终生成 5 个托盘')
expect(document.body.textContent).toContain('头程已有 4 托 / 海外仓补充 1 托')
@@ -974,7 +974,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
await openHiddenContainerWorkbench(wrapper)
expect(wrapper.text()).toContain('柜内接柜仓业务快照不一致')
- expect((button(wrapper, '生成入库计划')!.element as HTMLButtonElement).disabled).toBe(true)
+ expect((button(wrapper, '入库')!.element as HTMLButtonElement).disabled).toBe(true)
await wrapper.get('.container-workbench-card__detail').trigger('click')
await flushPromises()
expect(getContainerPlanProgress).toHaveBeenCalledWith(bookingId)
@@ -1183,7 +1183,7 @@ describe('LastMileOrdersView pending order and container conversion', () => {
const wrapper = await mountView()
await selectRows(wrapper, [selectedRow({ orderTypeCode: 'RETURN_RELABEL', currentStatusCode: 'INBOUND_COMPLETED', sourceOrderNo: 'RETURN-1' })])
const actions = wrapper.get('.data-table__business-actions')
- expect(actions.text()).not.toContain('生成入库计划')
+ expect(actions.text()).not.toContain('入库')
const packing = actions.findAll('button').find(action => action.text().includes('创建退货合箱计划(1)'))!
await packing.trigger('click')
await flushPromises()
diff --git a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue
index e11f1441..b91fc0e1 100644
--- a/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue
+++ b/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue
@@ -1000,9 +1000,9 @@ function lastMileRowActions(row: LastMileOrderListItem): RowActionItem[] {
{ key: 'review-direct-relabel', label: '审核', priority: 470, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer) && row.orderTypeCode === 'RELABEL_APPLICATION' && row.currentStatusCode === 'PENDING_REVIEW' },
{ key: 'review-return', label: '审核', priority: 460, tone: 'warning', visible: access.can(permission.returnOrderReview) && row.orderTypeCode === 'RETURN_RELABEL' && row.currentStatusCode === 'PENDING_REVIEW' },
{ key: 'review', label: '审核', priority: 450, tone: 'warning', visible: access.can(permission.warehouseTransferApprove) && row.orderTypeCode === 'WAREHOUSE_TRANSFER' && row.currentStatusCode === 'PENDING_REVIEW' },
- { key: 'inbound-plan', label: '生成入库计划', priority: 400, visible: access.can(permission.inboundOrderCreate) && canGenerateInboundPlan(row) && !inboundPlanCreated },
+ { key: 'inbound-plan', label: '入库', priority: 400, visible: access.can(permission.inboundOrderCreate) && canGenerateInboundPlan(row) && !inboundPlanCreated },
{ key: 'hold', label: row.detained ? '解除暂扣' : '暂扣订单', priority: 300, tone: 'warning', visible: access.can(permission.lastMileOrderTransfer) && (row.detained || row.currentStatusCode === 'INBOUND_COMPLETED') },
- { key: 'manual-nodes', label: '人工修改节点', priority: 200, visible: access.can(permission.lastMileOrderTransfer) },
+ { key: 'manual-nodes', label: '修改节点', priority: 200, visible: access.can(permission.lastMileOrderTransfer) },
{ key: 'confirm-relabel', label: '换标完成确认', priority: 150, visible: access.can(permission.relabelOrderStatus) && Boolean(row.relabelOrderNo) && canConfirmRelabel(row) },
{ key: 'rework-relabel', label: '换标退回重做', priority: 100, tone: 'danger', divided: true, visible: access.can(permission.relabelOrderStatus) && canReworkRelabel(row) },
]
@@ -1124,7 +1124,7 @@ onMounted(() => { void load() })
修改接柜仓
- {{ container.resourcePendingPlanCount && !container.pendingPlanCount ? '重试生成托盘码' : '生成入库计划' }}
+ {{ container.resourcePendingPlanCount && !container.pendingPlanCount ? '重试生成托盘码' : '入库' }}
@@ -1135,7 +1135,7 @@ onMounted(() => { void load() })
-