200 lines
17 KiB
TypeScript
200 lines
17 KiB
TypeScript
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('retries the same incoming registration after an uncertain result without creating a new command', 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); expect(api.getTripIncoming).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('keeps an uncertain receipt command retryable after closing and reopening without reloading or changing its hash', 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)
|
||
expect(api.getTripIncoming).toHaveBeenCalledOnce()
|
||
})
|
||
|
||
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 while preserving the original key for unknown retries', 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 new measurements for changed pallet composition and keeps them in the exact unknown-result retry', 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)
|
||
})
|
||
|
||
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)
|
||
})
|