Files
admin-web/src/modules/appointment-calendar/WorkDispatchForm.test.ts
T

147 lines
14 KiB
TypeScript

import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import WorkDispatchForm from './WorkDispatchForm.vue'
import { getDispatch, getBatchDispatchCandidates, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, preArrangeDraftDispatch, releaseDispatchDraft, type BatchDispatchCandidate } from '@/modules/overseas-warehouse'
import { getReceivingContext, type ReceivingContext } from './receivingApi'
import { batchContext } from './batch.test-support'
import type { LeaveGuard } from './useWorkDraft'
const access = vi.hoisted(() => ({ allowed: true }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.allowed }) }))
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
vi.mock('@/modules/overseas-warehouse', async original => ({ ...await original<typeof import('@/modules/overseas-warehouse')>(), getDispatch: vi.fn(), getBatchDispatchCandidates: vi.fn(), createDispatchDraftContainer: vi.fn(), listDispatchDrafts: vi.fn(), lockDispatchDraftPallet: vi.fn(), preArrangeDraftDispatch: vi.fn(), releaseDispatchDraft: vi.fn() }))
vi.mock('./receivingApi', async original => ({ ...await original<typeof import('./receivingApi')>(), getReceivingContext: vi.fn() }))
function receiving(batchKey: string, vehicles: ReceivingContext['transport']['vehicles'] = []): ReceivingContext { return { batch: batchContext('AP-1', batchKey).appointment, plans: [], agreements: [], pendingChanges: [], transport: { version: 'v1', vehicles, blockReasons: [] }, executionScope: { version: 'e1', members: [] }, allowedActions: [], blockReasons: [] } }
const candidate: BatchDispatchCandidate = { planPalletId: '9007199254740993', palletNo: 'PL-TEST', planId: 'plan-1', planNo: 'IP-001', customerId: 'customer-a', sourceStage: 'RECEIVED', packageCount: 12, allocations: [{ planPalletId: '9007199254740993', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', sourceKey: 'BOOKING:9', destinationKey: 'FBA:ONT8', packageCount: 12 }], blockReasons: [] }
let guard: LeaveGuard
function render() { return mount(WorkDispatchForm, { props: { batchKeys: ['FBA_APPOINTMENT:1'], canWrite: true }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: { WorkSharedTripForm: { name: 'WorkSharedTripForm', props: ['dispatchNo', 'canWrite'], template: '<div />' }, ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' }, LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true, TripPreparationDialog: { name: 'TripPreparationDialog', props: ['warehouseId', 'dispatchNos'], template: '<div />' } } } }) }
function button(wrapper: ReturnType<typeof render>, text: string) { return wrapper.findAll('button').find(button => button.text() === text)! }
beforeEach(() => {
vi.clearAllMocks(); access.allowed = true
vi.mocked(getReceivingContext).mockImplementation(async batchKey => receiving(batchKey))
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, candidates: [structuredClone(candidate)], blockReasons: [] })
vi.mocked(listDispatchDrafts).mockResolvedValue([])
vi.mocked(createDispatchDraftContainer).mockResolvedValue({ id: 'container-1', capacity: 30, palletIds: [] })
vi.mocked(lockDispatchDraftPallet).mockResolvedValue(null)
vi.mocked(preArrangeDraftDispatch).mockResolvedValue([{ draftContainerId: 'container-1', dispatch: { id: 'internal-dispatch', dispatchNo: 'DS-001', appointmentBatches: [], overseasWarehouseId: 'origin-1', overseasWarehouseName: '测试起运仓' } }])
})
it('restores the original FBA dispatch after remount and opens trip preparation without any draft or creation', async () => {
vi.mocked(getReceivingContext).mockResolvedValue(receiving('FBA_APPOINTMENT:1', [{ dispatchNo: 'DS-EXISTING', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', tripNo: null, version: null, status: 'PENDING_DEPARTURE', packageCount: 4 }]))
vi.mocked(getDispatch).mockResolvedValue({ id: 'dispatch-1', dispatchNo: 'DS-EXISTING', status: 'PENDING_DEPARTURE', overseasWarehouseId: 'origin-1', packageCount: 4, appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'customer-a', sources: [] }] })
for (let mountNumber = 0; mountNumber < 2; mountNumber++) {
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('DS-EXISTING')
expect(wrapper.text()).not.toContain('创建本客户排车单')
await button(wrapper, '继续安排本批车辆').trigger('click'); await flushPromises()
expect(wrapper.getComponent({ name: 'TripPreparationDialog' }).props()).toMatchObject({ warehouseId: 'origin-1', dispatchNos: ['DS-EXISTING'] })
await button(wrapper, '查看原排车单').trigger('click')
expect(wrapper.emitted('external')).toEqual([['/overseas-warehouse/dispatches?dispatchNo=DS-EXISTING']])
wrapper.unmount()
}
expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
expect(createDispatchDraftContainer).not.toHaveBeenCalled()
expect(lockDispatchDraftPallet).not.toHaveBeenCalled()
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
})
it('fails closed when transport relations cannot be read', async () => {
vi.mocked(getReceivingContext).mockRejectedValue(new Error('unavailable'))
const wrapper = render(); await flushPromises()
expect(wrapper.text()).not.toContain('创建本客户排车单')
expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
wrapper.unmount()
})
it('does not offer remaining-cargo creation when the original dispatch no longer owns this batch', async () => {
vi.mocked(getReceivingContext).mockResolvedValue(receiving('FBA_APPOINTMENT:1', [{ dispatchNo: 'DS-EXISTING', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', tripNo: null, version: null, status: 'PENDING_DEPARTURE', packageCount: 4 }]))
vi.mocked(getDispatch).mockResolvedValue({ id: 'dispatch-1', dispatchNo: 'DS-EXISTING', status: 'PENDING_DEPARTURE', appointmentBatches: [] })
const wrapper = render(); await flushPromises()
expect(wrapper.text()).toContain('排车单与工作批次关联不一致')
expect(wrapper.text()).not.toContain('创建本客户排车单')
expect(wrapper.text()).not.toContain('核对本批剩余可排实物')
expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
wrapper.unmount()
})
it('loads remaining eligible cargo only after an explicit choice while retaining the existing dispatch', async () => {
vi.mocked(getReceivingContext).mockResolvedValue(receiving('FBA_APPOINTMENT:1', [{ dispatchNo: 'DS-EXISTING', batchKey: 'FBA_APPOINTMENT:1', customerId: 'customer-a', tripNo: null, version: null, status: 'PENDING_DEPARTURE', packageCount: 4 }]))
vi.mocked(getDispatch).mockResolvedValue({ id: 'dispatch-1', dispatchNo: 'DS-EXISTING', status: 'PENDING_DEPARTURE', appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'customer-a', sources: [] }] })
const wrapper = render(); await flushPromises()
expect(getBatchDispatchCandidates).not.toHaveBeenCalled()
await button(wrapper, '核对本批剩余可排实物').trigger('click'); await flushPromises()
expect(getBatchDispatchCandidates).toHaveBeenCalledExactlyOnceWith('FBA_APPOINTMENT:1')
expect(wrapper.text()).toContain('DS-EXISTING')
expect(preArrangeDraftDispatch).not.toHaveBeenCalled()
wrapper.unmount()
})
it('copies exact server allocations into a real draft group and continues into original trip preparation', async () => {
const wrapper = render(); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
expect(preArrangeDraftDispatch).toHaveBeenCalledWith({ draftToken: expect.any(String), groups: [{ draftContainerId: 'container-1', planPalletIds: [candidate.planPalletId], returnReceiptIds: [], batchAllocations: candidate.allocations }] })
expect(wrapper.text()).toContain('DS-001'); expect(wrapper.text()).not.toContain('internal-dispatch')
const prepare = wrapper.getComponent({ name: 'TripPreparationDialog' })
expect(prepare.props()).toMatchObject({ warehouseId: 'origin-1', dispatchNos: ['DS-001'] })
prepare.vm.$emit('saved', { trip: { tripNo: 'TR-001' } }); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR-001']])
wrapper.unmount()
})
it('opens quantity splitting for only the current batch without locking physical cargo or creating a new full-source appointment', async () => {
const wrapper = render(); await flushPromises()
await button(wrapper, '按当前批次箱数分批').trigger('click')
expect(wrapper.emitted('batch')).toEqual([['FBA_APPOINTMENT:1']])
expect(lockDispatchDraftPallet).not.toHaveBeenCalled(); expect(preArrangeDraftDispatch).not.toHaveBeenCalled(); wrapper.unmount()
})
it('preselects only eligible physical cargo of the entered batch, never unrelated allocations or unknown quantity', async () => {
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, blockReasons: [], candidates: [candidate, { ...candidate, planPalletId: 'other-batch', allocations: [{ ...candidate.allocations[0]!, batchKey: 'FBA_APPOINTMENT:999' }] }, { ...candidate, planPalletId: 'unknown', packageCount: null }] })
const wrapper = render(); await flushPromises()
const inputs = wrapper.findAll('input[type=checkbox]')
expect(inputs.map(input => (input.element as HTMLInputElement).checked)).toEqual([true, false, false])
expect(inputs[1]!.attributes('disabled')).toBeDefined()
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
expect(vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0].groups[0]!.planPalletIds).toEqual([candidate.planPalletId])
expect(vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0].groups[0]!.batchAllocations).toEqual(candidate.allocations)
wrapper.unmount()
})
it('replays a submitted draft without re-locking already dispatched pallets after an unknown response', async () => {
vi.mocked(preArrangeDraftDispatch).mockRejectedValueOnce(new Error('lost response'))
const wrapper = render(); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
const first = vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0]
expect(await guard()).toBe(false)
await button(wrapper, '重试本次预排').trigger('click'); await flushPromises()
expect(preArrangeDraftDispatch).toHaveBeenNthCalledWith(2, first); expect(lockDispatchDraftPallet).toHaveBeenCalledTimes(1); expect(createDispatchDraftContainer).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('recovers a draft container from the same token when its creation response was lost', async () => {
vi.mocked(createDispatchDraftContainer).mockRejectedValueOnce(new Error('lost create response'))
const wrapper = render(); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
const token = vi.mocked(createDispatchDraftContainer).mock.calls[0]![0]
vi.mocked(listDispatchDrafts).mockResolvedValue([{ draftToken: token, containers: [{ id: 'container-1', capacity: 30, palletIds: [] }] }])
await button(wrapper, '重试本次预排').trigger('click'); await flushPromises()
expect(createDispatchDraftContainer).toHaveBeenCalledTimes(1); expect(preArrangeDraftDispatch).toHaveBeenCalledTimes(1); wrapper.unmount()
})
it('shows server blockers and prevents mixing customers or selecting blocked physical cargo', async () => {
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, blockReasons: [], candidates: [candidate, { ...candidate, planPalletId: '2', customerId: 'customer-b', allocations: [{ ...candidate.allocations[0]!, customerId: 'customer-b', planPalletId: '2' }] }, { ...candidate, planPalletId: '3', blockReasons: ['本托货量超过本批剩余可排量'] }] })
const wrapper = render(); await flushPromises(); const inputs = wrapper.findAll('input[type=checkbox]')
expect(inputs[2]!.attributes('disabled')).toBeDefined(); expect(wrapper.text()).toContain('本托货量超过本批剩余可排量')
await inputs[0]!.setValue(true); await inputs[1]!.setValue(true)
expect((inputs[1]!.element as HTMLInputElement).checked).toBe(false)
await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
expect(vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0].groups[0]!.planPalletIds).toEqual([candidate.planPalletId]); wrapper.unmount()
})
it('does not read or write scoped cargo without all original permissions', async () => {
access.allowed = false; const wrapper = render(); await flushPromises()
expect(getBatchDispatchCandidates).not.toHaveBeenCalled(); expect(preArrangeDraftDispatch).not.toHaveBeenCalled(); wrapper.unmount()
})
it('continues the actual later 60-box dispatch through the shared-trip entry without re-creating or reallocating cargo', async () => {
const sixty = { ...candidate, packageCount: 60, allocations: [{ ...candidate.allocations[0]!, packageCount: 60 }] }
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:1', version: 4, candidates: [sixty], blockReasons: [] })
vi.mocked(preArrangeDraftDispatch).mockResolvedValue([{ draftContainerId: 'container-1', dispatch: { id: 'internal-later', dispatchNo: 'DS-60', appointmentBatches: [], overseasWarehouseId: 'origin-1', packageCount: 60 } }])
const wrapper = render(); await flushPromises(); await button(wrapper, '创建本客户排车单').trigger('click'); await flushPromises()
await button(wrapper, '与同仓其他排车单共车').trigger('click'); await flushPromises()
const shared = wrapper.getComponent({ name: 'WorkSharedTripForm' })
expect(shared.props()).toMatchObject({ dispatchNo: 'DS-60', canWrite: true })
expect(preArrangeDraftDispatch).toHaveBeenCalledTimes(1); expect(vi.mocked(preArrangeDraftDispatch).mock.calls[0]![0].groups[0]!.batchAllocations).toEqual(sixty.allocations)
shared.vm.$emit('busy', true); await flushPromises(); expect(button(wrapper, '继续安排其他实物').attributes('disabled')).toBeDefined()
shared.vm.$emit('external', '/overseas-master-data/trips/TR-100'); expect(wrapper.emitted('external')).toEqual([['/overseas-master-data/trips/TR-100']])
wrapper.unmount()
})