Files
admin-web/src/modules/overseas-warehouse/components/ExceptionProcessingPanel.test.ts
T

286 lines
22 KiB
TypeScript
Raw Normal View History

import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import { ElForm, ElFormItem, ElInput, ElInputNumber } from 'element-plus'
import { beforeEach, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import ExceptionProcessingPanel from './ExceptionProcessingPanel.vue'
import { getExceptionDetail, commandException, uploadExceptionEvidence, type ExceptionDetail, normalizeExceptionDetail } from '../api/exceptionOrders'
import { getPallet } from '../api/pallets'
import { getOverseasLocation } from '../api/locations'
const can = vi.hoisted(() => vi.fn(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('../api/exceptionOrders', async original => ({ ...await original<typeof import('../api/exceptionOrders')>(), getExceptionDetail: vi.fn(), commandException: vi.fn(), uploadExceptionEvidence: vi.fn() }))
vi.mock('../api/pallets', () => ({ getPallet: vi.fn(), listPallets: vi.fn() }))
vi.mock('../api/locations', () => ({ getOverseasLocation: vi.fn() }))
const shell = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
const field = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], setup(props, { emit }) {
return () => h('input', { value: props.modelValue, onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value) })
} })
const quantity = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], setup(props, { emit }) {
return () => h('input', { value: props.modelValue, onInput: (event: Event) => emit('update:modelValue', Number((event.target as HTMLInputElement).value)) })
} })
function snapshot(number = 'EX-1', version = 1, remaining = 4): ExceptionDetail {
return normalizeExceptionDetail({ order: { id: '1', exceptionNo: number, customerId: 'C', exceptionType: 'RETURN', returnDirection: 'OVERSEAS_WAREHOUSE',
overseasWarehouseId: '92', status: remaining ? 'PROCESSING' : 'COMPLETED', version: String(version), allowedActions: remaining ? ['COMPLETE_EXCEPTION'] : [],
reason: '拒收', exceptionAt: '', updatedAt: '', evidenceFileIds: ['91'] },
returnSources: remaining ? [{ outboundPalletItemId: '10', inventoryPositionId: '20', palletNo: 'ORIGINAL', skuCode: 'SKU', maxReturnPackages: String(remaining) }] : [], returnReceipts: [] } as unknown as ExceptionDetail)
}
function open(realControls = false) { return mount(ExceptionProcessingPanel, { props: { customerId: 'C', exceptionNo: 'EX-1', active: true }, global: {
components: { ElForm, ElFormItem, ElInput, ElInputNumber },
directives: { loading: () => {} }, stubs: { ElDescriptions: shell, ElDescriptionsItem: shell, ElForm: realControls ? false : shell, ElFormItem: realControls ? false : shell,
ElInput: realControls ? false : field, ElInputNumber: realControls ? false : quantity, ElSelect: field, ElOption: true, RouterLink: { template: '<a><slot /></a>' }, ElButton: { template: '<button><slot /></button>' } },
} }) }
async function fill(wrapper: ReturnType<typeof open>, amount = 2) {
if (wrapper.find('[data-test="history-0"]').exists()) await wrapper.get('[data-test="history-0"]').setValue(true)
await wrapper.get('[data-test="notes"]').setValue('本批实际收到')
await wrapper.get('[data-test="quantity-0"]').setValue(amount)
await wrapper.get('[data-test="target-0"]').setValue('30'); await flushPromises()
}
it('completed detail retains multiple receipt batches and redispatches as read-only history without commands', async () => {
const completed = snapshot('EX-1', 7, 0)
completed.returnReceipts = [
{ receiptNo: 'RSR-1', receivedPackages: 2, receivedAt: '2026-10-03T23:24:13', redispatches: [
{ dispatchNo: 'DS-1', status: 'DEPARTED', selectedPackages: 1, pickedPackages: 1, deliveries: [{ deliveryNo: 'DL-1', status: 'DELIVERED' }] },
{ dispatchNo: 'DS-2', status: 'PENDING_DEPARTURE', selectedPackages: 1, pickedPackages: 0, deliveries: [] },
] },
{ receiptNo: 'RSR-2', receivedPackages: 1, receivedAt: '2026-10-03T23:25:13', redispatches: [] },
]
vi.mocked(getExceptionDetail).mockResolvedValue(completed)
const wrapper = open(); await flushPromises()
for (const text of ['RSR-1', 'RSR-2', 'DS-1', 'DS-2', 'DL-1', '尚未关联重派排车', '已发车', '待发车']) expect(wrapper.text()).toContain(text)
expect(wrapper.findAll('.exception-return-batch')).toHaveLength(2)
expect(wrapper.find('[data-test="start"]').exists()).toBe(false)
expect(wrapper.find('[data-test="complete"]').exists()).toBe(false)
expect(wrapper.find('[data-test="cancel"]').exists()).toBe(false)
expect(commandException).not.toHaveBeenCalled(); expect(uploadExceptionEvidence).not.toHaveBeenCalled()
wrapper.unmount()
})
it('related business numbers remain visible without independent list permissions, but no navigation links are offered', async () => {
can.mockReturnValue(false)
const completed = snapshot('EX-1', 7, 0)
completed.returnReceipts = [{ receiptNo: 'RSR-1', receivedPackages: 2, receivedAt: '', redispatches: [
{ dispatchNo: 'DS-1', status: 'DEPARTED', selectedPackages: 2, pickedPackages: 2, deliveries: [{ deliveryNo: 'DL-1', status: 'DELIVERED' }] },
] }]
vi.mocked(getExceptionDetail).mockResolvedValue(completed)
const wrapper = open(); await flushPromises()
expect(wrapper.text()).toContain('DS-1'); expect(wrapper.text()).toContain('DL-1')
expect(wrapper.find('.exception-return-history a').exists()).toBe(false)
expect(commandException).not.toHaveBeenCalled()
wrapper.unmount()
})
beforeEach(() => {
vi.resetAllMocks(); can.mockReturnValue(true)
vi.mocked(getExceptionDetail).mockResolvedValue(snapshot())
vi.mocked(getPallet).mockResolvedValue({ id: '30', warehouseId: '92', locationId: '40', palletCode: 'TARGET' } as Awaited<ReturnType<typeof getPallet>>)
vi.mocked(getOverseasLocation).mockResolvedValue({ id: '40', warehouseId: '92', locationCode: 'LOC' } as Awaited<ReturnType<typeof getOverseasLocation>>)
})
it('real receipt quantity control becomes accessible after starting processing', async () => {
const pending = snapshot('EX-1', 0, 2)
pending.order.status = 'PENDING'; pending.order.allowedActions = ['START_PROCESSING']; pending.returnSources = []
vi.mocked(getExceptionDetail).mockResolvedValueOnce(pending).mockResolvedValueOnce(snapshot('EX-1', 1, 2))
vi.mocked(commandException).mockResolvedValue({ exceptionNo: 'EX-1', version: 1, status: 'PROCESSING', restoredPackages: 0, replayed: false })
const wrapper = open(true); try { await flushPromises()
await wrapper.get('[data-test="start"]').trigger('click'); await flushPromises()
const input = wrapper.getComponent(ElInputNumber).get('input')
expect((input.element as HTMLInputElement).disabled).toBe(false)
expect(input.attributes('aria-disabled')).toBe('false')
} finally { wrapper.unmount() }
})
it('real quantity control reflects in-flight lock and unlocks after a partial receipt reload', async () => {
vi.mocked(getExceptionDetail).mockResolvedValueOnce(snapshot('EX-1', 1, 2)).mockResolvedValueOnce(snapshot('EX-1', 2, 1))
let finish!: (value: Awaited<ReturnType<typeof commandException>>) => void
vi.mocked(commandException).mockReturnValue(new Promise(resolve => { finish = resolve }))
const wrapper = open(true); try { await flushPromises()
await wrapper.get('[data-test="history-0"]').setValue(true)
wrapper.getComponent(ElInputNumber).vm.$emit('update:modelValue', 1)
await wrapper.get('[data-test="target-0"]').setValue('30'); await flushPromises()
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect((wrapper.getComponent(ElInputNumber).get('input').element as HTMLInputElement).disabled).toBe(true)
expect(wrapper.getComponent(ElInputNumber).get('input').attributes('aria-disabled')).toBe('true')
finish({ exceptionNo: 'EX-1', version: 2, status: 'PROCESSING', restoredPackages: 1, replayed: false }); await flushPromises()
const input = wrapper.getComponent(ElInputNumber).get('input')
expect((input.element as HTMLInputElement).disabled).toBe(false)
expect(input.attributes('aria-disabled')).toBe('false')
expect(wrapper.text()).toContain('剩余可收 1 箱')
expect(commandException).toHaveBeenCalledTimes(1)
} finally { wrapper.unmount() }
})
it('submits two actual batches using refreshed remaining sources and independent command keys', async () => {
vi.mocked(getExceptionDetail).mockResolvedValueOnce(snapshot()).mockResolvedValueOnce(snapshot('EX-1', 2, 2)).mockResolvedValueOnce(snapshot('EX-1', 3, 0))
vi.mocked(commandException).mockResolvedValueOnce({ exceptionNo: 'EX-1', version: 2, status: 'PROCESSING', restoredPackages: 2, replayed: false })
.mockResolvedValueOnce({ exceptionNo: 'EX-1', version: 3, status: 'COMPLETED', restoredPackages: 2, replayed: false })
const wrapper = open(); await flushPromises(); await fill(wrapper)
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('仍有待收货物'); expect(wrapper.text()).toContain('剩余可收 2 箱')
await fill(wrapper); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
const first = vi.mocked(commandException).mock.calls[0]!, second = vi.mocked(commandException).mock.calls[1]!
expect(first.slice(0, 3)).toEqual(['C', 'EX-1', 'complete'])
expect(first[3].receipts?.[0]).toEqual({ outboundPalletItemId: '10', inventoryPositionId: '20', receivedPackages: 2, targetPalletId: '30', targetPalletNo: 'TARGET', targetLocationId: '40', targetLocationCode: 'LOC' })
expect(first[3].expectedVersion).toBe(1); expect(second[3].expectedVersion).toBe(2)
expect(getPallet).toHaveBeenCalledTimes(2); expect(getOverseasLocation).toHaveBeenCalledTimes(2)
expect(getOverseasLocation).toHaveBeenNthCalledWith(1, '92', '40')
expect(second[3].idempotencyKey).not.toBe(first[3].idempotencyKey); expect(wrapper.find('[data-test="complete"]').exists()).toBe(false)
wrapper.unmount()
})
it('receives the native original carton whole into a verified target without inventing inventory', async () => {
const carton = snapshot('EX-1', 1, 1)
carton.returnSources = [{ outboundPalletItemId: '10', inventoryPositionId: null, returnCartonId: '9007199254740997', cartonMark: 'RETURN-CARTON-1', maxReturnPackages: 1 }]
vi.mocked(getExceptionDetail).mockResolvedValueOnce(carton).mockResolvedValue(snapshot('EX-1', 2, 0))
vi.mocked(commandException).mockResolvedValue({ exceptionNo: 'EX-1', version: 2, status: 'COMPLETED', restoredPackages: 1, replayed: false })
const wrapper = open(); await flushPromises()
expect(wrapper.text()).toContain('原退货箱 RETURN-CARTON-1(须整箱实收)')
expect(wrapper.text()).not.toContain('9007199254740997')
await fill(wrapper, 0.5); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(commandException).not.toHaveBeenCalled()
await fill(wrapper, 1); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(commandException).toHaveBeenCalledExactlyOnceWith('C', 'EX-1', 'complete', expect.objectContaining({ receipts: [{ outboundPalletItemId: '10', returnCartonId: '9007199254740997', receivedPackages: 1, targetPalletId: '30', targetPalletNo: 'TARGET', targetLocationId: '40', targetLocationCode: 'LOC' }] }))
expect(getOverseasLocation).toHaveBeenCalledWith('92', '40')
wrapper.unmount()
})
it('retains the exact command after unknown failure even if the visible form is changed', async () => {
vi.mocked(commandException).mockRejectedValueOnce(new Error('network lost')).mockResolvedValueOnce({ exceptionNo: 'EX-1', version: 2, status: 'PROCESSING', restoredPackages: 2, replayed: true })
const wrapper = open(); await flushPromises(); await fill(wrapper)
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
const payload = JSON.parse(JSON.stringify(vi.mocked(commandException).mock.calls[0]![3]))
expect(wrapper.text()).toContain('已保留原请求')
await wrapper.get('[data-test="notes"]').setValue('changed')
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(vi.mocked(commandException).mock.calls[1]![3]).toEqual(payload)
wrapper.unmount()
})
it('refreshes a definite stale version and requires a reviewed new submission', async () => {
vi.mocked(getExceptionDetail).mockResolvedValueOnce(snapshot()).mockResolvedValueOnce(snapshot('EX-1', 7, 2))
vi.mocked(commandException).mockRejectedValueOnce(new ApiResponseError('版本变化', 'A0409', 409))
const wrapper = open(); await flushPromises(); await fill(wrapper)
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('已重新读取当前版本'); expect(commandException).toHaveBeenCalledTimes(1)
await fill(wrapper); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(vi.mocked(commandException).mock.calls[1]![3].expectedVersion).toBe(7)
expect(vi.mocked(commandException).mock.calls[1]![3].idempotencyKey).not.toBe(vi.mocked(commandException).mock.calls[0]![3].idempotencyKey)
wrapper.unmount()
})
it('has no processing or upload controls without independent permission', async () => {
can.mockReturnValue(false); const wrapper = open(); await flushPromises()
expect(wrapper.find('[data-test="complete"]').exists()).toBe(false); expect(wrapper.find('[data-test="evidence"]').exists()).toBe(false)
expect(commandException).not.toHaveBeenCalled(); expect(uploadExceptionEvidence).not.toHaveBeenCalled(); wrapper.unmount()
})
it('ignores an old detail response after switching exceptions', async () => {
let resolve!: (value: ExceptionDetail) => void
vi.mocked(getExceptionDetail).mockImplementationOnce(() => new Promise(done => { resolve = done })).mockResolvedValueOnce(snapshot('EX-2', 2, 1))
const wrapper = open(); await wrapper.setProps({ exceptionNo: 'EX-2' }); await flushPromises()
resolve(snapshot('EX-1')); await flushPromises()
expect(wrapper.text()).toContain('EX-2'); expect(wrapper.text()).not.toContain('EX-1'); wrapper.unmount()
})
it('a late successful command never replaces the new exception detail', async () => {
let resolve!: (value: Awaited<ReturnType<typeof commandException>>) => void
vi.mocked(commandException).mockImplementationOnce(() => new Promise(done => { resolve = done }))
const wrapper = open(); await flushPromises(); await fill(wrapper); await wrapper.get('[data-test="complete"]').trigger('click')
vi.mocked(getExceptionDetail).mockResolvedValue(snapshot('EX-2', 2, 1))
await wrapper.setProps({ exceptionNo: 'EX-2' }); await flushPromises()
resolve({ exceptionNo: 'EX-1', version: 2, status: 'COMPLETED', restoredPackages: 2, replayed: false }); await flushPromises()
expect(wrapper.text()).toContain('EX-2'); expect(wrapper.emitted('changed')).toBeUndefined(); wrapper.unmount()
})
it('starts or cancels only when returned actions permit it, with the authoritative version', async () => {
const pending = snapshot(); pending.order.status = 'PENDING'; pending.order.allowedActions = ['START_PROCESSING', 'CANCEL_EXCEPTION']; pending.order.version = 4
vi.mocked(getExceptionDetail).mockResolvedValue(pending)
vi.mocked(commandException).mockResolvedValue({ exceptionNo: 'EX-1', version: 5, status: 'PROCESSING', restoredPackages: 0, replayed: false })
const wrapper = open(); await flushPromises()
await wrapper.get('[data-test="start"]').trigger('click'); await flushPromises()
expect(vi.mocked(commandException).mock.calls[0]![2]).toBe('start-processing')
expect(vi.mocked(commandException).mock.calls[0]![3].expectedVersion).toBe(4)
await wrapper.get('[data-test="notes"]').setValue('退回申请有误')
vi.mocked(commandException).mockResolvedValue({ exceptionNo: 'EX-1', version: 5, status: 'CANCELLED', restoredPackages: 0, replayed: false })
await wrapper.get('[data-test="cancel"]').trigger('click'); await flushPromises()
expect(vi.mocked(commandException).mock.calls[1]![3].reason).toBe('退回申请有误')
wrapper.unmount()
})
it('uploads owned evidence and includes the resulting file in the completion request', async () => {
vi.mocked(uploadExceptionEvidence).mockResolvedValue({ fileId: '991', fileName: 'receipt.txt' })
const wrapper = open(); await flushPromises()
const input = wrapper.get('[data-test="evidence"]'), file = new File(['proof'], 'receipt.txt')
Object.defineProperty(input.element, 'files', { value: [file], configurable: true })
await input.trigger('change'); await flushPromises()
expect(uploadExceptionEvidence).toHaveBeenCalledWith('C', 'EX-1', file)
await fill(wrapper); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(vi.mocked(commandException).mock.calls[0]![3].evidenceFileIds).toContain('991')
wrapper.unmount()
})
it('rejects a target pallet whose real warehouse differs from the exception warehouse', async () => {
vi.mocked(getPallet).mockResolvedValue({ id: '30', warehouseId: 'OTHER', locationId: '40', palletCode: 'WRONG' } as Awaited<ReturnType<typeof getPallet>>)
const wrapper = open(); await flushPromises(); await fill(wrapper)
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(commandException).not.toHaveBeenCalled(); expect(getOverseasLocation).not.toHaveBeenCalled()
wrapper.unmount()
})
it('uses standard audit text for actual warehouse receipt when the optional note is empty', async () => {
const wrapper = open(); await flushPromises(); await fill(wrapper)
await wrapper.get('[data-test="notes"]').setValue('')
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(vi.mocked(commandException).mock.calls[0]![3].handlingResult).toBe('确认本次退回入库')
wrapper.unmount()
})
it('allows cancellation and ordinary completion with standard audit text and still requires evidence', async () => {
const state = snapshot(); state.order.status = 'PENDING'; state.order.allowedActions = ['CANCEL_EXCEPTION']; state.order.exceptionType = 'DAMAGED'
vi.mocked(getExceptionDetail).mockResolvedValueOnce(state)
const wrapper = open(); await flushPromises(); await wrapper.get('[data-test="cancel"]').trigger('click'); await flushPromises()
expect(vi.mocked(commandException).mock.calls[0]![3].reason).toBe('取消异常处理')
const ordinary = snapshot('EX-2'); ordinary.order.exceptionType = 'DAMAGED'; ordinary.returnSources = []; ordinary.order.evidenceFileIds = []
vi.mocked(getExceptionDetail).mockResolvedValue(ordinary)
await wrapper.setProps({ exceptionNo: 'EX-2' }); await flushPromises(); await wrapper.get('[data-test="complete"]').trigger('click')
expect(commandException).toHaveBeenCalledTimes(1)
ordinary.order.evidenceFileIds = ['91']
await wrapper.setProps({ active: false }); await wrapper.setProps({ active: true }); await flushPromises()
await wrapper.get('[data-test="history-0"]').setValue(true)
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(vi.mocked(commandException).mock.calls[1]![3].handlingResult).toBe('确认异常处理完成')
expect(vi.mocked(commandException).mock.calls[1]![3].receipts).toEqual([])
wrapper.unmount()
})
it('closing and reopening cannot replay an in-flight command or lose a later unknown batch', async () => {
let rejectFirst!: (reason: Error) => void
vi.mocked(commandException).mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectFirst = reject }))
const wrapper = open(); await flushPromises(); await fill(wrapper)
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
const originalKey = vi.mocked(commandException).mock.calls[0]![3].idempotencyKey
await wrapper.setProps({ active: false }); await wrapper.setProps({ active: true }); await flushPromises()
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(commandException).toHaveBeenCalledTimes(1)
rejectFirst(new Error('R1 outcome unknown')); await flushPromises()
vi.mocked(getExceptionDetail).mockResolvedValue(snapshot('EX-1', 2, 2))
vi.mocked(commandException).mockResolvedValueOnce({ exceptionNo: 'EX-1', version: 2, status: 'PROCESSING', restoredPackages: 2, replayed: true })
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(vi.mocked(commandException).mock.calls[1]![3].idempotencyKey).toBe(originalKey)
await fill(wrapper)
vi.mocked(commandException).mockRejectedValueOnce(new Error('next batch unknown')).mockRejectedValueOnce(new Error('still unknown'))
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
const newKey = vi.mocked(commandException).mock.calls[2]![3].idempotencyKey
expect(newKey).not.toBe(originalKey)
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(vi.mocked(commandException).mock.calls[3]![3].idempotencyKey).toBe(newKey)
wrapper.unmount()
})
it('twenty-one historical files do not get resubmitted automatically with the new batch evidence', async () => {
const history = snapshot(); history.order.exceptionType = 'DAMAGED'; history.returnSources = []
history.order.evidenceFileIds = Array.from({ length: 21 }, (_, index) => String(100 + index))
vi.mocked(getExceptionDetail).mockResolvedValue(history)
vi.mocked(uploadExceptionEvidence).mockResolvedValue({ fileId: '991', fileName: 'new-evidence.txt' })
const wrapper = open(); await flushPromises()
expect(wrapper.text()).toContain('21 份历史证据')
const input = wrapper.get('[data-test="evidence"]')
Object.defineProperty(input.element, 'files', { value: [new File(['proof'], 'new-evidence.txt')] })
await input.trigger('change'); await flushPromises(); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(vi.mocked(commandException).mock.calls[0]![3].evidenceFileIds).toEqual(['991'])
wrapper.unmount()
})