diff --git a/src/modules/appointment-calendar/AppointmentBatchForm.test.ts b/src/modules/appointment-calendar/AppointmentBatchForm.test.ts index 32a6df7b..eb2aea64 100644 --- a/src/modules/appointment-calendar/AppointmentBatchForm.test.ts +++ b/src/modules/appointment-calendar/AppointmentBatchForm.test.ts @@ -14,7 +14,7 @@ vi.mock('./workApi', async original => ({ ...await original { guard = value; return () => undefined } }, stubs: { ElButton: { props: ['nativeType', 'disabled'], template: '' }, - LoadErrorState: { props: ['message'], template: '

{{ message }}

' }, ApiErrorMeta: true, CargoVerificationForm: true, + LoadErrorState: { props: ['message'], template: '
{{ message }}
' }, 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() +}) diff --git a/src/modules/appointment-calendar/AppointmentBatchForm.vue b/src/modules/appointment-calendar/AppointmentBatchForm.vue index 1c8d6d99..a404117b 100644 --- a/src/modules/appointment-calendar/AppointmentBatchForm.vue +++ b/src/modules/appointment-calendar/AppointmentBatchForm.vue @@ -20,16 +20,28 @@ const baseline = ref(JSON.stringify(form)), verification = ref baseline.value !== JSON.stringify(form)), computed(() => saving.value || !!pending.value)) const candidates = ref(null), candidatePage = ref(1), keyword = ref(''), candidateLoading = ref(false), candidateError = ref(null) let candidateRequest = 0 +let initializedKey: string | null = null const canWrite = computed(() => access.can('platform:overseas-warehouse:inbound-order:fba-appointment')) +const replaced = computed(() => !!context.value?.appointment.replacementBatchKeys?.length || !!context.value?.appointment.replacementAppointmentNos?.length) +const hasCurrentDestinations = computed(() => replaced.value || context.value?.currentBatches.some(batch => batch.batchKey !== context.value?.appointment.batchKey)) +const currentDestinations = computed(() => { + if (!context.value) return [] + const sourceIdentity = (item: FbaAppointmentBatchContext['appointment']['items'][number]) => JSON.stringify([item.customerId, item.sourceKey, item.destinationKey]) + const originalSources = new Set(context.value.originalSourceRefs.map(allocationKey)) + return context.value.currentBatches.map(appointment => ({ appointment, items: appointment.items.filter(item => item.destinationKey && originalSources.has(sourceIdentity(item)) && (item.packageCount == null || item.packageCount > 0)) })).filter(row => row.items.length) +}) +function bookingStatus(status: string) { return ({ PENDING: '待预约', SUCCESS: '已确认预约', FAILED: '未约成', CANCELLED: '已取消,分配保留' } as Record)[status] || '预约状态待核实' } +async function openAppointment(number: string) { if (await canLeave()) emit('appointment', number) } const canSubmit = computed(() => canWrite.value && ready.value && !loading.value && !error.value && !!context.value?.allowedActions.includes(mode.value === 'split' ? 'SPLIT' : 'MERGE') && (mode.value !== 'merge' || !!other.value?.allowedActions.includes('MERGE'))) async function load(preserve = false) { loading.value = true; ready.value = false; error.value = null try { context.value = await getAppointmentBatch(activeKey.value) if (other.value?.appointment.batchKey) other.value = await getAppointmentBatch(other.value.appointment.batchKey) - if (!preserve) { + if (!preserve || initializedKey !== activeKey.value) { Object.assign(form, { firstDate: context.value.appointment.plannedDate || '', nextDate: '', reason: '', remark: '', moved: Object.fromEntries(context.value.sources.map(source => [allocationKey(source), 0])) }) baseline.value = JSON.stringify(form) + initializedKey = activeKey.value } ready.value = true } catch (cause) { error.value = getApiErrorDetails(cause, '批次资料读取失败') } @@ -62,7 +74,7 @@ function sourceLabel(source: CargoVerificationSource) { } async function verify(source: CargoVerificationSource) { if (await canLeave()) { baseline.value = JSON.stringify(form); verification.value = source } } async function verified() { verification.value = null; await load(); emit('updated') } -async function openBatch(key: string) { if (!await canLeave()) return; activeKey.value = key; result.value = null; other.value = null; mode.value = 'split'; await load() } +async function openBatch(key: string) { if (!await canLeave()) return; activeKey.value = key; context.value = null; result.value = null; other.value = null; mode.value = 'split'; await load() } async function save() { if (!context.value || !canSubmit.value || saving.value) return if (!pending.value) { @@ -92,7 +104,20 @@ onMounted(() => load())