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

160 lines
16 KiB
TypeScript

import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import WorkDetailPanel from './WorkDetailPanel.vue'
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(), 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: { 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: '<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()
expect(wrapper.findComponent({ name: 'WorkDispatchForm' }).exists()).toBe(false); wrapper.unmount()
})
it('resolves a customer-warehouse batch using the frozen work item batch number', 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({ batchKey: 'DISPATCH:9007199254740999', batchNo: 'DS-ORIGINAL', allowedActions: [action], nextAction: action }) }), action); await flushPromises()
expect(wrapper.getComponent({ name: 'WorkExistingDispatchForm' }).props('dispatches')).toEqual([{ batchKey: 'DISPATCH:9007199254740999', dispatchNo: 'DS-ORIGINAL' }])
expect(wrapper.findComponent({ name: 'WorkDispatchForm' }).exists()).toBe(false); wrapper.unmount()
})
it('opens real batch dispatch when the server has not bound a trip yet', async () => {
const action: WorkAction = { code: 'ARRANGE_TRANSPORT', label: '安排运输', targetWorkItemKey: null, target: { kind: 'FLEET_TRIP', tripId: null, batchKeys: ['FBA_APPOINTMENT:1'] } }
const wrapper = render(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }), action); await flushPromises()
expect(wrapper.getComponent({ name: 'WorkDispatchForm' }).props('batchKeys')).toEqual(['FBA_APPOINTMENT:1']); wrapper.unmount()
})
it('loads the exact container plan list for a multiple-plan receiving target', async () => {
const action: WorkAction = { code: 'RECORD_EXECUTION', label: '查看接仓', targetWorkItemKey: null, target: { kind: 'WAREHOUSE_RECEIPT', inboundPlanId: null, inboundPlanNo: null, customerId: null, containerBookingId: '9007199254740999' } }
const wrapper = render(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }), action); await flushPromises()
expect(wrapper.getComponent({ name: 'WorkReceiptPlans' }).props('containerBookingId')).toBe('9007199254740999'); wrapper.unmount()
})
it('opens a real create form for unappointed sources with their exact customer scope', async () => {
const action: WorkAction = { code: 'SAVE_PLAN', label: '安排预约', targetWorkItemKey: null, target: { kind: 'FBA_APPOINTMENT', appointmentId: null, batchKey: null, sources: [{ sourceKey: 'BOOKING:1', customerId: 'customer-a', destinationKey: 'FBA:ONT8' }] } }
const wrapper = render(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }), action); await flushPromises()
expect(wrapper.getComponent({ name: 'WorkCreateAppointmentForm' }).props('sources')).toEqual(action.target.kind === 'FBA_APPOINTMENT' ? action.target.sources : [])
expect(wrapper.text()).not.toContain('尚未关联可操作的预约记录'); wrapper.unmount()
})
it('opens the server-selected prerequisite instead of issuing the current item command', async () => {
const action = { ...followAction, targetWorkItemKey: 'work-pickup-1' }
const wrapper = render(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }), action); await flushPromises()
expect(wrapper.emitted('open')).toEqual([['work-pickup-1', action]])
expect(wrapper.findComponent({ name: 'WorkCommandForm' }).exists()).toBe(false); wrapper.unmount()
})
it('uses explicit cargo context and preserves large customer IDs without inspecting a business key', async () => {
const action: WorkAction = { code: 'VERIFY_CARGO', label: '核实货物资料', targetWorkItemKey: null, target: { kind: 'CARGO_VERIFICATION', source: { customerId: '9007199254740999', sourceKey: 'BOOKING:B-1', destinationKey: 'FBA:ONT8' } } }
const wrapper = render(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }), action); await flushPromises()
expect(wrapper.getComponent({ name: 'CargoVerificationForm' }).props('source')).toEqual(action.target.kind === 'CARGO_VERIFICATION' ? action.target.source : null)
expect(wrapper.text()).not.toContain('9007199254740999'); wrapper.unmount()
})
it('rechecks current qualification before opening a previously offered write action', async () => {
const wrapper = render(workDetail({ item: workItem({ allowedActions: [], nextAction: null, blockReasons: ['预约已由其他人修改'] }) }), followAction); await flushPromises()
expect(wrapper.findComponent({ name: 'WorkCommandForm' }).exists()).toBe(false)
expect(wrapper.text()).toContain('预约已由其他人修改'); wrapper.unmount()
})
it('continues the real unbatched cargo action into original appointment creation with exact source scope', async () => {
const source = { customerId: 'customer-a', sourceKey: 'BOOKING:9007199254740999', destinationKey: 'FBA:ONT8' }
const action: WorkAction = { code: 'VERIFY_CARGO', label: '核实货物并建立预约', targetWorkItemKey: null, target: { kind: 'CARGO_VERIFICATION', source } }
const wrapper = render(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }), action); await flushPromises()
wrapper.getComponent({ name: 'CargoVerificationForm' }).vm.$emit('saved'); await flushPromises()
expect(wrapper.emitted('saved')).toBeUndefined()
await wrapper.findAll('button').find(button => button.text() === '核对货物并新建原预约')!.trigger('click'); await flushPromises()
expect(wrapper.getComponent({ name: 'WorkCreateAppointmentForm' }).props('sources')).toEqual([source]); wrapper.unmount()
})
it('uses the matched appointment business number and never passes the internal appointment ID to the old API', async () => {
const action: WorkAction = { code: 'CHANGE_BOOKING', label: '调整预约', targetWorkItemKey: null, target: { kind: 'FBA_APPOINTMENT', appointmentId: 'internal-id', batchKey: 'batch-1', sources: [] } }
const detail = workDetail({ item: workItem({ allowedActions: [action], nextAction: action }), references: [{ kind: 'FBA_APPOINTMENT', key: 'batch-1', businessNo: 'AP-TEST', label: '预约' }] })
const wrapper = render(detail, action); await flushPromises()
expect(wrapper.getComponent({ name: 'WorkAppointmentForm' }).props('appointmentNo')).toBe('AP-TEST'); wrapper.unmount()
const missing = render({ ...detail, references: [] }, action); await flushPromises()
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)
await wrapper.findAll('.container-links button')[1]!.trigger('click'); expect(wrapper.emitted('container')).toEqual([['2', 'TEST-CN-B']]); wrapper.unmount()
})