From 1028b715abd4efc71df36aab76e05de37a4600f4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 20 Oct 2026 08:42:35 +0800 Subject: [PATCH] TEST Admin dispatch dialog proposal 2026-10-20 Seed tree 270cbc0780a7598ea2f95fb5a279077028b9de6b; parent remains live cbd849444fc4e292626dbabd09125a135be94daa. Preserve the frozen 18-path scope and eleven explicit dialog/style/test overrides. Local commit-tree proposal only; not pushed, tagged, deployed, or runtime-verified. --- .../WorkDispatchForm.test.ts | 4 +- .../appointment-calendar/WorkDispatchForm.vue | 14 +- .../WorkExistingDispatchForm.test.ts | 2 +- .../overseas-warehouse/api/dispatches.test.ts | 5 +- .../overseas-warehouse/api/dispatches.ts | 1 - .../components/DispatchArrangeFields.test.ts | 2 +- .../components/DispatchArrangeFields.vue | 43 ++-- .../components/DispatchEvidenceFiles.vue | 22 +- .../components/DispatchResourceFields.test.ts | 12 +- .../components/DispatchResourceFields.vue | 20 +- .../DispatchWorkflowDialog.layout.test.ts | 217 ++++++++++++++++++ .../components/DispatchWorkflowDialog.test.ts | 24 +- .../components/DispatchWorkflowDialog.vue | 101 ++++++-- .../SmartDispatchDialog.layout.test.ts | 204 ++++++++++++++++ .../components/SmartDispatchDialog.test.ts | 29 ++- .../components/SmartDispatchDialog.vue | 88 +++++-- .../overseas-warehouse/dispatchContracts.ts | 56 ++++- .../dispatchSelectionGroups.test.ts | 43 ++++ .../dispatchTestFixtures.ts | 9 +- .../overseas-warehouse/dispatchWire.test.ts | 15 +- .../overseas-warehouse/dispatchWire.ts | 23 +- src/modules/overseas-warehouse/index.ts | 2 +- .../pages/DispatchDetailView.test.ts | 8 +- .../pages/DispatchDetailView.vue | 2 +- .../pages/prototypeContract.test.ts | 4 +- src/styles/adminDensity.css | 44 ++++ src/styles/adminDensityTokens.css | 7 + 27 files changed, 886 insertions(+), 115 deletions(-) create mode 100644 src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts create mode 100644 src/modules/overseas-warehouse/components/SmartDispatchDialog.layout.test.ts create mode 100644 src/modules/overseas-warehouse/dispatchSelectionGroups.test.ts diff --git a/src/modules/appointment-calendar/WorkDispatchForm.test.ts b/src/modules/appointment-calendar/WorkDispatchForm.test.ts index a36bb194..847c5f69 100644 --- a/src/modules/appointment-calendar/WorkDispatchForm.test.ts +++ b/src/modules/appointment-calendar/WorkDispatchForm.test.ts @@ -18,10 +18,10 @@ const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T const batch = 'FBA_APPOINTMENT:1' function receiving(key = batch, vehicles: ReceivingContext['transport']['vehicles'] = []): ReceivingContext { return { batch: batchContext('AP-1', key).appointment, plans: [], agreements: [], pendingChanges: [], transport: { version: 'transport', vehicles, blockReasons: [] }, executionScope: { version: 'execution', members: [] }, allowedActions: [], blockReasons: [] } } function row(id = '9007199254740993', customerId = 'customer-a', key = batch): BatchDispatchCandidate { return { planPalletId: id, palletNo: `PALLET-${id.slice(-2)}`, planId: `plan-${id}`, planNo: 'IP-001', customerId, sourceStage: 'RECEIVED', packageCount: 12, allocations: [{ planPalletId: id, batchKey: key, customerId, sourceKey: customerId === 'customer-a' ? 'ORDER:1' : 'BOOKING:2', destinationKey: 'FBA:ONT8', packageCount: 12, recordVersion: 4 }], blockReasons: [] } } -function candidate(value = row()): Candidate { return { selection: { sourceType: 'INBOUND_PLAN', planPalletId: value.planPalletId, returnReceiptId: null, relabelNo: null, warehousePalletId: `pallet-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}` }, sourceKey: `cargo-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}`, customerId: value.customerId, customerName: value.customerId === 'customer-a' ? '客户甲' : '客户乙', warehouseId: 'origin-1', warehouseCode: 'LAX', warehouseName: '真实起运仓', orderNo: 'LM-1', inboundCode: 'IN-A', planNo: value.planNo, palletNo: value.palletNo, containerNo: null, destination: { key: 'CODE:ONT8', type: 'FBA', warehouseId: null, warehouseCode: 'ONT8', warehouseName: 'ONT8', address: null, postalCode: null, contactName: null, contactPhone: null }, availablePackages: 12, suggestedWeightKg: null, suggestedVolumeCbm: null, measurementSource: null, appointmentBatches: [], blockReasons: [] } } +function candidate(value = row()): Candidate { const base = { selection: { sourceType: 'INBOUND_PLAN', planPalletId: value.planPalletId, returnReceiptId: null, relabelNo: null, warehousePalletId: `pallet-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}` }, sourceKey: `cargo-${value.planPalletId}`, sourceVersion: `source-${value.planPalletId}`, customerId: value.customerId, customerName: value.customerId === 'customer-a' ? '客户甲' : '客户乙', warehouseId: 'origin-1', warehouseCode: 'LAX', warehouseName: '真实起运仓', orderNo: 'LM-1', inboundCode: 'IN-A', planNo: value.planNo, palletNo: value.palletNo, containerNo: null, destination: { key: 'CODE:ONT8', type: 'FBA', warehouseId: null, warehouseCode: 'ONT8', warehouseName: 'ONT8', address: null, postalCode: null, contactName: null, contactPhone: null }, availablePackages: 12, suggestedWeightKg: null, suggestedVolumeCbm: null, measurementSource: null, appointmentBatches: [], blockReasons: [] }; return { ...base, selection: { ...base.selection, sourceType: 'INBOUND_PLAN' }, selectionGroup: { groupKey: base.sourceKey, groupVersion: 'group-1', sources: [{ selection: { ...base.selection, sourceType: 'INBOUND_PLAN' }, sourceKey: base.sourceKey, customerId: base.customerId, customerName: base.customerName, sourceBusinessType: null, sourceBusinessNo: null, packages: base.availablePackages }], pallets: [{ warehousePalletId: base.selection.warehousePalletId, palletNo: base.palletNo, sourceKeys: [base.sourceKey], packageCount: base.availablePackages }], complete: true, blockReasons: [] } } } function detail(no = 'DS/001', version = 1): DispatchDetail { const review = { status: 'NOT_SUBMITTED' as const, reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null } - return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: version, reviewState: 'DRAFT', reviews: { dispatchReview: review, loadingReview: review }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], vehicleAppointment: null, appointmentBatches: [{ batchKey: batch, batchNo: 'AP-1', recordVersion: 4, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] } + return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: version, reviewState: 'DRAFT', reviews: { dispatchReview: review, loadingReview: review }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [{ batchKey: batch, batchNo: 'AP-1', recordVersion: 4, customerId: 'customer-a', sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] } } function proof(key: string): CommandResult { return { dispatchNo: 'DS/001', action: 'CREATE', idempotencyKey: key, recordVersion: 1, status: 'ACTIVE', stage: 'BEFORE_LOADING', occurredAt: '2026-10-19T00:00:00Z', effect: 'APPLIED', counts: detail().counts, result: { dispatchNo: 'DS/001', changedItemIds: [] }, replayed: false, alreadyArranged: false } } let guard: LeaveGuard, natives: Candidate[], draft: DispatchDraft diff --git a/src/modules/appointment-calendar/WorkDispatchForm.vue b/src/modules/appointment-calendar/WorkDispatchForm.vue index 3f7c3ba8..2f23dfb9 100644 --- a/src/modules/appointment-calendar/WorkDispatchForm.vue +++ b/src/modules/appointment-calendar/WorkDispatchForm.vue @@ -4,7 +4,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { ElMessage } from 'element-plus' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAuthStore } from '@/core/auth/auth' -import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, dispatchVehicleSpecs, DispatchInputError, requireVersion, requireBatchVersions, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection, type BatchDispatchCandidate, type BatchDispatchCandidates, type Candidate, type DispatchDetail, type DispatchDraft, type ConvertDraft, type ConvertedDraft, type DraftSelection, type VersionCommand } from '@/modules/overseas-warehouse' +import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, dispatchVehicleSpecs, DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection, type BatchDispatchCandidate, type BatchDispatchCandidates, type Candidate, type DispatchDetail, type DispatchDraft, type ConvertDraft, type ConvertedDraft, type DraftSelection, type VersionCommand } from '@/modules/overseas-warehouse' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' @@ -38,7 +38,7 @@ function persist() { ensureOwner(); if (pending.value) sessionStorage.setItem(st try { const saved = owner && sessionStorage.getItem(storageKey); if (saved) pending.value = JSON.parse(saved) as Preparation } catch { corrupted.value = true; saveError.value = { message: '原排车请求记录损坏,请保留现场核对,不能另建排车单' } } function reasons(row: BatchRow) { return row.blockReasons } -function physicalCount(values: BatchRow[]) { return new Set(values.map(row => row.native?.selection.warehousePalletId || row.native?.sourceKey || row.key)).size } +function physicalCount(values: BatchRow[]) { return new Set(values.flatMap(row => row.native?.selectionGroup.pallets.map(pallet => pallet.warehousePalletId || pallet.palletNo || row.key) || [])).size } function detailPath(no: string) { return `/overseas-warehouse/dispatches/${encodeURIComponent(no)}` } async function load() { if (!readable.value || pending.value || corrupted.value) return @@ -78,7 +78,7 @@ async function load() { const sources = receiving[index]!.batch.items const blocks = [...context.blockReasons, ...row.blockReasons] if (!candidate || candidate.warehouseId !== warehouseId.value || !candidate.sourceVersion || candidate.selection.sourceVersion !== candidate.sourceVersion) blocks.push('原生实物来源、起运仓或版本待核实') - if (candidate) blocks.push(...candidate.blockReasons) + if (candidate) { const groupBlock = selectionGroupBlockReason(candidate); if (groupBlock) blocks.push(groupBlock) } if (row.packageCount == null || row.packageCount <= 0 || candidate && candidate.availablePackages !== row.packageCount) blocks.push('批次箱数与本托真实可排量不一致,请核对分批或拆托') if (!row.allocations.length || row.allocations.some(allocation => allocation.planPalletId !== row.planPalletId || allocation.customerId !== row.customerId || allocation.batchKey !== context.batchKey || !Number.isInteger(allocation.packageCount) || allocation.packageCount <= 0 || !sources.some(source => source.customerId === allocation.customerId && source.sourceKey === allocation.sourceKey && source.destinationKey === allocation.destinationKey && source.packageCount != null && source.packageCount >= allocation.packageCount))) blocks.push('候选不属于当前批次、客户和真实来源范围') if (row.allocations.reduce((sum, allocation) => sum + allocation.packageCount, 0) !== row.packageCount) blocks.push('逐来源批次分配与实物箱数不一致') @@ -132,7 +132,11 @@ async function executeStep() { const expected = request.rows.find(row => row.native.selection.planPalletId === selectedSource.planPalletId && row.native.selection.sourceType === selectedSource.sourceType && row.native.sourceVersion === selectedSource.sourceVersion) const source = expected && draft.sources.find(row => row.sourceKey === expected.native.sourceKey && row.customerId === expected.customerId) if (!source || !draft.containers.find(row => row.draftContainerId === request.containerId)?.sourceKeys.includes(source.sourceKey)) throw new DispatchInputError('原来源未确认进入本装车组,请核对原请求') - request.lockedKeys.push(source.sourceKey) + const group = requireSelectionGroup(source) + assertSelectionGroupVersions([expected!.native], [source]) + const boundKeys = draft.containers.find(row => row.draftContainerId === request.containerId)!.sourceKeys + if (group.sources.some(required => !boundKeys.includes(required.sourceKey))) throw new DispatchInputError('完整选货组尚未原子进入本装车组,请核对原请求') + request.lockedKeys.push(...group.sources.map(required => required.sourceKey)) } request.draft = draft } @@ -164,7 +168,7 @@ async function save() { else { const cargo = [...new Set(request.rows.map(row => row.native.sourceKey))].map(key => request.draft!.sources.find(source => source.sourceKey === key)) if (cargo.some(source => !source || source.warehouseId !== request.warehouseId || !source.sourceVersion || source.selection.sourceVersion !== source.sourceVersion || request.rows.some(row => row.native.sourceKey === source.sourceKey && row.customerId !== source.customerId))) throw new DispatchInputError('草稿真实来源尚未完整恢复,请保留原请求核对') - step('CONVERT', { ...common(request.draft), groups: [{ draftContainerId: request.containerId, dispatch: { idempotencyKey: request.dispatchKey, warehouseId: request.warehouseId, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: cargo.map(source => source!.selection), batchAllocations: request.rows.flatMap(row => row.allocations) } }] } satisfies ConvertDraft) + step('CONVERT', { ...common(request.draft), groups: [{ draftContainerId: request.containerId, dispatch: { idempotencyKey: request.dispatchKey, warehouseId: request.warehouseId, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: expandSelectionGroups(cargo as Candidate[]), batchAllocations: request.rows.flatMap(row => row.allocations) } }] } satisfies ConvertDraft) } } } diff --git a/src/modules/appointment-calendar/WorkExistingDispatchForm.test.ts b/src/modules/appointment-calendar/WorkExistingDispatchForm.test.ts index 447b1e81..074fe88f 100644 --- a/src/modules/appointment-calendar/WorkExistingDispatchForm.test.ts +++ b/src/modules/appointment-calendar/WorkExistingDispatchForm.test.ts @@ -8,7 +8,7 @@ const access = vi.hoisted(() => ({ allowed: true })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.allowed }) })) vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original(), getDispatch: vi.fn() })) vi.mock('./workApi', async original => ({ ...await original(), resolveBatchWork: vi.fn() })) -function detail(no = 'DS/ORIGINAL'): DispatchDetail { return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: { dispatchReview: { status: 'NOT_SUBMITTED', reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }, loadingReview: { status: 'NOT_SUBMITTED', reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null } }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], vehicleAppointment: null, appointmentBatches: [] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] } } +function detail(no = 'DS/ORIGINAL'): DispatchDetail { return { dispatchNo: no, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, reviewState: 'DRAFT', reviews: { dispatchReview: { status: 'NOT_SUBMITTED', reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null }, loadingReview: { status: 'NOT_SUBMITTED', reason: null, reviewerId: null, reviewedAt: null, reviewRound: 0, basisHash: null, invalidatedReason: null } }, counts: { itemCount: 1, palletCount: 1, packageCount: 12, pickedPackageCount: 0, shippedPackageCount: 0 }, pickingReconciled: false, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: null, departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [] }, documents: { dispatchNo: no, outbounds: [], deliveries: [], createdAt: '', updatedAt: '', remark: null }, orderDetails: [] } } function render(dispatches: Array<{ batchKey: string; dispatchNo: string; customerId?: string }> = [{ batchKey: 'DISPATCH:9007199254740999', dispatchNo: 'DS/ORIGINAL' }]) { return mount(WorkExistingDispatchForm, { props: { dispatches, canWrite: true }, global: { stubs: { ElButton: { props: ['disabled'], template: '' }, LoadErrorState: { props: ['message'], template: '

