From 89d237e1291ce813a0500f067618c59e66b95c91 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 9 Sep 2026 11:17:12 +0800 Subject: [PATCH] fix(delivery): version status commands and manage three document categories --- .../api/customerVisits.test.ts | 4 +- .../overseas-warehouse/api/customerVisits.ts | 2 +- .../api/deliveryDocuments.test.ts | 28 ++++ .../api/deliveryDocuments.ts | 33 ++++ .../overseas-warehouse/api/deliveryOrders.ts | 5 + .../DeliveryDocumentsDialog.test.ts | 154 ++++++++++++++++++ .../components/DeliveryDocumentsDialog.vue | 134 +++++++++++++++ .../pages/DeliveryOrderDetailView.test.ts | 1 + .../pages/DeliveryOrdersView.test.ts | 74 ++++++++- .../pages/DeliveryOrdersView.vue | 69 +++++++- .../pages/OutboundDetailView.test.ts | 2 +- src/modules/overseas-warehouse/permissions.ts | 1 + 12 files changed, 493 insertions(+), 14 deletions(-) create mode 100644 src/modules/overseas-warehouse/api/deliveryDocuments.test.ts create mode 100644 src/modules/overseas-warehouse/api/deliveryDocuments.ts create mode 100644 src/modules/overseas-warehouse/components/DeliveryDocumentsDialog.test.ts create mode 100644 src/modules/overseas-warehouse/components/DeliveryDocumentsDialog.vue diff --git a/src/modules/overseas-warehouse/api/customerVisits.test.ts b/src/modules/overseas-warehouse/api/customerVisits.test.ts index c1645c2e..389a5bf2 100644 --- a/src/modules/overseas-warehouse/api/customerVisits.test.ts +++ b/src/modules/overseas-warehouse/api/customerVisits.test.ts @@ -29,8 +29,8 @@ describe('customer visit API', () => { it('updates delivery status so selecting delivered can generate a visit', async () => { vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: { deliveryStatus: 'DELIVERED' } }) - await updateDeliveryOrderStatus('DEL/1', '100', { status: 'DELIVERED', remark: '完成' }) - expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/status', { status: 'DELIVERED', remark: '完成' }, { params: { customerId: '100' } }) + await updateDeliveryOrderStatus('DEL/1', '100', { status: 'DELIVERED', expectedVersion: 7, remark: '完成' }) + expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DEL%2F1/status', { status: 'DELIVERED', expectedVersion: 7, remark: '完成' }, { params: { customerId: '100' } }) }) it('uploads and saves repeatable visit material', async () => { diff --git a/src/modules/overseas-warehouse/api/customerVisits.ts b/src/modules/overseas-warehouse/api/customerVisits.ts index c657755c..5e8674ff 100644 --- a/src/modules/overseas-warehouse/api/customerVisits.ts +++ b/src/modules/overseas-warehouse/api/customerVisits.ts @@ -107,7 +107,7 @@ export async function completeDeliveryOrder(deliveryNo: string, customerId: stri return unwrapApiResponse(response) } -export async function updateDeliveryOrderStatus(deliveryNo: string, customerId: string, payload: { status: string; remark?: string }) { +export async function updateDeliveryOrderStatus(deliveryNo: string, customerId: string, payload: { status: string; expectedVersion: number; remark?: string }) { const response = await overseasHttp.put, ApiEnvelope<{ visitId?: string | null; deliveryNo: string; deliveryStatus: string; visitStatus?: string | null; replayed: boolean }>>( `/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/status`, payload, { params: { customerId } }, diff --git a/src/modules/overseas-warehouse/api/deliveryDocuments.test.ts b/src/modules/overseas-warehouse/api/deliveryDocuments.test.ts new file mode 100644 index 00000000..bc07ffb8 --- /dev/null +++ b/src/modules/overseas-warehouse/api/deliveryDocuments.test.ts @@ -0,0 +1,28 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { overseasHttp } from '@/shared/api/overseasHttp' +import { deliveryDocumentCategories, getDeliveryDocuments, saveDeliveryDocuments, uploadDeliveryDocument, downloadDeliveryDocument } from './deliveryDocuments' +vi.mock('@/shared/api/overseasHttp', () => ({ overseasHttp: { get: vi.fn(), post: vi.fn(), put: vi.fn() } })) +describe('delivery document API', () => { + beforeEach(() => vi.clearAllMocks()) + it('scopes reads and full three-category snapshots by customer and delivery number', async () => { + vi.mocked(overseasHttp.get).mockResolvedValue({ code: '200', data: {} }) + vi.mocked(overseasHttp.put).mockResolvedValue({ code: '200', data: {} }) + await getDeliveryDocuments('DL/1', '9007199254740993') + expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DL%2F1/documents', { params: { customerId: '9007199254740993' } }) + const body = { expectedVersion: 8, idempotencyKey: 'save-1', bolNo: null, documents: deliveryDocumentCategories.map(item => ({ category: item.category, fileIds: [] })) } + await saveDeliveryDocuments('DL/1', '9007199254740993', body) + expect(overseasHttp.put).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DL%2F1/documents', body, { params: { customerId: '9007199254740993' } }) + }) + it('uploads only the file and downloads through the authenticated category endpoint', async () => { + vi.mocked(overseasHttp.post).mockResolvedValue({ code: '200', data: { fileId: '9007199254740995' } }) + const file = new File(['data'], 'proof.bin') + await uploadDeliveryDocument('DL/1', '100', 'RECEIPT', file) + const [url, body, config] = vi.mocked(overseasHttp.post).mock.calls[0]! + expect(url).toBe('/platform/v1/last-leg/delivery-orders/DL%2F1/documents/RECEIPT/files') + expect(Array.from((body as FormData).keys())).toEqual(['file']) + expect((body as FormData).get('file')).toBe(file) + expect(config).toEqual({ params: { customerId: '100' } }) + await downloadDeliveryDocument('DL/1', '100', 'RECEIPT', '9007199254740995') + expect(overseasHttp.get).toHaveBeenCalledWith('/platform/v1/last-leg/delivery-orders/DL%2F1/documents/RECEIPT/files/9007199254740995/download', { params: { customerId: '100' }, responseType: 'blob' }) + }) +}) diff --git a/src/modules/overseas-warehouse/api/deliveryDocuments.ts b/src/modules/overseas-warehouse/api/deliveryDocuments.ts new file mode 100644 index 00000000..13eaf4e0 --- /dev/null +++ b/src/modules/overseas-warehouse/api/deliveryDocuments.ts @@ -0,0 +1,33 @@ +import { overseasHttp } from '@/shared/api/overseasHttp' +import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' + +export const deliveryDocumentCategories = [ + { category: 'LOADING_PHOTO', label: '装车照片' }, + { category: 'BOL', label: 'BOL文件(含签名)' }, + { category: 'RECEIPT', label: '签收文件(POD签收/快递签收)' }, +] as const +export type DeliveryDocumentCategory = typeof deliveryDocumentCategories[number]['category'] +export interface DeliveryDocumentFile { fileId: string; fileName: string; contentType: string | null; sizeBytes: number; uploadedAt: string } +export interface DeliveryDocumentCategorySummary { category: DeliveryDocumentCategory; status: 'UPLOADED' | 'MISSING'; fileCount: number } +export interface DeliveryDocumentSummary { uploadedCategoryCount: number; totalCategoryCount: number; categories: DeliveryDocumentCategorySummary[] } +export interface DeliveryDocuments extends DeliveryDocumentSummary { + deliveryNo: string; customerId: string; version: number; bolNo: string | null + categories: Array + allowedActions: string[]; replayed: boolean +} +export interface SaveDeliveryDocuments { expectedVersion: number; idempotencyKey: string; bolNo: string | null; documents: Array<{ category: DeliveryDocumentCategory; fileIds: string[] }> } +const path = (deliveryNo: string) => `/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}/documents` +export async function getDeliveryDocuments(deliveryNo: string, customerId: string) { + return unwrapApiResponse(await overseasHttp.get, ApiEnvelope>(path(deliveryNo), { params: { customerId } })) +} +export async function saveDeliveryDocuments(deliveryNo: string, customerId: string, body: SaveDeliveryDocuments) { + return unwrapApiResponse(await overseasHttp.put, ApiEnvelope>(path(deliveryNo), body, { params: { customerId } })) +} +export async function uploadDeliveryDocument(deliveryNo: string, customerId: string, category: DeliveryDocumentCategory, file: File) { + const body = new FormData() + body.append('file', file) + return unwrapApiResponse(await overseasHttp.post, ApiEnvelope>(`${path(deliveryNo)}/${category}/files`, body, { params: { customerId } })) +} +export async function downloadDeliveryDocument(deliveryNo: string, customerId: string, category: DeliveryDocumentCategory, fileId: string) { + return overseasHttp.get(`${path(deliveryNo)}/${category}/files/${encodeURIComponent(fileId)}/download`, { params: { customerId }, responseType: 'blob' }) +} diff --git a/src/modules/overseas-warehouse/api/deliveryOrders.ts b/src/modules/overseas-warehouse/api/deliveryOrders.ts index 45c1101e..b3291944 100644 --- a/src/modules/overseas-warehouse/api/deliveryOrders.ts +++ b/src/modules/overseas-warehouse/api/deliveryOrders.ts @@ -2,6 +2,7 @@ import { overseasHttp } from '@/shared/api/overseasHttp' import { unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' import { normalizeOverseasPage, type OverseasPagePayload } from './lastMileOrders' import type { ExceptionRelation } from './exceptionOrders' +import type { DeliveryDocumentSummary } from './deliveryDocuments' export interface DeliveryOrderQuery { page: number @@ -20,6 +21,10 @@ export interface DeliveryOrderQuery { } export interface DeliveryOrderRow { + version: number + allowedActions: string[] + bolNo?: string | null + documentSummary?: DeliveryDocumentSummary id: string customerId: string customerName?: string | null diff --git a/src/modules/overseas-warehouse/components/DeliveryDocumentsDialog.test.ts b/src/modules/overseas-warehouse/components/DeliveryDocumentsDialog.test.ts new file mode 100644 index 00000000..cb6c22a4 --- /dev/null +++ b/src/modules/overseas-warehouse/components/DeliveryDocumentsDialog.test.ts @@ -0,0 +1,154 @@ +import ElementPlus from 'element-plus' +import { defineComponent, h } from 'vue' +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import DeliveryDocumentsDialog from './DeliveryDocumentsDialog.vue' +import { commonPhase5Stubs } from '../pages/phase5TestStubs' +import { deliveryDocumentCategories, getDeliveryDocuments, saveDeliveryDocuments, uploadDeliveryDocument, type DeliveryDocuments } from '../api/deliveryDocuments' +const can = vi.hoisted(() => vi.fn(() => true)) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) })) +vi.mock('../api/deliveryDocuments', async importOriginal => ({ ...await importOriginal(), getDeliveryDocuments: vi.fn(), saveDeliveryDocuments: vi.fn(), uploadDeliveryDocument: vi.fn(), downloadDeliveryDocument: vi.fn() })) +const dialogStub = defineComponent({ + name: 'AppDialog', props: ['modelValue', 'showConfirm', 'confirmDisabled'], emits: ['confirm', 'update:modelValue'], + setup(props, { slots, emit }) { return () => props.modelValue ? h('section', [slots.default?.(), props.showConfirm ? h('button', { class: 'save', disabled: props.confirmDisabled, onClick: () => emit('confirm') }, '保存资料') : null, h('button', { class: 'close', onClick: () => emit('update:modelValue', false) }, '关闭')]) : null }, +}) +const uploadStub = defineComponent({ + name: 'ElUpload', props: ['onChange', 'disabled'], + setup(props, { slots }) { return () => h('label', [slots.default?.(), h('input', { type: 'file', disabled: props.disabled, onChange: (event: Event) => props.onChange({ raw: (event.target as HTMLInputElement).files?.[0] }) })]) }, +}) +const originalFile = { fileId: '9007199254740995', fileName: 'original.pdf', contentType: 'application/pdf', sizeBytes: 4, uploadedAt: '2026-09-09T09:00:00' } +function response(): DeliveryDocuments { + return { deliveryNo: 'DL/1', customerId: '100', version: 9, bolNo: 'BOL-1', uploadedCategoryCount: 1, totalCategoryCount: 3, allowedActions: ['MANAGE_DOCUMENTS'], replayed: false, categories: deliveryDocumentCategories.map(item => ({ category: item.category, status: item.category === 'BOL' ? 'UPLOADED' : 'MISSING', fileCount: item.category === 'BOL' ? 1 : 0, files: item.category === 'BOL' ? [{ ...originalFile }] : [] })) } +} +function mountDialog() { + const wrapper = mount(DeliveryDocumentsDialog, { props: { modelValue: true, deliveryNo: 'DL/1', customerId: '100' }, global: { plugins: [ElementPlus], stubs: { ...commonPhase5Stubs, AppDialog: dialogStub, ElUpload: uploadStub } } }) + return wrapper +} +async function choose(input: ReturnType['find']>, file: File) { + Object.defineProperty(input.element, 'files', { value: [file], configurable: true }) + await input.trigger('change'); await flushPromises() +} +describe('delivery document drafts', () => { + beforeEach(() => { + can.mockReset().mockReturnValue(true) + vi.mocked(getDeliveryDocuments).mockReset().mockResolvedValue(response()) + vi.mocked(saveDeliveryDocuments).mockReset().mockResolvedValue(response()) + vi.mocked(uploadDeliveryDocument).mockReset().mockResolvedValue({ ...originalFile, fileId: '9007199254740996', fileName: 'new.pdf' }) + }) + it('loads once and stages replacement and deletion until a full versioned save', async () => { + const wrapper = mountDialog(); await flushPromises() + expect(getDeliveryDocuments).toHaveBeenCalledTimes(1) + expect(wrapper.text()).toContain('1/3 已上传') + await choose(wrapper.find('[data-category="BOL"] .document-file input[type="file"]'), new File(['next'], 'new.pdf')) + expect(saveDeliveryDocuments).not.toHaveBeenCalled() + expect(wrapper.text()).not.toContain('original.pdf') + expect(wrapper.text()).toContain('new.pdf') + await wrapper.find('[data-category="BOL"] .document-file').findAll('button').find(button => button.text() === '删除')!.trigger('click') + expect(wrapper.text()).toContain('0/3 已上传') + await wrapper.find('.save').trigger('click'); await flushPromises() + expect(saveDeliveryDocuments).toHaveBeenCalledWith('DL/1', '100', { expectedVersion: 9, idempotencyKey: expect.any(String), bolNo: 'BOL-1', documents: deliveryDocumentCategories.map(item => ({ category: item.category, fileIds: [] })) }) + expect(wrapper.emitted('saved')).toHaveLength(1) + wrapper.unmount() + }) + it('discards unsaved uploads on cancel and reloads persisted categories on reopening', async () => { + const wrapper = mountDialog(); await flushPromises() + await choose(wrapper.find('[data-category="RECEIPT"] input[type="file"]'), new File(['proof'], 'new.pdf')) + expect(wrapper.text()).toContain('2/3 已上传') + await wrapper.find('.close').trigger('click') + await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises() + expect(saveDeliveryDocuments).not.toHaveBeenCalled() + expect(wrapper.emitted('saved')).toBeUndefined() + expect(wrapper.text()).toContain('1/3 已上传') + expect(wrapper.text()).not.toContain('new.pdf') + wrapper.unmount() + }) + it('preserves the draft and idempotency key on a failed save, and prevents duplicate submissions', async () => { + const wrapper = mountDialog(); await flushPromises() + let reject!: (error: Error) => void + vi.mocked(saveDeliveryDocuments).mockReturnValueOnce(new Promise((_resolve, rejectRequest) => { reject = rejectRequest })) + await wrapper.find('.save').trigger('click'); await wrapper.find('.save').trigger('click') + expect(saveDeliveryDocuments).toHaveBeenCalledTimes(1) + reject(new Error('保存失败')); await flushPromises() + expect(wrapper.text()).toContain('original.pdf') + expect(wrapper.emitted('saved')).toBeUndefined() + const key = vi.mocked(saveDeliveryDocuments).mock.calls[0]![2].idempotencyKey + await wrapper.find('.save').trigger('click'); await flushPromises() + expect(vi.mocked(saveDeliveryDocuments).mock.calls[1]![2].idempotencyKey).toBe(key) + wrapper.unmount() + }) + it('retains the existing file after replacement upload fails and rejects invalid file sizes', async () => { + const wrapper = mountDialog(); await flushPromises() + vi.mocked(uploadDeliveryDocument).mockRejectedValueOnce(new Error('上传失败')) + await choose(wrapper.find('[data-category="BOL"] .document-file input[type="file"]'), new File(['data'], 'bad.pdf')) + expect(wrapper.text()).toContain('original.pdf') + await choose(wrapper.find('[data-category="RECEIPT"] input[type="file"]'), new File([], 'empty.bin')) + const oversized = new File(['data'], 'large.bin'); Object.defineProperty(oversized, 'size', { value: 52428801 }) + await choose(wrapper.find('[data-category="RECEIPT"] input[type="file"]'), oversized) + expect(uploadDeliveryDocument).toHaveBeenCalledTimes(1) + const exact = new File(['data'], 'valid.bin'); Object.defineProperty(exact, 'size', { value: 52428800 }) + await choose(wrapper.find('[data-category="RECEIPT"] input[type="file"]'), exact) + expect(uploadDeliveryDocument).toHaveBeenCalledTimes(2) + wrapper.unmount() + }) + it.each(['permission', 'action'])('is read-only without the required %s', async kind => { + if (kind === 'permission') can.mockReturnValue(false) + else vi.mocked(getDeliveryDocuments).mockResolvedValue({ ...response(), allowedActions: [] }) + const wrapper = mountDialog(); await flushPromises() + expect(wrapper.find('.save').exists()).toBe(false) + expect(wrapper.find('input[type="file"]').exists()).toBe(false) + expect(wrapper.text()).toContain('original.pdf') + expect(saveDeliveryDocuments).not.toHaveBeenCalled() + wrapper.unmount() + }) + it('blocks retries after a version conflict without losing draft files', async () => { + vi.mocked(saveDeliveryDocuments).mockRejectedValue({ isAxiosError: true, response: { status: 409, data: { code: 'A0409', message: '版本已变化', requestId: 'req-1', traceId: 'trace-1' } } }) + const wrapper = mountDialog(); await flushPromises() + await wrapper.find('.save').trigger('click'); await flushPromises() + expect(wrapper.find('.save').attributes('disabled')).toBeDefined() + expect(wrapper.text()).toContain('original.pdf') + expect(wrapper.emitted('saved')).toBeUndefined() + wrapper.unmount() + }) + + it('limits each category to twenty files while allowing replacement', async () => { + const snapshot = response() + snapshot.categories.find(item => item.category === 'BOL')!.files = Array.from({ length: 20 }, (_, index) => ({ ...originalFile, fileId: String(index + 1) })) + snapshot.categories.find(item => item.category === 'BOL')!.fileCount = 20 + vi.mocked(getDeliveryDocuments).mockResolvedValue(snapshot) + const wrapper = mountDialog(); await flushPromises() + expect(wrapper.find('[data-category="BOL"] header input[type="file"]').attributes('disabled')).toBeDefined() + await choose(wrapper.find('[data-category="BOL"] .document-file input[type="file"]'), new File(['data'], 'replace.pdf')) + await wrapper.find('.save').trigger('click'); await flushPromises() + expect(vi.mocked(saveDeliveryDocuments).mock.calls[0]![2].documents.find(item => item.category === 'BOL')!.fileIds).toHaveLength(20) + expect(vi.mocked(saveDeliveryDocuments).mock.calls[0]![2].documents.find(item => item.category === 'BOL')!.fileIds).toContain('9007199254740996') + wrapper.unmount() + }) + + it('ignores a late save response after switching to another customer delivery', async () => { + let finish!: (value: DeliveryDocuments) => void + vi.mocked(saveDeliveryDocuments).mockReturnValueOnce(new Promise(resolve => { finish = resolve })) + const wrapper = mountDialog(); await flushPromises() + await wrapper.find('.save').trigger('click') + vi.mocked(getDeliveryDocuments).mockResolvedValue({ ...response(), deliveryNo: 'DL/2', customerId: '200', bolNo: 'BOL-2' }) + await wrapper.setProps({ deliveryNo: 'DL/2', customerId: '200' }); await flushPromises() + finish(response()); await flushPromises() + expect(wrapper.emitted('saved')).toBeUndefined() + expect(wrapper.emitted('update:modelValue')).toBeUndefined() + expect(wrapper.find('.save').exists()).toBe(true) + wrapper.unmount() + }) + + it('ignores a late upload failure after closing and reopening on another delivery', async () => { + let fail!: (reason: Error) => void + vi.mocked(uploadDeliveryDocument).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject })) + const wrapper = mountDialog(); await flushPromises() + await choose(wrapper.find('[data-category="RECEIPT"] input[type="file"]'), new File(['data'], 'pending.pdf')) + await wrapper.setProps({ modelValue: false }); await flushPromises() + await wrapper.setProps({ modelValue: true, deliveryNo: 'DL/2', customerId: '200' }); await flushPromises() + fail(new Error('old upload failed')); await flushPromises() + expect(wrapper.text()).not.toContain('old upload failed') + expect(wrapper.text()).toContain('1/3 已上传') + expect(wrapper.find('[data-category="RECEIPT"] input[type="file"]').attributes('disabled')).toBeUndefined() + wrapper.unmount() + }) +}) diff --git a/src/modules/overseas-warehouse/components/DeliveryDocumentsDialog.vue b/src/modules/overseas-warehouse/components/DeliveryDocumentsDialog.vue new file mode 100644 index 00000000..7088276e --- /dev/null +++ b/src/modules/overseas-warehouse/components/DeliveryDocumentsDialog.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/src/modules/overseas-warehouse/pages/DeliveryOrderDetailView.test.ts b/src/modules/overseas-warehouse/pages/DeliveryOrderDetailView.test.ts index 7caa4701..615019d0 100644 --- a/src/modules/overseas-warehouse/pages/DeliveryOrderDetailView.test.ts +++ b/src/modules/overseas-warehouse/pages/DeliveryOrderDetailView.test.ts @@ -13,6 +13,7 @@ describe('DeliveryOrderDetailView', () => { routeState.query = { customerId: '100' } vi.mocked(getDeliveryOrder).mockReset().mockResolvedValue({ order: { + version: 3, allowedActions: ['COMPLETE_DELIVERY'], id: '1', customerId: '100', deliveryNo: 'DEL/1', outboundNo: 'OUT-1', dispatchNo: 'PC-1', status: 'DELIVERING', overseasWarehouseName: '洛杉矶仓', palletCount: 1, packageCount: 2, createdAt: '2026-09-01T10:00:00', diff --git a/src/modules/overseas-warehouse/pages/DeliveryOrdersView.test.ts b/src/modules/overseas-warehouse/pages/DeliveryOrdersView.test.ts index d49f27d4..9a45cbac 100644 --- a/src/modules/overseas-warehouse/pages/DeliveryOrdersView.test.ts +++ b/src/modules/overseas-warehouse/pages/DeliveryOrdersView.test.ts @@ -1,19 +1,23 @@ import ElementPlus from 'element-plus' +import { defineComponent, h, type PropType } from 'vue' import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { listDeliveryOrders, listDeliveryReturnInboundOptions, markDeliveryReturn } from '../api/deliveryOrders' +import { getDeliveryOrder, listDeliveryOrders, listDeliveryReturnInboundOptions, markDeliveryReturn } from '../api/deliveryOrders' import { updateDeliveryOrderStatus } from '../api/customerVisits' import DeliveryOrdersView from './DeliveryOrdersView.vue' import { commonPhase5Stubs } from './phase5TestStubs' +import { getDeliveryDocuments, deliveryDocumentCategories } from '../api/deliveryDocuments' const canPermission = vi.hoisted(() => vi.fn((_code: string) => false)) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) })) vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) })) -vi.mock('../api/deliveryOrders', () => ({ listDeliveryOrders: vi.fn(), listDeliveryReturnInboundOptions: vi.fn(), markDeliveryReturn: vi.fn() })) +vi.mock('../api/deliveryOrders', () => ({ getDeliveryOrder: vi.fn(), listDeliveryOrders: vi.fn(), listDeliveryReturnInboundOptions: vi.fn(), markDeliveryReturn: vi.fn() })) vi.mock('../api/customerVisits', () => ({ updateDeliveryOrderStatus: vi.fn() })) vi.mock('../api/pallets', () => ({ listReceivingOverseasWarehouses: vi.fn() })) +vi.mock('../api/deliveryDocuments', async importOriginal => ({ ...await importOriginal(), getDeliveryDocuments: vi.fn() })) const row = { + version: 2, allowedActions: ['START_DELIVERY', 'COMPLETE_DELIVERY', 'REJECT_DELIVERY', 'CANCEL_DELIVERY'], id: '1', customerId: '100', deliveryNo: 'DEL/1', outboundNo: 'OUT-1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', palletCount: 1, packageCount: 2, createdAt: '2026-09-01T10:00:00', } @@ -25,6 +29,8 @@ function mountView() { describe('DeliveryOrdersView permissions and commands', () => { beforeEach(() => { canPermission.mockReset().mockReturnValue(false) + vi.mocked(getDeliveryDocuments).mockReset() + vi.mocked(getDeliveryOrder).mockReset().mockResolvedValue({ order: { ...row, version: 7 }, skus: [], fees: [], operations: [] }) vi.mocked(listDeliveryOrders).mockReset().mockResolvedValue({ records: [row], page: 1, size: 10, total: 1, pages: 1 }) vi.mocked(listDeliveryReturnInboundOptions).mockReset().mockResolvedValue([{ inboundOrderId: '9', inboundCode: 'IN-1', lastMileOrderNo: 'LM-1', customerId: '100' }]) vi.mocked(markDeliveryReturn).mockReset().mockResolvedValue({ id: '19', exceptionNo: 'EXC-1', status: 'PENDING' }) @@ -47,9 +53,11 @@ describe('DeliveryOrdersView permissions and commands', () => { await flushPromises() await wrapper.findAll('button').find(button => button.text().includes('编辑状态'))!.trigger('click') + await flushPromises() + await wrapper.find('[data-dialog="编辑派送状态"] select').setValue('DELIVERED') await wrapper.find('[data-dialog="编辑派送状态"] .dialog-confirm').trigger('click') await flushPromises() - expect(updateDeliveryOrderStatus).toHaveBeenCalledWith('DEL/1', '100', { status: 'WAIT_DELIVERY', remark: undefined }) + expect(updateDeliveryOrderStatus).toHaveBeenCalledWith('DEL/1', '100', { status: 'DELIVERED', expectedVersion: 7, remark: undefined }) await wrapper.findAll('button').find(button => button.text().includes('标记退货'))!.trigger('click') await flushPromises() @@ -75,4 +83,64 @@ describe('DeliveryOrdersView permissions and commands', () => { expect(markDeliveryReturn).not.toHaveBeenCalled() wrapper.unmount() }) + + it('maps delivering separately from completion and blocks duplicate or conflicted submission', async () => { + canPermission.mockReturnValue(true) + const wrapper = mountView() + await flushPromises() + await wrapper.findAll('button').find(button => button.text().includes('编辑状态'))!.trigger('click') + await flushPromises() + const select = wrapper.find('[data-dialog="编辑派送状态"] select') + expect(select.find('option[value="DELIVERING"]').text()).toBe('派送中') + expect(select.find('option[value="DELIVERED"]').text()).toBe('派送完成') + expect(select.find('option[value="WAIT_DELIVERY"]').exists()).toBe(false) + await select.setValue('DELIVERING') + await wrapper.find('[data-dialog="编辑派送状态"] textarea').setValue('123') + let reject!: (error: unknown) => void + vi.mocked(updateDeliveryOrderStatus).mockReturnValueOnce(new Promise((_resolve, rejectRequest) => { reject = rejectRequest })) + const button = wrapper.find('[data-dialog="编辑派送状态"] .dialog-confirm') + await button.trigger('click'); await button.trigger('click') + expect(updateDeliveryOrderStatus).toHaveBeenCalledTimes(1) + expect(updateDeliveryOrderStatus).toHaveBeenCalledWith('DEL/1', '100', { status: 'DELIVERING', expectedVersion: 7, remark: '123' }) + reject({ isAxiosError: true, response: { status: 409, data: { code: 'A0409', message: '版本冲突', requestId: 'req-conflict', traceId: 'trace-conflict' } } }) + await flushPromises() + const state = wrapper.vm as unknown as { statusConflict: boolean; statusOpen: boolean; statusForm: { remark: string }; refreshStatus: () => Promise } + expect(state.statusConflict).toBe(true) + expect(state.statusOpen).toBe(true) + expect(state.statusForm.remark).toBe('123') + await button.trigger('click') + expect(updateDeliveryOrderStatus).toHaveBeenCalledTimes(1) + vi.mocked(getDeliveryOrder).mockResolvedValue({ order: { ...row, version: 8, allowedActions: ['COMPLETE_DELIVERY'] }, skus: [], fees: [], operations: [] }) + await state.refreshStatus(); await flushPromises() + expect(select.findAll('option').map(option => option.attributes('value'))).toEqual(['DELIVERED']) + wrapper.unmount() + }) + + it('hides status editing when the server supplies no actions', async () => { + canPermission.mockReturnValue(true) + vi.mocked(listDeliveryOrders).mockResolvedValue({ records: [{ ...row, status: 'DELIVERED', allowedActions: [] }], page: 1, size: 10, total: 1, pages: 1 }) + const wrapper = mountView(); await flushPromises() + expect(wrapper.find('[data-testid="data-table"]').text()).not.toContain('编辑状态') + expect(getDeliveryOrder).not.toHaveBeenCalled() + wrapper.unmount() + }) + + it('renders category counts from the list without per-row document requests', async () => { + canPermission.mockReturnValue(true) + const documentSummary = { uploadedCategoryCount: 1, totalCategoryCount: 3, categories: deliveryDocumentCategories.map(item => ({ category: item.category, status: item.category === 'RECEIPT' ? 'UPLOADED' as const : 'MISSING' as const, fileCount: item.category === 'RECEIPT' ? 3 : 0 })) } + vi.mocked(listDeliveryOrders).mockResolvedValue({ records: [{ ...row, documentSummary }], page: 1, size: 10, total: 1, pages: 1 }) + const table = defineComponent({ props: { data: Array as PropType }, setup(props, { slots }) { return () => h('div', props.data?.flatMap(item => slots.documents?.({ row: item }) || [])) } }) + const wrapper = mount(DeliveryOrdersView, { global: { plugins: [ElementPlus], stubs: { ...commonPhase5Stubs, DataTable: table, DeliveryDocumentsDialog: true } } }) + await flushPromises() + expect(wrapper.text()).toContain('1/3 已上传') + expect(wrapper.text()).toContain('签收文件(POD签收/快递签收) · 已上传') + expect(getDeliveryDocuments).not.toHaveBeenCalled() + await wrapper.findAll('button').find(button => button.text().includes('管理资料'))!.trigger('click') + const dialog = wrapper.findComponent({ name: 'DeliveryDocumentsDialog' }) + expect(dialog.props()).toMatchObject({ deliveryNo: 'DEL/1', customerId: '100', modelValue: true }) + const calls = vi.mocked(listDeliveryOrders).mock.calls.length + dialog.vm.$emit('saved'); await flushPromises() + expect(listDeliveryOrders).toHaveBeenCalledTimes(calls + 1) + wrapper.unmount() + }) }) diff --git a/src/modules/overseas-warehouse/pages/DeliveryOrdersView.vue b/src/modules/overseas-warehouse/pages/DeliveryOrdersView.vue index c5766ad4..a1278f8c 100644 --- a/src/modules/overseas-warehouse/pages/DeliveryOrdersView.vue +++ b/src/modules/overseas-warehouse/pages/DeliveryOrdersView.vue @@ -1,6 +1,6 @@ - + - + diff --git a/src/modules/overseas-warehouse/pages/OutboundDetailView.test.ts b/src/modules/overseas-warehouse/pages/OutboundDetailView.test.ts index 55a3123b..60342e81 100644 --- a/src/modules/overseas-warehouse/pages/OutboundDetailView.test.ts +++ b/src/modules/overseas-warehouse/pages/OutboundDetailView.test.ts @@ -15,7 +15,7 @@ describe('OutboundDetailView', () => { vi.mocked(getOutbound).mockReset().mockResolvedValue({ outbound: { id: '1', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'PICKED', palletCount: 2, packageCount: 20, createdAt: '2026-09-01T10:00:00' }, pallets: [{ palletNo: 'IP-ACY2-20260907-597057-P001', inboundCode: 'NYEXX2609060063', destinationWarehouseCode: 'ONT8', destinationWarehouseName: 'Amazon ONT8', skuCode: 'SKU-001', packageCount: 20 }], - delivery: { id: '2', customerId: 'customer-1', deliveryNo: 'DEL-1', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', palletCount: 2, packageCount: 20, createdAt: '2026-09-01T10:00:00' }, + delivery: { version: 2, allowedActions: ['START_DELIVERY'], id: '2', customerId: 'customer-1', deliveryNo: 'DEL-1', outboundNo: 'OUT/1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', palletCount: 2, packageCount: 20, createdAt: '2026-09-01T10:00:00' }, }) }) diff --git a/src/modules/overseas-warehouse/permissions.ts b/src/modules/overseas-warehouse/permissions.ts index 7045a30a..617504aa 100644 --- a/src/modules/overseas-warehouse/permissions.ts +++ b/src/modules/overseas-warehouse/permissions.ts @@ -19,6 +19,7 @@ export const overseasWarehousePermissions = { exceptionOrderCreate: 'platform:overseas-warehouse:exception-order:create', deliveryReturn: 'platform:overseas-warehouse:delivery:return', deliveryStatus: 'platform:overseas-warehouse:delivery:status', + deliveryDocuments: 'platform:overseas-warehouse:delivery:documents', customerVisitList: 'platform:overseas-warehouse:customer-visit:list', customerVisitEdit: 'platform:overseas-warehouse:customer-visit:edit', inventoryList: 'platform:overseas-warehouse:inventory:list',