Files
admin-web/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.test.ts
T

200 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ElMessage, ElMessageBox } from 'element-plus'
import { ElTable, ElTableColumn } from 'element-plus/es/components/table/index'
import { ElInputNumber } from 'element-plus/es/components/input-number/index'
import { ElInput } from 'element-plus/es/components/input/index'
import { createPinia } from 'pinia'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { mount as mountFull, shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue'
import * as api from '../api/tripBookings'
import type { FleetTrip } from '../tripTypes'
import type { TripBooking, TripIncomingPreview } from '../bookingTypes'
import { ApiResponseError } from '@/shared/api/http'
const writable = vi.hoisted(() => ({ value: true }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => writable.value }) }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
vi.mock('../api/tripBookings', () => ({ getTripIncoming: vi.fn(), startTripIncoming: vi.fn(), acceptTripIncoming: vi.fn(), bindTripBookingDispatch: vi.fn() }))
const trip = { tripNo: 'TR-1', status: 'PLANNED', recordVersion: 4, plannedDepartureAt: '2026-10-03T10:00:00Z' } as FleetTrip
const booking: TripBooking = { offerNo: null, expectedPalletCount: null, bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户甲', status: 'CONFIRMED', incomingNo: 'WT-1', dispatchNo: null, receipt: null, quoteSource: null,
destinationLabel: 'ONT8', agreedAmount: 100, recordVersion: 2, updatedAt: '2026-10-03T00:00:00Z', cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: 20, palletSpaces: 2, weightKg: 100, volumeCbm: 1, priceBasis: 'PALLET', unitPrice: 50, currency: 'USD', contactName: null, contactPhone: null, remark: null } }
const preview: TripIncomingPreview = { tripRecordVersion: 5, recordVersion: 2, incoming: { incomingNo: 'WT-1', inboundCode: 'WT-1', warehouseId: '92', destinationKey: 'FBA:ONT8', status: 'WAIT_CUSTOMER_CONFIRMATION', receiptHash: 'receipt-hash', receivedPackages: 22,
pallets: [{ warehousePalletId: '902', palletNo: 'PLT-1', packages: 22, weightKg: 150, volumeCbm: 1.5 }] } }
const wrappers: VueWrapper[] = []
function mount(mode: 'incoming' | 'receipt' | 'bind' | 'view') {
const field = { props: ['modelValue'], emits: ['update:modelValue'], template: '<input />' }
const wrapper = shallowMount(TripBookingFulfillmentDialog, { props: { modelValue: true, trip, booking, mode, arrivalAt: '2026-10-03T08:00:00Z' }, global: { plugins: [createPinia()], directives: { loading: () => undefined }, stubs: {
AppDialog: { name: 'AppDialog', props: ['modelValue', 'confirmDisabled'], emits: ['confirm'], template: '<div><slot/></div>' },
DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><div v-for="row in data" :key="row.warehousePalletId"><slot name="spaces" :row="row"/><slot name="weightKg" :row="row"/><slot name="volumeCbm" :row="row"/></div></div>' },
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElInputNumber: { ...field, name: 'ElInputNumber' }, ElDatePicker: field, ElInput: field,
ElButton: { template: '<button><slot/></button>' }, LoadErrorState: true, ApiErrorMeta: true,
} } }); wrappers.push(wrapper); return wrapper
}
beforeEach(() => { vi.resetAllMocks(); writable.value = true; vi.mocked(ElMessageBox.confirm).mockResolvedValue('confirm' as Awaited<ReturnType<typeof ElMessageBox.confirm>>); vi.mocked(api.getTripIncoming).mockResolvedValue(structuredClone(preview)) })
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
it('uses server actual pallets and preview versions while requiring explicit price confirmation', async () => {
vi.mocked(api.acceptTripIncoming).mockResolvedValue({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: false })
const wrapper = mount('receipt'); await flushPromises()
expect(wrapper.findComponent({ name: 'DataTable' }).props('columns')).toContainEqual(expect.objectContaining({ key: 'spaces', singleLine: false }))
const inputs = wrapper.findAllComponents({ name: 'ElInputNumber' }); inputs[0]!.vm.$emit('update:modelValue', 2); inputs[1]!.vm.$emit('update:modelValue', 55); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.acceptTripIncoming).toHaveBeenCalledWith('TR-1', 'FB-1', expect.objectContaining({ tripRecordVersion: 5, recordVersion: 2, receiptHash: 'receipt-hash', unitPrice: 55, pallets: [{ warehousePalletId: '902', palletSpaces: 2 }] }))
expect(api.startTripIncoming).not.toHaveBeenCalled(); expect(api.bindTripBookingDispatch).not.toHaveBeenCalled()
expect(ElMessage.success).toHaveBeenCalledWith('实收货量及约定运费已核实')
})
it('does not accept a receipt while the warehouse is still waiting for actual goods', async () => {
vi.mocked(api.getTripIncoming).mockResolvedValue({ ...preview, incoming: { ...preview.incoming!, status: 'WAIT_PDA_RECEIVING', pallets: [], receivedPackages: 0 } })
const wrapper = mount('receipt'); await flushPromises(); wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.acceptTripIncoming).not.toHaveBeenCalled(); expect(wrapper.findComponent({ name: 'AppDialog' }).props('confirmDisabled')).toBe(true)
})
it('submits a fresh incoming registration after failure', async () => {
vi.mocked(api.startTripIncoming).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ tripRecordVersion: 5, booking, replayed: true })
const wrapper = mount('incoming'); await flushPromises(); wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
const first = vi.mocked(api.startTripIncoming).mock.calls[0]![2]
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.startTripIncoming).toHaveBeenNthCalledWith(2, 'TR-1', 'FB-1', { ...first, idempotencyKey: expect.any(String) }); expect(vi.mocked(api.startTripIncoming).mock.calls[1]![2].idempotencyKey).not.toBe(first.idempotencyKey); expect(api.getTripIncoming).not.toHaveBeenCalled()
})
it('reloads current receipt on reopening and submits a fresh command', async () => {
vi.mocked(api.acceptTripIncoming).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: true })
const wrapper=mount('receipt'); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
const original=vi.mocked(api.acceptTripIncoming).mock.calls[0]![2]
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect(wrapper.findComponent({ name: 'AppDialog' }).props('confirmDisabled')).toBe(false)
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.acceptTripIncoming).toHaveBeenNthCalledWith(2, 'TR-1', 'FB-1', { ...original, idempotencyKey: expect.any(String) }); expect(vi.mocked(api.acceptTripIncoming).mock.calls[1]![2].idempotencyKey).not.toBe(original.idempotencyKey)
expect(api.getTripIncoming).toHaveBeenCalledTimes(2)
})
it.each([
{ bookingStatus: 'BOUND' as const, incomingStatus: 'READY_FOR_DISPATCH', expected: '已确认实收,已排入本车' },
{ bookingStatus: 'RECEIVED' as const, incomingStatus: 'READY_FOR_DISPATCH', expected: '已确认实收,可安排排车' },
{ bookingStatus: 'BOUND' as const, incomingStatus: 'OUTBOUNDED', expected: '已出库' },
])('shows truthful receipt guidance for $bookingStatus / $incomingStatus', async ({ bookingStatus, incomingStatus, expected }) => {
vi.mocked(api.getTripIncoming).mockResolvedValue({ ...preview, incoming: { ...preview.incoming!, status: incomingStatus } })
const wrapper = mount('view')
await wrapper.setProps({ booking: { ...booking, status: bookingStatus, dispatchNo: bookingStatus === 'BOUND' ? 'DS-1' : null } })
await flushPromises()
expect(wrapper.text()).toContain(expected)
if (bookingStatus === 'BOUND') expect(wrapper.text()).not.toContain('可安排排车')
expect(wrapper.findComponent({ name: 'DataTable' }).props('data')).toEqual(preview.incoming!.pallets)
expect(api.acceptTripIncoming).not.toHaveBeenCalled()
expect(api.bindTripBookingDispatch).not.toHaveBeenCalled()
})
it('anchors receipt control sizing inside the real DataTable slot and disables cell ellipsis', async () => {
const wrapper = mountFull(TripBookingFulfillmentDialog, {
props: { modelValue: true, trip, booking, mode: 'receipt' },
global: { plugins: [createPinia()], components: { ElTable, ElTableColumn, ElInputNumber, ElInput }, directives: { loading: () => undefined }, stubs: {
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' }, ElButton: { template: '<button><slot/></button>' },
AppDialog: { template: '<div><slot/></div>' }, LoadErrorState: true, ApiErrorMeta: true,
} },
})
wrappers.push(wrapper); await flushPromises()
const control = wrapper.get('.el-table__body .receipt-spaces-control')
const scope = control.element.getAttributeNames().find(name => name.startsWith('data-v-'))
expect(scope).toBeDefined()
expect(control.find('.el-input-number').exists()).toBe(true)
expect(control.element.closest('td')?.classList.contains('data-table__single-line-cell')).toBe(false)
expect(control.element.closest('.cell')?.classList.contains('el-tooltip')).toBe(false)
// DataTable forwards classes to its inner table, which does not carry this SFC's scope.
expect(wrapper.find(`.receipt-pallet-table[${scope}]`).exists()).toBe(false)
const input = control.get('input')
await input.setValue('2'); await input.trigger('change'); await flushPromises()
expect(wrapper.text()).toContain('2 托位')
})
it('compares real receipt with the forecast, preserves unknown values, and does not invent historical forecasts', async () => {
const wrapper = mount('receipt'); await flushPromises()
const rows = () => wrapper.get('[aria-label="预报与实收对照"]').findAll('tbody tr').map(row => row.findAll('td').map(cell => cell.text()))
expect(rows()).toEqual([
['箱数', '20', '22', '+2'], ['实托数', '—', '1', '—'], ['标准托位(待核对)', '2', '1', '-1'],
['重量(kg)', '100', '150', '+50'], ['体积(m³)', '1', '1.5', '+0.5'],
])
await wrapper.setProps({ booking: { ...booking, status: 'RECEIVED' } })
expect(rows().every(row => row[1] === '—' && row[3] === '—')).toBe(true)
expect(wrapper.text()).toContain('无法还原原始预报')
await wrapper.setProps({ mode: 'view' }); await flushPromises()
expect(wrapper.find('[aria-label="预报与实收对照"]').exists()).toBe(false)
expect(api.acceptTripIncoming).not.toHaveBeenCalled()
})
it('requires explicit acceptance of a changed received snapshot and submits the current complete pallets and versions', async () => {
const previous = { ...booking, status: 'RECEIVED' as const, receipt: { source: { ...preview.incoming!, receiptHash: 'previous-hash' }, pallets: [{ warehousePalletId: '902', palletSpaces: 2 }], operatorId: 'operator', confirmedAt: '2026-10-01T00:00:00Z' } }
vi.mocked(api.getTripIncoming).mockResolvedValue({ ...preview, tripRecordVersion: 11, recordVersion: 8, incoming: { ...preview.incoming!, status: 'READY_FOR_DISPATCH', receiptHash: 'merged-hash', pallets: [
{ warehousePalletId: '903', palletNo: 'PLT-MERGED', packages: 22, weightKg: 150, volumeCbm: 1.5 },
] } })
const wrapper = mount('receipt'); await wrapper.setProps({ booking: previous }); await flushPromises()
expect(wrapper.text()).toContain('上次核实与当前实收对照')
expect(wrapper.text()).not.toContain('无法还原原始预报')
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel')
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.acceptTripIncoming).not.toHaveBeenCalled()
vi.mocked(api.acceptTripIncoming).mockResolvedValue({ tripRecordVersion: 12, booking: { ...previous, recordVersion: 9 }, replayed: false })
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(ElMessageBox.confirm).toHaveBeenLastCalledWith(expect.stringContaining('50.00 USD'), '确认实收差异', expect.any(Object))
expect(api.acceptTripIncoming).toHaveBeenCalledExactlyOnceWith('TR-1', 'FB-1', { tripRecordVersion: 11, recordVersion: 8, idempotencyKey: expect.any(String), receiptHash: 'merged-hash', unitPrice: 50, pallets: [{ warehousePalletId: '903', palletSpaces: 1 }] })
})
it('uses a new key after a completed confirmation and after failed requests', async () => {
vi.mocked(api.acceptTripIncoming).mockResolvedValue({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: false })
const wrapper = mount('receipt'); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
const first = vi.mocked(api.acceptTripIncoming).mock.calls[0]![2]
await wrapper.setProps({ modelValue: false })
vi.mocked(api.getTripIncoming).mockResolvedValue({ ...preview, tripRecordVersion: 6, recordVersion: 3, incoming: { ...preview.incoming!, status: 'READY_FOR_DISPATCH', receiptHash: 'new-hash' } })
await wrapper.setProps({ modelValue: true, booking: { ...booking, status: 'RECEIVED', recordVersion: 3 } }); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
const next = vi.mocked(api.acceptTripIncoming).mock.calls[1]![2]
expect(next.idempotencyKey).not.toBe(first.idempotencyKey)
expect(next).toMatchObject({ tripRecordVersion: 6, recordVersion: 3, receiptHash: 'new-hash' })
})
it('does not report success when the server rejects the updated receipt capacity', async () => {
vi.mocked(api.acceptTripIncoming).mockRejectedValue(new ApiResponseError('当前车次容量不足,请重新核对', 'A0409', 409, null, 'capacity-request'))
const wrapper = mount('receipt'); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(wrapper.emitted('saved')).toBeUndefined()
expect(ElMessage.success).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'LoadErrorState' }).attributes('message')).toContain('当前车次容量不足')
expect((wrapper.vm as unknown as { error: { requestId: string } }).error).toMatchObject({ requestId: 'capacity-request' })
})
it.each(['bound', 'departed', 'permission'] as const)('rejects a receipt write if the context changes to %s after opening', async context => {
const wrapper = mount('receipt'); await flushPromises()
if (context === 'bound') await wrapper.setProps({ booking: { ...booking, status: 'BOUND', dispatchNo: 'DS-1' } })
if (context === 'departed') await wrapper.setProps({ trip: { ...trip, status: 'IN_TRANSIT' } })
if (context === 'permission') {
writable.value = false
await wrapper.setProps({ booking: { ...booking, recordVersion: 3 } })
}
await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.acceptTripIncoming).not.toHaveBeenCalled()
expect(wrapper.findComponent({ name: 'AppDialog' }).props('confirmDisabled')).toBe(true)
})
it('requires measurements for changed pallet composition and submits them with a new key after failure', async () => {
const merged = { ...preview, incoming: { ...preview.incoming!, status: 'READY_FOR_DISPATCH', receiptHash: 'merged-null-measurement', pallets: [{ warehousePalletId: '903', palletNo: 'MERGED-1', packages: 22, weightKg: null, volumeCbm: null }] } }
vi.mocked(api.getTripIncoming).mockResolvedValue(merged)
vi.mocked(api.acceptTripIncoming).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ tripRecordVersion: 6, booking: { ...booking, status: 'RECEIVED' }, replayed: true })
const wrapper = mount('receipt'); await wrapper.setProps({ booking: { ...booking, status: 'RECEIVED' } }); await flushPromises()
const confirm = () => wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm')
confirm(); await flushPromises()
expect(api.acceptTripIncoming).not.toHaveBeenCalled()
expect(ElMessage.warning).toHaveBeenCalledWith(expect.stringContaining('重新填写'))
const fields = wrapper.findAllComponents({ name: 'ElInputNumber' })
fields.find(field => field.attributes('aria-label') === 'MERGED-1 实测重量')!.vm.$emit('update:modelValue', 123.456)
fields.find(field => field.attributes('aria-label') === 'MERGED-1 实测体积')!.vm.$emit('update:modelValue', 1.234)
await flushPromises(); confirm(); await flushPromises()
const original = vi.mocked(api.acceptTripIncoming).mock.calls[0]![2]
expect(original).toMatchObject({ receiptHash: 'merged-null-measurement', pallets: [{ warehousePalletId: '903', palletSpaces: 1, weightKg: 123.456, volumeCbm: 1.234 }] })
confirm(); await flushPromises()
expect(api.acceptTripIncoming).toHaveBeenNthCalledWith(2, 'TR-1', 'FB-1', { ...original, idempotencyKey: expect.any(String) }); expect(vi.mocked(api.acceptTripIncoming).mock.calls[1]![2].idempotencyKey).not.toBe(original.idempotencyKey)
})
it('does not offer measurement overrides for unchanged source pallets', async () => {
const wrapper = mount('receipt'); await flushPromises()
expect(wrapper.findAllComponents({ name: 'ElInputNumber' }).some(field => /实测重量|实测体积/.test(field.attributes('aria-label') || ''))).toBe(false)
})