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

150 lines
14 KiB
TypeScript
Raw Normal View History

import { ElInputNumber } from 'element-plus/es/components/input-number/index'
import { ElInput } from 'element-plus/es/components/input/index'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
import TripBookingPanel from './TripBookingPanel.vue'
import * as api from '../api/tripBookings'
import type { FleetTrip } from '../tripTypes'
import type { TripBooking } from '../bookingTypes'
import type { Component } from 'vue'
const access = vi.hoisted(() => ({ writable: true, quote: true }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permission === 'platform:fleet:quote:list' ? access.quote : access.writable }) }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(undefined) } }))
vi.mock('../api/tripBookings', () => ({ listTripBookingQuoteCandidates: vi.fn(), listFleetCustomers: vi.fn(), listTripBookings: vi.fn(), createTripBooking: vi.fn(), updateTripBooking: vi.fn(), confirmTripBooking: vi.fn(), cancelTripBooking: vi.fn() }))
const row: TripBooking = { offerNo: null, expectedPalletCount: null, bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户甲', status: 'DRAFT', incomingNo: null, dispatchNo: null, receipt: null, quoteSource: null, destinationLabel: 'ONT8', agreedAmount: 100, recordVersion: 0, updatedAt: '2026-10-03T00:00:00Z',
cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: 10, palletSpaces: 2, weightKg: 100, volumeCbm: 1, priceBasis: 'PALLET', unitPrice: 50, currency: 'USD', contactName: null, contactPhone: null, remark: null } }
const trip = { tripNo: 'TR-1', status: 'PLANNED', recordVersion: 4, stops: [{ name: '起点' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }] } as FleetTrip
const wrappers: VueWrapper[] = []
function mount(realNumber?: Component, realInput?: Component) {
const field = { props: ['modelValue'], emits: ['update:modelValue'], template: '<input />' }
const wrapper = shallowMount(TripBookingPanel, { props: { trip }, global: { stubs: {
DataTable: { props: ['data'], template: '<div><slot name="toolbar-actions"/><div v-for="row in data" :key="row.bookingNo"><slot name="actions" :row="row"/></div></div>' },
AppDialog: { name: 'AppDialog', props: { modelValue: Boolean, showConfirm: Boolean, destroyOnClose: Boolean }, emits: ['confirm', 'update:modelValue'], template: realNumber ? '<div v-if="modelValue || !destroyOnClose" v-show="modelValue"><slot/></div>' : '<div v-if="modelValue"><slot/></div>' },
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot/></button>' },
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' },
ElSelect: { ...field, name: 'ElSelect' }, ElInputNumber: realNumber || { ...field, name: 'ElInputNumber' }, ElInput: realInput || field, ElOption: true,
LoadErrorState: true, ApiErrorMeta: true,
} } }); wrappers.push(wrapper); return wrapper
}
function button(wrapper: VueWrapper, text: string) { return wrapper.findAll('button').find(node => node.text() === text)! }
beforeEach(() => { vi.clearAllMocks(); access.writable = true; access.quote = true; vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [structuredClone(row)] }) })
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
it('shows pool inquiries read-only without legacy edit, confirm or cancel actions', async () => {
vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [{ ...row, offerNo: 'CO-1', expectedPalletCount: null, cargo: { ...row.cargo, packageCount: null } }] })
const wrapper = mount(); await flushPromises()
for (const label of ['编辑', '确认承运', '取消预订']) expect(wrapper.findAll('button').some(node => node.text() === label)).toBe(false)
await button(wrapper, '详情').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('请从拼车需求池管理询价与承运')
expect(wrapper.findComponent({ name: 'AppDialog' }).props('showConfirm')).toBe(false)
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.updateTripBooking).not.toHaveBeenCalled(); expect(api.confirmTripBooking).not.toHaveBeenCalled()
})
it('confirms using both current versions and updates the parent version without extra list reads', async () => {
vi.mocked(api.confirmTripBooking).mockResolvedValue({ tripRecordVersion: 5, booking: { ...row, status: 'CONFIRMED', recordVersion: 1 }, replayed: false })
const wrapper = mount(); await flushPromises(); await button(wrapper, '确认承运').trigger('click'); await flushPromises()
expect(api.confirmTripBooking).toHaveBeenCalledWith('TR-1', 'FB-1', expect.objectContaining({ tripRecordVersion: 4, recordVersion: 0, idempotencyKey: expect.any(String) }))
expect(wrapper.emitted('changed')?.[0]).toEqual([5]); expect(api.listTripBookings).toHaveBeenCalledOnce()
expect(wrapper.findAll('button').some(node => node.text() === '确认承运')).toBe(false)
})
it('reopens saved negotiated price in a fresh numeric control while retaining its separate quote source', async () => {
const initial = { ...row, cargo: { ...row.cargo, unitPrice: 16 }, quoteSource: { ...source, quotedUnitPrice: 16 } }
const saved = { ...initial, cargo: { ...initial.cargo, unitPrice: 9 }, agreedAmount: 18, recordVersion: 1 }
vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [initial] })
vi.mocked(api.updateTripBooking).mockResolvedValue({ tripRecordVersion: 5, booking: saved, replayed: false })
const wrapper = mount(ElInputNumber, ElInput); await flushPromises()
await button(wrapper, '编辑').trigger('click'); await flushPromises()
const price = wrapper.findAllComponents({ name: 'ElInputNumber' })[4]!
await price.find('input').setValue('9'); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(wrapper.findAllComponents({ name: 'ElInputNumber' })).toHaveLength(0)
await button(wrapper, '详情').trigger('click'); await flushPromises()
expect(wrapper.findAllComponents({ name: 'ElInputNumber' })[4]!.find<HTMLInputElement>('input').element.value).toBe('9.00')
expect(wrapper.text()).toContain('来源单价:16.00 USD')
expect(wrapper.text()).toContain('约定运费:18.00 USD')
})
it('keeps inquiry estimates and contacts optional and retries the same uncertain create command', async () => {
vi.mocked(api.createTripBooking).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ tripRecordVersion: 5, booking: row, replayed: true })
const wrapper = mount(); await flushPromises(); await button(wrapper, '登记外接询价').trigger('click'); await flushPromises()
const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0]!.vm.$emit('update:modelValue', '9'); selects[1]!.vm.$emit('update:modelValue', 'FBA:ONT8'); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
const first = vi.mocked(api.createTripBooking).mock.calls[0]![1]
expect(first.cargo).toMatchObject({ palletSpaces: null, weightKg: null, volumeCbm: null, unitPrice: null, contactPhone: null })
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.createTripBooking).toHaveBeenNthCalledWith(2, 'TR-1', first)
})
it('does not expose booking writes to a read-only operator', async () => {
access.writable = false; const wrapper = mount(); await flushPromises()
for (const label of ['登记外接询价', '编辑', '确认承运', '取消预订']) expect(wrapper.findAll('button').some(button => button.text() === label)).toBe(false)
})
it('opens reactive booking data for editing and read-only details without losing the original row', async () => {
const wrapper = mount(); await flushPromises(); await button(wrapper, '编辑').trigger('click'); await flushPromises()
expect(wrapper.findComponent({ name: 'AppDialog' }).props('modelValue')).toBe(true)
await button(wrapper, '详情').trigger('click'); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.updateTripBooking).not.toHaveBeenCalled(); expect(api.createTripBooking).not.toHaveBeenCalled()
})
it('can edit an inquiry after closing its read-only details', async () => {
const wrapper = mount(); await flushPromises()
await button(wrapper, '详情').trigger('click'); await flushPromises()
const dialog = wrapper.findComponent({ name: 'AppDialog' })
expect(dialog.props('showConfirm')).toBe(false)
dialog.vm.$emit('update:modelValue', false); await flushPromises()
expect(button(wrapper, '编辑').attributes('disabled')).toBeUndefined()
await button(wrapper, '编辑').trigger('click'); await flushPromises()
expect(dialog.props('showConfirm')).toBe(true)
vi.mocked(api.updateTripBooking).mockResolvedValue({ tripRecordVersion: 5, booking: { ...row, recordVersion: 1 }, replayed: false })
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.updateTripBooking).toHaveBeenCalledWith('TR-1', 'FB-1', {
tripRecordVersion: 4, recordVersion: 0, cargo: row.cargo, quoteRef: null,
})
expect(api.listTripBookings).toHaveBeenCalledOnce()
})
const source = { quoteNo: 'FQ-ZERO', recordVersion: '9007199254740993', priceItem: 'LOOSE_DELIVERY' as const, customerId: '9', customerName: '客户甲', originCity: 'LA', destinationCode: 'ONT8', distanceMiles: 30, currency: 'USD' as const, priceBasis: 'PALLET' as const, quotedUnitPrice: 0, validFrom: '2026-10-01', validTo: '2026-10-31', effectiveOn: '2026-10-03' }
const candidatePage = { records: [source], page: 1, size: 20, total: 1, pages: 1 }
it('queries only on request, keeps the exact source version and saves independently negotiated price', async () => {
vi.mocked(api.listTripBookingQuoteCandidates).mockResolvedValue(candidatePage)
vi.mocked(api.updateTripBooking).mockResolvedValue({ tripRecordVersion: 5, booking: row, replayed: false })
const wrapper = mount(); await flushPromises(); await button(wrapper, '编辑').trigger('click'); await flushPromises()
expect(api.listTripBookingQuoteCandidates).not.toHaveBeenCalled()
await button(wrapper, '查询报价库').trigger('click'); await flushPromises()
expect(api.listTripBookingQuoteCandidates).toHaveBeenCalledExactlyOnceWith('TR-1', { customerId: '9', destinationKey: 'FBA:ONT8', currency: 'USD', priceBasis: 'PALLET', page: 1, size: 20 })
wrapper.findAllComponents({ name: 'ElSelect' }).find(component => component.attributes('data-testid') === 'booking-quote-candidate')!.vm.$emit('update:modelValue', 'FQ-ZERO:9007199254740993:LOOSE_DELIVERY'); await flushPromises()
const price = wrapper.findAllComponents({ name: 'ElInputNumber' })[4]!
expect(price.props('modelValue')).toBe(0)
price.vm.$emit('update:modelValue', 12.34); await flushPromises()
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.updateTripBooking).toHaveBeenCalledWith('TR-1', 'FB-1', expect.objectContaining({ quoteRef: { quoteNo: source.quoteNo, recordVersion: source.recordVersion, priceItem: source.priceItem }, cargo: expect.objectContaining({ unitPrice: 12.34 }) }))
})
it('ignores an old candidate response after changing destination and allows manual pricing', async () => {
let resolve!: (page: typeof candidatePage) => void
vi.mocked(api.listTripBookingQuoteCandidates).mockReturnValue(new Promise(done => { resolve = done }))
const wrapper = mount(); await flushPromises(); await button(wrapper, '编辑').trigger('click'); await flushPromises()
await button(wrapper, '查询报价库').trigger('click')
wrapper.findAllComponents({ name: 'ElSelect' })[1]!.vm.$emit('update:modelValue', 'PRIVATE:ADDRESS'); await flushPromises()
resolve(candidatePage); await flushPromises()
expect(wrapper.find('[data-testid="booking-quote-candidate"]').exists()).toBe(false)
expect(button(wrapper, '查询报价库').attributes('disabled')).toBeDefined()
expect(wrapper.findAllComponents({ name: 'ElInputNumber' })[4]!.props('modelValue')).toBe(50)
})
it('clears a selected source explicitly without erasing the negotiated price', async () => {
vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [{ ...row, quoteSource: source }] })
vi.mocked(api.updateTripBooking).mockResolvedValue({ tripRecordVersion: 5, booking: row, replayed: false })
const wrapper = mount(); await flushPromises(); await button(wrapper, '编辑').trigger('click'); await flushPromises()
await button(wrapper, '改为手工询价').trigger('click')
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.updateTripBooking).toHaveBeenCalledWith('TR-1', 'FB-1', expect.objectContaining({ quoteRef: null, cargo: row.cargo }))
})
it('keeps manual entry available when candidate lookup fails and hides lookup without quote permission', async () => {
vi.mocked(api.listTripBookingQuoteCandidates).mockRejectedValue(new Error('仓库未配置时区'))
const wrapper = mount(); await flushPromises(); await button(wrapper, '编辑').trigger('click'); await flushPromises()
await button(wrapper, '查询报价库').trigger('click'); await flushPromises()
expect(wrapper.find('[role="alert"]').text()).toContain('报价库查询未完成,可继续手工询价')
expect(wrapper.findAllComponents({ name: 'ElInputNumber' })[4]!.props('modelValue')).toBe(50)
access.quote = false
const denied = mount(); await flushPromises(); await button(denied, '编辑').trigger('click'); await flushPromises()
expect(denied.findAll('button').some(node => node.text() === '查询报价库')).toBe(false)
})