349 lines
29 KiB
TypeScript
349 lines
29 KiB
TypeScript
import ElementPlus, { ElMessage } from 'element-plus'
|
|
import { ApiResponseError } from '@/shared/api/http'
|
|
import { flushPromises, mount } from '@vue/test-utils'
|
|
import { beforeEach, expect, it, vi } from 'vitest'
|
|
import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue'
|
|
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
|
import * as api from '../api/dispatches'
|
|
import { command, detail, loadingPreview } from '../dispatchTestFixtures'
|
|
import type { CommandResult, DispatchAction, DispatchDetail, Driver, Vehicle } from '../dispatchContracts'
|
|
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
|
|
const identity = vi.hoisted(() => ({ id: '' }))
|
|
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
|
vi.mock('./DispatchResourceFields.vue', () => ({ default: { template: '<div />' } }))
|
|
vi.mock('../api/dispatches', () => ({ markDispatchException: vi.fn(), getDispatch: vi.fn(), getDispatchItems: vi.fn(), getDispatchRoute: vi.fn(), reorderDispatchRoute: vi.fn(), removeDispatchItem: vi.fn(), getDispatchLoading: vi.fn(), getDispatchCommand: vi.fn(), listDispatches: vi.fn(), listDispatchCandidates: vi.fn(), completeDispatchLoading: vi.fn(), reviewDispatch: vi.fn(), reviewDispatchLoading: vi.fn(), returnDispatchToReview: vi.fn(), cancelDispatch: vi.fn(), updateDispatch: vi.fn(), addDispatchCargo: vi.fn(), submitDispatchReview: vi.fn() }))
|
|
vi.mock('../api/transportTasks', () => ({ getTransport: vi.fn() }))
|
|
import * as transportApi from '../api/transportTasks'
|
|
interface State { returnSelections: import('../dispatchContracts').DispatchReturnSelection[]; exception: { exceptionType: string; returnDirection: string; description: string; imageFileIds: string[] }; submit(submitReviewAfterSave?: boolean): Promise<void>; retryError(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string }; detail: DispatchDetail }
|
|
async function open(action: DispatchAction, allowed = true, overrides: Partial<DispatchDetail> = {}) {
|
|
vi.mocked(api.getDispatch).mockResolvedValue(detail({ ...overrides, actions: { [action]: { allowed, blockCode: null, blockReason: allowed ? null : '前置审核未通过' } } }))
|
|
const wrapper = mount(DispatchWorkflowDialog, { props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises()
|
|
return { wrapper, state: wrapper.vm as unknown as State }
|
|
}
|
|
beforeEach(() => { vi.resetAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(api.getDispatchLoading).mockResolvedValue(loadingPreview()) })
|
|
it('shows only source pickup destinations in warehouse-transfer review submission', async () => {
|
|
vi.mocked(transportApi.getTransport).mockResolvedValue({ dispatchNo: 'PC/1', taskType: 'PICKUP', execution: {} as never, customerId: null, orderNo: null, pickups: [{ pickupAddress: 'US-LAX-01 · 123 Warehouse Ave' }, { pickupAddress: 'US-LAX-01 · 123 Warehouse Ave' }] as never, completionEvidence: [], dispatchRecordVersion: 0, resources: null })
|
|
const { wrapper } = await open('SUBMIT_REVIEW', true, { taskType: 'PICKUP', dispatchInfo: { ...detail().dispatchInfo, route: '到仓取货 · 1单 · US-LAX-01' } })
|
|
const text = wrapper.getComponent({ name: 'ElDialogContent' }).text()
|
|
expect(text).toContain('取货目的地')
|
|
expect(text).toContain('US-LAX-01 · 123 Warehouse Ave')
|
|
expect(text).not.toContain('起运地')
|
|
expect(transportApi.getTransport).toHaveBeenCalledExactlyOnceWith('PC/1')
|
|
wrapper.unmount()
|
|
})
|
|
it('confirms scanned cargo without requesting measurements and preserves native version zero', async () => {
|
|
const value = loadingPreview(); value.pallets[0]!.weightKg = 100; value.pallets[0]!.volumeCbm = 2
|
|
vi.mocked(api.getDispatchLoading).mockResolvedValue(value)
|
|
const { wrapper, state } = await open('LOADING_COMPLETE')
|
|
expect(state.measurements[0]).toMatchObject({ weightKg: null, volumeCbm: null })
|
|
expect(wrapper.find('[aria-label="确认装车完成"] input').exists()).toBe(false)
|
|
vi.mocked(api.completeDispatchLoading).mockImplementation(async (_, body) => command('LOADING_COMPLETE', body.idempotencyKey))
|
|
vi.mocked(api.getDispatch).mockResolvedValue(detail({ stage: 'DELIVERING', recordVersion: 1 }))
|
|
await state.submit()
|
|
expect(api.completeDispatchLoading).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, manifestHash: 'manifest' }))
|
|
expect(vi.mocked(api.completeDispatchLoading).mock.calls[0]![1]).not.toHaveProperty('measurements')
|
|
expect(api.completeDispatchLoading).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
|
|
})
|
|
it('accepts actual evidence and a matching confirmed manifest while refusing an unknown unloading index', async () => {
|
|
const value = loadingPreview(); value.pallets[0]!.weightKg = 92; value.pallets[0]!.volumeCbm = 1.5; value.pallets[0]!.deliveryIndex = null
|
|
value.pallets[0]!.evidence.push({ field: 'weightKg', actual: true, value: 92, sourceType: 'MEASURED', sourceBusinessNo: 'PALLET-1', observedAt: null })
|
|
value.confirmedLoad = { manifestHash: 'manifest', operatorId: 'operator', confirmedAt: '2026-10-19T00:00:00Z', pallets: [{ warehousePalletId: '21', palletSpaces: 1, weightKg: 92, volumeCbm: 1.5, deliveryIndex: 1 }] }
|
|
vi.mocked(api.getDispatchLoading).mockResolvedValue(value)
|
|
const { wrapper, state } = await open('LOADING_COMPLETE'); expect(state.measurements[0]).toMatchObject({ weightKg: 92, volumeCbm: 1.5, deliveryIndex: null })
|
|
await state.submit(); expect(api.completeDispatchLoading).not.toHaveBeenCalled(); wrapper.unmount()
|
|
})
|
|
it.each(['DISPATCH_REVIEW','LOADING_REVIEW'] as const)('allows editing the %s decision after a failed request',async action=>{
|
|
const reviewApi=action==='DISPATCH_REVIEW'?api.reviewDispatch:api.reviewDispatchLoading
|
|
const {wrapper,state}=await open(action);state.form.decision='REJECTED'
|
|
await state.submit();expect(reviewApi).not.toHaveBeenCalled()
|
|
state.form.reason='预约未核实';vi.mocked(reviewApi).mockRejectedValueOnce(new Error('unknown result'))
|
|
await state.submit()
|
|
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
|
|
state.form.reason='重新确认';state.form.decision='APPROVED'
|
|
vi.mocked(reviewApi).mockImplementationOnce(async(_,body)=>command(action,body.idempotencyKey))
|
|
vi.mocked(api.getDispatch).mockResolvedValue(detail({recordVersion:1}))
|
|
await state.submit()
|
|
const calls=vi.mocked(reviewApi).mock.calls
|
|
expect(calls).toHaveLength(2)
|
|
expect(calls[1]![1].decision).toBe('APPROVED')
|
|
expect(calls[1]![1].idempotencyKey).not.toBe(calls[0]![1].idempotencyKey)
|
|
expect(api.getDispatchCommand).not.toHaveBeenCalled();wrapper.unmount()
|
|
})
|
|
it('shows the preserved rejection while customer service edits vehicle information', async () => {
|
|
const { wrapper, state } = await open('EDIT')
|
|
state.detail.reviews.loadingReview = { ...state.detail.reviews.loadingReview, status: 'REJECTED', reason: '车牌照片不清晰' }
|
|
await flushPromises()
|
|
expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).toContain('理货员审核退回:车牌照片不清晰')
|
|
wrapper.unmount()
|
|
})
|
|
it('blocks an action without a literal service allowance', async () => {
|
|
const { wrapper, state } = await open('SUBMIT_REVIEW', false); await state.submit(); expect(api.submitDispatchReview).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('前置审核未通过'); wrapper.unmount()
|
|
})
|
|
it('does not reuse another dispatch detail when the target changes while its dialog remains open', async () => {
|
|
const { wrapper, state } = await open('SUBMIT_REVIEW')
|
|
let resolve!: (value: DispatchDetail) => void
|
|
vi.mocked(api.getDispatch).mockImplementationOnce(() => new Promise(done => { resolve = done }))
|
|
await wrapper.setProps({ dispatchNo: 'PC/2' }); await state.submit(); expect(api.submitDispatchReview).not.toHaveBeenCalled()
|
|
resolve(detail({ dispatchNo: 'PC/2', actions: { SUBMIT_REVIEW: { allowed: false, blockCode: 'NOT_READY', blockReason: '另一车尚未就绪' } } })); await flushPromises()
|
|
await state.submit(); expect(api.submitDispatchReview).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('另一车尚未就绪'); wrapper.unmount()
|
|
})
|
|
it('preserves external destinations and retries route EDIT through its original route endpoint instead of a resource update', async () => {
|
|
const toast = vi.spyOn(ElMessage, 'success')
|
|
vi.mocked(api.getDispatchRoute).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, edit: { allowed: true, blockCode: null, blockReason: null }, stops: [{ index: 0, name: '起运仓', address: '实际起点', destinationKey: null }, { index: 1, name: 'ONT8', address: '实际地址A', destinationKey: 'CODE:ONT8' }, { index: 2, name: '外接卸货点', address: '实际地址B', destinationKey: 'PRIVATE:actual' }] })
|
|
const { wrapper, state } = await open('EDIT'); await wrapper.setProps({ operation: 'ROUTE' }); await flushPromises()
|
|
const routeState = wrapper.vm as unknown as { moveStop(index: number, offset: number): void }; routeState.moveStop(1, -1); state.form.reason = '按本车现场顺序卸货'
|
|
vi.mocked(api.reorderDispatchRoute).mockRejectedValueOnce(new Error('unknown route')).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
|
|
await state.submit(); vi.mocked(api.getDispatchCommand).mockRejectedValueOnce(new ApiResponseError('not found', 'HTTP_404'))
|
|
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 })); await state.submit()
|
|
const first = vi.mocked(api.reorderDispatchRoute).mock.calls[0]![1]
|
|
expect(first).toMatchObject({ recordVersion: 0, destinationKeys: ['PRIVATE:actual', 'CODE:ONT8'], reason: '按本车现场顺序卸货' }); expect(vi.mocked(api.reorderDispatchRoute).mock.calls[1]![1]).toMatchObject({destinationKeys:first.destinationKeys,reason:first.reason}); expect(vi.mocked(api.reorderDispatchRoute).mock.calls[1]![1].idempotencyKey).not.toBe(first.idempotencyKey); expect(api.updateDispatch).not.toHaveBeenCalled(); expect(toast).toHaveBeenLastCalledWith('卸货顺序调整已确认,请重新核对两级审核'); toast.mockRestore(); wrapper.unmount()
|
|
})
|
|
it('refuses a route that drops an authoritative destination', async () => {
|
|
vi.mocked(api.getDispatchRoute).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, edit: { allowed: true, blockCode: null, blockReason: null }, stops: [{ index: 0, name: '起运仓', address: null, destinationKey: null }, { index: 1, name: 'ONT8', address: null, destinationKey: 'CODE:ONT8' }, { index: 2, name: '外接点', address: null, destinationKey: 'EXTERNAL:destination' }] })
|
|
const route = await open('EDIT'); await route.wrapper.setProps({ operation: 'ROUTE' }); await flushPromises(); (route.wrapper.vm as unknown as { stops: unknown[] }).stops.pop(); route.state.form.reason = '调整'; await route.state.submit(); expect(api.reorderDispatchRoute).not.toHaveBeenCalled(); expect(route.wrapper.text()).toContain('完整保留'); route.wrapper.unmount()
|
|
})
|
|
it.each(['PICKUP', 'LOCAL_TRUCK'] as const)('requires saved vehicle and driver identities for %s review submission', async taskType => {
|
|
vi.mocked(transportApi.getTransport).mockResolvedValue({ dispatchNo: 'PC/1', pickups: [] } as unknown as Awaited<ReturnType<typeof transportApi.getTransport>>)
|
|
const { wrapper, state } = await open('SUBMIT_REVIEW', true, { taskType })
|
|
await state.submit()
|
|
expect(api.submitDispatchReview).not.toHaveBeenCalled()
|
|
expect(wrapper.text()).toContain('车辆信息尚未维护完整')
|
|
wrapper.unmount()
|
|
})
|
|
it('reports A0409 and immediately releases the form without a forced readback', async () => {
|
|
const { wrapper, state } = await open('SUBMIT_REVIEW')
|
|
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new ApiResponseError('本轮调度审核已提交,请勿重复提交', 'A0409', 409, undefined, 'review-request', 'review-trace'))
|
|
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { SUBMIT_REVIEW: { allowed: false, blockCode: 'ALREADY_SUBMITTED', blockReason: '审核已提交' } } }))
|
|
await state.submit(); await flushPromises()
|
|
expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
|
|
expect(api.getDispatch).toHaveBeenCalledTimes(1)
|
|
expect(api.getDispatchCommand).not.toHaveBeenCalled()
|
|
expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull()
|
|
expect(wrapper.emitted('rejected')).toEqual([[expect.objectContaining({ code: 'A0409', requestId: 'review-request', traceId: 'review-trace' })]])
|
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
|
expect(wrapper.emitted('saved')).toBeUndefined()
|
|
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
|
|
wrapper.unmount()
|
|
})
|
|
it('retains edited values and the original validation response when the action is still allowed', async () => {
|
|
const { wrapper, state } = await open('EDIT'); state.form.remark = '用户未保存的本轮备注'
|
|
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new ApiResponseError('预计到达时间须在发车之后', 'A0400', 400, undefined, 'validation-request'))
|
|
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 7, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null } } }))
|
|
await state.submit(); await flushPromises()
|
|
expect(state.form.remark).toBe('用户未保存的本轮备注')
|
|
expect(state.detail.recordVersion).toBe(0)
|
|
expect(wrapper.text()).toContain('预计到达时间须在发车之后')
|
|
expect(wrapper.text()).not.toContain('A0400')
|
|
expect(wrapper.text()).not.toContain('validation-request')
|
|
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
|
|
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
|
await state.retryError()
|
|
expect(state.form.remark).toBe('用户未保存的本轮备注')
|
|
expect(api.updateDispatch).toHaveBeenCalledTimes(1)
|
|
expect(wrapper.text()).toContain('预计到达时间须在发车之后')
|
|
wrapper.unmount()
|
|
})
|
|
it('allows another click after rejection even when a later read would fail', async () => {
|
|
const { wrapper, state } = await open('SUBMIT_REVIEW'); state.form.remark = '保留本次备注'
|
|
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new ApiResponseError('审核已提交', 'A0409', 409, undefined, 'reject-request'))
|
|
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('GET network failure'))
|
|
await state.submit(); await flushPromises()
|
|
expect(wrapper.emitted('rejected')).toEqual([[expect.objectContaining({ code: 'A0409', requestId: 'reject-request' })]])
|
|
expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull()
|
|
expect(wrapper.text()).not.toContain('A0409')
|
|
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
|
|
expect(state.form.remark).toBe('保留本次备注')
|
|
await state.submit()
|
|
expect(api.submitDispatchReview).toHaveBeenCalledTimes(2)
|
|
wrapper.unmount()
|
|
})
|
|
it('ends every failed request and displays a later business rejection without replaying',async()=>{
|
|
const {wrapper,state}=await open('SUBMIT_REVIEW')
|
|
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new Error('unknown')).mockRejectedValueOnce(new ApiResponseError('当前已提交','A0409',409))
|
|
await state.submit()
|
|
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
|
|
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
|
|
await state.submit();await flushPromises()
|
|
const calls=vi.mocked(api.submitDispatchReview).mock.calls
|
|
expect(calls).toHaveLength(2);expect(calls[1]![1].idempotencyKey).not.toBe(calls[0]![1].idempotencyKey)
|
|
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
|
|
expect(api.getDispatchCommand).not.toHaveBeenCalled();expect(wrapper.text()).toContain('当前已提交');wrapper.unmount()
|
|
})
|
|
it('unlocks when a successful write is followed by a failed read',async()=>{
|
|
const {wrapper,state}=await open('SUBMIT_REVIEW')
|
|
vi.mocked(api.submitDispatchReview).mockImplementation(async(_,body)=>command('SUBMIT_REVIEW',body.idempotencyKey))
|
|
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('current detail unavailable'))
|
|
await state.submit();await flushPromises()
|
|
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
|
|
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
|
|
vi.mocked(api.getDispatch).mockResolvedValue(detail({recordVersion:1}))
|
|
await state.submit()
|
|
expect(api.submitDispatchReview).toHaveBeenCalledTimes(2)
|
|
expect(api.getDispatchCommand).not.toHaveBeenCalled();wrapper.unmount()
|
|
})
|
|
|
|
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('blocks a fresh %s command after the existing cargo editor is retired', async action => {
|
|
const { wrapper, state } = await open(action)
|
|
try {
|
|
expect(wrapper.text()).toContain('当前入口不支持调整已建排车货物')
|
|
await state.submit()
|
|
expect(api.addDispatchCargo).not.toHaveBeenCalled()
|
|
expect(api.removeDispatchItem).not.toHaveBeenCalled()
|
|
expect(api.getDispatchItems).not.toHaveBeenCalled()
|
|
expect(api.listDispatchCandidates).not.toHaveBeenCalled()
|
|
expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull()
|
|
expect(wrapper.emitted('saved')).toBeUndefined()
|
|
} finally { wrapper.unmount() }
|
|
})
|
|
it.each(['ADD_CARGO','REMOVE_CARGO'] as const)('ignores old %s records when opening another operation',async action=>{
|
|
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'PC/ORIGINAL',action,attempted:true,body:{idempotencyKey:'old'}}))
|
|
const {wrapper,state}=await open('EDIT')
|
|
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
|
|
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
|
|
expect(wrapper.text()).not.toContain('请从智能排车恢复原请求')
|
|
expect(api.addDispatchCargo).not.toHaveBeenCalled();expect(api.removeDispatchItem).not.toHaveBeenCalled();wrapper.unmount()
|
|
})
|
|
|
|
it('requires an exception type and submits optional direction, description and images once', async () => {
|
|
const { wrapper, state } = await open('MARK_EXCEPTION')
|
|
await state.submit(); expect(api.markDispatchException).not.toHaveBeenCalled()
|
|
expect(wrapper.text()).toContain('请选择异常类型')
|
|
Object.assign(state.exception, { exceptionType: 'PARTIAL_RECEIPT', returnDirection: 'OVERSEAS_WAREHOUSE', description: ' 现场部分签收 ', imageFileIds: ['91'] })
|
|
await state.submit(); expect(api.markDispatchException).not.toHaveBeenCalled()
|
|
state.returnSelections = [{ deliveryNo: 'DL1', lastMileOrderId: '9', items: [{ outboundPalletItemId: '1', inventoryPositionId: '2', returnCartonId: null, packageCount: 1 }] }]
|
|
vi.mocked(api.markDispatchException).mockImplementation(async (_, body) => ({ ...command('MARK_EXCEPTION', body.idempotencyKey), stage: 'DELIVERING' }))
|
|
vi.mocked(api.getDispatch).mockResolvedValue(detail({ stage: 'DELIVERING', recordVersion: 1 }))
|
|
await state.submit()
|
|
expect(api.markDispatchException).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, exceptionType: 'PARTIAL_RECEIPT', returnDirection: 'OVERSEAS_WAREHOUSE', description: '现场部分签收', imageFileIds: ['91'] }))
|
|
expect(api.markDispatchException).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toHaveLength(1)
|
|
expect(api.completeDispatchLoading).not.toHaveBeenCalled(); wrapper.unmount()
|
|
})
|
|
it('does not submit an exception when the server disallows the action', async () => {
|
|
const { wrapper, state } = await open('MARK_EXCEPTION', false)
|
|
state.exception.exceptionType = 'REFUSED'; await state.submit()
|
|
expect(api.markDispatchException).not.toHaveBeenCalled(); wrapper.unmount()
|
|
})
|
|
|
|
it('submits review from vehicle maintenance after saving the current inputs and reading the new version', async () => {
|
|
const { wrapper, state } = await open('EDIT')
|
|
const gate = { allowed: true, blockCode: null, blockReason: null }
|
|
expect(wrapper.find('[aria-label="本次操作备注"]').exists()).toBe(false)
|
|
expect(wrapper.findAll('button').some(button => button.text() === '保存')).toBe(true)
|
|
expect(wrapper.findAll('button').some(button => button.text() === '提交审核')).toBe(true)
|
|
vi.mocked(api.updateDispatch).mockImplementation(async (_, body) => command('EDIT', body.idempotencyKey))
|
|
vi.mocked(api.submitDispatchReview).mockImplementation(async (_, body) => ({ ...command('SUBMIT_REVIEW', body.idempotencyKey), recordVersion: 2 }))
|
|
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { EDIT: gate, SUBMIT_REVIEW: gate } })).mockResolvedValueOnce(detail({ recordVersion: 2 }))
|
|
await state.submit(true)
|
|
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, vehicle: expect.any(Object), driver: expect.any(Object) }))
|
|
expect(api.submitDispatchReview).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 1, remark: null }))
|
|
expect(vi.mocked(api.updateDispatch).mock.invocationCallOrder[0]).toBeLessThan(vi.mocked(api.submitDispatchReview).mock.invocationCallOrder[0]!)
|
|
expect(wrapper.emitted('update:modelValue')).toContainEqual([false]); wrapper.unmount()
|
|
})
|
|
it('save alone never submits review and a failed save cannot advance review', async () => {
|
|
const first = await open('EDIT')
|
|
vi.mocked(api.updateDispatch).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
|
|
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1 }))
|
|
await first.state.submit(); expect(api.submitDispatchReview).not.toHaveBeenCalled(); first.wrapper.unmount()
|
|
const second = await open('EDIT')
|
|
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new ApiResponseError('车辆资料不完整', 'A0400'))
|
|
await second.state.submit(true); expect(api.submitDispatchReview).not.toHaveBeenCalled()
|
|
expect(second.wrapper.text()).toContain('车辆资料不完整'); second.wrapper.unmount()
|
|
})
|
|
it('rechecks review eligibility after save and leaves the saved editor open if blocked', async () => {
|
|
const { wrapper, state } = await open('EDIT')
|
|
vi.mocked(api.updateDispatch).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
|
|
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, SUBMIT_REVIEW: { allowed: false, blockCode: 'MISSING', blockReason: '预约资料未齐全' } } }))
|
|
await state.submit(true)
|
|
expect(api.submitDispatchReview).not.toHaveBeenCalled(); expect(state.detail.recordVersion).toBe(1)
|
|
expect(wrapper.text()).toContain('预约资料未齐全'); expect(wrapper.emitted('update:modelValue')).toBeUndefined(); wrapper.unmount()
|
|
})
|
|
it('allows a fresh save and review after a failed save without persisting review intent',async()=>{
|
|
const {wrapper,state}=await open('EDIT')
|
|
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new Error('save unknown'))
|
|
await state.submit(true)
|
|
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
|
|
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
|
|
vi.mocked(api.updateDispatch).mockImplementationOnce(async(_,body)=>command('EDIT',body.idempotencyKey))
|
|
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({recordVersion:1,actions:{SUBMIT_REVIEW:{allowed:true,blockCode:null,blockReason:null}}})).mockResolvedValueOnce(detail({recordVersion:2}))
|
|
vi.mocked(api.submitDispatchReview).mockImplementationOnce(async(_,body)=>({...command('SUBMIT_REVIEW',body.idempotencyKey),recordVersion:2}))
|
|
await state.submit(true)
|
|
expect(api.updateDispatch).toHaveBeenCalledTimes(2);expect(api.submitDispatchReview).toHaveBeenCalledOnce()
|
|
expect(api.getDispatchCommand).not.toHaveBeenCalled();wrapper.unmount()
|
|
})
|
|
it('allows a new save-and-review click after review submission fails',async()=>{
|
|
const {wrapper,state}=await open('EDIT')
|
|
vi.mocked(api.updateDispatch).mockImplementation(async(_,body)=>command('EDIT',body.idempotencyKey))
|
|
vi.mocked(api.getDispatch).mockResolvedValue(detail({recordVersion:1,actions:{EDIT:{allowed:true,blockCode:null,blockReason:null},SUBMIT_REVIEW:{allowed:true,blockCode:null,blockReason:null}}}))
|
|
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new Error('review unknown'))
|
|
await state.submit(true)
|
|
expect(useDispatchCommandRecovery(()=>identity.id).pending.value).toBeNull()
|
|
vi.mocked(api.submitDispatchReview).mockImplementationOnce(async(_,body)=>command('SUBMIT_REVIEW',body.idempotencyKey))
|
|
await state.submit(true)
|
|
expect(api.updateDispatch).toHaveBeenCalledTimes(2);expect(api.submitDispatchReview).toHaveBeenCalledTimes(2)
|
|
expect(api.getDispatchCommand).not.toHaveBeenCalled();wrapper.unmount()
|
|
})
|
|
|
|
it('keeps publication fixed when saving vehicle maintenance', async () => {
|
|
const { wrapper, state } = await open('EDIT')
|
|
expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).not.toContain('发布公共散板需求')
|
|
vi.mocked(api.updateDispatch).mockImplementation(async (_, body) => command('EDIT', body.idempotencyKey))
|
|
await state.submit()
|
|
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0 }))
|
|
expect(vi.mocked(api.updateDispatch).mock.calls[0]![1]).not.toHaveProperty('publishLooseboardDemand')
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it('saves a cleared unavailable master vehicle as an empty draft resource', async () => {
|
|
const { wrapper, state } = await open('EDIT')
|
|
const editor = wrapper.vm as unknown as { vehicle: { mode: 'MASTER'; vehicleId: string | null } }
|
|
editor.vehicle.mode = 'MASTER'; editor.vehicle.vehicleId = null
|
|
vi.mocked(api.updateDispatch).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey))
|
|
await state.submit()
|
|
expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ vehicle: null }))
|
|
expect(api.submitDispatchReview).not.toHaveBeenCalled()
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it.each(['VEHICLE', 'DRIVER_PHOTO', 'DRIVER'] as const)('does not silently discard %s files when the master resource is missing', async kind => {
|
|
const { wrapper, state } = await open('EDIT')
|
|
const editor = wrapper.vm as unknown as { vehicle: Vehicle; driver: Driver }
|
|
editor.vehicle.mode = 'MASTER'; editor.vehicle.vehicleId = null
|
|
editor.driver.mode = 'MASTER'; editor.driver.driverId = null
|
|
await flushPromises()
|
|
const card = wrapper.findAllComponents(DispatchEvidenceFiles).find(item => item.props('kind') === kind)!
|
|
expect(card.get('.evidence-upload-button').attributes('disabled')).toBeUndefined()
|
|
expect(wrapper.text()).not.toContain('请先在“排车信息”')
|
|
// A pending reference must also be guarded at save time, rather than being converted to null.
|
|
card.vm.$emit('update:modelValue', ['301'])
|
|
await state.submit(); await flushPromises()
|
|
expect(api.updateDispatch).not.toHaveBeenCalled()
|
|
expect(wrapper.emitted('saved')).toBeUndefined()
|
|
expect(card.props('modelValue')).toEqual(['301'])
|
|
expect(wrapper.text()).toContain(kind === 'VEHICLE' ? '请先选择车辆' : '请先选择司机')
|
|
wrapper.unmount()
|
|
})
|
|
|
|
it.each(['MASTER', 'EXTERNAL'] as const)('keeps all three %s photo references after saving, reopening and saving again', async mode => {
|
|
const { wrapper, state } = await open('EDIT')
|
|
const editor = wrapper.vm as unknown as { vehicle: Vehicle; driver: Driver }
|
|
editor.vehicle.mode = mode; editor.vehicle.vehicleId = mode === 'MASTER' ? '11' : null
|
|
editor.driver.mode = mode; editor.driver.driverId = mode === 'MASTER' ? '12' : null
|
|
const expected = { VEHICLE: ['301'], DRIVER_PHOTO: ['302'], DRIVER: ['303'] }
|
|
for (const card of wrapper.findAllComponents(DispatchEvidenceFiles)) card.vm.$emit('update:modelValue', expected[card.props('kind') as keyof typeof expected])
|
|
let saved: DispatchDetail | undefined
|
|
vi.mocked(api.updateDispatch).mockImplementation(async (_, body) => {
|
|
saved = detail({ recordVersion: 1, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: JSON.parse(JSON.stringify({ vehicle: body.vehicle, driver: body.driver, maintenance: body.maintenance })) })
|
|
vi.mocked(api.getDispatch).mockImplementation(async () => JSON.parse(JSON.stringify(saved)))
|
|
return command('EDIT', body.idempotencyKey)
|
|
})
|
|
await state.submit(); await flushPromises()
|
|
expect(wrapper.emitted('saved')).toHaveLength(1)
|
|
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
|
for (const card of wrapper.findAllComponents(DispatchEvidenceFiles)) expect(card.props('modelValue')).toEqual(expected[card.props('kind') as keyof typeof expected])
|
|
await state.submit()
|
|
expect(api.updateDispatch).toHaveBeenCalledTimes(2)
|
|
expect(vi.mocked(api.updateDispatch).mock.calls[1]![1]).toMatchObject({ vehicle: { evidenceFileIds: ['301'] }, driver: { evidence: { photoFileIds: ['302'], fileIds: ['303'] } } })
|
|
wrapper.unmount()
|
|
})
|