From 67acd02ea5fcf8491ee1fd317ff7d033b47c546b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 17:08:59 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DispatchDocumentsDialog.test.ts | 10 ++- .../DispatchLoadingDocuments.test.ts | 63 +++++++++++++++++++ .../dispatchCommandRecovery.test.ts | 6 +- .../dispatchTestFixtures.ts | 2 +- .../pages/DispatchesView.test.ts | 10 +-- .../pages/DispatchesView.vue | 2 +- 6 files changed, 82 insertions(+), 11 deletions(-) create mode 100644 src/modules/overseas-warehouse/components/DispatchLoadingDocuments.test.ts diff --git a/src/modules/overseas-warehouse/components/DispatchDocumentsDialog.test.ts b/src/modules/overseas-warehouse/components/DispatchDocumentsDialog.test.ts index b8835c35..13c83582 100644 --- a/src/modules/overseas-warehouse/components/DispatchDocumentsDialog.test.ts +++ b/src/modules/overseas-warehouse/components/DispatchDocumentsDialog.test.ts @@ -15,7 +15,7 @@ const dialogStub = defineComponent({ props: ['modelValue', 'showConfirm'], emits function documents(no = 'DL-1', customerId = '101'): DeliveryDocuments { return { deliveryNo: no, customerId, version: 1, bolNo: 'BOL-ORIGINAL', uploadedCategoryCount: 1, totalCategoryCount: 3, allowedActions: ['MANAGE_DOCUMENTS'], replayed: false, categories: deliveryDocumentCategories.map(item => ({ category: item.category, status: item.category === 'RECEIPT' ? 'UPLOADED' : 'MISSING', fileCount: item.category === 'RECEIPT' ? 1 : 0, files: item.category === 'RECEIPT' ? [{ fileId: '99', fileName: 'existing-pod.pdf', contentType: 'application/pdf', sizeBytes: 10, uploadedAt: '2026-10-21T00:00:00' }] : [] })) } } -function setup() { return mount(DispatchDocumentsDialog, { props: { modelValue: true, dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus], stubs: { AppDialog: dialogStub, DispatchVehicleDocuments: true, DispatchLoadingDocuments: true, DispatchEvidenceFiles: true } } }) } +function setup() { return mount(DispatchDocumentsDialog, { props: { modelValue: true, dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus], stubs: { AppDialog: dialogStub, DispatchVehicleDocuments: true, DispatchLoadingDocuments: true, DispatchLoadingDocuments: true, DispatchEvidenceFiles: true } } }) } beforeEach(() => { vi.clearAllMocks(); can.mockReturnValue(true) const value = detail(); value.stage = 'DELIVERING' @@ -27,6 +27,14 @@ beforeEach(() => { vi.mocked(getDeliveryDocuments).mockImplementation(async (no, customer) => documents(no, customer)) vi.mocked(saveDeliveryDocuments).mockResolvedValue({ ...documents(), version: 2 }) }) +it('opens loading documents before delivery orders exist and without delivery-list permission', async () => { + vi.mocked(getDispatch).mockResolvedValue(detail({ stage: 'AFTER_LOADING' })) + can.mockImplementation((value?: string) => value !== 'platform:overseas-warehouse:delivery:list') + const wrapper = setup(); await flushPromises() + expect(wrapper.find('dispatch-loading-documents-stub').exists()).toBe(true) + expect(wrapper.findAll('[role="tab"]').find(tab => tab.text() === '装车资料')!.attributes('aria-selected')).toBe('true') + expect(getDeliveryDocuments).not.toHaveBeenCalled(); wrapper.unmount() +}) it('reads existing delivery files in one dialog and saves receipt without requiring loading or BOL files', async () => { const wrapper = setup(); await flushPromises() expect(getDeliveryDocuments).toHaveBeenCalledWith('DL-1', '101') diff --git a/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.test.ts b/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.test.ts new file mode 100644 index 00000000..1bfc46a6 --- /dev/null +++ b/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.test.ts @@ -0,0 +1,63 @@ +import { flushPromises, mount } from '@vue/test-utils' +import ElementPlus from 'element-plus' +import { beforeEach, expect, it, vi } from 'vitest' +import { ApiResponseError } from '@/shared/api/http' +import DispatchLoadingDocuments from './DispatchLoadingDocuments.vue' +import * as api from '../api/dispatchLoadingDocuments' + +const can = vi.hoisted(() => vi.fn(() => true)) +vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: { id: 'operator' } }) })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) })) +vi.mock('../api/dispatchLoadingDocuments', async importOriginal => ({ ...await importOriginal(), getLoadingDocuments: vi.fn(), uploadLoadingPhoto: vi.fn(), recoverLoadingPhoto: vi.fn(), bindLoadingPhoto: vi.fn(), readLoadingPhoto: vi.fn() })) +const photo: api.LoadingPhoto = { slot: 'LOADING_SCENE', fileId: '101', fileName: 'loading.png', contentType: 'image/png', savedAt: null } +const data = (extra: Partial = {}): api.LoadingDocuments => ({ dispatchNo: 'PC/1', recordVersion: '0', dispatchRecordVersion: '3', stage: 'AFTER_LOADING', editable: true, complete: false, photos: [], ...extra }) +function setup() { return mount(DispatchLoadingDocuments, { props: { dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus] } }) } +async function upload(wrapper: ReturnType) { + const input = wrapper.get('input[aria-label="装车照片 1"]') + Object.defineProperty(input.element, 'files', { configurable: true, value: [new File(['image'], 'loading.png', { type: 'image/png' })] }) + await input.trigger('change'); await flushPromises() +} +beforeEach(() => { + vi.clearAllMocks(); sessionStorage.clear(); can.mockReturnValue(true) + vi.mocked(api.getLoadingDocuments).mockResolvedValue(data()) + vi.mocked(api.uploadLoadingPhoto).mockResolvedValue(photo) + vi.mocked(api.bindLoadingPhoto).mockResolvedValue(data({ recordVersion: '1', photos: [photo] })) + vi.mocked(api.readLoadingPhoto).mockRejectedValue(new Error('preview unavailable')) +}) +it('offers five whole-dispatch slots and only reports saved after binding succeeds', async () => { + const wrapper = setup(); await flushPromises() + expect(wrapper.findAll('input[type="file"]')).toHaveLength(5) + await upload(wrapper) + expect(api.uploadLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.any(File), expect.any(String)) + expect(api.bindLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ recordVersion: '0', fileId: '101' })) + expect(wrapper.emitted('saved')).toHaveLength(1) + expect(wrapper.text()).toContain('图片读取失败') + wrapper.unmount() +}) +it('keeps the original binding and file after an unknown result and never uploads again', async () => { + vi.mocked(api.bindLoadingPhoto).mockRejectedValueOnce(new Error('network unknown')) + const wrapper = setup(); await flushPromises(); await upload(wrapper) + expect(wrapper.emitted('saved')).toBeUndefined() + const original = vi.mocked(api.bindLoadingPhoto).mock.calls[0]![2] + await wrapper.findAll('button').find(item => item.text() === '核对原请求并继续保存')!.trigger('click'); await flushPromises() + expect(api.bindLoadingPhoto).toHaveBeenLastCalledWith('PC/1', 'LOADING_SCENE', original) + expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(1) + expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount() +}) +it('refreshes a definitive version conflict while retaining the uploaded file', async () => { + vi.mocked(api.bindLoadingPhoto).mockRejectedValueOnce(new ApiResponseError('version conflict', 'A0409')) + const wrapper = setup(); await flushPromises(); await upload(wrapper) + const original = vi.mocked(api.bindLoadingPhoto).mock.calls[0]![2] + vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ recordVersion: '4' })) + vi.mocked(api.bindLoadingPhoto).mockResolvedValue(data({ stage: 'DELIVERING', complete: true, recordVersion: '5' })) + await wrapper.findAll('button').find(item => item.text() === '刷新并重试保存')!.trigger('click'); await flushPromises() + expect(api.bindLoadingPhoto).toHaveBeenLastCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ recordVersion: '4', fileId: '101' })) + expect(vi.mocked(api.bindLoadingPhoto).mock.calls[1]![2].idempotencyKey).not.toBe(original.idempotencyKey) + expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount() +}) +it('shows existing photos without exposing upload for a read-only account', async () => { + can.mockReturnValue(false) + const wrapper = setup(); await flushPromises() + expect(wrapper.find('input[type="file"]').exists()).toBe(false) + expect(api.uploadLoadingPhoto).not.toHaveBeenCalled(); wrapper.unmount() +}) diff --git a/src/modules/overseas-warehouse/dispatchCommandRecovery.test.ts b/src/modules/overseas-warehouse/dispatchCommandRecovery.test.ts index 8f8757bc..8310d631 100644 --- a/src/modules/overseas-warehouse/dispatchCommandRecovery.test.ts +++ b/src/modules/overseas-warehouse/dispatchCommandRecovery.test.ts @@ -3,11 +3,11 @@ import { ApiResponseError } from '@/shared/api/http' import { useDispatchCommandRecovery } from './dispatchCommandRecovery' import { command } from './dispatchTestFixtures' beforeEach(() => sessionStorage.clear()) -it('does not accept an intermediate loading stage as completion of the original command', async () => { +it('rejects a loading result that skips the loading documents stage', async () => { const owner = crypto.randomUUID(), recovery = useDispatchCommandRecovery(() => owner) recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'key' }) const read = vi.fn() - await expect(recovery.execute(vi.fn().mockResolvedValue({ ...command('LOADING_COMPLETE'), stage: 'AFTER_LOADING' }), read, vi.fn())).rejects.toThrow('派送中') + await expect(recovery.execute(vi.fn().mockResolvedValue({ ...command('LOADING_COMPLETE'), stage: 'DELIVERING' }), read, vi.fn())).rejects.toThrow('装车后') expect(read).not.toHaveBeenCalled() expect(recovery.pending.value?.result).toBeNull() expect(recovery.pending.value?.body.idempotencyKey).toBe('key') @@ -27,7 +27,7 @@ it('keeps a proven result across GET failure and does not send the command a sec recovery.begin('PC/1', 'LOADING_COMPLETE', { recordVersion: 0, idempotencyKey: 'key' }) const send = vi.fn().mockResolvedValue(command('LOADING_COMPLETE')), read = vi.fn().mockRejectedValueOnce(new Error('read failed')).mockResolvedValueOnce({ dispatchNo: 'PC/1', recordVersion: 1 }), find = vi.fn() await expect(recovery.execute(send, read, find)).rejects.toThrow('read failed') - expect(recovery.pending.value?.result?.stage).toBe('DELIVERING') + expect(recovery.pending.value?.result?.stage).toBe('AFTER_LOADING') expect(JSON.parse(sessionStorage.getItem(`dispatch-command:${owner}`)!).result).toEqual(command('LOADING_COMPLETE')) const reopened = useDispatchCommandRecovery(() => owner) expect(reopened.pending.value?.result).toEqual(command('LOADING_COMPLETE')) diff --git a/src/modules/overseas-warehouse/dispatchTestFixtures.ts b/src/modules/overseas-warehouse/dispatchTestFixtures.ts index 7de47b7e..76fcb56b 100644 --- a/src/modules/overseas-warehouse/dispatchTestFixtures.ts +++ b/src/modules/overseas-warehouse/dispatchTestFixtures.ts @@ -13,7 +13,7 @@ export function listRow(overrides: Partial = {}): DispatchListR 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 { 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' ? 'DELIVERING' as const : 'BEFORE_LOADING' as const, occurredAt: '2026-10-19T10:00:00Z', effect: 'APPLIED' as const, counts: counts(), replayed: false, alreadyArranged: false } + const base = { dispatchNo: no, action, idempotencyKey: key, recordVersion: 1, status: 'ACTIVE' as const, stage: action === 'DEPART' ? 'DELIVERING' as const : action === 'LOADING_COMPLETE' ? 'AFTER_LOADING' as const : 'BEFORE_LOADING' as const, occurredAt: '2026-10-19T10:00:00Z', effect: 'APPLIED' as const, counts: counts(), replayed: false, alreadyArranged: false } return { ...base, result: action === 'DEPART' ? { departedAt: '2026-10-19T10:00:00Z', shippedPackages: 2, outbounds: [], deliveries: [] } : action === 'LOADING_COMPLETE' ? { loadingCompletionHash: 'load-hash', loadingCompletedAt: '2026-10-19T10:00:00Z', confirmedLoad: { manifestHash: 'manifest', pallets: [], operatorId: 'operator', confirmedAt: '2026-10-19T10:00:00Z' } } : { dispatchNo: no, changedItemIds: [] } } as CommandResult } export function loadingPreview(): LoadingPreview { return { dispatchNo: 'PC/1', recordVersion: 0, status: 'ACTIVE', stage: 'LOADING', manifestHash: 'manifest', pickingReconciled: true, resources: resources(), pallets: [{ warehousePalletId: '21', palletNo: 'PALLET-1', scheduledPackages: 2, pickedPackages: 2, palletSpaces: 1, weightKg: null, volumeCbm: null, deliveryIndex: 1, missingFields: ['weightKg', 'volumeCbm'], evidence: [{ field: 'weightKg', sourceType: 'ORDER_SNAPSHOT_ALLOCATION', sourceBusinessNo: 'IN-1', observedAt: null, value: 100, actual: false }] }], confirmedLoad: null, loadingComplete: { allowed: true, blockCode: null, blockReason: null }, missingFields: ['weightKg', 'volumeCbm'] } } diff --git a/src/modules/overseas-warehouse/pages/DispatchesView.test.ts b/src/modules/overseas-warehouse/pages/DispatchesView.test.ts index b7dd5636..212b202f 100644 --- a/src/modules/overseas-warehouse/pages/DispatchesView.test.ts +++ b/src/modules/overseas-warehouse/pages/DispatchesView.test.ts @@ -25,7 +25,7 @@ it('never offers a second arrangement or departure after loading completion', as const gate = { allowed: true, blockCode: null, blockReason: null } vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ stage: 'DELIVERING', actions: { ARRANGE_DELIVERY: gate, DEPART: gate } })])) const wrapper = mount(DispatchesView, options); await flushPromises() - expect(wrapper.findAll('[role="tab"]').some(tab => tab.text().startsWith('装车后'))).toBe(false) + expect(wrapper.findAll('[role="tab"]').some(tab => tab.text().startsWith('装车后'))).toBe(true) const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')! const keys = (row.props('actions') as Array<{ key: string }>).map(action => action.key) expect(keys).not.toContain('ARRANGE_DELIVERY'); expect(keys).not.toContain('DEPART') @@ -102,13 +102,13 @@ it.each([1, 2])('keeps only details in audit tab %s without its review permissio expect((row.props('actions') as Array<{ key: string }>).map(item => item.key)).toEqual(['detail']) wrapper.unmount() }) -it('shows four working phases and uses server stage counts instead of current page length', async () => { +it('shows five working phases and uses server stage counts instead of current page length', async () => { const wrapper = mount(DispatchesView, options); await flushPromises() const tabs = wrapper.get('.data-table__toolbar-start .workbench-list-tabs[aria-label="排车执行阶段"]').findAll('button') expect(wrapper.find('.data-table__filters [aria-label="装车前审核分区"]').exists()).toBe(true) - expect(tabs.map(tab => tab.text())).toEqual(['装车前 3', '装车中 1', '派送中 1', '派送完成 1']) + expect(tabs.map(tab => tab.text())).toEqual(['装车前 3', '装车中 1', '装车后 1', '派送中 1', '派送完成 1']) expect(listDispatches).toHaveBeenCalledWith(expect.objectContaining({ stage: 'BEFORE_LOADING', status: 'ACTIVE', beforeLoadingSection: 'VEHICLE_INFO' })) - await tabs[2]!.trigger('click'); await flushPromises() + await tabs[3]!.trigger('click'); await flushPromises() expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'DELIVERING' })); expect(wrapper.find('[aria-label="装车前审核分区"]').exists()).toBe(false); wrapper.unmount() }) it.each([0, 1, 2])('preserves section %s and its query after clearing the active dispatch filter', async index => { @@ -166,7 +166,7 @@ it('keeps waiting vehicles in the current loading phase and synchronizes an acti toolbar.vm.$emit('search', { vehicleStatus: 'WAIT_DEPARTURE', status: 'ACTIVE' }); await flushPromises() expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'LOADING' })) expect(vi.mocked(listDispatches).mock.calls.at(-1)?.[0]).not.toHaveProperty('beforeLoadingSection') - await tabs[2]!.trigger('click'); await flushPromises() + await tabs[3]!.trigger('click'); await flushPromises() expect(toolbar.props('modelValue').vehicleStatus).toBe('DELIVERING') await tabs[0]!.trigger('click'); await flushPromises() expect(toolbar.props('modelValue').vehicleStatus).toBe('WAIT_DEPARTURE') diff --git a/src/modules/overseas-warehouse/pages/DispatchesView.vue b/src/modules/overseas-warehouse/pages/DispatchesView.vue index 90af3078..8745bd53 100644 --- a/src/modules/overseas-warehouse/pages/DispatchesView.vue +++ b/src/modules/overseas-warehouse/pages/DispatchesView.vue @@ -51,7 +51,7 @@ const vehicleStatuses = [ { value: 'DELIVERED', label: '已送达', type: 'success' }, ] as const function vehicleStatusForStage(value: DispatchStage) { - if (value === 'BEFORE_LOADING' || value === 'LOADING') return vehicleStatuses[0] + if (value === 'BEFORE_LOADING' || value === 'LOADING' || value === 'AFTER_LOADING') return vehicleStatuses[0] return vehicleStatuses.find(item => item.value === value) } const operation = ref<'ROUTE' | undefined>()