From 45f662ecf299aa93bd768db82b01552aa32f5139 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 16 Oct 2026 15:41:56 +0800 Subject: [PATCH] feat(appointment): complete customer receiving and trip continuation workflows --- .../AppointmentBatchForm.test.ts | 112 +++- .../AppointmentBatchForm.vue | 31 +- .../AppointmentCalendarView.test.ts | 80 ++- .../AppointmentCalendarView.vue | 82 +-- .../appointment-calendar/PROTOTYPE_MATRIX.md | 139 ++++- .../appointment-calendar/ReceivingScope.vue | 15 + .../TripAppointmentPanel.test.ts | 102 ++++ .../TripAppointmentPanel.vue | 103 ++++ .../WorkAppointmentForm.test.ts | 66 ++- .../WorkAppointmentForm.vue | 33 +- .../WorkBatchContinuation.test.ts | 23 + .../WorkBatchContinuation.vue | 26 + .../WorkCalendarPanel.test.ts | 11 + .../WorkCalendarPanel.vue | 20 +- .../WorkDetailPanel.test.ts | 79 ++- .../appointment-calendar/WorkDetailPanel.vue | 50 +- .../WorkDispatchForm.test.ts | 37 +- .../appointment-calendar/WorkDispatchForm.vue | 24 +- .../WorkExistingDispatchForm.test.ts | 10 +- .../WorkExistingDispatchForm.vue | 2 +- .../WorkReceivingPanel.test.ts | 510 ++++++++++++++++++ .../WorkReceivingPanel.vue | 302 +++++++++++ .../WorkSharedTripForm.test.ts | 48 ++ .../WorkSharedTripForm.vue | 50 ++ .../batch.test-support.ts | 9 +- src/modules/appointment-calendar/batchApi.ts | 2 +- src/modules/appointment-calendar/index.ts | 1 + .../appointment-calendar/receivingApi.test.ts | 44 ++ .../appointment-calendar/receivingApi.ts | 36 ++ .../appointment-calendar/workApi.test.ts | 8 +- src/modules/appointment-calendar/workApi.ts | 4 + .../overseas-master-data/api/trips.test.ts | 29 +- src/modules/overseas-master-data/api/trips.ts | 10 +- .../components/TripHandoverPanel.test.ts | 2 +- .../components/TripPreparationDialog.test.ts | 2 +- src/modules/overseas-master-data/index.ts | 2 + .../pages/TripDetailView.vue | 10 +- .../pages/TripsBehavior.test.ts | 16 +- src/modules/overseas-master-data/tripTypes.ts | 6 +- .../overseas-warehouse/api/dispatches.ts | 2 + .../components/SmartDispatchDialog.test.ts | 12 +- src/modules/overseas-warehouse/index.ts | 2 +- .../pages/DispatchDetailView.test.ts | 4 +- .../pages/DispatchesView.test.ts | 4 +- src/styles/adminDensity.test.ts | 13 +- 45 files changed, 2042 insertions(+), 131 deletions(-) create mode 100644 src/modules/appointment-calendar/ReceivingScope.vue create mode 100644 src/modules/appointment-calendar/TripAppointmentPanel.test.ts create mode 100644 src/modules/appointment-calendar/TripAppointmentPanel.vue create mode 100644 src/modules/appointment-calendar/WorkBatchContinuation.test.ts create mode 100644 src/modules/appointment-calendar/WorkBatchContinuation.vue create mode 100644 src/modules/appointment-calendar/WorkReceivingPanel.test.ts create mode 100644 src/modules/appointment-calendar/WorkReceivingPanel.vue create mode 100644 src/modules/appointment-calendar/WorkSharedTripForm.test.ts create mode 100644 src/modules/appointment-calendar/WorkSharedTripForm.vue create mode 100644 src/modules/appointment-calendar/receivingApi.test.ts create mode 100644 src/modules/appointment-calendar/receivingApi.ts 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())