feat(fleet): select versioned sales quotes and isolate cost drafts
This commit is contained in:
@@ -4,11 +4,11 @@ import TripBookingPanel from './TripBookingPanel.vue'
|
||||
import * as api from '../api/tripBookings'
|
||||
import type { FleetTrip } from '../tripTypes'
|
||||
import type { TripBooking } from '../bookingTypes'
|
||||
const access = vi.hoisted(() => ({ writable: true }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.writable }) }))
|
||||
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', () => ({ listFleetCustomers: vi.fn(), listTripBookings: vi.fn(), createTripBooking: vi.fn(), updateTripBooking: vi.fn(), confirmTripBooking: vi.fn(), cancelTripBooking: vi.fn() }))
|
||||
const row: TripBooking = { bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户甲', status: 'DRAFT', incomingNo: null, dispatchNo: null, receipt: null, destinationLabel: 'ONT8', agreedAmount: 100, recordVersion: 0, updatedAt: '2026-10-03T00:00:00Z',
|
||||
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 = { 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[] = []
|
||||
@@ -24,7 +24,7 @@ function mount() {
|
||||
} } }); 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; vi.mocked(api.listTripBookings).mockResolvedValue({ tripRecordVersion: 4, records: [structuredClone(row)] }) })
|
||||
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('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 })
|
||||
@@ -68,7 +68,53 @@ it('can edit an inquiry after closing its read-only details', async () => {
|
||||
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,
|
||||
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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user