From 080042d9419d1ead75182b989f356118288757fd Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 7 Oct 2026 23:43:14 +0800 Subject: [PATCH] test(fleet): verify offer-scoped quote selection and pricing --- .../api/capacityOffers.test.ts | 15 ++++- .../components/CapacityInquiryDialog.test.ts | 61 +++++++++++++++++++ 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 src/modules/overseas-master-data/components/CapacityInquiryDialog.test.ts diff --git a/src/modules/overseas-master-data/api/capacityOffers.test.ts b/src/modules/overseas-master-data/api/capacityOffers.test.ts index ce9d3d1a..f590acf3 100644 --- a/src/modules/overseas-master-data/api/capacityOffers.test.ts +++ b/src/modules/overseas-master-data/api/capacityOffers.test.ts @@ -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()), 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 }] }) +}) diff --git a/src/modules/overseas-master-data/components/CapacityInquiryDialog.test.ts b/src/modules/overseas-master-data/components/CapacityInquiryDialog.test.ts new file mode 100644 index 00000000..a44c4d2f --- /dev/null +++ b/src/modules/overseas-master-data/components/CapacityInquiryDialog.test.ts @@ -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() })) +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) +})