237 lines
15 KiB
TypeScript
237 lines
15 KiB
TypeScript
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 { 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', () => ({ 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<typeof import('../api/deliveryDocuments')>(), 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, createdAt: '2026-09-01T10:00:00',
|
|
}
|
|
|
|
function mountView() {
|
|
return mount(DeliveryOrdersView, { global: { plugins: [ElementPlus], stubs: commonPhase5Stubs } })
|
|
}
|
|
|
|
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', returnSources: [{ outboundPalletItemId: '9007199254740995', inventoryPositionId: '9007199254740996', 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', {
|
|
inboundOrderId: '9', 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)
|
|
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<void> }
|
|
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('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 inbound order is chosen', async () => {
|
|
canPermission.mockImplementation(code => code.endsWith(':return'))
|
|
vi.mocked(listDeliveryReturnInboundOptions).mockResolvedValue([
|
|
{ inboundOrderId: '9', inboundCode: 'IN-1', customerId: '100', returnSources: [{ outboundPalletItemId: '11', inventoryPositionId: '12', palletNo: 'PL-1', skuCode: 'SKU-1', maxReturnPackages: 3 }] },
|
|
{ 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('9')
|
|
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('10')
|
|
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<object[]>, columns: Array as PropType<Array<{ key: string; formatter?: (row: object) => 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<object[]> }, 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()
|
|
})
|
|
})
|