const tripQuery = vi.hoisted(() => ({ tab: '', appointmentReturn: '' })) import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils' import { ElMessage, ElMessageBox } from 'element-plus' import TripsView from './TripsView.vue' import TripDetailView from './TripDetailView.vue' import TripPlanDialog from '../components/TripPlanDialog.vue' import TripLoadingPanel from '../components/TripLoadingPanel.vue' import * as api from '../api/trips' import type { FleetTrip, TripDetail, TripLoading } from '../tripTypes' const state = vi.hoisted(() => ({ writable: true, push: vi.fn() })) vi.mock('vue-router', () => ({ useRoute: () => ({ params: { tripNo: 'TR-TEST' }, query: tripQuery }), useRouter: () => ({ push: state.push }) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (code: string) => code.endsWith(':list') || state.writable }) })) vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined), prompt: vi.fn().mockResolvedValue({ value: '请核对预约时间' }) } })) vi.mock('../api/drivers', () => ({ listDriverWarehouses: vi.fn().mockResolvedValue([{ id: '92', name: '海外仓', code: 'WH-US' }]) })) vi.mock('../api/trips', () => ({ listTrips: vi.fn(), getTrip: vi.fn(), createTrip: vi.fn(), updateTripPlan: vi.fn(), assignTrip: vi.fn(), bindTripDispatch: vi.fn(), unbindTripDispatch: vi.fn(), cancelTrip: vi.fn(), getTripLoading: vi.fn(), confirmTripLoading: vi.fn(), getTripCompletion: vi.fn(), completeTrip: vi.fn(), releaseTripResource: vi.fn(), listTripHistory: vi.fn(), reviewTripDispatch: vi.fn(), reviewTripLoading: vi.fn(), getTripCapacityPlan: vi.fn(), confirmTripCapacityPlan: vi.fn() })) const trip: FleetTrip = { tripNo: 'TR-TEST', status: 'PLANNED', warehouseId: '92', warehouseName: '海外仓', warehouseCode: 'WH-US', vehicleId: '71', vehiclePlate: 'TEST-TRUCK', driverId: '81', driverName: '测试司机', driverPhone: '123', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, plannedDepartureAt: '2026-10-03T00:00:00Z', expectedDriverEndAt: '2026-10-04T00:00:00Z', departedAt: null, completedAt: null, stops: [{ name: '起点' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }], remark: null, recordVersion: 3, updatedAt: '2026-10-02T00:00:00Z', vehicleRelease: null, driverRelease: null } const reviewState = { status: 'PENDING' as const, reason: null, reviewerId: null, reviewedAt: null, recordVersion: null } const detail: TripDetail = { trip, driverQualification: null, dispatches: [], loadingReview: reviewState } const layout = { template: '
' } const input = { name: 'ElInput', props: ['modelValue'], emits: ['update:modelValue'], template: '' } const dialog = { name: 'AppDialog', props: ['modelValue'], emits: ['confirm'], template: '
' } const table = { name: 'DataTable', props: ['data', 'columns', 'page', 'pageSize'], emits: ['update:page'], template: '
' } const wrappers: VueWrapper[] = [] function mount(component: object, props: Record = {}): VueWrapper { const wrapper = shallowMount(component, { props, global: { directives: { loading: () => undefined }, stubs: { AdminPage: layout, ListPageLayout: layout, DetailPageLayout: layout, FormSection: layout, DataTable: table, QueryToolbar: true, ApiErrorMeta: true, LoadErrorState: true, AppDialog: dialog, TripPlanDialog: component === TripPlanDialog ? false : true, TripAssignmentPanel: true, TripLoadingPanel: component === TripLoadingPanel ? false : true, ElButton: { props: ['disabled', 'loading'], template: '' }, ElForm: layout, ElFormItem: layout, ElInput: input, ElSelect: { ...input, name: 'ElSelect' }, ElDatePicker: { ...input, name: 'ElDatePicker' }, ElInputNumber: { ...input, name: 'ElInputNumber' }, ElOption: true, ElTabs: { name: 'ElTabs', props: ['modelValue'], emits: ['update:modelValue'], template: '
' }, ElTabPane: true, } } }); wrappers.push(wrapper); return wrapper } function button(wrapper: VueWrapper, text: string) { return wrapper.findAll('button').find(node => node.text() === text)! } beforeEach(() => { vi.clearAllMocks(); state.writable = true; tripQuery.tab = ''; tripQuery.appointmentReturn = '' vi.mocked(api.listTrips).mockResolvedValue({ records: [structuredClone(trip)], page: 1, size: 20, total: 1, pages: 1 }) vi.mocked(api.getTrip).mockResolvedValue(structuredClone(detail)) vi.mocked(api.listTripHistory).mockResolvedValue({ records: [], page: 1, size: 20, total: 0, pages: 0 }) }) afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount())) it('returns from a trip to the exact appointment work context and preserves it while choosing a trip', async () => { tripQuery.appointmentReturn = '/appointment-calendar?workItem=work-1&workPage=3' const list = mount(TripsView); await flushPromises(); await button(list, '详情').trigger('click') expect(state.push).toHaveBeenCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-TEST' }, query: { appointmentReturn: tripQuery.appointmentReturn } }) const page = mount(TripDetailView); await flushPromises(); await button(page, '返回预约工作').trigger('click') expect(state.push).toHaveBeenLastCalledWith(tripQuery.appointmentReturn) }) it('shows mixed signature and return quantities from one lazy completion read', async () => { vi.mocked(api.getTrip).mockResolvedValue({ ...structuredClone(detail), trip: { ...trip, status: 'IN_TRANSIT' } }) const outcome = { dispatchNo: 'DP-1', deliveryNo: 'DL-1', customerId: 'A', destination: 'ONT8', status: 'DELIVERED', packages: 12, signedPackages: 8, returnedPackages: 4, remainingPackages: 0, terminalAt: trip.updatedAt } vi.mocked(api.getTripCompletion).mockResolvedValue({ tripNo: trip.tripNo, recordVersion: 3, canComplete: true, blockReason: null, outcomes: [outcome] }) const wrapper = mount(TripDetailView); await flushPromises() expect(api.getTripCompletion).not.toHaveBeenCalled() wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'completion'); await flushPromises() expect(api.getTripCompletion).toHaveBeenCalledExactlyOnceWith('TR-TEST') const result = wrapper.findComponent({ name: 'DataTable' }) expect(result.props('data')).toEqual([outcome]) expect(result.props('columns')).toEqual(expect.arrayContaining([ expect.objectContaining({ prop: 'packages', label: '原实发箱数' }), expect.objectContaining({ prop: 'signedPackages', label: '实签箱数' }), expect.objectContaining({ prop: 'returnedPackages', label: '本趟实退箱数' }), expect.objectContaining({ prop: 'remainingPackages', label: '未结箱数' }), ])) expect(wrapper.text()).toContain('余货已签收') expect(api.completeTrip).not.toHaveBeenCalled() }) it('mounts direct costs only when the costs tab is selected', async () => { const wrapper = mount(TripDetailView); await flushPromises() expect(wrapper.findComponent({ name: 'TripCostPanel' }).exists()).toBe(false) wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'costs'); await flushPromises() expect(wrapper.findComponent({ name: 'TripCostPanel' }).props('tripNo')).toBe('TR-TEST') }) it('loads one trip page and opens a real detail route without fetching each row', async () => { const wrapper = mount(TripsView); await flushPromises() expect(api.listTrips).toHaveBeenCalledTimes(1); expect(api.getTrip).not.toHaveBeenCalled() await button(wrapper, '详情').trigger('click') expect(state.push).toHaveBeenCalledWith({ name: 'overseas-master-data-trip-detail', params: { tripNo: 'TR-TEST' }, query: {} }) wrapper.findComponent({ name: 'DataTable' }).vm.$emit('update:page', 2); await flushPromises() expect(api.listTrips).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 })) }) it('keeps read-only trip detail free of write actions and reads history only when opened', async () => { state.writable = false const wrapper = mount(TripDetailView); await flushPromises() expect(api.getTrip).toHaveBeenCalledTimes(1); expect(api.listTripHistory).not.toHaveBeenCalled(); expect(api.getTripLoading).not.toHaveBeenCalled() expect(wrapper.text()).not.toContain('编辑计划'); expect(wrapper.findComponent({ name: 'TripAssignmentPanel' }).exists()).toBe(false) wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'history'); await flushPromises() expect(api.listTripHistory).toHaveBeenCalledExactlyOnceWith('TR-TEST', 1, 20) }) it('keeps the exact create command for an uncertain retry and accepts optional notes', async () => { vi.mocked(api.createTrip).mockRejectedValueOnce(new Error('连接中断')).mockResolvedValueOnce(structuredClone(detail)) const wrapper = mount(TripPlanDialog, { modelValue: false, trip: null }) await wrapper.setProps({ modelValue: true }); await flushPromises() wrapper.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '92'); await flushPromises() wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises() const first = vi.mocked(api.createTrip).mock.calls[0]![0] expect(first.idempotencyKey).toBeTruthy(); expect(first.remark).toBeNull() wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises() expect(api.createTrip).toHaveBeenNthCalledWith(2, first) expect(wrapper.emitted('saved')?.[0]?.[0]).toEqual(detail) }) it('counts a shared physical pallet once and submits only operator-entered measurements', async () => { const cargo = { dispatchNo: 'DP-A', customerId: 'A', dispatchItemId: '100', warehousePalletId: '901', palletNo: 'PLT-1', packages: 10, destinationKey: 'FBA:ONT8', destinationLabel: 'ONT8' } const loading: TripLoading = { tripNo: trip.tripNo, recordVersion: 3, cargo: [cargo, { ...cargo, dispatchNo: 'DP-B', customerId: 'B', dispatchItemId: '101' }], manifestHash: 'a'.repeat(64), confirmedLoad: null, blockReason: null } vi.mocked(api.getTripLoading).mockResolvedValue(loading) vi.mocked(api.confirmTripLoading).mockResolvedValue({ ...loading, recordVersion: 4 }) const wrapper = mount(TripLoadingPanel, { trip, editable: true }); await flushPromises() expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toHaveLength(1) await button(wrapper, '确认实际装载').trigger('click'); expect(api.confirmTripLoading).not.toHaveBeenCalled(); expect(ElMessage.warning).toHaveBeenCalled() const fields = wrapper.findAllComponents({ name: 'ElInputNumber' }) fields[0]!.vm.$emit('update:modelValue', 100); fields[1]!.vm.$emit('update:modelValue', 1); await flushPromises() await button(wrapper, '确认实际装载').trigger('click'); await flushPromises() expect(api.confirmTripLoading).toHaveBeenCalledExactlyOnceWith('TR-TEST', expect.objectContaining({ recordVersion: 3, manifestHash: loading.manifestHash, pallets: [{ warehousePalletId: '901', palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex: 1 }] })) }) it('submits a specific dispatch review with optional approval notes and a reason when rejecting', async () => { const pending: TripDetail = { ...structuredClone(detail), dispatches: [{ dispatchNo: 'DP-A', customerId: 'A', packageCount: 10, boundBy: '1', boundAt: trip.updatedAt, review: reviewState }] } vi.mocked(api.getTrip).mockResolvedValue(pending) vi.mocked(api.reviewTripDispatch).mockResolvedValue({ ...pending, trip: { ...trip, recordVersion: 4 } }) const wrapper = mount(TripDetailView); await flushPromises() await button(wrapper, '审核通过').trigger('click'); await flushPromises() expect(api.reviewTripDispatch).toHaveBeenCalledWith('TR-TEST', 'DP-A', { recordVersion: 3, decision: 'APPROVED', reason: null }) await button(wrapper, '打回').trigger('click'); await flushPromises() expect(ElMessageBox.prompt).toHaveBeenCalled() expect(api.reviewTripDispatch).toHaveBeenLastCalledWith('TR-TEST', 'DP-A', { recordVersion: 4, decision: 'REJECTED', reason: '请核对预约时间' }) }) it('clears displayed loading approval after new actual load and uses the returned version for review', async () => { vi.mocked(api.getTrip).mockResolvedValue({ ...structuredClone(detail), loadingReview: { ...reviewState, status: 'APPROVED' } }) vi.mocked(api.reviewTripLoading).mockResolvedValue(structuredClone(detail)) const wrapper = mount(TripDetailView); await flushPromises() wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'loading'); await flushPromises() expect(button(wrapper, '理货审核通过').attributes('disabled')).toBeDefined() wrapper.findComponent({ name: 'TripLoadingPanel' }).vm.$emit('saved', { tripNo: trip.tripNo, recordVersion: 7 }); await flushPromises() expect(button(wrapper, '理货审核通过').attributes('disabled')).toBeUndefined() await button(wrapper, '理货审核通过').trigger('click'); await flushPromises() expect(api.reviewTripLoading).toHaveBeenCalledWith('TR-TEST', { recordVersion: 7, decision: 'APPROVED', reason: null }) }) it('lets an operator plan capacity before picking without confirming actual loading', async () => { const cargo = { dispatchNo: 'DP-A', customerId: 'A', dispatchItemId: '100', warehousePalletId: '901', palletNo: 'PLT-1', packages: 10, destinationKey: 'FBA:ONT8', destinationLabel: 'ONT8' } const basis = { tripNo: trip.tripNo, recordVersion: 3, cargo: [cargo], manifestHash: 'a'.repeat(64), capacityPlan: null, planValid: false, blockReason: null, availableSegments: [] } vi.mocked(api.getTripCapacityPlan).mockResolvedValue(basis) vi.mocked(api.confirmTripCapacityPlan).mockResolvedValue({ ...basis, recordVersion: 4, planValid: true }) const wrapper = mount(TripLoadingPanel, { trip, editable: true, planning: true }); await flushPromises() expect(api.getTripLoading).not.toHaveBeenCalled() const fields = wrapper.findAllComponents({ name: 'ElInputNumber' }) fields[0]!.vm.$emit('update:modelValue', 100); fields[1]!.vm.$emit('update:modelValue', 1); await flushPromises() await button(wrapper, '确认计划容量').trigger('click'); await flushPromises() expect(api.confirmTripCapacityPlan).toHaveBeenCalledOnce(); expect(api.confirmTripLoading).not.toHaveBeenCalled() expect(wrapper.emitted('planned')?.[0]?.[0]).toMatchObject({ recordVersion: 4 }) expect(wrapper.emitted('saved')).toBeUndefined() }) it('refreshes trip members and loading review after external dispatch binding without leaving the current tab', async () => { const wrapper = mount(TripDetailView); await flushPromises() wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'bookings'); await flushPromises() wrapper.findComponent({ name: 'TripBookingPanel' }).vm.$emit('linked'); await flushPromises() expect(api.getTrip).toHaveBeenCalledTimes(2) expect(wrapper.findComponent({ name: 'ElTabs' }).props('modelValue')).toBe('bookings') }) it('opens the loading review section from the dispatch list deep link', async () => { tripQuery.tab = 'loading' const wrapper = mount(TripDetailView) await flushPromises() expect(wrapper.findComponent({ name: 'ElTabs' }).props('modelValue')).toBe('loading') expect(wrapper.findComponent({ name: 'TripLoadingPanel' }).exists()).toBe(true) expect(wrapper.text()).toContain('理货审核') }) it.each([null, 'FBA:OTHER'])('blocks a same-name ACY2 stop with an unmatched identity %s before submitting', async (destinationKey) => { const cargo = { dispatchNo: 'DP-A', customerId: 'A', dispatchItemId: '100', warehousePalletId: '901', palletNo: 'D0009-101-001', packages: 10, destinationKey: 'FBA:ACY2', destinationLabel: 'ACY2' } const basis = { tripNo: trip.tripNo, recordVersion: 3, cargo: [cargo], manifestHash: 'hash', capacityPlan: null, planValid: false, blockReason: null, availableSegments: [] } vi.mocked(api.getTripCapacityPlan).mockResolvedValue(basis) const currentTrip = { ...trip, stops: [{ name: '起运仓' }, { name: 'ACY2', destinationKey }] } const wrapper = mount(TripLoadingPanel, { trip: currentTrip, editable: true, planning: true }); await flushPromises() expect(wrapper.text()).toContain('站点同名不代表已匹配') expect(wrapper.text()).toContain('按货物匹配卸货站') expect(button(wrapper, '确认计划容量').attributes('disabled')).toBeDefined() expect(wrapper.findComponent({ name: 'DataTable' }).props('data')[0].deliveryIndex).toBeNull() await button(wrapper, '确认计划容量').trigger('click') expect(api.confirmTripCapacityPlan).not.toHaveBeenCalled() await wrapper.setProps({ trip: { ...currentTrip, recordVersion: 4, stops: [{ name: '起运仓' }, { name: 'ACY2', destinationKey: 'FBA:ACY2' }] } }); await flushPromises() expect(wrapper.text()).not.toContain('站点同名不代表已匹配') expect(wrapper.findComponent({ name: 'DataTable' }).props('data')[0].deliveryIndex).toBe(1) }) it('blocks a shared pallet with different identities even when both labels are ACY2', async () => { const cargo = { dispatchNo: 'DP-A', customerId: 'A', dispatchItemId: '100', warehousePalletId: '901', palletNo: 'PLT-1', packages: 10, destinationKey: 'FBA:ACY2', destinationLabel: 'ACY2' } vi.mocked(api.getTripCapacityPlan).mockResolvedValue({ tripNo: trip.tripNo, recordVersion: 3, cargo: [cargo, { ...cargo, destinationKey: 'WAREHOUSE:ACY2' }], manifestHash: 'hash', capacityPlan: null, planValid: false, blockReason: null, availableSegments: [] }) const wrapper = mount(TripLoadingPanel, { trip: { ...trip, stops: [{ name: '起运仓' }, { name: 'ACY2', destinationKey: 'FBA:ACY2' }] }, editable: true, planning: true }); await flushPromises() expect(wrapper.text()).toContain('包含不同目的地,请先拆托') expect(button(wrapper, '确认计划容量').attributes('disabled')).toBeDefined() })