Files
admin-web/src/modules/overseas-master-data/pages/TripsBehavior.test.ts
T

210 lines
18 KiB
TypeScript
Raw Normal View History

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: '<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-start"/><slot name="toolbar-actions"/><slot name="filters"/><slot name="notices"/><div v-for="(row,index) in data" :key="index"><slot name="actions" :row="row"/><slot name="status" :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; 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('理货审核')
})
2026-10-09 11:43:38 +08:00
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()
})