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

186 lines
14 KiB
TypeScript

import { DatePickerStub, TimePickerStub } from './temporal-picker.test-support'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import AppointmentBatchForm from './AppointmentBatchForm.vue'
import { getAppointmentBatch, repartitionAppointments } from './batchApi'
import { getWorkItems } from './workApi'
import { ApiResponseError } from '@/shared/api/http'
import { batchContext } from './batch.test-support'
import { workItem } from './work.test-support'
import type { LeaveGuard } from './useWorkDraft'
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(true) } }))
vi.mock('./batchApi', async original => ({ ...await original<typeof import('./batchApi')>(), getAppointmentBatch: vi.fn(), repartitionAppointments: vi.fn() }))
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkItems: vi.fn() }))
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: {
ElDatePicker: DatePickerStub, ElTimePicker: TimePickerStub,
ElButton: { props: ['nativeType', 'disabled'], template: '<button :type="nativeType || \'button\'" :disabled="disabled"><slot /></button>' },
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())
vi.mocked(repartitionAppointments).mockResolvedValue({ replacedBatchKeys: ['FBA_APPOINTMENT:1'], replacedAppointmentNos: ['AP-001'], appointments: [batchContext('AP-NEW-1', 'FBA_APPOINTMENT:11').appointment, batchContext('AP-NEW-2', 'FBA_APPOINTMENT:12').appointment] })
vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem({ batchKey: 'FBA_APPOINTMENT:2' })], page: 1, size: 8, total: 1, pages: 1 })
})
it('submits two conservative allocations without fake confirmation times and renders replacement business numbers', async () => {
const wrapper = render(); await flushPromises()
await wrapper.get('[aria-label="来源1分出箱数"]').setValue(20)
await wrapper.findAll('textarea')[0]!.setValue('分两次送仓'); await wrapper.get('form').trigger('submit'); await flushPromises()
const request = vi.mocked(repartitionAppointments).mock.calls[0]![0]
expect(request.originals).toEqual([{ appointmentNo: 'AP-001', version: 3 }])
expect(request.batches.map(row => row.plannedDate)).toEqual([null, null])
expect(request.batches[0]!.allocations.map(row => row.packageCount)).toEqual([50, 30])
expect(request.batches[1]!.allocations.map(row => row.packageCount)).toEqual([20])
expect(request).not.toHaveProperty('appointmentAt')
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()
await wrapper.get('[aria-label="来源1分出箱数"]').setValue(20); await wrapper.findAll('textarea')[0]!.setValue('分批')
await wrapper.get('form').trigger('submit'); await flushPromises()
const first = vi.mocked(repartitionAppointments).mock.calls[0]![0]
expect(await guard()).toBe(false); expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(repartitionAppointments).toHaveBeenNthCalledWith(2, first); wrapper.unmount()
})
it('keeps edited allocations after a version conflict and requires a fresh explicit submit', async () => {
vi.mocked(repartitionAppointments).mockRejectedValueOnce(new ApiResponseError('版本变化', 'A0409', 409))
const wrapper = render(); await flushPromises()
await wrapper.get('[aria-label="来源1分出箱数"]').setValue(20); await wrapper.findAll('textarea')[0]!.setValue('保留分批原因')
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(getAppointmentBatch).toHaveBeenCalledTimes(2); expect(repartitionAppointments).toHaveBeenCalledTimes(1)
expect((wrapper.get('[aria-label="来源1分出箱数"]').element as HTMLInputElement).value).toBe('20')
expect((wrapper.findAll('textarea')[0]!.element as HTMLTextAreaElement).value).toBe('保留分批原因'); wrapper.unmount()
})
it('shows full appointment cargo when confirmed or replaced batches have no repartition sources', async () => {
const context = batchContext(); context.sources = []; context.allowedActions = []; context.blockReasons = ['原批次已有正式确认']; context.appointment.appointmentStatus = 'SUCCESS'
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
const wrapper = render(); await flushPromises()
expect(wrapper.get('[aria-label="原批次货物快照"]').text()).toContain('IN-A')
expect(wrapper.text()).toContain('FBA-B'); expect(wrapper.text()).toContain('当前批次无分批资格')
await wrapper.get('form').trigger('submit'); expect(repartitionAppointments).not.toHaveBeenCalled(); wrapper.unmount()
})
it('loads a candidate context before merging and preserves both original versions', async () => {
const other = batchContext('AP-002', 'FBA_APPOINTMENT:2'); other.appointment.version = 6
vi.mocked(getAppointmentBatch).mockImplementation(async key => key === 'FBA_APPOINTMENT:2' ? other : batchContext())
const wrapper = render(); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '同仓合并安排')!.trigger('click'); await flushPromises()
await wrapper.findAll('button').find(button => button.text() === '核对并选择')!.trigger('click'); await flushPromises()
await wrapper.findAll('textarea')[0]!.setValue('同仓合并'); await wrapper.get('form').trigger('submit'); await flushPromises()
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[data-picker-kind="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()
})