{{ message }}

' }, ApiErrorMeta: true, WorkSharedTripForm: { name: 'WorkSharedTripForm', props: ['dispatchNo', 'canWrite'], template: '
' } } } }) } const button = (wrapper: ReturnType, label: string) => wrapper.findAll('button').find(row => row.text() === label)! beforeEach(() => { vi.clearAllMocks(); access.allowed = true; vi.mocked(getDispatch).mockResolvedValue(detail()); vi.mocked(resolveBatchWork).mockResolvedValue({ batchKey: 'DISPATCH:9007199254740999', customerId: 'customer-a', detail: workDetail({ item: workItem({ batchKey: 'DISPATCH:9007199254740999', batchNo: 'DS/ORIGINAL' }) }), candidates: [] }) }) diff --git a/src/modules/overseas-warehouse/api/dispatches.test.ts b/src/modules/overseas-warehouse/api/dispatches.test.ts index dbb7cf7e..c31a7686 100644 --- a/src/modules/overseas-warehouse/api/dispatches.test.ts +++ b/src/modules/overseas-warehouse/api/dispatches.test.ts @@ -98,10 +98,9 @@ describe('native whole-vehicle dispatch API', () => { 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 } } + it('updates vehicle resources and timing with the explicit dispatch version', async () => { + const request: UpdateDispatch = { ...body, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null } 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 () => { diff --git a/src/modules/overseas-warehouse/api/dispatches.ts b/src/modules/overseas-warehouse/api/dispatches.ts index 66e1527d..62ad60e4 100644 --- a/src/modules/overseas-warehouse/api/dispatches.ts +++ b/src/modules/overseas-warehouse/api/dispatches.ts @@ -28,7 +28,6 @@ export const getDispatchCommand = (dispatchNo: string, key: string) => get { 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) } diff --git a/src/modules/overseas-warehouse/components/DispatchArrangeFields.test.ts b/src/modules/overseas-warehouse/components/DispatchArrangeFields.test.ts index be1c16b7..46557abd 100644 --- a/src/modules/overseas-warehouse/components/DispatchArrangeFields.test.ts +++ b/src/modules/overseas-warehouse/components/DispatchArrangeFields.test.ts @@ -4,7 +4,7 @@ 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 +const cargo = (sourceKey: string, customerId = 'C1'): CargoLine => ({ sourceKey, customerId, inboundCode: `IN-${sourceKey}`, sourceOrigin: null }) 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 })) } diff --git a/src/modules/overseas-warehouse/components/DispatchArrangeFields.vue b/src/modules/overseas-warehouse/components/DispatchArrangeFields.vue index 4c80fe37..f307e57e 100644 --- a/src/modules/overseas-warehouse/components/DispatchArrangeFields.vue +++ b/src/modules/overseas-warehouse/components/DispatchArrangeFields.vue @@ -26,29 +26,32 @@ function required(index: number, preview: PreviewGroup, service: string) { defineExpose({ covered }) diff --git a/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.vue b/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.vue index 2411f091..912587a4 100644 --- a/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.vue +++ b/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.vue @@ -1,6 +1,9 @@ diff --git a/src/modules/overseas-warehouse/components/DispatchResourceFields.test.ts b/src/modules/overseas-warehouse/components/DispatchResourceFields.test.ts index dd1bf87c..d3aa7a59 100644 --- a/src/modules/overseas-warehouse/components/DispatchResourceFields.test.ts +++ b/src/modules/overseas-warehouse/components/DispatchResourceFields.test.ts @@ -8,21 +8,21 @@ 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 () => { +it.each([false, true])('renders known business labels without internal IDs (continuous=%s)', async continuous => { 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() + const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous }, 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 () => { +it.each([false, true])('resets only the switched resource and keeps choices independent (continuous=%s)', async continuous => { 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 wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous }, 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 () => { +it.each([false, true])('requires each directory permission before reading candidates (continuous=%s)', async continuous => { can.mockReturnValue(false); const value = resources() - const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false }, global: { plugins: [ElementPlus] } }) + const wrapper = mount(DispatchResourceFields, { props: { vehicle: value.vehicle, driver: value.driver, disabled: false, continuous }, 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 index 6ce8a6c2..220c42f4 100644 --- a/src/modules/overseas-warehouse/components/DispatchResourceFields.vue +++ b/src/modules/overseas-warehouse/components/DispatchResourceFields.vue @@ -5,7 +5,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview' import type { Driver, Vehicle } from '../dispatchContracts' const vehicle = defineModel('vehicle', { required: true }) const driver = defineModel('driver', { required: true }) -defineProps<{ disabled: boolean }>() +defineProps<{ disabled: boolean; continuous?: boolean }>() const access = useAccessPreviewStore(), vehicles = ref([]), drivers = ref([]), error = ref('') let vehicleSequence = 0, driverSequence = 0 async function searchVehicles(keyword = '') { @@ -32,6 +32,21 @@ function selectDriver(id: string) { } diff --git a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts new file mode 100644 index 00000000..9c044cad --- /dev/null +++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.layout.test.ts @@ -0,0 +1,217 @@ +import ElementPlus from 'element-plus' +import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue' +import DispatchArrangeFields from './DispatchArrangeFields.vue' +import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue' +import DispatchResourceFields from './DispatchResourceFields.vue' +import AppDialog from '@/shared/components/AppDialog.vue' +import LoadErrorState from '@/shared/components/LoadErrorState.vue' +import { ApiResponseError } from '@/shared/api/http' +import * as api from '../api/dispatches' +import { command, detail, destination, loadingPreview, page, resources, sharedPalletCandidates } from '../dispatchTestFixtures' +import type { ArrangementPreview, CargoLine, DispatchAction, DispatchActions, DispatchCargoItem, DispatchEvidenceFile, PreviewGroup, UpdateDispatch } from '../dispatchContracts' + +const identity = vi.hoisted(() => ({ id: '' })) +const can = vi.hoisted(() => vi.fn((_code: string) => true)) +vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) })) +vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), overseasMasterDataPermissions: { vehicleList: 'vehicle:list', driverList: 'driver:list' } })) +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(), getDispatchEvidence: vi.fn(), getDispatchEvidenceContent: vi.fn(), uploadDispatchEvidence: vi.fn() })) + +enableAutoUnmount(afterEach) +afterEach(() => vi.unstubAllGlobals()) +const gate = { allowed: true, blockCode: null, blockReason: null } +const actions: DispatchActions = { EDIT: gate, ADD_CARGO: gate, REMOVE_CARGO: gate, CANCEL: gate, SUBMIT_REVIEW: gate, DISPATCH_REVIEW: gate, LOADING_REVIEW: gate, RETURN_TO_REVIEW: gate, LOADING_COMPLETE: gate, ARRANGE_DELIVERY: gate, DEPART: gate } +const evidence = (fileId = '301'): DispatchEvidenceFile => ({ fileId, fileName: fileId === '301' ? '旧附件.png' : '新附件.png', contentType: 'image/png', sizeBytes: 3 }) +function item(id: string, picked = 0): DispatchCargoItem { return { dispatchItemId: id, customerId: 'C1', customerName: '客户甲', sourceKey: 'source-1', sourceVersion: 'source-v1', sourceType: 'INBOUND_PLAN', planPalletId: '11', returnReceiptId: null, relabelNo: null, warehousePalletId: '21', palletNo: 'PALLET-1', orderNo: 'ORDER-1', inboundCode: 'IN-1', destination: destination(), scheduledPackages: 2, pickedPackages: picked, remove: picked ? { allowed: false, blockCode: 'PICKED', blockReason: '已经扫码,不可移除' } : gate } } +function cargo(key: string, customerId: string): CargoLine { return { dispatchItemId: 'item-' + key, customerId, customerName: '客户' + customerId, orderId: null, orderNo: 'ORDER-' + key, sourceKey: key, sourceStage: 'RECEIVED', sourceRegion: null, sourceInboundId: null, inboundCode: 'IN-' + key, planPalletId: null, warehousePalletId: 'pallet-' + key, palletNo: 'PALLET-' + key, pickId: null, attributionId: null, inventoryPositionId: null, receiptId: null, returnReceiptId: null, returnCartonId: null, cartonMark: 'BOX-' + key, relabelNo: null, destination: destination(), packages: 2, productQuantity: 4, grossWeightKg: 10, volumeCbm: 1, batchAllocations: [], sourceOrigin: null } } +function arrangement(): ArrangementPreview { + const group = (key: string, customerId: string): PreviewGroup => ({ groupKey: key, customerId, customerName: '客户' + customerId, destination: destination(), groupCargoHash: 'cargo-' + key, cargo: [cargo(key, customerId)], externalFreightHash: 'external-' + key, externalQuotes: [], feeCoverageCargoHash: 'coverage-' + key, feeSources: [], requiredServices: ['DELIVERY_FEE'], blockReasons: [] }) + const first = group('A', 'C1'), second = group('B', 'C2') + first.feeSources = [{ financeFeeId: 'original-fee', customerId: 'C1', currency: 'USD', amount: 100, sourceVersion: 0, sourceHash: 'original-fee-hash', status: 'ACTIVE', services: ['DELIVERY_FEE'], sourceKeys: ['A'] }] + second.externalQuotes = [{ customerId: 'C2', groupKey: 'B', bookingNo: 'BOOK-2', inboundCode: 'IN-B', currency: 'USD', amount: 200, sourceHash: 'quote-hash' }] + return { dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'whole-cargo', loadingCompletionHash: 'loaded', arrange: gate, groups: [first, second], arrangement: null } +} +beforeEach(() => { + vi.resetAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); can.mockReturnValue(true) + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }) + URL.createObjectURL = vi.fn().mockReturnValue('blob:owned-dispatch'); URL.revokeObjectURL = vi.fn() + vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1, actions })) + vi.mocked(api.getDispatchCommand).mockRejectedValue(new ApiResponseError('not found', 'A0404', 404)) + vi.mocked(api.updateDispatch).mockImplementation(async (_, body) => command('EDIT', body.idempotencyKey)) + vi.mocked(api.reviewDispatch).mockImplementation(async (_, body) => command('DISPATCH_REVIEW', body.idempotencyKey)) + vi.mocked(api.reviewDispatchLoading).mockImplementation(async (_, body) => command('LOADING_REVIEW', body.idempotencyKey)) + vi.mocked(api.cancelDispatch).mockImplementation(async (_, body) => command('CANCEL', body.idempotencyKey)) + vi.mocked(api.returnDispatchToReview).mockImplementation(async (_, body) => command('RETURN_TO_REVIEW', body.idempotencyKey)) + vi.mocked(api.submitDispatchReview).mockImplementation(async (_, body) => command('SUBMIT_REVIEW', body.idempotencyKey)) + vi.mocked(api.departDispatch).mockImplementation(async (_, body) => command('DEPART', body.idempotencyKey)) + vi.mocked(api.addDispatchCargo).mockImplementation(async (_, body) => command('ADD_CARGO', body.idempotencyKey)) + vi.mocked(api.removeDispatchItem).mockImplementation(async (_, __, body) => command('REMOVE_CARGO', body.idempotencyKey)) + vi.mocked(api.reorderDispatchRoute).mockImplementation(async (_, body) => command('EDIT', body.idempotencyKey)) + vi.mocked(api.completeDispatchLoading).mockImplementation(async (_, body) => command('LOADING_COMPLETE', body.idempotencyKey)) + vi.mocked(api.getDispatchLoading).mockResolvedValue(loadingPreview()) + vi.mocked(api.getDispatchRoute).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, edit: gate, stops: [{ index: 0, name: '起运仓', address: '起点地址', destinationKey: null }, { index: 1, name: '目的仓甲', address: '甲地址', destinationKey: 'A' }, { index: 2, name: '外接卸货点', address: '乙地址', destinationKey: 'B' }] }) + vi.mocked(api.getDispatchItems).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: '9007199254740993', status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [item('9223372036854775001'), item('picked', 1)] }) + vi.mocked(api.listDispatches).mockResolvedValue(page()) + vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [sharedPalletCandidates()[0]!], complete: true, snapshotAt: '' }) + vi.mocked(api.getDispatchArrangement).mockResolvedValue(arrangement()) + vi.mocked(api.getDispatchEvidence).mockImplementation(async (_, __, id) => evidence(id)) + vi.mocked(api.getDispatchEvidenceContent).mockResolvedValue(new Blob(['png'], { type: 'image/png' })) + vi.mocked(api.uploadDispatchEvidence).mockResolvedValue(evidence('302')) +}) +async function render(action: DispatchAction, operation?: 'ROUTE', files = false) { + const initial = detail({ actions }); if (files) initial.vehicleInfo.vehicle!.evidenceFileIds = ['301'] + vi.mocked(api.getDispatch).mockResolvedValueOnce(initial) + const wrapper = mount(DispatchWorkflowDialog, { attachTo: document.body, props: { modelValue: true, dispatchNo: 'PC/1', action, operation }, global: { plugins: [ElementPlus] } }); await flushPromises() + return { wrapper, dialog: wrapper.findComponent(AppDialog) } +} +function field(wrapper: Pick, label: string) { return wrapper.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! } + +describe('dispatch workflow native layout', () => { + it('submits all edited resources, dates and zero version through the continuous fullscreen form', async () => { + const { dialog } = await render('EDIT') + expect(dialog.props('title')).toBe('维护车辆信息 · PC/1') + expect(dialog.getComponent(DispatchResourceFields).props('continuous')).toBe(true) + await field(dialog, '车牌号').get('input').setValue('CA-NEW') + field(dialog, '实际托位容量').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 22) + await field(dialog, '司机姓名').get('input').setValue('新司机') + field(dialog, '计划发车时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-01T10:00:00Z') + await dialog.get('textarea[placeholder="请输入本次备注(选填)"]').setValue('维护备注') + await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises() + dialog.vm.$emit('confirm'); await flushPromises() + const { vehicleStatus: _status, ...vehicle } = resources().vehicle + expect(api.updateDispatch).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), vehicle: { ...vehicle, plate: 'CA-NEW', capacity: { ...vehicle.capacity, palletSpaces: 22 } }, driver: { ...resources().driver, name: '新司机' }, plannedDepartureAt: '2026-11-01T10:00:00Z', estimatedArrivalAt: null, remark: '维护备注' }) + }) + it('retains the locally submitted draft after close and reopen and retries exactly the same failed request', async () => { + vi.mocked(api.updateDispatch).mockRejectedValueOnce(new ApiResponseError('结果未确认', 'A0503', 503, null, 'workflow-request')).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey)) + const { wrapper, dialog } = await render('EDIT') + await field(dialog, '车牌号').get('input').setValue('CA-PRESERVED') + await dialog.get('textarea[placeholder="请输入本次备注(选填)"]').setValue('保留本次输入') + dialog.vm.$emit('confirm'); await flushPromises() + const original = JSON.parse(JSON.stringify(vi.mocked(api.updateDispatch).mock.calls[0]![1])) as UpdateDispatch + expect(dialog.text()).toContain('workflow-request') + expect(field(dialog, '车牌号').get('input').attributes('disabled')).toBeDefined() + await wrapper.setProps({ dispatchNo: 'PC/2', action: 'CANCEL' }); await flushPromises() + expect(dialog.props('title')).toBe('维护车辆信息 · PC/1') + await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises() + expect((field(dialog, '车牌号').get('input').element as HTMLInputElement).value).toBe('CA-PRESERVED') + expect((dialog.get('textarea[placeholder="请输入本次备注(选填)"]').element as HTMLTextAreaElement).value).toBe('保留本次输入') + dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises() + expect(vi.mocked(api.updateDispatch).mock.calls[1]![1]).toEqual(original) + }) + it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('keeps %s facts read-only and requires a rejection reason', async action => { + const { dialog } = await render(action) + expect(dialog.get('[aria-label="本车审核依据"]').findAll('input')).toHaveLength(0) + expect(dialog.text()).toContain('审核通过不代替后续现场扫码') + field(dialog, '审核结果').getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'REJECTED'); await flushPromises() + const review = action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading + dialog.vm.$emit('confirm'); await flushPromises(); expect(review).not.toHaveBeenCalled() + await dialog.get('textarea').setValue(' 证据不完整 '); dialog.vm.$emit('confirm'); await flushPromises() + expect(review).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), decision: 'REJECTED', reason: '证据不完整' }) + expect(dialog.findAll('input[type="file"]')).toHaveLength(0) + }) + it.each(['CANCEL', 'RETURN_TO_REVIEW', 'SUBMIT_REVIEW', 'DEPART'] as const)('preserves the %s confirmation branch and its exact reason or remark', async action => { + const { dialog } = await render(action) + const needsReason = action === 'CANCEL' || action === 'RETURN_TO_REVIEW' + const send = action === 'CANCEL' ? api.cancelDispatch : action === 'RETURN_TO_REVIEW' ? api.returnDispatchToReview : action === 'DEPART' ? api.departDispatch : api.submitDispatchReview + if (needsReason) { dialog.vm.$emit('confirm'); await flushPromises(); expect(send).not.toHaveBeenCalled() } + await dialog.get('textarea').setValue(needsReason ? ' 现场原因 ' : '本次交接') + dialog.vm.$emit('confirm'); await flushPromises() + expect(send).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), ...(needsReason ? { reason: '现场原因' } : { remark: '本次交接' }) }) + }) + it('reorders only real unload stops and preserves the external stop and zero route version', async () => { + const { dialog } = await render('EDIT', 'ROUTE') + const rows = dialog.get('[aria-label="本车卸货顺序"]').findAll('tbody tr').filter(row => row.findAll('button').length) + expect(rows).toHaveLength(2) + expect(rows[0]!.findAll('button')[0]!.attributes('disabled')).toBeDefined() + await rows[1]!.findAll('button').find(button => button.text() === '上移')!.trigger('click'); await flushPromises() + await dialog.get('textarea').setValue(' 顺序调整 ') + dialog.vm.$emit('confirm'); await flushPromises() + expect(api.reorderDispatchRoute).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), destinationKeys: ['B', 'A'], reason: '顺序调整' }) + }) + it('removes the real item identity while keeping scanned cargo unavailable', async () => { + const { dialog } = await render('REMOVE_CARGO') + const radios = dialog.get('[aria-label="本车可移除货物"]').findAllComponents({ name: 'ElRadio' }) + expect(radios[1]!.props('disabled')).toBe(true) + radios[0]!.vm.$emit('update:modelValue', '9223372036854775001'); await dialog.get('textarea').setValue(' 计划变更 ') + expect(dialog.text()).not.toContain('9223372036854775001') + dialog.vm.$emit('confirm'); await flushPromises() + expect(api.removeDispatchItem).toHaveBeenCalledExactlyOnceWith('PC/1', '9223372036854775001', { recordVersion: '9007199254740993', idempotencyKey: expect.any(String), reason: '计划变更' }) + }) + it('adds the complete filtered shared-pallet group and keeps every source visible when expanded', async () => { + const [first, second] = sharedPalletCandidates() + const { dialog } = await render('ADD_CARGO') + const source = dialog.get('details') + await source.get('summary').trigger('click'); await flushPromises() + expect(source.findAll('li')).toHaveLength(2) + await dialog.get('input[type="checkbox"]').setValue(true); await flushPromises() + dialog.vm.$emit('confirm'); await flushPromises() + expect(api.addDispatchCargo).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), cargo: [first!.selection, second!.selection], batchAllocations: [] }) + expect(dialog.findComponent({ name: 'ElTable' }).props('maxHeight')).toBeUndefined() + }) + it('keeps references separate from real loading measurements and submits the exact manifest', async () => { + const { dialog } = await render('LOADING_COMPLETE') + const numbers = dialog.get('[aria-label="真实实载登记"]').findAllComponents({ name: 'ElInputNumber' }) + expect(numbers[1]!.props('modelValue')).toBeNull() + expect(dialog.get('[aria-label="实载与参考依据"]').text()).toContain('仅供参考') + dialog.vm.$emit('confirm'); await flushPromises(); expect(api.completeDispatchLoading).not.toHaveBeenCalled() + numbers[0]!.vm.$emit('update:modelValue', 1); numbers[1]!.vm.$emit('update:modelValue', 120); numbers[2]!.vm.$emit('update:modelValue', 1.2); await flushPromises() + await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises() + dialog.vm.$emit('confirm'); await flushPromises() + expect(api.completeDispatchLoading).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), manifestHash: 'manifest', measurements: [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 120, volumeCbm: 1.2, deliveryIndex: 1 }], remark: '真实备注' }) + }) + it('keeps grouped delivery fields, explicit old-fee coverage and only actual uncovered charges', async () => { + const { dialog } = await render('ARRANGE_DELIVERY') + const sections = dialog.findAll('[data-tone="work"]').filter(section => section.attributes('aria-label')?.startsWith('派送分组登记')) + expect(sections).toHaveLength(2) + const groups = dialog.getComponent(DispatchArrangeFields).props('modelValue') + await dialog.get('[aria-label="派送分组依据 1"] input[type="checkbox"]').setValue(true); await flushPromises() + for (let index = 0; index < 2; index++) { + const section = sections[index]! + for (const [label, value] of [['派送方式', 'TRUCK_PALLET'], ['提货方式', 'ONE_PICKUP_ONE_DROP'], ['装车类型', index ? 'PALLET' : 'FLOOR']] as const) field(section, label).getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value) + field(section, '预计派送时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T10:00:00Z') + } + field(sections[0]!, '预约账号').getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'BUY_APPOINTMENT'); await flushPromises() + dialog.vm.$emit('confirm'); await flushPromises(); expect(api.transferDispatchToDelivery).not.toHaveBeenCalled() + await field(sections[0]!, '卖家姓名').get('input').setValue('卖家甲') + field(sections[0]!, '装车费(未覆盖部分)').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 25) + field(sections[0]!, '装车费(未覆盖部分)').getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'CNY') + field(sections[0]!, '买约费(未覆盖部分)').getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 40); await flushPromises() + expect(groups[0]!.feeCoverage).toEqual([{ financeFeeId: 'original-fee', sourceVersion: 0, sourceHash: 'original-fee-hash', services: ['DELIVERY_FEE'] }]) + dialog.vm.$emit('confirm'); await flushPromises() + expect(api.transferDispatchToDelivery).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ recordVersion: 0, cargoHash: 'whole-cargo', loadingCompletionHash: 'loaded', groups: [expect.objectContaining({ groupKey: 'A', sellerName: '卖家甲', feeCoverage: groups[0]!.feeCoverage, fees: [expect.objectContaining({ feeType: 'LOADING_FEE', amount: 25, currency: 'CNY' }), expect.objectContaining({ feeType: 'APPOINTMENT_FEE', amount: 40, currency: 'USD' })] }), expect.objectContaining({ groupKey: 'B', fees: [] })] })) + }) + it('previews owned images fullscreen and locks the parent while a real file reference upload is pending', async () => { + let resolve!: (value: DispatchEvidenceFile) => void + vi.mocked(api.uploadDispatchEvidence).mockReturnValue(new Promise(done => { resolve = done })) + const { dialog } = await render('EDIT', undefined, true) + const files = dialog.findAllComponents(DispatchEvidenceFiles)[0]! + await files.findAll('button').find(button => button.text() === '预览')!.trigger('click'); await flushPromises() + const image = files.findComponent(AppDialog) + expect(image.props('modelValue')).toBe(true) + expect(image.get('img').attributes('src')).toBe('blob:owned-dispatch') + await image.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises() + await image.findAll('button').find(button => button.text() === '关闭')!.trigger('click'); await flushPromises() + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:owned-dispatch') + const input = files.get('input[type="file"]'), file = new File(['png'], 'new.png', { type: 'image/png' }) + Object.defineProperty(input.element, 'files', { configurable: true, value: [file] }); await input.trigger('change'); await flushPromises() + expect(dialog.props('confirmLoading')).toBe(true) + expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false }) + expect(field(dialog, '车牌号').get('input').attributes('disabled')).toBeDefined() + dialog.vm.$emit('confirm'); await flushPromises(); expect(api.updateDispatch).not.toHaveBeenCalled() + resolve(evidence('302')); await flushPromises() + expect(dialog.props('confirmLoading')).toBe(false) + expect(files.props('modelValue')).toEqual(['301', '302']) + dialog.vm.$emit('confirm'); await flushPromises() + expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ vehicle: expect.objectContaining({ evidenceFileIds: ['301', '302'] }) })) + }) + it('keeps server gates and write permissions independent from readable facts', async () => { + can.mockReturnValue(false) + const { dialog } = await render('EDIT') + expect(dialog.props('confirmDisabled')).toBe(true) + expect(field(dialog, '车牌号').get('input').attributes('disabled')).toBeDefined() + dialog.vm.$emit('confirm'); await flushPromises() + expect(api.updateDispatch).not.toHaveBeenCalled() + }) +}) diff --git a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts index 599aa8d5..1918a79c 100644 --- a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts +++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts @@ -5,7 +5,7 @@ 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 { candidate, command, detail, destination, loadingPreview, page, sharedPalletCandidates } 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 }) })) @@ -13,12 +13,28 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ 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('adds all hidden shared-pallet sources once and rejects a changed group composition before another command', async () => { + const [first, second] = sharedPalletCandidates() + vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [first!], complete: true, snapshotAt: '' }) + const { wrapper, state } = await open('ADD_CARGO') + const selection = wrapper.vm as unknown as { selectCargo(row: NonNullable, checked: boolean): void } + selection.selectCargo(first!, true) + first!.selectionGroup = { ...first!.selectionGroup, groupVersion: 'composition-2' } + await state.submit(); expect(api.addDispatchCargo).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('组成已变化') + first!.selectionGroup.complete = false; selection.selectCargo(first!, false) + await state.submit(); expect(api.addDispatchCargo).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('请选择来源完整') + first!.selectionGroup.complete = true + selection.selectCargo(first!, true) + 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).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ cargo: [first!.selection, second!.selection] })) + wrapper.unmount() +}) 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] + (wrapper.vm as unknown as { selectCargo(row: typeof value, checked: boolean): void }).selectCargo(value, true) 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 }] })) @@ -29,7 +45,7 @@ it('blocks cargo addition when the authoritative batch version is absent', async 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() + (wrapper.vm as unknown as { selectCargo(row: typeof value, checked: boolean): void }).selectCargo(value, true); await state.submit() expect(api.addDispatchCargo).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('预约批次版本缺失或无效,请重新读取候选') } finally { wrapper.unmount() } }) @@ -89,7 +105,7 @@ 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: [] })) + 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, sourceOrigin: null } 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 diff --git a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue index 307739b2..06b25eaf 100644 --- a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue +++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue @@ -1,16 +1,18 @@ diff --git a/src/modules/overseas-warehouse/components/SmartDispatchDialog.layout.test.ts b/src/modules/overseas-warehouse/components/SmartDispatchDialog.layout.test.ts new file mode 100644 index 00000000..852822ab --- /dev/null +++ b/src/modules/overseas-warehouse/components/SmartDispatchDialog.layout.test.ts @@ -0,0 +1,204 @@ +import ElementPlus from 'element-plus' +import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import SmartDispatchDialog from './SmartDispatchDialog.vue' +import DispatchResourceFields from './DispatchResourceFields.vue' +import AppDialog from '@/shared/components/AppDialog.vue' +import LineClampText from '@/shared/components/LineClampText.vue' +import LoadErrorState from '@/shared/components/LoadErrorState.vue' +import { ApiResponseError } from '@/shared/api/http' +import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses' +import { listDrivers, listVehicles, type DriverDetail, type VehicleDetail } from '@/modules/overseas-master-data' +import * as api from '../api/dispatches' +import { candidate, command, detail, sharedPalletCandidates } from '../dispatchTestFixtures' +import type { ConvertDraft, DispatchDraft, Vehicle } from '../dispatchContracts' + +const identity = vi.hoisted(() => ({ id: '' })) +const can = vi.hoisted(() => vi.fn((_code: string) => true)) +const push = vi.hoisted(() => vi.fn()) +vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) })) +vi.mock('vue-router', () => ({ useRouter: () => ({ push }) })) +vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: vi.fn() })) +vi.mock('@/modules/overseas-master-data', () => ({ listVehicles: vi.fn(), listDrivers: vi.fn(), overseasMasterDataPermissions: { vehicleList: 'vehicle:list', driverList: 'driver:list' } })) +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() })) + +enableAutoUnmount(afterEach) +afterEach(() => vi.unstubAllGlobals()) +const warehouse: PlatformWarehouseResource = { id: '321', tenantId: '0', warehouseCode: 'LAX', internalCode: null, warehouseName: '洛杉矶仓', warehouseType: 'OVERSEAS', countryCode: 'US', timezone: 'America/Los_Angeles', contactName: null, contactPhone: null, contactEmail: null, status: 'ACTIVE', remark: null } +const vehicleRecord: VehicleDetail = { id: '9223372036854775001', plateNumber: 'CA-MASTER', model: '档案车型', maxPalletSpaces: 17, maxLoadKg: 15000, maxVolumeCbm: 62, fuelConsumptionPer100Km: 10, mileageKm: 100, driverOrDispatchCompany: null, phone: null, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: null, recordVersion: 0, updatedBy: 'operator', updatedAt: '' } +const driverRecord: DriverDetail = { id: '9223372036854775002', driverNo: 'DR-1', active: true, reviewStatus: 'APPROVED', licenseFileId: 'LICENSE-FILE', recordVersion: 0, updatedBy: 'operator', updatedAt: '', licenseState: 'VALID', warehouseCode: 'LAX', warehouseName: '洛杉矶仓', qualification: { canDispatch: true, expiringSoon: false, reason: 'ELIGIBLE', licenseExpiresOn: '2030-01-01' }, details: { name: '档案司机', phone: '555-0101', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: 'LICENSE-1', licenseType: null, licenseIssuedOn: null, licenseExpiresOn: '2030-01-01', licenseZone: 'America/Los_Angeles', remark: null, profile: null } } +function draft(): DispatchDraft { + const first = candidate(), second = candidate({ sourceKey: 'source-2', customerId: 'C2', customerName: '客户乙', selection: { ...first.selection, planPalletId: '12', warehousePalletId: '22' }, palletNo: 'PALLET-2' }) + return { draftToken: 'server-token', recordVersion: 0, warehouseId: '321', status: 'ACTIVE', expiresAt: '2026-12-01T00:00:00Z', containers: [{ draftContainerId: 'container-1', sequenceNo: 1, capacity: 30, sourceKeys: [first.sourceKey] }, { draftContainerId: 'container-2', sequenceNo: 2, capacity: 15, sourceKeys: [second.sourceKey] }], sources: [first, second] } +} +beforeEach(() => { + vi.resetAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); can.mockReturnValue(true) + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }) + vi.mocked(listAllPlatformWarehouses).mockResolvedValue([warehouse]) + vi.mocked(api.listDispatchDrafts).mockResolvedValue([draft()]) + vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: draft().sources, complete: true, snapshotAt: '' }) + vi.mocked(api.createDispatch).mockImplementation(async body => command('CREATE', body.idempotencyKey)) + vi.mocked(api.convertDispatchDraft).mockImplementation(async body => ({ draftToken: body.draftToken, replayed: false, vehicles: body.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC/${index + 1}`) })) })) + vi.mocked(api.getDispatch).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 })) + vi.mocked(listVehicles).mockResolvedValue({ records: [vehicleRecord], page: 1, size: 100, total: 1, pages: 1 }) + vi.mocked(listDrivers).mockResolvedValue({ records: [driverRecord], page: 1, size: 100, total: 1, pages: 1, summary: { total: 1, owned: 1, dispatched: 0, expiringSoon: 0, expired: 0 } }) +}) +async function render() { const wrapper = mount(SmartDispatchDialog, { attachTo: document.body, props: { modelValue: true, orders: [] }, global: { plugins: [ElementPlus] } }); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } } +function field(dialog: VueWrapper, label: string) { return dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! } +async function selectWarehouse(dialog: VueWrapper) { const select = field(dialog, '起运仓').getComponent({ name: 'ElSelect' }); select.vm.$emit('update:modelValue', '321'); select.vm.$emit('change', '321'); await flushPromises() } +async function emptyMode(dialog: VueWrapper) { field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'empty'); await flushPromises() } +async function restore(dialog: VueWrapper) { dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '显式恢复已有草稿')!.vm.$emit('change', 'server-token'); await flushPromises() } +async function externalResources(dialog: VueWrapper, plate: string, name: string) { + for (const label of ['车辆来源', '司机来源']) { const select = field(dialog, label).getComponent({ name: 'ElSelect' }); select.vm.$emit('update:modelValue', 'EXTERNAL'); select.vm.$emit('change', 'EXTERNAL'); await flushPromises() } + await field(dialog, '车牌号').get('input').setValue(plate) + expect(dialog.getComponent(DispatchResourceFields).props('vehicle').plate, 'plate immediately after typing').toBe(plate) + await field(dialog, '拖车公司名称').get('input').setValue('本趟承运公司') + for (const [label, value] of [['实际托位容量', 17], ['最大载重(kg)', 15000], ['最大容积(m³)', 62]] as const) field(dialog, label).getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', value) + await field(dialog, '司机姓名').get('input').setValue(name); await field(dialog, '联系电话').get('input').setValue('555-0102'); await field(dialog, '驾驶证号').get('input').setValue('EXTERNAL-LICENSE') + field(dialog, '驾驶证到期日').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2030-01-01') + field(dialog, '预计任务结束').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-03T18:00:00Z'); await flushPromises() + expect(dialog.getComponent(DispatchResourceFields).props('vehicle').plate, 'plate after completing driver fields').toBe(plate) +} + +describe('new dispatch native layout', () => { + it('requires an active cargo group before accepting per-group settings while keeping setup controls available', async () => { + const { dialog } = await render() + expect(dialog.text()).toContain('请先选择装车组') + expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true) + expect(field(dialog, '备注').get('input').attributes('disabled')).toBeDefined() + expect(field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).props('disabled')).toBe(false) + await restore(dialog) + expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(false) + expect(field(dialog, '备注').get('input').attributes('disabled')).toBeUndefined() + }) + it('preserves every external field and actual capacity through fullscreen in a real empty creation', async () => { + const { wrapper, dialog } = await render(); await emptyMode(dialog); await selectWarehouse(dialog) + await externalResources(dialog, 'CA-EXTERNAL', '临时司机') + const state = wrapper.vm as unknown as { vehicle: Vehicle } + expect(state.vehicle.plate, 'parent vehicle after external inputs').toBe('CA-EXTERNAL') + field(dialog, '计划发车时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T10:00:00Z') + field(dialog, '预计到达时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T18:00:00Z') + await field(dialog, '备注').get('input').setValue('本趟备注') + expect(state.vehicle.plate, 'parent vehicle after dates and remark').toBe('CA-EXTERNAL') + await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises() + expect(state.vehicle.plate, 'parent vehicle after fullscreen').toBe('CA-EXTERNAL') + expect(dialog.getComponent(DispatchResourceFields).props('continuous')).toBe(true) + expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true) + expect(dialog.text()).toContain('空单不能核平、装车或发车') + expect(dialog.find('[aria-label="排车候选货物"]').exists()).toBe(false) + await dialog.findAll('button').find(button => button.text() === '保存空排车单')!.trigger('click'); await flushPromises() + expect(api.createDispatch).toHaveBeenCalledExactlyOnceWith({ idempotencyKey: expect.any(String), warehouseId: '321', cargo: [], batchAllocations: [], plannedDepartureAt: '2026-11-02T10:00:00Z', estimatedArrivalAt: '2026-11-02T18:00:00Z', remark: '本趟备注', vehicle: { mode: 'EXTERNAL', vehicleId: null, plate: 'CA-EXTERNAL', carrierName: '本趟承运公司', capacity: { palletSpaces: 17, maxLoadKg: 15000, maxVolumeCbm: 62 }, evidenceFileIds: [] }, driver: { mode: 'EXTERNAL', driverId: null, name: '临时司机', phone: '555-0102', evidence: { licenseNo: 'EXTERNAL-LICENSE', licenseExpiresOn: '2030-01-01', fileIds: [], expectedTaskEndAt: '2026-11-03T18:00:00Z' } } }) + }) + it('queries real master directories and keeps business labels and task-end evidence in the command', async () => { + const { dialog } = await render(); await emptyMode(dialog); await selectWarehouse(dialog) + field(dialog, '预计任务结束').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-03T18:00:00Z'); await flushPromises() + const vehicleSelect = field(dialog, '车辆').getComponent({ name: 'ElSelect' }) + vehicleSelect.vm.$emit('focus'); await flushPromises() + vehicleSelect.vm.$emit('update:modelValue', vehicleRecord.id); vehicleSelect.vm.$emit('change', vehicleRecord.id); await flushPromises() + const driverSelect = field(dialog, '司机').getComponent({ name: 'ElSelect' }) + driverSelect.vm.$emit('focus'); await flushPromises() + driverSelect.vm.$emit('update:modelValue', driverRecord.id); driverSelect.vm.$emit('change', driverRecord.id); await flushPromises() + expect(listVehicles).toHaveBeenCalledWith({ page: 1, size: 100, keyword: '', region: 'OVERSEAS', status: 'IN_SERVICE' }) + expect(listDrivers).toHaveBeenCalledWith({ page: 1, size: 100, keyword: '', region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: '2026-11-03T18:00:00Z' }) + expect(dialog.text()).not.toContain(vehicleRecord.id) + dialog.vm.$emit('confirm'); await flushPromises() + expect(api.createDispatch).toHaveBeenCalledWith(expect.objectContaining({ vehicle: { mode: 'MASTER', vehicleId: vehicleRecord.id, plate: 'CA-MASTER', carrierName: null, capacity: null, evidenceFileIds: [] }, driver: { mode: 'MASTER', driverId: driverRecord.id, name: '档案司机', phone: '555-0101', evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: '2026-11-03T18:00:00Z' } } })) + }) + it('keeps each group configuration independent and verifies proven conversions without creating them twice', async () => { + vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('details not ready')).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 })) + const { wrapper, dialog } = await render(); await restore(dialog) + await externalResources(dialog, 'CA-A', '司机甲'); await field(dialog, '备注').get('input').setValue('第一组') + const radios = dialog.get('[aria-label="排车装车组"]').findAllComponents({ name: 'ElRadio' }) + radios[1]!.vm.$emit('update:modelValue', 'container-2'); await flushPromises() + await externalResources(dialog, 'CA-B', '司机乙'); await field(dialog, '备注').get('input').setValue('第二组') + radios[0]!.vm.$emit('update:modelValue', 'container-1'); await flushPromises() + expect((field(dialog, '车牌号').get('input').element as HTMLInputElement).value).toBe('CA-A') + expect((field(dialog, '备注').get('input').element as HTMLInputElement).value).toBe('第一组') + await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises() + dialog.vm.$emit('confirm'); await flushPromises() + expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1) + const request = vi.mocked(api.convertDispatchDraft).mock.calls[0]![0] + expect(request.recordVersion).toBe(0) + expect(request.groups.map(group => [group.dispatch.vehicle?.plate, group.dispatch.driver?.name, group.dispatch.remark])).toEqual([['CA-A', '司机甲', '第一组'], ['CA-B', '司机乙', '第二组']]) + expect(request.groups.map(group => group.dispatch.vehicle?.capacity?.palletSpaces)).toEqual([17, 17]) + expect(wrapper.emitted('created')).toBeUndefined() + expect(dialog.text()).toContain('草稿操作结果未确认,原请求已保留') + dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises() + expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1) + expect(wrapper.emitted('created')).toHaveLength(1) + }) + it('locks the real frame and all resource edits while pending and reuses the original conversion after failure', async () => { + let reject!: (reason: unknown) => void + vi.mocked(api.convertDispatchDraft).mockReturnValueOnce(new Promise((_, no) => { reject = no })).mockImplementationOnce(async body => ({ draftToken: body.draftToken, replayed: true, vehicles: body.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC/${index + 1}`) })) })) + const { wrapper, dialog } = await render(); await restore(dialog); await externalResources(dialog, 'CA-A', '司机甲') + dialog.vm.$emit('confirm'); dialog.vm.$emit('confirm'); await flushPromises() + expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1) + const original = JSON.parse(JSON.stringify(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0])) as ConvertDraft + expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false }) + expect(field(dialog, '车牌号').get('input').attributes('disabled')).toBeDefined() + reject(new ApiResponseError('转换结果尚未确认', 'A0503', 503, null, 'new-dispatch-request')); await flushPromises() + expect(dialog.text()).toContain('new-dispatch-request') + expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true) + await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises() + dialog.vm.$emit('confirm'); await flushPromises() + expect(vi.mocked(api.convertDispatchDraft).mock.calls[1]![0]).toEqual(original) + }) + it('keeps all candidate rows and complete source details in the body with shared ellipsis components', async () => { + const rows = Array.from({ length: 25 }, (_, index) => candidate({ sourceKey: `SOURCE-${index}`, palletNo: `PALLET-${index}`, orderNo: `ORDER-${index}`, customerName: '很长的客户名称'.repeat(8) })) + rows[0] = sharedPalletCandidates()[0]! + vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: rows, complete: true, snapshotAt: '' }) + const { dialog } = await render(); await selectWarehouse(dialog) + const table = dialog.get('[aria-label="排车候选货物"]') + expect(table.findAll('.el-table__body-wrapper .el-table__row')).toHaveLength(25) + expect(table.text()).toContain('PALLET-24') + expect(dialog.findComponent({ name: 'ElTable' }).props('height')).toBeUndefined() + expect(dialog.findAllComponents(LineClampText).length).toBeGreaterThanOrEqual(50) + const sourceGroup = table.findAll('details')[0]! + await sourceGroup.get('summary').trigger('click'); await flushPromises() + expect(sourceGroup.element.hasAttribute('open')).toBe(true) + expect(sourceGroup.findAll('li')).toHaveLength(2) + expect(sourceGroup.text()).toContain('客户A') + expect(sourceGroup.text()).toContain('客户B') + expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled() + }) + it('keeps query conditions and regional tabs on the original candidate transport', async () => { + const { dialog } = await render(); await selectWarehouse(dialog) + await dialog.get('input[placeholder="订单号/入仓码"]').setValue('IN-QUERY') + await dialog.get('input[placeholder="目的仓码"]').setValue('ONT8') + dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '全部来源')!.vm.$emit('update:modelValue', 'RELABEL'); await flushPromises() + await dialog.findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises() + expect(api.listDispatchCandidates).toHaveBeenLastCalledWith({ warehouseId: '321', orderNo: 'IN-QUERY', destinationWarehouseCode: 'ONT8', sourceStage: 'RELABEL', draftToken: undefined }) + const calls = vi.mocked(api.listDispatchCandidates).mock.calls.length + await dialog.get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.trigger('click'); await flushPromises() + expect(dialog.get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.attributes('aria-selected')).toBe('true') + expect(api.listDispatchCandidates).toHaveBeenCalledTimes(calls) + }) + it('shows loading and initialization diagnostics without issuing create commands', async () => { + vi.mocked(api.listDispatchDrafts).mockRejectedValueOnce(new ApiResponseError('草稿加载失败', 'A0503', 503, null, 'draft-load-request')).mockResolvedValueOnce([draft()]) + const { dialog } = await render() + expect(dialog.text()).toContain('draft-load-request') + dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises() + expect(api.listDispatchDrafts).toHaveBeenCalledTimes(2) + expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.createDispatchDraft).not.toHaveBeenCalled() + }) + it('requires the explicit recheck action after a definite rejection without automatically resubmitting', async () => { + vi.mocked(api.createDispatchDraft).mockRejectedValue(new ApiResponseError('草稿条件已变化', 'A0409', 409, null, 'draft-rejected-request')) + const { dialog } = await render(); await selectWarehouse(dialog) + await dialog.findAll('button').find(button => button.text() === '新建草稿')!.trigger('click'); await flushPromises() + expect(dialog.text()).toContain('draft-rejected-request') + expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true) + await dialog.findAll('button').find(button => button.text() === '按明确驳回结果重新核对')!.trigger('click'); await flushPromises() + expect(api.createDispatchDraft).toHaveBeenCalledTimes(1) + expect(field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).props('disabled')).toBe(false) + expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true) + expect(api.listDispatchDrafts).toHaveBeenCalledTimes(2) + }) + it('keeps creation unavailable without its write permission', async () => { + can.mockReturnValue(false) + const { dialog } = await render() + expect(dialog.props('confirmDisabled')).toBe(true) + dialog.vm.$emit('confirm'); await flushPromises() + expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.convertDispatchDraft).not.toHaveBeenCalled() + }) +}) diff --git a/src/modules/overseas-warehouse/components/SmartDispatchDialog.test.ts b/src/modules/overseas-warehouse/components/SmartDispatchDialog.test.ts index f0adfbe6..4aed223a 100644 --- a/src/modules/overseas-warehouse/components/SmartDispatchDialog.test.ts +++ b/src/modules/overseas-warehouse/components/SmartDispatchDialog.test.ts @@ -5,7 +5,7 @@ import SmartDispatchDialog from './SmartDispatchDialog.vue' 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 { candidate, command, detail, sharedPalletCandidates } from '../dispatchTestFixtures' import type { ConvertDraft, DispatchDraft, Version } from '../dispatchContracts' import { useDispatchCommandRecovery } from '../dispatchCommandRecovery' const identity = vi.hoisted(() => ({ id: 'smart-operator' })) @@ -17,6 +17,29 @@ 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' }) +it('converts a filtered relabel group with both authoritative sources and locks it through one HTTP command', async () => { + const [first, second] = sharedPalletCandidates(), original = draft() + original.sources = [first!]; original.containers[0]!.sourceKeys = [first!.sourceKey] + vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [first!], complete: true, snapshotAt: '' }) + vi.mocked(api.listDispatchDrafts).mockResolvedValue([original]); vi.mocked(api.lockDispatchDraftPallet).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) })) })) + const wrapper = mount(SmartDispatchDialog, options); await flushPromises() + const state = wrapper.vm as unknown as { restoreDraft(token: string): void; draftAction(action: string, row: typeof first, container: string): Promise; submit(): Promise } + state.restoreDraft('server-token'); await flushPromises(); await state.draftAction('LOCK', first, original.containers[0]!.draftContainerId) + expect(api.lockDispatchDraftPallet).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ source: first!.selection })) + await state.submit(); expect(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.cargo).toEqual([first!.selection, second!.selection]) + wrapper.unmount() +}) +it('allows one atomic removal of a restored group that is no longer selectable', async () => { + const first = sharedPalletCandidates()[0]!, original = draft() + first.selectionGroup = { ...first.selectionGroup, complete: false, blockReasons: ['来源待核齐'] } + original.sources = [first]; original.containers[0]!.sourceKeys = [first.sourceKey] + vi.mocked(api.listDispatchDrafts).mockResolvedValue([original]); vi.mocked(api.unlockDispatchDraftPallet).mockResolvedValue(original) + const wrapper = mount(SmartDispatchDialog, options); await flushPromises() + const state = wrapper.vm as unknown as { restoreDraft(token: string): void; draftAction(action: string, row: typeof first, container: string): Promise } + state.restoreDraft('server-token'); await flushPromises(); await state.draftAction('UNLOCK', first, original.containers[0]!.draftContainerId) + expect(api.unlockDispatchDraftPallet).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ source: first.selection })); wrapper.unmount() +}) 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 => { @@ -55,12 +78,12 @@ it('requires an explicit warehouse and allows saving a true empty dispatch with expect(wrapper.emitted('created')).toHaveLength(1); 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') + const original = draft(); original.sources.push(candidate({ sourceKey: 'source-B', customerId: 'B', customerName: '客户乙', selection: { ...source().selection, planPalletId: '22', warehousePalletId: '32' } })); 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(api.convertDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ recordVersion: 0, draftToken: 'server-token', groups: [expect.objectContaining({ draftContainerId: '100000000000000001', dispatch: expect.objectContaining({ cargo: [source().selection, original.sources[1]!.selection] }) })] })) 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 () => { diff --git a/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue b/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue index 300670c3..9317ecef 100644 --- a/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue +++ b/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue @@ -8,11 +8,13 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview' import { listAllPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import AppDialog from '@/shared/components/AppDialog.vue' +import InlineHelp from '@/shared/components/InlineHelp.vue' +import LineClampText from '@/shared/components/LineClampText.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import * as api from '../api/dispatches' import type { Candidate, ConvertedDraft, ConvertDraft, CreateDispatch, DispatchDraft, Driver, DraftSelection, Vehicle, VersionCommand } from '../dispatchContracts' -import { DispatchInputError, requireVersion, requireBatchVersions } from '../dispatchContracts' +import { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason } from '../dispatchContracts' import { dispatchActionPermissions } from '../dispatchPresentation' import { useDispatchCommandRecovery, validateCurrentDispatch, isDefiniteDispatchRejection } from '../dispatchCommandRecovery' import type { InboundOrderListItem } from '../types' @@ -36,7 +38,8 @@ const visibleCandidates = computed(() => candidates.value.filter(row => matchesS const settings = reactive>({}) const currentSettings = computed(() => { const key = mode.value === 'empty' ? 'empty' : activeContainerId.value || 'unselected' - return settings[key] ||= { form: { plannedDepartureAt: '', estimatedArrivalAt: '', remark: '' }, vehicle: { mode: 'MASTER', vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] }, driver: { mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } } } + settings[key] ||= { form: { plannedDepartureAt: '', estimatedArrivalAt: '', remark: '' }, vehicle: { mode: 'MASTER', vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] }, driver: { mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } } } + return settings[key]! }) const form = computed(() => currentSettings.value.form) const vehicle = computed({ get: () => currentSettings.value.vehicle, set: (value: Vehicle) => { currentSettings.value.vehicle = value } }) @@ -78,10 +81,11 @@ async function loadCandidates(sequence = generation) { async function search() { if (frozen.value) return; loading.value = true; error.value = null; try { await loadCandidates() } catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '货物候选读取失败') } finally { loading.value = false } } function restoreDraft(token: string) { if (frozen.value) return; const value = drafts.value.find(row => row.draftToken === token); if (!value) return; draft.value = value; warehouseId.value = value.warehouseId; activeContainerId.value = value.containers[0]?.draftContainerId || ''; void search() } function creation(cargo: Candidate[], containerId?: string): CreateDispatch { + assertSelectionGroupVersions(cargo, candidates.value) requireBatchVersions(cargo.flatMap(row => row.appointmentBatches)) if (cargo.some(row => row.warehouseId !== warehouseId.value || !row.sourceVersion || row.sourceVersion !== row.selection.sourceVersion || row.appointmentBatches.some(batch => batch.customerId !== row.customerId))) throw new DispatchInputError('货物仓库、来源版本或预约客户不一致,请重新读取') const config = containerId ? settings[containerId] : currentSettings.value - return { idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: config && (config.vehicle.vehicleId || config.vehicle.plate) ? JSON.parse(JSON.stringify(config.vehicle)) as Vehicle : null, driver: config && (config.driver.driverId || config.driver.name) ? JSON.parse(JSON.stringify(config.driver)) as Driver : null, plannedDepartureAt: config?.form.plannedDepartureAt || null, estimatedArrivalAt: config?.form.estimatedArrivalAt || null, remark: config?.form.remark || null, cargo: cargo.map(row => row.selection), batchAllocations: cargo.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => { if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对'); return { planPalletId: row.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion } }))) } + return { idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: config && (config.vehicle.vehicleId || config.vehicle.plate) ? JSON.parse(JSON.stringify(config.vehicle)) as Vehicle : null, driver: config && (config.driver.driverId || config.driver.name) ? JSON.parse(JSON.stringify(config.driver)) as Driver : null, plannedDepartureAt: config?.form.plannedDepartureAt || null, estimatedArrivalAt: config?.form.estimatedArrivalAt || null, remark: config?.form.remark || null, cargo: expandSelectionGroups(cargo), batchAllocations: cargo.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => { if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对'); return { planPalletId: row.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion } }))) } } async function draftAction(action: DraftCommand['action'], source?: Candidate, containerId?: string) { if (!canCreate.value || loading.value || saving.value) return @@ -96,7 +100,7 @@ async function draftAction(action: DraftCommand['action'], source?: Candidate, c if (!current) throw new DispatchInputError('请先新建或恢复草稿') const common = { recordVersion: requireVersion(current.recordVersion), idempotencyKey: key, draftToken: current.draftToken } if (action === 'CONTAINER') body = { ...common, capacity: capacity.value } - else if (action === 'LOCK' || action === 'UNLOCK') { if (!source || !containerId || source.blockReasons.length) throw new DispatchInputError('请选择可排货物和装车容器'); body = { ...common, draftContainerId: containerId, source: source.selection } } + else if (action === 'LOCK' || action === 'UNLOCK') { if (!source || !containerId) throw new DispatchInputError('请选择货物和装车容器'); if (action === 'LOCK') requireSelectionGroup(source); body = { ...common, draftContainerId: containerId, source: source.selection } } else if (action === 'DELETE') body = { ...common, reason: '移除本草稿装车容器' } else if (action === 'RELEASE') body = common else { @@ -162,24 +166,66 @@ async function submit() { watch(() => props.modelValue, value => { if (value) void init(); else generation++ }, { immediate: true }) diff --git a/src/modules/overseas-warehouse/dispatchContracts.ts b/src/modules/overseas-warehouse/dispatchContracts.ts index 0d28d2ab..a3c27d53 100644 --- a/src/modules/overseas-warehouse/dispatchContracts.ts +++ b/src/modules/overseas-warehouse/dispatchContracts.ts @@ -28,14 +28,13 @@ export interface DispatchListRow { dispatchNo: string; status: DispatchStatus; s 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 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; sourceBusinessType: string | null; sourceBusinessNo: string | null } +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[]; 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 UpdateDispatch extends VersionCommand { vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | 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 } @@ -43,7 +42,10 @@ export interface LoadingMeasurement { warehousePalletId: string; palletSpaces: n 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 SelectionGroupSource { selection: CargoSelection; sourceKey: string; customerId: string; customerName: string; sourceBusinessType: string | null; sourceBusinessNo: string | null; packages: number } +export interface SelectionGroupPallet { warehousePalletId: string | null; palletNo: string | null; sourceKeys: string[]; packageCount: number } +export interface SelectionGroup { groupKey: string; groupVersion: string; sources: SelectionGroupSource[]; pallets: SelectionGroupPallet[]; complete: boolean; blockReasons: 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[]; selectionGroup: SelectionGroup } 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[] } @@ -63,7 +65,8 @@ export interface FeeItem { feeType: string; feeName: string; direction: string; 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 SourceOrigin { dispatchItemSourceId: string | null; relabelApplicationId: string | null; relabelItemId: string | null; sourceInventoryBalanceId: string | null; sourceInboundOrderId: string | null; sourceInboundOrderNo: string | null; sourceInboundCode: string | null; productQuantity: number | null } +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[]; sourceOrigin: SourceOrigin | null } 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 } @@ -98,3 +101,44 @@ export function requireBatchVersions(values: readonly { batchKey: string; record versions.set(value.batchKey, canonical) } } + +function selectionIdentity(value: CargoSelection): string { + return JSON.stringify([value.sourceType, value.planPalletId, value.returnReceiptId, value.relabelNo, value.warehousePalletId]) +} +export function requireSelectionGroup(row: Candidate): SelectionGroup { + const group = row.selectionGroup + if (!group || !group.groupKey || !group.groupVersion || !Array.isArray(group.sources) || !Array.isArray(group.pallets) || !Array.isArray(group.blockReasons)) throw new DispatchInputError('完整选货组或组成版本缺失,请重新读取候选') + if (group.complete !== true || group.blockReasons.length || row.blockReasons.length) throw new DispatchInputError([...row.blockReasons, ...group.blockReasons].join(';') || '选货组来源尚未完整,请重新读取候选') + if (!group.sources.length || group.sources.some(source => !source.sourceKey || !source.customerId || !source.selection?.sourceVersion || !Number.isSafeInteger(source.packages) || source.packages <= 0 || source.selection.sourceType === 'RELABEL' && (source.sourceBusinessType !== 'RELABEL_ORDER' || !source.sourceBusinessNo || source.sourceBusinessNo !== source.selection.relabelNo))) throw new DispatchInputError('选货组来源或数量不完整,请重新读取候选') + const palletKeys = group.pallets.map(pallet => pallet.warehousePalletId || pallet.palletNo) + if (!palletKeys.length || palletKeys.some(key => !key) || new Set(palletKeys).size !== palletKeys.length || group.pallets.some(pallet => !Array.isArray(pallet.sourceKeys) || !pallet.sourceKeys.length || pallet.sourceKeys.some(key => !group.sources.some(source => source.sourceKey === key)) || !Number.isSafeInteger(pallet.packageCount) || pallet.packageCount <= 0)) throw new DispatchInputError('选货组实托事实缺失或重复,请重新读取候选') + if (!group.sources.some(source => source.sourceKey === row.sourceKey && source.customerId === row.customerId && selectionIdentity(source.selection) === selectionIdentity(row.selection) && source.selection.sourceVersion === row.sourceVersion && row.selection.sourceVersion === row.sourceVersion)) throw new DispatchInputError('候选与完整选货组来源不一致,请重新读取候选') + return group +} +export function selectionGroupBlockReason(row: Candidate): string { + try { requireSelectionGroup(row); return '' } catch (cause) { return cause instanceof Error ? cause.message : '完整选货组无效,请重新读取候选' } +} +export function assertSelectionGroupVersions(rows: readonly Candidate[], latest: readonly Candidate[]): void { + const versions = new Map() + for (const row of rows) { + const group = row.selectionGroup + if (!group?.groupKey || !group.groupVersion) throw new DispatchInputError('完整选货组或组成版本缺失,请重新读取候选') + if (versions.has(group.groupKey) && versions.get(group.groupKey) !== group.groupVersion) throw new DispatchInputError('选货组组成已变化,请重新读取草稿并选择货物') + versions.set(group.groupKey, group.groupVersion) + } + for (const row of latest) { + const group = row.selectionGroup, selected = rows.find(value => value.sourceKey === row.sourceKey) + if (selected && selected.selectionGroup.groupKey !== group?.groupKey || group && versions.has(group.groupKey) && versions.get(group.groupKey) !== group.groupVersion) throw new DispatchInputError('选货组组成已变化,请重新读取草稿并选择货物') + } +} +export function expandSelectionGroups(rows: readonly Candidate[]): CargoSelection[] { + assertSelectionGroupVersions(rows, []) + const sources = new Map(), selections = new Map() + for (const row of rows) for (const source of requireSelectionGroup(row).sources) { + const previous = sources.get(source.sourceKey), identity = selectionIdentity(source.selection), selected = selections.get(identity) + if (previous && (selectionIdentity(previous.selection) !== identity || previous.selection.sourceVersion !== source.selection.sourceVersion || previous.customerId !== source.customerId || previous.packages !== source.packages)) throw new DispatchInputError('完整选货组来源事实不一致,请重新读取候选') + if (selected && selected.sourceVersion !== source.selection.sourceVersion) throw new DispatchInputError('完整选货组来源版本不一致,请重新读取候选') + sources.set(source.sourceKey, source); selections.set(identity, source.selection) + } + return [...selections.values()] +} diff --git a/src/modules/overseas-warehouse/dispatchSelectionGroups.test.ts b/src/modules/overseas-warehouse/dispatchSelectionGroups.test.ts new file mode 100644 index 00000000..7df7167a --- /dev/null +++ b/src/modules/overseas-warehouse/dispatchSelectionGroups.test.ts @@ -0,0 +1,43 @@ +import { expect, it } from 'vitest' +import { assertSelectionGroupVersions, expandSelectionGroups, requireSelectionGroup, type SelectionGroup } from './dispatchContracts' +import { candidate, sharedPalletCandidates } from './dispatchTestFixtures' +import { normalizeCandidateList, type CandidateWire } from './dispatchWire' + +it('expands a filtered physical group into both exact relabel selections once while retaining one physical pallet', () => { + const [first, second] = sharedPalletCandidates() + expect(expandSelectionGroups([first!])).toEqual([first!.selection, second!.selection]) + expect(expandSelectionGroups([first!, second!, first!])).toEqual([first!.selection, second!.selection]) + expect(requireSelectionGroup(first!).pallets).toEqual([{ warehousePalletId: '21', palletNo: 'PALLET-1', sourceKeys: ['RELABEL:A', 'RELABEL:B'], packageCount: 8 }]) +}) +it.each(['INBOUND_PLAN', 'RETURN_RECEIPT'] as const)('allows authoritative nullable business display fields for ordinary %s groups', sourceType => { + const row = candidate({ selection: { ...candidate().selection, sourceType, planPalletId: sourceType === 'INBOUND_PLAN' ? '11' : null, returnReceiptId: sourceType === 'RETURN_RECEIPT' ? '12' : null } }) + expect(row.selectionGroup.sources[0]).toMatchObject({ sourceBusinessType: null, sourceBusinessNo: null }) + expect(expandSelectionGroups([row])).toEqual([row.selection]) +}) +it('requires the actual RELABEL_ORDER business identity for relabel sources without accepting a legacy alias', () => { + const row = sharedPalletCandidates()[0]! + row.selectionGroup.sources[0]!.sourceBusinessNo = null + expect(() => expandSelectionGroups([row])).toThrow('来源或数量不完整') + row.selectionGroup.sources[0]!.sourceBusinessNo = 'RL-A'; row.selectionGroup.sources[0]!.sourceBusinessType = 'RELABEL' + expect(() => expandSelectionGroups([row])).toThrow('来源或数量不完整') +}) +it.each([undefined, { complete: false }, { blockReasons: ['换标来源尚未核齐'] }])('rejects a missing, incomplete or blocked selection group instead of falling back to the visible row', change => { + const row = sharedPalletCandidates()[0]! + row.selectionGroup = change ? { ...row.selectionGroup, ...change } : undefined as unknown as SelectionGroup + expect(() => expandSelectionGroups([row])).toThrow(/完整选货组|尚未完整|尚未核齐/) +}) +it('rejects changed group composition and conflicting source versions before creating a command', () => { + const [first, second] = sharedPalletCandidates() + expect(() => assertSelectionGroupVersions([first!], [{ ...second!, selectionGroup: { ...second!.selectionGroup, groupVersion: 'composition-2' } }])).toThrow('组成已变化') + const conflict = { ...second!, selectionGroup: { ...second!.selectionGroup, sources: second!.selectionGroup.sources.map((source, index) => index ? source : { ...source, selection: { ...source.selection, sourceVersion: 'changed' } }) } } + expect(() => expandSelectionGroups([first!, conflict])).toThrow('来源事实不一致') +}) +it('normalizes nested group Long quantities and preserves source identities and versions without loss', () => { + const row = sharedPalletCandidates()[0]!, id = '9007199254740993' + const value: CandidateWire = { ...row, selectionGroup: { ...row.selectionGroup, sources: row.selectionGroup.sources.map(source => ({ ...source, customerId: id, selection: { ...source.selection, sourceVersion: id }, packages: String(source.packages) })), pallets: [{ warehousePalletId: id, palletNo: 'PALLET-1', sourceKeys: ['RELABEL:A', 'RELABEL:B'], packageCount: '8' }] } } + const result = normalizeCandidateList({ candidates: [value], complete: true, snapshotAt: '' }).candidates[0]! + expect(result.selectionGroup.sources.map(source => source.packages)).toEqual([3, 5]); expect(result.selectionGroup.pallets[0]!.packageCount).toBe(8) + expect(result.selectionGroup.sources[0]!.selection.sourceVersion).toBe(id); expect(result.selectionGroup.pallets[0]!.warehousePalletId).toBe(id) + value.selectionGroup.sources[0]!.packages = '9007199254740993' + expect(() => normalizeCandidateList({ candidates: [value], complete: true, snapshotAt: '' })).toThrow('安全整数') +}) diff --git a/src/modules/overseas-warehouse/dispatchTestFixtures.ts b/src/modules/overseas-warehouse/dispatchTestFixtures.ts index 497a01c9..87686a15 100644 --- a/src/modules/overseas-warehouse/dispatchTestFixtures.ts +++ b/src/modules/overseas-warehouse/dispatchTestFixtures.ts @@ -3,10 +3,15 @@ export const counts = () => ({ itemCount: 1, palletCount: 1, packageCount: 2, pi 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 sharedPalletCandidates(): Candidate[] { + const rows = ['A', 'B'].map((suffix, index) => candidate({ sourceKey: `RELABEL:${suffix}`, sourceVersion: `source-${suffix}`, customerId: `C${index + 1}`, customerName: `客户${suffix}`, availablePackages: index ? 5 : 3, selection: { sourceType: 'RELABEL', planPalletId: null, returnReceiptId: null, relabelNo: `RL-${suffix}`, warehousePalletId: '21', sourceVersion: `source-${suffix}` } })) + const group = { groupKey: 'physical-group-21', groupVersion: 'composition-1', complete: true, blockReasons: [], sources: rows.map(row => row.selectionGroup.sources[0]!), pallets: [{ warehousePalletId: '21', palletNo: 'PALLET-1', sourceKeys: rows.map(row => row.sourceKey), packageCount: 8 }] } + return rows.map(row => ({ ...row, selectionGroup: group })) +} +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: [], 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', sourceBusinessType: null, sourceBusinessNo: null }], ...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 candidate(overrides: Partial = {}): Candidate { const value = { selection: { sourceType: 'INBOUND_PLAN' as const, 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 }; return { ...value, selectionGroup: overrides.selectionGroup ?? { groupKey: value.sourceKey, groupVersion: 'group-version-1', complete: true, blockReasons: [], sources: [{ selection: value.selection, sourceKey: value.sourceKey, customerId: value.customerId, customerName: value.customerName, sourceBusinessType: value.selection.sourceType === 'RELABEL' ? 'RELABEL_ORDER' : null, sourceBusinessNo: value.selection.relabelNo, packages: value.availablePackages }], pallets: [{ warehousePalletId: value.selection.warehousePalletId, palletNo: value.palletNo, sourceKeys: [value.sourceKey], packageCount: value.availablePackages }] } } } 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 diff --git a/src/modules/overseas-warehouse/dispatchWire.test.ts b/src/modules/overseas-warehouse/dispatchWire.test.ts index 649883fd..55ba4bed 100644 --- a/src/modules/overseas-warehouse/dispatchWire.test.ts +++ b/src/modules/overseas-warehouse/dispatchWire.test.ts @@ -13,6 +13,19 @@ const draft = (): wire.DispatchDraftWire => ({ draftToken: 'draft', recordVersio 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() }) +it('keeps nullable original-source provenance distinct from the fulfillment order and normalizes only its evidenced quantity', () => { + const origin: wire.SourceOriginWire = { dispatchItemSourceId: id, relabelApplicationId: '9007199254740994', relabelItemId: '9007199254740995', sourceInventoryBalanceId: '9007199254740996', sourceInboundOrderId: '9007199254740997', sourceInboundOrderNo: 'ORIGINAL-IN-1', sourceInboundCode: null, productQuantity: '7' } + const cargo: wire.ArrangementPreviewWire['groups'][number]['cargo'][number] = { dispatchItemId: id, customerId: id, customerName: '客户甲', orderId: id, orderNo: 'FULFILLMENT-1', sourceKey: 'source-1', sourceStage: 'RELABEL', sourceRegion: null, sourceInboundId: null, inboundCode: 'CURRENT-IN-1', planPalletId: null, warehousePalletId: id, palletNo: 'PALLET-1', pickId: id, attributionId: id, inventoryPositionId: id, receiptId: null, returnReceiptId: null, returnCartonId: null, cartonMark: null, relabelNo: 'RL-1', destination: destination(), packages: '2', productQuantity: '24', grossWeightKg: null, volumeCbm: null, batchAllocations: [], sourceOrigin: origin } + const value: wire.ArrangementPreviewWire = { dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'cargo', loadingCompletionHash: 'loading', arrange: gate, arrangement: null, groups: [{ groupKey: 'group-1', customerId: id, customerName: '客户甲', destination: destination(), groupCargoHash: 'group-cargo', cargo: [cargo], externalFreightHash: '', externalQuotes: [], feeCoverageCargoHash: '', feeSources: [], requiredServices: [], blockReasons: [] }] } + const normalized = wire.normalizeArrangementPreview(parsed(value)).groups[0]!.cargo[0]! + expect(normalized.sourceOrigin).toEqual({ ...origin, productQuantity: 7 }); expect(normalized.orderId).toBe(id); expect(normalized.orderNo).toBe('FULFILLMENT-1'); expect(normalized.inboundCode).toBe('CURRENT-IN-1'); expect(normalized.productQuantity).toBe(24) + cargo.sourceOrigin = { ...origin, sourceInboundOrderNo: null, sourceInboundCode: null, productQuantity: null } + expect(wire.normalizeArrangementPreview(parsed(value)).groups[0]!.cargo[0]!.sourceOrigin).toEqual({ ...cargo.sourceOrigin }) + cargo.sourceOrigin = null; expect(wire.normalizeArrangementPreview(parsed(value)).groups[0]!.cargo[0]!.sourceOrigin).toBeNull() + cargo.sourceOrigin = { ...origin, productQuantity: '9007199254740993' } + expect(() => wire.normalizeArrangementPreview(parsed(value))).toThrow('安全整数') +}) + 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 }] } @@ -68,7 +81,7 @@ describe('native dispatch Long quantity boundary', () => { 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)) + 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: [], sourceOrigin: null }], 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', () => { diff --git a/src/modules/overseas-warehouse/dispatchWire.ts b/src/modules/overseas-warehouse/dispatchWire.ts index 2417fbe0..e1f81c23 100644 --- a/src/modules/overseas-warehouse/dispatchWire.ts +++ b/src/modules/overseas-warehouse/dispatchWire.ts @@ -1,4 +1,4 @@ -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' +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, type SelectionGroup, type SourceOrigin } from './dispatchContracts' // common-web serializes both Long and long as decimal strings. Only quantity // fields are normalized here; IDs, recordVersion and reviewRound stay lossless. @@ -17,14 +17,17 @@ type StatisticsWire = 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 +type SelectionGroupWire = Omit & { sources: Array>; pallets: Array> } +export type CandidateWire = Omit, 'selectionGroup'> & { selectionGroup: SelectionGroupWire } 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 SourceOriginWire = Omit & { productQuantity: LongQuantityWire | null } +type CargoLineWire = Omit, 'sourceOrigin'> & { sourceOrigin: SourceOriginWire | null } +type PreviewGroupWire = Omit & { cargo: CargoLineWire[] } 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 } @@ -46,7 +49,12 @@ function numbers(value: LongFields, fields: readonly 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') } } +const candidate = (value: CandidateWire): Candidate => { + requireBatchVersions(value.appointmentBatches) + const group = value.selectionGroup + if (!group || typeof group.groupKey !== 'string' || typeof group.groupVersion !== 'string' || !Array.isArray(group.sources) || !Array.isArray(group.pallets) || !Array.isArray(group.blockReasons)) throw new DispatchInputError('完整选货组或组成版本缺失,请重新读取候选') + return { ...value, availablePackages: dispatchQuantity(value.availablePackages, 'availablePackages'), selectionGroup: { ...group, sources: group.sources.map(source => ({ ...source, packages: dispatchQuantity(source.packages, 'selectionGroup.sources.packages') })), pallets: group.pallets.map(pallet => ({ ...pallet, packageCount: dispatchQuantity(pallet.packageCount, 'selectionGroup.pallets.packageCount') })) } } +} 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) } } } @@ -58,7 +66,12 @@ export function normalizeDispatchItems(value: DispatchItemsWire): DispatchItems 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 } } +function normalizeSourceOrigin(value: SourceOriginWire | null): SourceOrigin | null { + if (value == null) return null + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new DispatchInputError('排车响应原货源结构缺失,请重新读取') + return { ...value, productQuantity: value.productQuantity == null ? null : dispatchQuantity(value.productQuantity, 'sourceOrigin.productQuantity') } +} +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'), sourceOrigin: normalizeSourceOrigin(row.sourceOrigin) })) })), arrangement: value.arrangement ? arrangementResult(value.arrangement) : null } } export function normalizeDispatchCommand(value: CommandResultWire): CommandResult { const normalizedCounts = counts(value.counts) switch (value.action) { diff --git a/src/modules/overseas-warehouse/index.ts b/src/modules/overseas-warehouse/index.ts index f7363bec..5ecb9980 100644 --- a/src/modules/overseas-warehouse/index.ts +++ b/src/modules/overseas-warehouse/index.ts @@ -9,7 +9,7 @@ export { overseasWarehousePermissions } from './permissions' 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 { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, stageLabel } from './dispatchContracts' export { useDispatchCommandRecovery, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection } from './dispatchCommandRecovery' export { default as DispatchWorkflowDialog } from './components/DispatchWorkflowDialog.vue' export { dispatchVehicleSpecs } from './dispatchVehicleSpecs' diff --git a/src/modules/overseas-warehouse/pages/DispatchDetailView.test.ts b/src/modules/overseas-warehouse/pages/DispatchDetailView.test.ts index 6d648c46..23e58ab1 100644 --- a/src/modules/overseas-warehouse/pages/DispatchDetailView.test.ts +++ b/src/modules/overseas-warehouse/pages/DispatchDetailView.test.ts @@ -16,6 +16,8 @@ const options = { global: { plugins: [ElementPlus], stubs: { DataTable: DataTabl 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 value = detail(); value.dispatchInfo.fbaAppointments = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'BATCH-1', appointmentNo: 'FBA-AP-1', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'CONFIRMED', confirmedAt: null, blockReason: null }] + vi.mocked(getDispatch).mockResolvedValueOnce(value) const wrapper = mount(DispatchDetailView, options); await flushPromises() expect(getDispatch).toHaveBeenCalledWith('PC/1') const tabs = wrapper.findAll('[role="tab"]') @@ -23,14 +25,18 @@ describe('whole-dispatch four-tab detail', () => { 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') + const dispatchInfo = wrapper.get(`#${tabs[1]!.attributes('aria-controls')}`) + expect(dispatchInfo.text()).toContain('LAX → ONT8'); expect(dispatchInfo.text()).toContain('FBA预约'); expect(dispatchInfo.text()).toContain('ONT8 · FBA-AP-1 · CONFIRMED') + expect(dispatchInfo.findAll('dt').map(field => field.text())).toEqual(['路线', '发车时间', '预计到达时间', 'FBA预约']) expect(getDispatch).toHaveBeenCalledTimes(1); expect(wrapper.text()).toContain('装车前'); 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 }) + value.orderDetails.forEach((row, index) => { row.sourceBusinessType = 'RELABEL_ORDER'; row.sourceBusinessNo = `RL-${index + 1}` }) vi.mocked(getDispatch).mockResolvedValueOnce(value) const wrapper = mount(DispatchDetailView, options); await flushPromises() expect(wrapper.getComponent(DataTableStub).props('data')).toEqual(value.orderDetails) + expect(wrapper.getComponent(DataTableStub).vm.$attrs.columns).toContainEqual(expect.objectContaining({ prop: 'sourceBusinessNo', label: '来源业务号' })) expect(value.orderDetails[0]!.grossWeightKg).toBeNull(); wrapper.unmount() }) it('renders all customer delivery references and real update time/remark without leaking internal IDs', async () => { diff --git a/src/modules/overseas-warehouse/pages/DispatchDetailView.vue b/src/modules/overseas-warehouse/pages/DispatchDetailView.vue index 10f9f2ac..3049a957 100644 --- a/src/modules/overseas-warehouse/pages/DispatchDetailView.vue +++ b/src/modules/overseas-warehouse/pages/DispatchDetailView.vue @@ -34,7 +34,6 @@ const dispatchFields = computed(() => [ ['路线', text(detail.value?.dispatchInfo.route)], ['发车时间', time(detail.value?.dispatchInfo.departedAt)], ['预计到达时间', time(detail.value?.dispatchInfo.estimatedArrivalAt)], ['FBA预约', detail.value?.dispatchInfo.fbaAppointments.map(item => [item.destinationWarehouseCode, item.appointmentNo, item.status].filter(Boolean).join(' · ')).join(';') || '—'], - ['车辆预约', detail.value?.dispatchInfo.vehicleAppointment ? [detail.value.dispatchInfo.vehicleAppointment.referenceNo, detail.value.dispatchInfo.vehicleAppointment.status, time(detail.value.dispatchInfo.vehicleAppointment.appointmentAt)].join(' · ') : '—'], ]) const documentFields = computed(() => [ ['排车单号', text(detail.value?.documents.dispatchNo)], ['创建时间', time(detail.value?.documents.createdAt)], @@ -44,6 +43,7 @@ const relatedOrders = computed(() => detail.value?.orderDetails || []) const orderColumns: DataTableColumn[] = [ { key: 'customerName', prop: 'customerName', label: '客户', minWidth: 140 }, { key: 'orderNo', prop: 'orderNo', label: '订单号', minWidth: 210 }, + { key: 'sourceBusinessNo', prop: 'sourceBusinessNo', label: '来源业务号', minWidth: 180 }, { key: 'inboundCode', prop: 'inboundCode', label: '入仓码', minWidth: 170 }, { key: 'weight', prop: 'grossWeightKg', label: '重量(kg)', minWidth: 130, align: 'right' }, { key: 'palletCount', prop: 'palletCount', label: '托数', minWidth: 100, align: 'right' }, diff --git a/src/modules/overseas-warehouse/pages/prototypeContract.test.ts b/src/modules/overseas-warehouse/pages/prototypeContract.test.ts index ce5d89dd..4fee3571 100644 --- a/src/modules/overseas-warehouse/pages/prototypeContract.test.ts +++ b/src/modules/overseas-warehouse/pages/prototypeContract.test.ts @@ -258,7 +258,9 @@ describe('overseas warehouse first-batch prototype contract', () => { it('connects native same-warehouse multi-customer draft containers and independent resources', () => { const dialog = source('../components/SmartDispatchDialog.vue') for (const symbol of ['listDispatchCandidates', 'createDispatchDraft', 'lockDispatchDraftPallet', 'unlockDispatchDraftPallet', 'releaseDispatchDraft', 'convertDispatchDraft', 'requireVersion', 'draftContainerId', 'sourceVersion', 'result.complete !== true', 'validateCurrentDispatch', 'warehouse-tabs density-tabs', '洛杉矶', '纽约', '先建空单后加货', 'settings[containerId]']) expect(dialog).toContain(symbol) - expect(dialog).toContain('cargo: cargo.map(row => row.selection)') + // A filtered candidate must still submit its complete server-owned selection group. + expect(dialog).toContain('assertSelectionGroupVersions(cargo, candidates.value)') + expect(dialog).toContain('cargo: expandSelectionGroups(cargo)') expect(dialog).toContain('同仓可混装多个客户与目的仓') expect(dialog).not.toContain('一次排车只能装同一客户') expect(dialog).not.toContain('ensureDraftToken') diff --git a/src/styles/adminDensity.css b/src/styles/adminDensity.css index 0763a2e4..1ecb3f99 100644 --- a/src/styles/adminDensity.css +++ b/src/styles/adminDensity.css @@ -213,3 +213,47 @@ :is(.admin-density, .admin-density-dialog) .density-fields > div { display: grid; grid-template-columns: var(--admin-field-label-width, var(--admin-label-width)) minmax(0, 1fr); min-width: 0; min-height: var(--admin-row-height); gap: 0; padding: 0; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); border-radius: 0; background: var(--admin-surface); } :is(.admin-density, .admin-density-dialog) .density-fields dt { display: flex; align-items: center; margin: 0; padding: var(--admin-space-2) var(--admin-space-3); color: var(--cc-muted); background: var(--admin-label-bg); font-size: var(--admin-font-caption); line-height: 20px; } :is(.admin-density, .admin-density-dialog) .density-fields dd { align-self: center; min-width: 0; margin: 0; padding: var(--admin-space-2) var(--admin-space-3); color: #25324a; font-size: var(--admin-font-body); font-weight: 400; line-height: 20px; overflow-wrap: anywhere; white-space: normal; } + +/* Dispatch dialog sections: explicit opt-in, scoped to dialogs. */ +.admin-density-dialog .density-dialog-section { + min-width: 0; + margin: 0 0 var(--admin-space-5); + padding: var(--admin-space-4) var(--admin-space-5); + border: 1px solid var(--admin-table-border); + border-radius: var(--admin-dialog-section-radius); + background: var(--admin-surface); +} +.admin-density-dialog .density-dialog-section:last-child { margin-bottom: 0; } +.admin-density-dialog .density-dialog-sections { display: grid; gap: var(--admin-space-5); } +.admin-density-dialog .density-dialog-sections > .density-dialog-section { margin: 0; } +.admin-density-dialog .density-dialog-section[data-tone="summary"] { + border-color: var(--admin-dialog-summary-border); + background: var(--admin-dialog-summary-bg); +} +.admin-density-dialog .density-dialog-section[data-tone="source"] { + border-color: var(--admin-dialog-source-border); + background: var(--admin-dialog-source-bg); +} +.admin-density-dialog .density-dialog-section[data-tone="work"] { + border-color: var(--admin-dialog-work-border); + background: var(--admin-dialog-work-bg); +} +.admin-density-dialog .density-dialog-section > .density-dialog-section__title { + display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--admin-space-3); + margin: 0 0 var(--admin-space-4); color: var(--cc-text); font-size: var(--admin-font-section); font-weight: 600; +} +.admin-density-dialog .density-dialog-section__title small { color: var(--cc-muted); font-size: var(--admin-font-caption); font-weight: 400; } +.admin-density-dialog .density-dialog-note { margin: 0 0 var(--admin-space-3); color: var(--cc-muted); font-size: var(--admin-font-caption); } +.admin-density-dialog .density-dialog-section > .el-form > .el-form-item:last-child { margin-bottom: 0; } +.admin-density-dialog .density-dialog-section .el-table { --el-table-tr-bg-color: var(--admin-surface); } + +/* Dispatch dialog continuous table editors. */ +.admin-density-dialog .density-table--editable :is(.el-input, .el-textarea) { + --el-input-border-color: transparent; + --el-input-hover-border-color: var(--admin-table-border); + --el-input-bg-color: transparent; +} +.admin-density-dialog .density-table--editable .el-input__inner { text-align: left; } +.admin-density-dialog .density-table--editable .el-form-item { margin-bottom: 0; } +.admin-density-dialog .density-table--editable .el-form-item.is-error { margin-bottom: var(--admin-space-5); } +.admin-density-dialog .density-table--editable :is(.el-input-number, .el-segmented, .el-date-editor) { width: 100%; min-width: 0; } diff --git a/src/styles/adminDensityTokens.css b/src/styles/adminDensityTokens.css index 94efcaef..61296547 100644 --- a/src/styles/adminDensityTokens.css +++ b/src/styles/adminDensityTokens.css @@ -27,6 +27,13 @@ --admin-table-border: #e5e7eb; --admin-row-hover: #f3f7fb; --admin-row-stripe: #fcfcfd; + --admin-dialog-section-radius: 8px; + --admin-dialog-summary-bg: #f4fbf8; + --admin-dialog-summary-border: #cce8df; + --admin-dialog-source-bg: #f7f9ff; + --admin-dialog-source-border: #d9e2fa; + --admin-dialog-work-bg: #fffbf4; + --admin-dialog-work-border: #eadfc9; --admin-action-neutral: #1777ff; --admin-action-neutral-border: #dfe3ee; --admin-action-warning: #c76500;