Files
admin-web/src/modules/overseas-warehouse/pages/DispatchDetailView.test.ts
T

189 lines
15 KiB
TypeScript

import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import { getDispatch, getDispatchEvidence } from '../api/dispatches'
import DispatchDetailView from './DispatchDetailView.vue'
import { detail, destination } from '../dispatchTestFixtures'
import { DataTableStub } from './phase5TestStubs'
const query = vi.hoisted(() => ({} as Record<string, string>))
const push = vi.hoisted(() => vi.fn())
const can = vi.hoisted(() => vi.fn(() => false))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('vue-router', () => ({ useRoute: () => ({ params: { dispatchNo: 'PC/1' }, query }), useRouter: () => ({ push }) }))
vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchEvidence: vi.fn() }))
vi.mock('@/modules/overseas-master-data', async () => ({
vehicleStatusLabels: (await vi.importActual<typeof import('../../overseas-master-data/presentation')>('../../overseas-master-data/presentation')).vehicleStatusLabels,
inquiryReturnTarget: (value: Record<string, string>) => value.fleetOfferNo ? { name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: value.fleetOfferNo }, query: { inquiry: value.fleetBookingNo } } : null,
}))
const options = { global: { plugins: [ElementPlus], stubs: { DataTable: DataTableStub, DispatchWorkflowDialog: true, TransportExecutionPanel: true } } }
beforeEach(() => { vi.clearAllMocks(); vi.mocked(getDispatch).mockResolvedValue(detail()); can.mockReturnValue(false); push.mockReset(); Object.keys(query).forEach(key => delete query[key]) })
describe('whole-dispatch four-tab detail', () => {
it('shows saved review attachments beside resource evidence without providing upload controls', async () => {
const value = detail(); value.vehicleInfo.vehicle!.evidenceFileIds = ['301']
value.reviews.dispatchReview.attachments = { vehicleFileIds: ['301', '302'], driverPhotoFileIds: ['303'], licenseFileIds: [] }
value.reviews.loadingReview.attachments = { vehicleFileIds: [], driverPhotoFileIds: [], licenseFileIds: ['304'] }
vi.mocked(getDispatch).mockResolvedValue(value)
vi.mocked(getDispatchEvidence).mockImplementation(async (_, __, fileId) => ({ fileId, fileName: `审核附件${fileId}.png`, contentType: 'image/png', sizeBytes: 3 }))
const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(vi.mocked(getDispatchEvidence).mock.calls).toEqual([['PC/1', 'VEHICLE', '301'], ['PC/1', 'VEHICLE', '302'], ['PC/1', 'DRIVER_PHOTO', '303'], ['PC/1', 'DRIVER', '304']])
expect(wrapper.findAll('input[type="file"]')).toHaveLength(0)
expect(wrapper.text()).toContain('司机照片')
expect(wrapper.text()).toContain('审核附件304.png')
wrapper.unmount()
})
it.each(['dispatchReview', 'loadingReview'] as const)('keeps the %s rejection readable in the detail', async reviewKey => {
const value = detail()
value.reviews[reviewKey] = { ...value.reviews[reviewKey], status: 'REJECTED', reason: '司机资料需要更正', invalidatedReason: '资源已修改,待重新提交' }
vi.mocked(getDispatch).mockResolvedValue(value)
const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(wrapper.text()).toContain('已退回客服处理(车辆信息)')
expect(wrapper.text()).toContain('司机资料需要更正')
expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).exists()).toBe(false)
wrapper.unmount()
})
it('keeps every write action out of the detail even when authorized', async () => {
can.mockReturnValue(true)
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ actions: { EDIT: { allowed: true, blockCode: null, blockReason: null }, ADD_CARGO: { allowed: true, blockCode: null, blockReason: null }, REMOVE_CARGO: { allowed: true, blockCode: null, blockReason: null } } }))
const wrapper = mount(DispatchDetailView, options); await flushPromises()
const actions = wrapper.findAll('button').map(button => button.text())
expect(actions).not.toContain('维护车辆信息')
expect(actions).not.toContain('添加货物')
expect(actions).not.toContain('移除货物')
wrapper.unmount()
})
it.each(['PICKUP', 'LOCAL_TRUCK'] as const)('hosts %s execution in the existing dispatch page', async taskType => {
vi.mocked(getDispatch).mockResolvedValueOnce({ ...detail(), taskType })
const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(wrapper.get('transport-execution-panel-stub').attributes('dispatchno')).toBe('PC/1')
expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props('readonly')).toBe(true)
expect(wrapper.findAll('[role="tab"]')).toHaveLength(0)
expect(wrapper.find('[aria-label="排车操作"]').exists()).toBe(false); wrapper.unmount()
})
it('returns to the original appointment date and view from the dispatch detail', async () => {
query.appointmentReturn = '/appointment-calendar?date=2026-10-21&mode=week'
const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(wrapper.get('button.density-back').text()).toContain('返回预约日历')
await wrapper.get('button.density-back').trigger('click')
expect(push).toHaveBeenCalledWith('/appointment-calendar?date=2026-10-21&mode=week')
wrapper.unmount()
})
it('ignores an external calendar return URL and keeps the normal dispatch-list return', async () => {
query.appointmentReturn = 'https://outside.invalid/appointment-calendar?date=2026-10-21'
const wrapper = mount(DispatchDetailView, options); await flushPromises()
await wrapper.get('button.density-back').trigger('click')
expect(push).toHaveBeenCalledWith({ name: 'overseas-dispatches' })
wrapper.unmount()
})
it('uses only four accessible colored tabs and switches without another network read', async () => {
const value = detail(); value.dispatchInfo.fbaAppointments = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'BATCH-1', appointmentNo: 'FBA-AP-1', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'CONFIRMED', confirmedAt: null, blockReason: null }]
vi.mocked(getDispatch).mockResolvedValueOnce(value)
const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(getDispatch).toHaveBeenCalledWith('PC/1')
const tabs = wrapper.findAll('[role="tab"]')
expect(tabs.map(tab => tab.text())).toEqual(['🚚车辆信息', '📋排车信息', '📝单据信息', '📦关联订单明细'])
expect(wrapper.findAll('.density-tab-icon')).toHaveLength(4)
await tabs[1]!.trigger('click'); await flushPromises()
expect(tabs[1]!.attributes('aria-selected')).toBe('true')
const dispatchInfo = wrapper.get(`#${tabs[1]!.attributes('aria-controls')}`)
expect(dispatchInfo.text()).toContain('LAX → ONT8'); expect(dispatchInfo.text()).toContain('FBA预约'); expect(dispatchInfo.text()).toContain('ONT8 · FBA-AP-1 · CONFIRMED')
expect(dispatchInfo.findAll('dt').map(field => field.text())).toEqual(['路线', '发车时间', '预计到达时间', 'FBA预约'])
expect(getDispatch).toHaveBeenCalledTimes(1); expect(wrapper.text()).toContain('装车前'); wrapper.unmount()
})
it('uses exact server order attribution including unknown measurements and real container numbers', async () => {
const value = detail(); value.orderDetails.push({ ...value.orderDetails[0]!, customerId: 'C2', customerName: '客户乙', sourceKey: 'source-2', orderNo: 'LM-2', grossWeightKg: 12.25 })
value.orderDetails.forEach((row, index) => { row.sourceBusinessType = 'RELABEL_ORDER'; row.sourceBusinessNo = `RL-${index + 1}` })
vi.mocked(getDispatch).mockResolvedValueOnce(value)
const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(wrapper.getComponent(DataTableStub).props('data')).toEqual(value.orderDetails)
expect(wrapper.getComponent(DataTableStub).vm.$attrs.columns).toContainEqual(expect.objectContaining({ prop: 'sourceBusinessNo', label: '来源业务号' }))
expect(value.orderDetails[0]!.grossWeightKg).toBeNull(); wrapper.unmount()
})
it.each([
['IN_SERVICE', '在役'], ['DISPATCHED', '外派中'], ['MAINTENANCE', '维修中'], ['INACTIVE', '停用'], ['RETURNED', '已归还'],
['FUTURE_STATUS', '—'], ['toString', '—'], [null, '—'],
])('presents vehicle status %s through the existing master-data labels or an explicit placeholder', async (status, label) => {
const value = detail(); value.vehicleInfo.vehicle!.vehicleStatus = status
vi.mocked(getDispatch).mockResolvedValueOnce(value)
const wrapper = mount(DispatchDetailView, options); await flushPromises()
const field = wrapper.findAll('.detail-fields > div').find(row => row.find('dt').text() === '车辆状态')!
expect(field.get('dd').text()).toBe(label)
if (status) expect(field.text()).not.toContain(status)
wrapper.unmount()
})
it('renders all customer delivery references and real update time/remark without leaking internal IDs', async () => {
const value = detail(); value.documents.deliveries = ['DEL-A', 'DEL-B'].map((deliveryNo, index) => ({ deliveryNo, customerId: `C${index}`, customerName: `客户${index}`, outboundNo: `OUT-${index}`, groupKey: `group-${index}`, destination: destination(), status: 'WAIT_DELIVERY', packageCount: 1, palletCount: 1 }))
vi.mocked(getDispatch).mockResolvedValueOnce(value)
const wrapper = mount(DispatchDetailView, options); await flushPromises(); await wrapper.findAll('[role="tab"]')[2]!.trigger('click')
expect(wrapper.text()).toContain('DEL-A'); expect(wrapper.text()).toContain('DEL-B'); expect(wrapper.text()).toContain('真实备注')
expect(wrapper.findAll('button').some(button => button.text() === 'DEL-A')).toBe(false); wrapper.unmount()
})
it('requires a literal service allowance and permission for a write action', async () => {
can.mockReturnValue(true); vi.mocked(getDispatch).mockResolvedValueOnce(detail({ actions: { DEPART: { allowed: false, blockCode: 'NOT_ARRANGED', blockReason: '尚未安排派送' } } }))
const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(wrapper.text()).not.toContain('尚未安排派送'); expect(wrapper.findAll('button').some(button => button.text() === '确认发车')).toBe(false); wrapper.unmount()
})
it('shows stage facts without execution actions or unrelated permission refusals', async () => {
can.mockReturnValue(true)
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ stage: 'LOADING', actions: {
LOADING_COMPLETE: { allowed: true, blockCode: null, blockReason: null },
EDIT: { allowed: false, blockCode: 'STAGE', blockReason: '当前阶段或作业事实不满足操作条件' },
PICK: { allowed: false, blockCode: 'PERMISSION', blockReason: '当前无此操作权限' },
} }))
const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(wrapper.text()).toContain('装车中')
expect(wrapper.text()).not.toContain('当前阶段或作业事实不满足操作条件')
expect(wrapper.text()).not.toContain('当前无此操作权限')
expect(wrapper.findAll('button').some(button => button.text() === '维护车辆信息')).toBe(false)
expect(wrapper.findAll('button').some(button => button.text() === '确认装车完成')).toBe(false)
expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).exists()).toBe(false)
wrapper.unmount()
})
it('retains a real attachment A0404 failure and its request diagnostics in the vehicle tab', async () => {
const value = detail(); value.vehicleInfo.vehicle!.evidenceFileIds = ['301']
vi.mocked(getDispatch).mockResolvedValueOnce(value)
vi.mocked(getDispatchEvidence).mockRejectedValueOnce(new ApiResponseError('附件不存在', 'A0404', 404, undefined, 'evidence-request', 'evidence-trace'))
const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(getDispatchEvidence).toHaveBeenCalledWith('PC/1', 'VEHICLE', '301')
expect(wrapper.text()).toContain('本趟附件操作未完成')
expect(wrapper.text()).toContain('A0404')
expect(wrapper.text()).toContain('evidence-request')
expect(wrapper.text()).toContain('evidence-trace')
expect(value.vehicleInfo.vehicle!.evidenceFileIds).toEqual(['301'])
wrapper.unmount()
})
it('retains a real API failure and exposes retry rather than rendering a successful empty detail', async () => {
vi.mocked(getDispatch).mockRejectedValueOnce(new Error('permission denied'))
const wrapper = mount(DispatchDetailView, options); await flushPromises(); expect(wrapper.text()).toContain('排车详情加载失败,请重试'); expect(wrapper.findAll('[role="tab"]')).toHaveLength(0); wrapper.unmount()
})
it('keeps the original inquiry return target', async () => {
Object.assign(query, { fleetOfferNo: 'FO-1', fleetBookingNo: 'FB-2' }); const wrapper = mount(DispatchDetailView, options); await flushPromises(); await wrapper.get('button.density-back').trigger('click')
expect(push).toHaveBeenCalledWith({ name: 'overseas-master-data-capacity-offer-detail', params: { offerNo: 'FO-1' }, query: { inquiry: 'FB-2' } }); wrapper.unmount()
})
it('does not mount review or mutation workflows for an authorized administrator', async () => {
can.mockReturnValue(true)
const allowed = { allowed: true, blockCode: null, blockReason: null }
vi.mocked(getDispatch).mockResolvedValueOnce(detail({ actions: { EDIT: allowed, CANCEL: allowed, SUBMIT_REVIEW: allowed, DISPATCH_REVIEW: allowed, LOADING_REVIEW: allowed } }))
const wrapper = mount(DispatchDetailView, options); await flushPromises()
expect(wrapper.findComponent({ name: 'DispatchWorkflowDialog' }).exists()).toBe(false)
expect(wrapper.find('[aria-label="排车操作"]').exists()).toBe(false)
expect(wrapper.findAll('input, textarea')).toHaveLength(0)
expect(wrapper.findAll('[role="tab"]')).toHaveLength(4)
expect(getDispatch).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
})
it('shows saved dispatch exceptions and their own images in the existing dispatch tab', async () => {
const value = detail({ stage: 'DELIVERING' })
value.dispatchInfo.exceptions = [{ exceptionType: 'REFUSED', returnDirection: 'OVERSEAS_WAREHOUSE', description: '现场拒收待处理', imageFileIds: ['901'], occurredAt: '2026-10-21T10:00:00Z' }]
vi.mocked(getDispatch).mockResolvedValue(value)
vi.mocked(getDispatchEvidence).mockResolvedValue({ fileId: '901', fileName: '异常现场.png', contentType: 'image/png', sizeBytes: 3 })
const wrapper = mount(DispatchDetailView, options); await flushPromises()
await wrapper.findAll('[role="tab"]')[1]!.trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('拒收'); expect(wrapper.text()).toContain('退回海外仓'); expect(wrapper.text()).toContain('现场拒收待处理')
expect(getDispatchEvidence).toHaveBeenCalledWith('PC/1', 'EXCEPTION', '901')
expect(wrapper.findAll('[role="tab"]')).toHaveLength(4); expect(wrapper.findAll('input[type="file"]')).toHaveLength(0)
wrapper.unmount()
})