test(fleet): verify offer-scoped quote selection and pricing

This commit is contained in:
2026-10-07 23:43:14 +08:00
parent 9ff2720721
commit 080042d941
2 changed files with 75 additions and 1 deletions
@@ -1,6 +1,6 @@
import { beforeEach, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { listCapacityOffers, listCapacityInquiries, listOfferCustomers, normalizeInquiry, normalizeOffer, saveCapacityInquiry, commandCapacityInquiry } from './capacityOffers'
import { listCapacityOffers, listCapacityInquiries, listOfferCustomers, listOfferQuotes, normalizeInquiry, normalizeOffer, saveCapacityInquiry, commandCapacityInquiry } from './capacityOffers'
import type { CapacityOffer, CapacityInquiry, InquiryWrite } from '../capacityOfferTypes'
vi.mock('@/shared/api/http', async original => ({ ...(await original<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } }))
const offer = { offerNo: 'CO/1', tripNo: 'TR-1', recordVersion: '2', tripRecordVersion: '3', capacityKnown: false, destinations: [{ destinationKey: 'FBA:X', name: 'X', deliveryIndex: '1', remaining: null }], segments: [{ fromIndex: '0', toIndex: '1', fromName: '我仓', toName: 'X', planned: null, reserved: { palletSpaces: '0', weightKg: '0', volumeCbm: '0' }, remaining: null }] } as unknown as CapacityOffer
@@ -27,3 +27,16 @@ it('submits one command with encoded business numbers and an unchanged retry key
await commandCapacityInquiry('FB/1', 'confirm', command)
expect(http.post).toHaveBeenLastCalledWith('/platform/v1/last-leg/fleet/capacity-offers/inquiries/FB%2F1/confirm', command)
})
it('reads both quote pages from the offer-scoped URL and preserves the exact source version', async () => {
const source = { quoteNo: 'FQ-POOL', recordVersion: '9007199254740993', priceItem: 'LOOSE_DELIVERY', customerId: '19', customerName: '测试客户', originCity: 'LA', destinationCode: 'ONT8', distanceMiles: '35', currency: 'CNY', priceBasis: 'PALLET', quotedUnitPrice: '25.50', validFrom: '2026-10-01', validTo: '2026-10-31', effectiveOn: '2026-10-20' }
vi.mocked(http.get).mockResolvedValueOnce(envelope({ records: [source], page: '1', size: '20', total: '21', pages: '2' })).mockResolvedValueOnce(envelope({ records: [{ ...source, quoteNo: 'FQ-POOL-SECOND' }], page: '2', size: '20', total: '21', pages: '2' }))
const criteria = { customerId: '19', destinationKey: 'FBA:ONT8', currency: 'CNY' as const, priceBasis: 'PALLET' as const, page: 1, size: 20 }
const first = await listOfferQuotes('CO/1', criteria)
const second = await listOfferQuotes('CO/1', { ...criteria, page: 2 })
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/last-leg/fleet/capacity-offers/CO%2F1/quote-candidates', { params: criteria })
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/last-leg/fleet/capacity-offers/CO%2F1/quote-candidates', { params: { ...criteria, page: 2 } })
expect(http.get).toHaveBeenCalledTimes(2)
expect(vi.mocked(http.get).mock.calls.every(([url]) => !url.includes('/fleet/trips/'))).toBe(true)
expect(first.records[0]).toMatchObject({ quoteNo: source.quoteNo, recordVersion: source.recordVersion, quotedUnitPrice: 25.5, distanceMiles: 35 })
expect(second).toMatchObject({ page: 2, size: 20, total: 21, pages: 2, records: [{ quoteNo: 'FQ-POOL-SECOND', recordVersion: source.recordVersion }] })
})
@@ -0,0 +1,61 @@
import ElementPlus, { ElMessage } from 'element-plus'
import { createPinia } from 'pinia'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import CapacityInquiryDialog from './CapacityInquiryDialog.vue'
import { listOfferCustomers, listOfferQuotes, saveCapacityInquiry } from '../api/capacityOffers'
import type { CapacityOffer, CapacityInquiry } from '../capacityOfferTypes'
import type { FleetQuoteSourceSnapshot } from '../api/fleetQuoteCandidates'
const permissions = vi.hoisted(() => ({ values: new Set<string>() }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => permissions.values.has(permission) }) }))
vi.mock('../api/capacityOffers', () => ({ listOfferCustomers: vi.fn(), listOfferQuotes: vi.fn(), saveCapacityInquiry: vi.fn() }))
const offer: CapacityOffer = { offerNo: 'CO-QUOTE-POSITIVE', tripNo: 'TR-QUOTE-POSITIVE', status: 'OPEN', recordVersion: 4, tripRecordVersion: 7, warehouseId: '92', warehouseName: '测试起运仓', plannedDepartureAt: '2026-10-20T12:00:00Z', cutoffAt: '2026-10-20T10:00:00Z', contactName: null, contactPhone: null, remark: null, destinations: [{ destinationKey: 'FBA:ONT8', name: 'ONT8', deliveryIndex: 1, remaining: { palletSpaces: 8, weightKg: 1000, volumeCbm: 20 } }], segments: [], capacityKnown: true, canCreateInquiry: true, canConfirm: true, blockReason: null, actions: { canEdit: false, canPublish: false, canPause: false, canResume: false, canClose: false } }
const wrappers: VueWrapper[] = []
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(listOfferCustomers).mockResolvedValue([{ id: '19', code: 'C-19', name: '报价测试客户' }])
})
afterEach(async () => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); ElMessage.closeAll(); await flushPromises(); document.body.innerHTML = '' })
function formItem(wrapper: VueWrapper, label: string) { return wrapper.findAll('.el-form-item').find(item => item.find('.el-form-item__label').text() === label)! }
async function select(wrapper: VueWrapper, label: string, value: string) {
formItem(wrapper, label).findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value)
await flushPromises()
}
const scenarios = [
{ actor: 'sales', priceBasis: 'PALLET' as const, permissions: ['platform:fleet:capacity-offer:list', 'platform:fleet:capacity-offer:inquiry-create', 'platform:fleet:quote:list'] },
{ actor: 'manager', priceBasis: 'ORDER' as const, permissions: ['platform:fleet:capacity-offer:manage', 'platform:fleet:quote:list'] },
]
it.each(scenarios)('uses explicit offer quote queries, pagination and negotiated-price saving for $actor', async scenario => {
permissions.values = new Set(scenario.permissions)
const source: FleetQuoteSourceSnapshot = { quoteNo: 'FQ-PAGE-2', recordVersion: '9007199254740993', priceItem: scenario.priceBasis === 'ORDER' ? 'FULL_DELIVERY' : 'LOOSE_DELIVERY', customerId: '19', customerName: '报价测试客户', originCity: 'LA', destinationCode: 'ONT8', distanceMiles: 35, currency: 'CNY', priceBasis: scenario.priceBasis, quotedUnitPrice: 25.5, validFrom: '2026-10-01', validTo: '2026-10-31', effectiveOn: '2026-10-20' }
vi.mocked(listOfferQuotes).mockResolvedValueOnce({ records: Array.from({ length: 20 }, (_, index) => ({ ...source, quoteNo: `FQ-PAGE-1-${index + 1}` })), total: 21, page: 1, size: 20, pages: 2 }).mockResolvedValueOnce({ records: [source], total: 21, page: 2, size: 20, pages: 2 })
vi.mocked(saveCapacityInquiry).mockImplementation(async (_offerNo, _bookingNo, body) => {
const inquiry: CapacityInquiry = { bookingNo: 'FB-QUOTE-SAVED', offerNo: offer.offerNo, tripNo: offer.tripNo, status: 'DRAFT', recordVersion: 1, offerRecordVersion: offer.recordVersion, tripRecordVersion: offer.tripRecordVersion, salesOwnerUserId: '17', customerId: body.customerId, customerCode: 'C-19', customerName: '报价测试客户', businessType: body.businessType, deliveryMode: body.deliveryMode, estimatedArrivalAt: body.estimatedArrivalAt, expectedPalletCount: body.expectedPalletCount, cargo: { ...body.cargo }, destinationLabel: 'ONT8', agreedAmount: null, quoteSource: source, businessOrderNo: null, incomingNo: null, dispatchNo: null, updatedAt: '2026-10-07T00:00:00Z', actions: { canEdit: true, canConfirm: false, canCancel: true, blockReason: null } }
return { offer, inquiry, replayed: false }
})
const wrapper = mount(CapacityInquiryDialog, { attachTo: document.body, props: { modelValue: true, offer, inquiry: null, readonly: false }, global: { plugins: [createPinia(), ElementPlus] } }); wrappers.push(wrapper)
await flushPromises()
expect(listOfferQuotes).not.toHaveBeenCalled()
await select(wrapper, '委托客户', '19'); await select(wrapper, '本车卸货站', 'FBA:ONT8'); await select(wrapper, '币种', 'CNY'); await select(wrapper, '计价方式', scenario.priceBasis)
expect(listOfferQuotes).not.toHaveBeenCalled()
const quotePanel = wrapper.get('section[aria-label="报价来源"]')
await quotePanel.findAll('button').find(button => button.text() === '查询报价库')!.trigger('click'); await flushPromises()
const criteria = { customerId: '19', destinationKey: 'FBA:ONT8', currency: 'CNY', priceBasis: scenario.priceBasis, page: 1, size: 20 }
expect(listOfferQuotes).toHaveBeenCalledExactlyOnceWith(offer.offerNo, criteria)
await quotePanel.findAll('button').find(button => button.text() === '下一页')!.trigger('click'); await flushPromises()
expect(listOfferQuotes).toHaveBeenNthCalledWith(2, offer.offerNo, { ...criteria, page: 2 })
const reference = { quoteNo: source.quoteNo, recordVersion: source.recordVersion, priceItem: source.priceItem }
quotePanel.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', `${source.quoteNo}:${source.recordVersion}:${source.priceItem}`); await flushPromises()
const price = formItem(wrapper, scenario.priceBasis === 'PALLET' ? '每托位单价' : '整单价格').findComponent({ name: 'ElInputNumber' })
expect(price.props('modelValue')).toBe(25.5)
expect(quotePanel.findComponent({ name: 'ElSelect' }).props('modelValue')).toBe(`${source.quoteNo}:${source.recordVersion}:${source.priceItem}`)
expect(quotePanel.text()).toContain('来源报价 FQ-PAGE-2 · 25.50 CNY')
await price.get('input').setValue('21.75'); await flushPromises()
expect(price.props('modelValue')).toBe(21.75)
expect(quotePanel.text()).toContain('来源报价 FQ-PAGE-2 · 25.50 CNY')
await wrapper.findAll('.app-dialog__footer button').find(button => button.text() === '保存意向')!.trigger('click'); await flushPromises()
expect(saveCapacityInquiry).toHaveBeenCalledExactlyOnceWith(offer.offerNo, null, expect.objectContaining({ customerId: '19', offerRecordVersion: 4, tripRecordVersion: 7, recordVersion: null, quoteRef: reference, idempotencyKey: expect.any(String), cargo: expect.objectContaining({ destinationKey: 'FBA:ONT8', currency: 'CNY', priceBasis: scenario.priceBasis, unitPrice: 21.75 }) }))
expect(listOfferQuotes).toHaveBeenCalledTimes(2)
expect(wrapper.emitted('saved')).toHaveLength(1)
})