import ElementPlus, { ElMessage } 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 { 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 }) })) const routerPush = vi.hoisted(() => vi.fn()) vi.mock('vue-router', () => ({ useRouter: () => ({ push: routerPush }) })) 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', 'MARK_RETURN'], id: '1', customerId: '100', deliveryNo: 'DEL/1', outboundNo: 'OUT-1', dispatchNo: 'PC-1', status: 'WAIT_DELIVERY', palletCount: 1, packageCount: 2, signedPackages: null, createdAt: '2026-09-01T10:00:00', } function mountView() { return mount(DeliveryOrdersView, { global: { plugins: [ElementPlus], stubs: commonPhase5Stubs } }) } describe('DeliveryOrdersView permissions and commands', () => { beforeEach(() => { routerPush.mockReset() 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([{ lastMileOrderId: '109', inboundOrderId: '9', inboundCode: 'IN-1', lastMileOrderNo: 'LM-1', customerId: '100', returnSources: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: '9007199254740996', returnCartonId: null, palletNo: 'PL-1', skuCode: 'SKU-1', maxReturnPackages: 3 }] }]) vi.mocked(markDeliveryReturn).mockReset().mockResolvedValue({ id: '19', exceptionNo: 'EXC-1', status: 'PENDING' }) vi.mocked(updateDeliveryOrderStatus).mockReset().mockResolvedValue({ deliveryNo: 'DEL/1', deliveryStatus: 'WAIT_DELIVERY', replayed: false }) }) it('keeps detail read-only while hiding independent write operations', async () => { const wrapper = mountView() await flushPromises() const tableText = wrapper.find('[data-testid="data-table"]').text() expect(tableText).toContain('详情') expect(tableText).not.toContain('编辑状态') expect(tableText).not.toContain('标记退货') wrapper.unmount() }) it('submits status and return commands with the row customer scope', async () => { canPermission.mockImplementation(code => code.endsWith(':status') || code.endsWith(':return')) const wrapper = mountView() await flushPromises() await wrapper.findAll('button').find(button => button.text().includes('编辑状态'))!.trigger('click') await flushPromises() await wrapper.find('[data-dialog^="编辑派送状态"] select').setValue('DELIVERING') await wrapper.find('[data-dialog^="编辑派送状态"] .dialog-confirm').trigger('click') await flushPromises() expect(updateDeliveryOrderStatus).toHaveBeenCalledWith('DEL/1', '100', { status: 'DELIVERING', expectedVersion: 7, remark: undefined }) await wrapper.findAll('button').find(button => button.text().includes('标记退货'))!.trigger('click') await flushPromises() expect(listDeliveryReturnInboundOptions).toHaveBeenCalledWith('DEL/1', '100') await wrapper.find('[data-dialog^="标记退货"] textarea').setValue(' 客户拒收 ') await wrapper.find('[data-dialog^="标记退货"] input[type="checkbox"]').setValue(true) await wrapper.find('[data-dialog^="标记退货"] input[type="number"]').setValue(2) await wrapper.find('[data-dialog^="标记退货"] .dialog-confirm').trigger('click') await flushPromises() expect(markDeliveryReturn).toHaveBeenCalledWith('DEL/1', '100', { lastMileOrderId: '109', reason: '客户拒收', returnDirection: 'OVERSEAS_WAREHOUSE', returnLogisticsNo: undefined, items: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: '9007199254740996', packageCount: 2 }], idempotencyKey: expect.any(String), }) wrapper.unmount() }) it('does not submit an incomplete return form', async () => { canPermission.mockImplementation(code => code.endsWith(':return')) vi.mocked(listDeliveryReturnInboundOptions).mockResolvedValue([]) const wrapper = mountView() await flushPromises() await wrapper.findAll('button').find(button => button.text().includes('标记退货'))!.trigger('click') await flushPromises() await wrapper.find('[data-dialog^="标记退货"] .dialog-confirm').trigger('click') expect(markDeliveryReturn).not.toHaveBeenCalled() wrapper.unmount() }) it('excludes manual completion and blocks duplicate or conflicted status 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"]').exists()).toBe(false) expect(select.find('option[value="WAIT_DELIVERY"]').exists()).toBe(false) expect(select.find('option[value="CANCELLED"]').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')).toHaveLength(0) wrapper.unmount() }) it('keeps return hidden without the server action even when the user has permission', async () => { canPermission.mockImplementation(code => code.endsWith(':return')) vi.mocked(listDeliveryOrders).mockResolvedValue({ records: [{ ...row, status: 'RETURNED', 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(listDeliveryReturnInboundOptions).not.toHaveBeenCalled() wrapper.unmount() }) it('removes status editing when cancellation is the only action and routes through the original outbound', async () => { canPermission.mockReturnValue(true) vi.mocked(listDeliveryOrders).mockResolvedValue({ records: [{ ...row, allowedActions: ['CANCEL_DELIVERY'] }], page: 1, size: 10, total: 1, pages: 1 }) const wrapper = mountView(); await flushPromises() expect(wrapper.findAll('button').some(button => button.text() === '编辑状态')).toBe(false) await wrapper.findAll('button').find(button => button.text() === '前往出库')!.trigger('click') expect(routerPush).toHaveBeenCalledExactlyOnceWith({ name: 'overseas-outbounds', query: { documentNo: 'OUT-1' } }) expect(updateDeliveryOrderStatus).not.toHaveBeenCalled() wrapper.unmount() }) it('offers no early delivery or rejection action when the server returns no executable action', async () => { canPermission.mockReturnValue(true) vi.mocked(listDeliveryOrders).mockResolvedValue({ records: [{ ...row, allowedActions: [] }], page: 1, size: 10, total: 1, pages: 1 }) const wrapper = mountView(); await flushPromises() expect(wrapper.findAll('button').some(button => ['编辑状态', '标记退货'].includes(button.text()))).toBe(false) expect(updateDeliveryOrderStatus).not.toHaveBeenCalled() wrapper.unmount() }) it('marks an original return carton as one whole box without submitting an inventory position', async () => { canPermission.mockReturnValue(true) vi.mocked(listDeliveryReturnInboundOptions).mockResolvedValue([{ lastMileOrderId: '9007199254741299', lastMileOrderNo: 'LM-RETURN-1', inboundOrderId: null, inboundCode: null, customerId: '100', returnSources: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: null, returnCartonId: '9007199254740997', cartonMark: 'RETURN-CARTON-1', maxReturnPackages: 1 }] }]) const wrapper = mountView(); await flushPromises() await wrapper.findAll('button').find(button => button.text() === '标记退货')!.trigger('click'); await flushPromises() const dialog = wrapper.get('[data-dialog^="标记退货"]') expect(dialog.text()).toContain('原退货箱 RETURN-CARTON-1(整箱退回)') expect(dialog.text()).toContain('LM-RETURN-1 · 无普通入仓关联') expect(dialog.text()).not.toContain('9007199254741299') expect(dialog.text()).not.toContain('9007199254740997') await dialog.get('textarea').setValue('原箱拒收') await dialog.get('input[type="checkbox"]').setValue(true) expect(dialog.get('input[type="number"]').attributes('disabled')).toBeDefined() await dialog.get('.dialog-confirm').trigger('click'); await flushPromises() expect(markDeliveryReturn).toHaveBeenCalledExactlyOnceWith('DEL/1', '100', expect.objectContaining({ lastMileOrderId: '9007199254741299', items: [{ outboundPalletItemId: '9007199254740995', returnCartonId: '9007199254740997', packageCount: 1 }] })) expect(vi.mocked(markDeliveryReturn).mock.calls[0]?.[2]).not.toHaveProperty('inboundOrderId') wrapper.unmount() }) it('returns an ordinary relabel master with inventory sources without requiring an inbound record', async () => { canPermission.mockReturnValue(true) vi.mocked(listDeliveryReturnInboundOptions).mockResolvedValue([{ lastMileOrderId: '9007199254741300', lastMileOrderNo: 'LM-RELABEL-1', inboundOrderId: null, inboundCode: null, customerId: '100', returnSources: [{ outboundPalletItemId: '11', inventoryPositionId: '12', returnCartonId: null, palletNo: 'RELABEL-PALLET-1', skuCode: 'RELABEL-SKU', maxReturnPackages: 3 }] }]) const wrapper = mountView(); await flushPromises() try { await wrapper.findAll('button').find(button => button.text() === '标记退货')!.trigger('click'); await flushPromises() const dialog = wrapper.get('[data-dialog^="标记退货"]') expect(dialog.text()).toContain('LM-RELABEL-1 · 无普通入仓关联') expect(dialog.text()).not.toContain('9007199254741300') await dialog.get('textarea').setValue('换标主单拒收') await dialog.get('input[type="checkbox"]').setValue(true) await dialog.get('input[type="number"]').setValue(2) await dialog.get('.dialog-confirm').trigger('click'); await flushPromises() expect(markDeliveryReturn).toHaveBeenCalledExactlyOnceWith('DEL/1', '100', expect.objectContaining({ lastMileOrderId: '9007199254741300', items: [{ outboundPalletItemId: '11', inventoryPositionId: '12', packageCount: 2 }] })) expect(vi.mocked(markDeliveryReturn).mock.calls[0]?.[2]).not.toHaveProperty('inboundOrderId') } finally { wrapper.unmount() } }) it('keeps native cartons isolated when switching two masters that both have no inbound record', async () => { canPermission.mockReturnValue(true) const cartonA = { outboundPalletItemId: '11', inventoryPositionId: null, returnCartonId: '21', cartonMark: 'ORIGINAL-CARTON-A', maxReturnPackages: 1 } const cartonB = { ...cartonA, returnCartonId: '22', cartonMark: 'ORIGINAL-CARTON-B' } vi.mocked(listDeliveryReturnInboundOptions).mockResolvedValue([ { lastMileOrderId: '109', lastMileOrderNo: 'LM-RETURN-A', inboundOrderId: null, inboundCode: null, customerId: '100', returnSources: [cartonA] }, { lastMileOrderId: '110', lastMileOrderNo: 'LM-RETURN-B', inboundOrderId: null, inboundCode: null, customerId: '100', returnSources: [cartonB] }, ]) const wrapper = mountView(); await flushPromises() try { await wrapper.findAll('button').find(button => button.text() === '标记退货')!.trigger('click'); await flushPromises() const dialog = wrapper.get('[data-dialog^="标记退货"]') const masterSelect = dialog.get('select') expect(masterSelect.findAll('option').map(option => option.attributes('value'))).toEqual(['109', '110']) await masterSelect.setValue('109') await dialog.get('textarea').setValue('原箱拒收') await dialog.get('input[type="checkbox"]').setValue(true) await masterSelect.setValue('110') expect(dialog.text()).not.toContain('ORIGINAL-CARTON-A') expect(dialog.text()).toContain('ORIGINAL-CARTON-B') expect((dialog.get('input[type="checkbox"]').element as HTMLInputElement).checked).toBe(false) await dialog.get('.dialog-confirm').trigger('click'); await flushPromises() expect(markDeliveryReturn).not.toHaveBeenCalled() await dialog.get('input[type="checkbox"]').setValue(true) await dialog.get('.dialog-confirm').trigger('click'); await flushPromises() expect(markDeliveryReturn).toHaveBeenCalledExactlyOnceWith('DEL/1', '100', expect.objectContaining({ lastMileOrderId: '110', items: [{ outboundPalletItemId: '11', returnCartonId: '22', packageCount: 1 }] })) } finally { wrapper.unmount() } }) it('rejects a stale carton selection from another master before posting', async () => { canPermission.mockReturnValue(true) const ownCarton = { outboundPalletItemId: '11', inventoryPositionId: null, returnCartonId: '21', cartonMark: 'CURRENT-CARTON', maxReturnPackages: 1 } vi.mocked(listDeliveryReturnInboundOptions).mockResolvedValue([{ lastMileOrderId: '109', lastMileOrderNo: 'LM-RETURN-A', inboundOrderId: null, inboundCode: null, customerId: '100', returnSources: [ownCarton] }]) const warning = vi.spyOn(ElMessage, 'warning') const wrapper = mountView(); await flushPromises() try { await wrapper.findAll('button').find(button => button.text() === '标记退货')!.trigger('click'); await flushPromises() const dialog = wrapper.get('[data-dialog^="标记退货"]') await dialog.get('textarea').setValue('拒收') const state = wrapper.vm as unknown as { returnItems: Array } state.returnItems = [{ ...ownCarton, returnCartonId: '22', cartonMark: 'FOREIGN-CARTON', selected: true, packageCount: 1 }] await dialog.get('.dialog-confirm').trigger('click'); await flushPromises() expect(markDeliveryReturn).not.toHaveBeenCalled() expect(warning).toHaveBeenCalledWith(expect.stringContaining('不属于当前尾程主单')) } finally { warning.mockRestore(); wrapper.unmount() } }) it('validates selected return sources and reuses the idempotency key after a failed request', async () => { canPermission.mockImplementation(code => code.endsWith(':return')) const wrapper = mountView(); await flushPromises() try { await wrapper.findAll('button').find(button => button.text().includes('标记退货'))!.trigger('click'); await flushPromises() const dialog = wrapper.find('[data-dialog^="标记退货"]') expect(dialog.text()).toContain('PL-1 · SKU-1(最多 3 箱)') expect(dialog.text()).not.toContain('9007199254740995') expect(dialog.text()).not.toContain('可自由填写') await dialog.find('textarea').setValue('拒收') await dialog.find('.dialog-confirm').trigger('click') expect(markDeliveryReturn).not.toHaveBeenCalled() await dialog.find('input[type="checkbox"]').setValue(true) for (const invalid of [0, 4, 1.5]) { await dialog.find('input[type="number"]').setValue(invalid) await dialog.find('.dialog-confirm').trigger('click') expect(markDeliveryReturn).not.toHaveBeenCalled() } await dialog.find('input[type="number"]').setValue(2) let reject!: (error: Error) => void vi.mocked(markDeliveryReturn).mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail })) await dialog.find('.dialog-confirm').trigger('click'); await dialog.find('.dialog-confirm').trigger('click') expect(markDeliveryReturn).toHaveBeenCalledTimes(1) const first = vi.mocked(markDeliveryReturn).mock.calls[0]![2] reject(new Error('网络异常')); await flushPromises() expect(dialog.text()).toContain('标记退货失败') expect((dialog.find('textarea').element as HTMLTextAreaElement).value).toBe('拒收') await dialog.find('.dialog-confirm').trigger('click'); await flushPromises() expect(vi.mocked(markDeliveryReturn).mock.calls[1]![2]).toEqual(first) expect(first.items).toEqual([{ outboundPalletItemId: '9007199254740995', inventoryPositionId: '9007199254740996', packageCount: 2 }]) expect(first).not.toHaveProperty('skuCode') expect(first).not.toHaveProperty('packageCount') } finally { wrapper.unmount() } }) it('clears the selected source when a different real master is chosen', async () => { canPermission.mockImplementation(code => code.endsWith(':return')) vi.mocked(listDeliveryReturnInboundOptions).mockResolvedValue([ { lastMileOrderId: '109', lastMileOrderNo: 'LM-1', inboundOrderId: '9', inboundCode: 'IN-1', customerId: '100', returnSources: [{ outboundPalletItemId: '11', inventoryPositionId: '12', returnCartonId: null, palletNo: 'PL-1', skuCode: 'SKU-1', maxReturnPackages: 3 }] }, { lastMileOrderId: '110', lastMileOrderNo: 'LM-2', inboundOrderId: '10', inboundCode: 'IN-2', customerId: '100', returnSources: [] }, ]) const wrapper = mountView(); await flushPromises() try { await wrapper.findAll('button').find(button => button.text().includes('标记退货'))!.trigger('click'); await flushPromises() const dialog = wrapper.find('[data-dialog^="标记退货"]') await dialog.find('select').setValue('109') await dialog.find('input[type="checkbox"]').setValue(true) await dialog.find('input[type="number"]').setValue(2) await dialog.find('textarea').setValue('拒收') await dialog.find('select').setValue('110') expect(dialog.find('input[type="checkbox"]').exists()).toBe(false) await dialog.find('.dialog-confirm').trigger('click') expect(markDeliveryReturn).not.toHaveBeenCalled() } finally { 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('hides status editing when completion is the only server action', async () => { canPermission.mockImplementation(code => code.endsWith(':status')) vi.mocked(getDeliveryOrder).mockResolvedValue({ order: { ...row, version: 7, status: 'DELIVERING', allowedActions: ['COMPLETE_DELIVERY'] }, skus: [], fees: [], operations: [] }) vi.mocked(listDeliveryOrders).mockResolvedValue({ records: [{ ...row, status: 'DELIVERING', allowedActions: ['COMPLETE_DELIVERY'] }], 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() expect(updateDeliveryOrderStatus).not.toHaveBeenCalled() wrapper.unmount() }) it('shows the destination FBA warehouse that identifies each split delivery', async () => { vi.mocked(listDeliveryOrders).mockResolvedValue({ records: [{ ...row, destinationWarehouseCode: 'ACY2', destinationWarehouseName: 'Amazon ACY2' }], page: 1, size: 10, total: 1, pages: 1 }) const table = defineComponent({ props: { data: Array as PropType, columns: Array as PropType string }>> }, setup(props) { return () => h('div', props.columns?.find(column => column.key === 'destinationWarehouse')?.formatter?.(props.data?.[0] || {}) || '') }, }) const wrapper = mount(DeliveryOrdersView, { global: { plugins: [ElementPlus], stubs: { ...commonPhase5Stubs, DataTable: table } } }) await flushPromises() expect(wrapper.text()).toContain('ACY2 · Amazon ACY2') 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() }) })