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
@@ -1,24 +1,60 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import WorkDetailPanel from './WorkDetailPanel.vue'
import { getWorkDetail, type WorkAction, type WorkDetail } from './workApi'
import { getWorkDetail, resolveBatchWork, type WorkAction, type WorkDetail } from './workApi'
import { batchContext } from './batch.test-support'
import { followAction, workDetail, workItem } from './work.test-support'
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkDetail: vi.fn() }))
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkDetail: vi.fn(), resolveBatchWork: vi.fn() }))
function render(detail: WorkDetail, initialAction?: WorkAction) {
vi.mocked(getWorkDetail).mockResolvedValue(detail)
return mount(WorkDetailPanel, { props: { itemKey: detail.item.workItemKey, initialAction, canLeave: async () => true }, global: { stubs: {
ElButton: { template: '<button><slot /></button>' },
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' },
CargoVerificationForm: { name: 'CargoVerificationForm', props: ['source'], template: '<div data-cargo />' },
WorkCommandForm: { name: 'WorkCommandForm', props: ['detail', 'action'], template: '<div data-command />' },
WorkAppointmentForm: { name: 'WorkAppointmentForm', props: ['appointmentNo', 'canWrite'], template: '<div data-appointment />' },
WorkReceivingPanel: { name: 'WorkReceivingPanel', props: ['batchKey', 'canWrite'], template: '<div data-receiving-scope />' },
WorkCreateAppointmentForm: { name: 'WorkCreateAppointmentForm', props: ['sources', 'canWrite'], template: '<div data-create />' },
WorkDispatchForm: { name: 'WorkDispatchForm', props: ['batchKeys', 'canWrite'], template: '<div data-dispatch />' },
WorkExistingDispatchForm: { name: 'WorkExistingDispatchForm', props: ['dispatches', 'canWrite'], template: '<div data-existing-dispatch />' },
WorkReceiptPlans: { name: 'WorkReceiptPlans', props: ['containerBookingId'], template: '<div data-receiving />' },
LoadErrorState: { props: ['message'], template: '<p>{{ message }}</p>' }, ApiErrorMeta: true,
LoadErrorState: { props: ['message'], template: '<section role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></section>' }, ApiErrorMeta: true,
} } })
}
beforeEach(() => vi.clearAllMocks())
it('stays on the saved item and offers only the newly fetched primary next step', async () => {
const wrapper = render(workDetail(), followAction); await flushPromises()
const transport: WorkAction = { code: 'ARRANGE_TRANSPORT', label: '继续安排本批车辆', targetWorkItemKey: 'transport-current', target: { kind: 'FLEET_TRIP', tripId: null, batchKeys: ['FBA_APPOINTMENT:1'] } }
vi.mocked(getWorkDetail).mockResolvedValue(workDetail({ item: workItem({ nextAction: transport, allowedActions: [transport, followAction] }) }))
wrapper.getComponent({ name: 'WorkCommandForm' }).vm.$emit('saved', { item: workItem(), affectedWorkItemKeys: [] }, 'SAVE_FOLLOWUP'); await flushPromises()
expect(wrapper.find('[data-command]').exists()).toBe(false)
expect(wrapper.text()).toContain('本次保存已完成')
expect(wrapper.get('.primary-next-action').text()).toBe('继续安排本批车辆')
expect(wrapper.findAll('.more-actions button').map(button => button.text())).toEqual(['记录回复'])
await wrapper.get('.primary-next-action').trigger('click'); await flushPromises()
expect(wrapper.emitted('open')).toEqual([['transport-current', transport]])
wrapper.unmount()
})
it('does not repeat a saved command or enable stale actions when the next-step read fails', async () => {
const wrapper = render(workDetail(), followAction); await flushPromises()
vi.mocked(getWorkDetail).mockRejectedValueOnce(new Error('下一步服务暂不可用'))
wrapper.getComponent({ name: 'WorkCommandForm' }).vm.$emit('saved', { item: workItem(), affectedWorkItemKeys: [] }, 'SAVE_FOLLOWUP'); await flushPromises()
expect(wrapper.text()).toContain('无需重复提交已保存内容')
expect(wrapper.get('.primary-next-action').attributes('disabled')).toBeDefined()
await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises()
expect(wrapper.get('.primary-next-action').attributes('disabled')).toBeUndefined()
expect(wrapper.emitted('saved')).toHaveLength(1)
wrapper.unmount()
})
it('shows an undated job separately from its follow-up date and flags missing follow-up ownership', async () => {
const wrapper = render(workDetail()); await flushPromises()
expect(wrapper.get('.followup-summary').text()).toContain('2026-10-15')
expect(wrapper.get('.followup-summary').text()).toContain('作业日期未定,跟进日不作为作业日期')
wrapper.unmount()
const missing = render(workDetail({ item: workItem({ owner: null, followupDate: null }) })); await flushPromises()
expect(missing.text()).toContain('负责人待分派')
expect(missing.text()).toContain('请补充下一次跟进安排')
missing.unmount()
})
it('never sends customer-warehouse dispatch batches to the FBA candidate endpoint', async () => {
const action: WorkAction = { code: 'ARRANGE_TRANSPORT', label: '安排运输', targetWorkItemKey: null, target: { kind: 'FLEET_TRIP', tripId: null, batchKeys: ['DISPATCH:9007199254740999'] } }
const wrapper = render(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }), action); await flushPromises()
@@ -81,6 +117,41 @@ it('uses the matched appointment business number and never passes the internal a
expect(missing.findComponent({ name: 'WorkAppointmentForm' }).exists()).toBe(false)
expect(missing.text()).toContain('尚未关联可操作的预约记录'); missing.unmount()
})
it('opens the receiving scope for the exact batch without changing the current work identity or losing result context', async () => {
const action: WorkAction = { code: 'CHANGE_BOOKING', label: '调整预约', targetWorkItemKey: null, target: { kind: 'FBA_APPOINTMENT', appointmentId: 'internal-id', batchKey: 'FBA_APPOINTMENT:1', sources: [] } }
const wrapper = render(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }), references: [{ kind: 'FBA_APPOINTMENT', key: 'FBA_APPOINTMENT:1', businessNo: 'AP-TEST', label: '预约' }] }), action)
await flushPromises(); wrapper.getComponent({ name: 'WorkAppointmentForm' }).vm.$emit('receiving', 'FBA_APPOINTMENT:1'); await flushPromises()
const scope = wrapper.getComponent({ name: 'WorkReceivingPanel' })
expect(scope.props('batchKey')).toBe('FBA_APPOINTMENT:1')
scope.vm.$emit('updated'); await flushPromises()
expect(wrapper.emitted('updated')).toHaveLength(1)
expect(getWorkDetail).toHaveBeenCalledTimes(2)
expect(getWorkDetail).toHaveBeenLastCalledWith('work-delivery-1', expect.any(AbortSignal))
expect(wrapper.findComponent({ name: 'WorkReceivingPanel' }).exists()).toBe(true)
expect(wrapper.emitted('saved')).toBeUndefined()
scope.vm.$emit('appointment', 'AP-LATER'); await flushPromises()
expect(wrapper.getComponent({ name: 'WorkAppointmentForm' }).props('appointmentNo')).toBe('AP-LATER')
wrapper.unmount()
})
it('resolves the saved later batch and offers its real vehicle next action instead of refreshing the historical work', async () => {
const edit: WorkAction = { code: 'CHANGE_BOOKING', label: '调整预约', targetWorkItemKey: null, target: { kind: 'FBA_APPOINTMENT', appointmentId: '1', batchKey: 'FBA_APPOINTMENT:1', sources: [] } }
const original = workDetail({ item: workItem({ allowedActions: [edit], nextAction: edit }), references: [{ kind: 'FBA_APPOINTMENT', key: 'FBA_APPOINTMENT:1', businessNo: 'AP-OLD', label: '预约' }] })
const wrapper = render(original, edit); await flushPromises()
wrapper.getComponent({ name: 'WorkAppointmentForm' }).vm.$emit('receiving', 'FBA_APPOINTMENT:1'); await flushPromises()
wrapper.getComponent({ name: 'WorkReceivingPanel' }).vm.$emit('appointment', 'AP-LATER-40'); await flushPromises()
const transport: WorkAction = { code: 'ARRANGE_TRANSPORT', label: '继续安排本批车辆', targetWorkItemKey: 'real-transport-later', target: { kind: 'FLEET_TRIP', tripId: null, batchKeys: ['FBA_APPOINTMENT:40'] } }
const childDetail = workDetail({ item: workItem({ workItemKey: 'real-work-child', batchKey: 'FBA_APPOINTMENT:40', nextAction: transport, allowedActions: [transport] }) })
vi.mocked(resolveBatchWork).mockResolvedValue({ batchKey: 'FBA_APPOINTMENT:40', customerId: 'customer-a', detail: childDetail, candidates: [] })
wrapper.getComponent({ name: 'WorkAppointmentForm' }).vm.$emit('saved', batchContext('AP-LATER-40', 'FBA_APPOINTMENT:40').appointment); await flushPromises()
expect(resolveBatchWork).toHaveBeenCalledWith('FBA_APPOINTMENT:40', undefined, expect.any(AbortSignal))
expect(getWorkDetail).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('saved')).toBeUndefined()
expect(wrapper.emitted('open')).toEqual([['real-work-child']]); wrapper.unmount()
const current = render(childDetail); await flushPromises()
expect(current.get('.primary-next-action').text()).toBe('继续安排本批车辆')
await current.get('.primary-next-action').trigger('click'); await flushPromises()
expect(current.emitted('open')).toEqual([['real-transport-later', transport]]); current.unmount()
})
it('displays all associated containers of a shared trip without creating duplicate transport work', async () => {
const wrapper = render(workDetail({ item: workItem({ type: 'TRANSPORT', containers: [{ containerBookingId: '1', bookingNo: 'D-1', containerNo: 'TEST-CN-A' }, { containerBookingId: '2', bookingNo: 'D-2', containerNo: 'TEST-CN-B' }] }) })); await flushPromises()
expect(wrapper.findAll('.container-links button')).toHaveLength(2)