feat(appointment): complete customer receiving and trip continuation workflows

This commit is contained in:
2026-10-16 15:41:56 +08:00
parent 148085286d
commit 45f662ecf2
45 changed files with 2042 additions and 131 deletions
@@ -14,7 +14,7 @@ vi.mock('./workApi', async original => ({ ...await original<typeof import('./wor
let guard: LeaveGuard
function render() { return mount(AppointmentBatchForm, { props: { batchKey: 'FBA_APPOINTMENT:1' }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: {
ElButton: { props: ['nativeType', 'disabled'], template: '<button :type="nativeType || \'button\'" :disabled="disabled"><slot /></button>' },
LoadErrorState: { props: ['message'], template: '<p role="alert">{{ message }}</p>' }, ApiErrorMeta: true, CargoVerificationForm: true,
LoadErrorState: { props: ['message'], template: '<section role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></section>' }, ApiErrorMeta: true, CargoVerificationForm: true,
} } }) }
beforeEach(() => {
vi.clearAllMocks(); vi.mocked(getAppointmentBatch).mockResolvedValue(batchContext())
@@ -34,6 +34,21 @@ it('submits two conservative allocations without fake confirmation times and ren
expect(wrapper.text()).toContain('AP-NEW-1'); expect(wrapper.text()).not.toContain('FBA_APPOINTMENT:')
expect(wrapper.emitted('updated')).toHaveLength(1); wrapper.unmount()
})
it('splits only the entered 60-box later batch into 40/20, never reallocating its original 160-box source', async () => {
const context = batchContext('AP-LATER-60', 'FBA_APPOINTMENT:1')
context.appointment.items = [{ ...context.appointment.items[0]!, packageCount: 60 }]
context.sources = [{ ...context.sources[0]!, packageCount: 60, originalPackages: 160 }]
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
const wrapper = render(); await flushPromises()
await wrapper.get('[aria-label="来源1分出箱数"]').setValue(20)
await wrapper.findAll('textarea')[0]!.setValue('本批40先安排,20待排')
await wrapper.get('form').trigger('submit'); await flushPromises()
const request = vi.mocked(repartitionAppointments).mock.calls[0]![0]
expect(request.originals).toEqual([{ appointmentNo: 'AP-LATER-60', version: 3 }])
expect(request.batches.map(batch => batch.allocations.map(row => row.packageCount))).toEqual([[40], [20]])
expect(request.batches.flatMap(batch => batch.allocations).every(row => row.customerId === 'customer-a' && row.sourceKey === 'ORDER:1')).toBe(true)
wrapper.unmount()
})
it('locks an unknown outcome and retries the same immutable repartition command', async () => {
vi.mocked(repartitionAppointments).mockRejectedValueOnce(new Error('unknown outcome'))
const wrapper = render(); await flushPromises()
@@ -71,3 +86,98 @@ it('loads a candidate context before merging and preserves both original version
expect(repartitionAppointments).toHaveBeenCalledWith(expect.objectContaining({ originals: [{ appointmentNo: 'AP-001', version: 3 }, { appointmentNo: 'AP-002', version: 6 }], batches: [expect.objectContaining({ allocations: [expect.objectContaining({ customerId: 'customer-a', packageCount: 140 }), expect.objectContaining({ customerId: 'customer-b', packageCount: 60 })] })] }))
wrapper.unmount()
})
it('shows every terminal branch with only the original customer/source/destination quantities and opens the real batch', async () => {
const context = batchContext()
const source = context.appointment.items[0]!
context.appointment.items = [source]
context.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2', 'FBA_APPOINTMENT:3']
context.appointment.replacementAppointmentNos = ['AP-INTERMEDIATE', 'AP-20']
context.sources = []; context.allowedActions = []
const forty = batchContext('AP-40', 'FBA_APPOINTMENT:4').appointment
forty.appointmentStatus = 'SUCCESS'
forty.items = [
{ ...source, packageCount: 40 },
{ ...source, id: 'foreign-customer', customerId: 'other-customer', inboundCode: 'FOREIGN-CUSTOMER', packageCount: 900 },
{ ...source, id: 'foreign-source', sourceKey: 'ORDER:OTHER', inboundCode: 'FOREIGN-SOURCE', packageCount: 800 },
{ ...source, id: 'foreign-destination', destinationKey: 'FBA:OTHER', inboundCode: 'FOREIGN-DESTINATION', packageCount: 700 },
]
const twenty = batchContext('AP-20', 'FBA_APPOINTMENT:3').appointment
twenty.appointmentStatus = 'CANCELLED'; twenty.items = [{ ...source, packageCount: 20 }]
context.currentBatches = [forty, twenty]
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
const wrapper = render(); await flushPromises()
const destinations = wrapper.get('[aria-label="当前货物去向"]')
expect(destinations.findAll('article')).toHaveLength(2)
expect(destinations.text()).toContain('40 箱'); expect(destinations.text()).toContain('20 箱')
expect(destinations.text()).toContain('已取消,分配保留')
expect(destinations.text()).not.toContain('FOREIGN-'); expect(destinations.text()).not.toContain('AP-INTERMEDIATE')
expect(destinations.text()).not.toContain('FBA_APPOINTMENT:')
await destinations.findAll('button').find(button => button.text() === '查看本批预约')!.trigger('click')
expect(wrapper.emitted('appointment')).toEqual([['AP-40']])
await destinations.findAll('button').find(button => button.text() === '查看完整批次')!.trigger('click'); await flushPromises()
expect(getAppointmentBatch).toHaveBeenLastCalledWith('FBA_APPOINTMENT:4')
expect(repartitionAppointments).not.toHaveBeenCalled(); wrapper.unmount()
})
it('does not create zero-quantity destinations or convert unknown quantities to zero', async () => {
const context = batchContext()
context.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2']
context.sources = []; context.allowedActions = []
const unknown = batchContext('AP-UNKNOWN', 'FBA_APPOINTMENT:2').appointment
unknown.items = [{ ...context.appointment.items[0]!, packageCount: null }]
const empty = batchContext('AP-EMPTY', 'FBA_APPOINTMENT:3').appointment
empty.items = [{ ...context.appointment.items[0]!, packageCount: 0 }]
context.currentBatches = [unknown, empty]
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
const wrapper = render(); await flushPromises()
const destinations = wrapper.get('[aria-label="当前货物去向"]')
expect(destinations.findAll('article')).toHaveLength(1)
expect(destinations.text()).toContain('箱数待核实'); expect(destinations.text()).not.toContain('0 箱')
expect(destinations.text()).not.toContain('AP-EMPTY'); wrapper.unmount()
})
it('does not fall back to historical replacement links when current destinations are empty', async () => {
const context = batchContext()
context.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2']
context.appointment.replacementAppointmentNos = ['AP-OLD-REPLACEMENT']
context.sources = []; context.allowedActions = []; context.currentBatches = []
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
const wrapper = render(); await flushPromises()
const destinations = wrapper.get('[aria-label="当前货物去向"]')
expect(destinations.findAll('article')).toHaveLength(0)
expect(destinations.text()).toContain('原快照不代表当前待办')
expect(destinations.text()).not.toContain('AP-OLD-REPLACEMENT'); wrapper.unmount()
})
it('keeps every 40/20 destination after the entire customer has left the original current items', async () => {
const context = batchContext(), originalItem = context.appointment.items[0]!
context.appointment.items = [context.appointment.items[1]!]
context.sources = [context.sources[1]!]
const forty = batchContext('AP-40', 'FBA_APPOINTMENT:40').appointment
forty.items = [{ ...originalItem, packageCount: 40 }, { ...originalItem, id: 'unrelated', sourceKey: 'ORDER:OTHER', inboundCode: 'IN-UNRELATED', packageCount: 999 }]
const twenty = batchContext('AP-20', 'FBA_APPOINTMENT:20').appointment
twenty.items = [{ ...originalItem, packageCount: 20 }]
context.currentBatches = [context.appointment, forty, twenty]
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
const wrapper = render(); await flushPromises()
const destinations = wrapper.get('[aria-label="当前货物去向"]')
expect(destinations.text()).toContain('40 箱'); expect(destinations.text()).toContain('20 箱')
expect(destinations.text()).not.toContain('IN-UNRELATED')
expect(wrapper.findAll('[aria-label="来源1分出箱数"]')).toHaveLength(1)
expect((wrapper.get('[aria-label="来源1分出箱数"]').attributes('max'))).toBe('30')
wrapper.unmount()
})
it('retries a new destination without exposing the prior batch or retaining its allocation inputs', async () => {
const original = batchContext()
original.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2']; original.sources = []; original.allowedActions = []
const current = batchContext('AP-CURRENT', 'FBA_APPOINTMENT:2')
current.appointment.plannedDate = '2026-10-22'
original.currentBatches = [current.appointment]
vi.mocked(getAppointmentBatch).mockResolvedValueOnce(original).mockRejectedValueOnce(new Error('新批次暂不可用')).mockResolvedValue(current)
const wrapper = render(); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '查看完整批次')!.trigger('click'); await flushPromises()
expect(wrapper.text()).not.toContain('AP-001'); expect(wrapper.find('form').exists()).toBe(false)
await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('AP-CURRENT')
expect((wrapper.findAll('input[type="date"]')[0]!.element as HTMLInputElement).value).toBe('2026-10-22')
expect((wrapper.get('[aria-label="来源1分出箱数"]').element as HTMLInputElement).value).toBe('0')
expect(getAppointmentBatch).toHaveBeenLastCalledWith('FBA_APPOINTMENT:2')
expect(repartitionAppointments).not.toHaveBeenCalled(); wrapper.unmount()
})