2026-10-03 04:35:55 +08:00
|
|
|
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' } }), 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: '<div><slot name="header"/><slot/></div>' }
|
|
|
|
|
const input = { name: 'ElInput', props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\',$event.target.value)" />' }
|
|
|
|
|
const dialog = { name: 'AppDialog', props: ['modelValue'], emits: ['confirm'], template: '<div v-if="modelValue"><slot/><button @click="$emit(\'confirm\')">保存计划</button></div>' }
|
|
|
|
|
const table = { name: 'DataTable', props: ['data', 'columns', 'page', 'pageSize'], emits: ['update:page'], template: '<div><slot name="toolbar-actions"/><div v-for="(row,index) in data" :key="index"><slot name="actions" :row="row"/><slot name="weight" :row="row"/><slot name="volume" :row="row"/></div></div>' }
|
|
|
|
|
const wrappers: VueWrapper[] = []
|
|
|
|
|
function mount(component: object, props: Record<string, unknown> = {}): 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: '<button :disabled="disabled || loading"><slot/></button>' }, 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: '<div><slot/></div>' }, 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
|
|
|
|
|
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()))
|
|
|
|
|
|
2026-10-03 12:43:25 +08:00
|
|
|
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')
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-03 04:35:55 +08:00
|
|
|
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' } })
|
|
|
|
|
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')
|
|
|
|
|
})
|