diff --git a/src/modules/overseas-master-data/AGENTS.md b/src/modules/overseas-master-data/AGENTS.md index c87d23fd..bc08e05a 100644 --- a/src/modules/overseas-master-data/AGENTS.md +++ b/src/modules/overseas-master-data/AGENTS.md @@ -1,5 +1,7 @@ # 海外通用资料与车辆管理 +- 外接预订可选销售报价:`TripBookingPanel.vue`沿车次作用域`/bookings/quote-candidates`读取候选,不由浏览器推城市或有效日期;来源报价版本与实际成交价分别显示。引用要求quote:list,手填成交价不新增该权限门禁;改变客户/卸货站/币种/计价方式后清除不再匹配的引用,迟到候选不覆盖新表单。确认承运由后端复验版本和有效性,实收调价保留来源快照。 + - 车次“在途交接”页签按需挂载`TripHandoverPanel.vue`,沿`fleet-handovers-v1.yaml`读取剩余货物和交接记录;确认用trip:handover,旧资源结束用trip:release,saved刷新车次。原DEPART不重写,司机/车辆任务分别结束。 - 车队报价`pages/FleetQuotesView.vue`、`api/fleetQuotes.ts`沿`fleet-quotes-v1.yaml`区分采购/销售;原型通用菜单,编辑780px、CSV导入1040px,未知价格与零价分开。出车记录`pages/TripOperationsView.vue`、`api/tripOperations.ts`沿`fleet-operations-v1.yaml`按实际DEPART查询,列表/图表同筛选,缺少成本或财务事实不推为零;分别使用fleet:quote:list与fleet:operations:list,写入/导出独立权限。 diff --git a/src/modules/overseas-master-data/api/fleetQuoteCandidates.test.ts b/src/modules/overseas-master-data/api/fleetQuoteCandidates.test.ts new file mode 100644 index 00000000..4b28c285 --- /dev/null +++ b/src/modules/overseas-master-data/api/fleetQuoteCandidates.test.ts @@ -0,0 +1,23 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { listFleetSaleQuoteCandidates, fleetQuoteReference, type FleetSaleQuoteCriteria } from './fleetQuoteCandidates' +vi.mock('@/shared/api/http', async original => ({ ...(await original()), http: { get: vi.fn() } })) +const criteria: FleetSaleQuoteCriteria = { customerId: '90071992547409931', originCity: 'LA', destinationCode: 'ONT8', currency: 'USD', effectiveOn: '2026-10-03', priceBasis: 'PALLET', page: 1, size: 20 } +const snapshot = { quoteNo: 'FQ-SOURCE', recordVersion: '90071992547409932', priceItem: 'LOOSE_DELIVERY', customerId: criteria.customerId, customerName: '委托客户', originCity: 'LA', destinationCode: 'ONT8', distanceMiles: '10', currency: 'USD', priceBasis: 'PALLET', quotedUnitPrice: '0.00', validFrom: '2026-10-01', validTo: '2026-10-31', effectiveOn: criteria.effectiveOn } +beforeEach(() => vi.clearAllMocks()) +it('keeps source identity/version and explicit zero, without constructing a booking write or total', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [snapshot], page: '1', size: '20', total: '1', pages: '1' } }) + const result = await listFleetSaleQuoteCandidates(criteria) + expect(result.records[0]).toMatchObject({ recordVersion: snapshot.recordVersion, customerId: criteria.customerId, quotedUnitPrice: 0, distanceMiles: 10 }) + expect(fleetQuoteReference(result.records[0]!)).toEqual({ quoteNo: snapshot.quoteNo, recordVersion: snapshot.recordVersion, priceItem: 'LOOSE_DELIVERY' }) + expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/quotes/sale-candidates', { params: criteria }) + expect(result.records[0]).not.toHaveProperty('agreedAmount') +}) +it('does not turn an unknown price into an eligible zero-price candidate', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ ...snapshot, quotedUnitPrice: null }], page: 1, size: 20, total: 1, pages: 1 } }) + await expect(listFleetSaleQuoteCandidates(criteria)).rejects.toThrow('金额格式无效') +}) +it('surfaces permission errors instead of returning an empty successful list', async () => { + vi.mocked(http.get).mockResolvedValue({ code: 'A0403', message: '无报价读取权限', requestId: 'candidate-test-request' }) + await expect(listFleetSaleQuoteCandidates(criteria)).rejects.toMatchObject({ code: 'A0403', requestId: 'candidate-test-request' }) +}) diff --git a/src/modules/overseas-master-data/api/fleetQuoteCandidates.ts b/src/modules/overseas-master-data/api/fleetQuoteCandidates.ts new file mode 100644 index 00000000..a9f3f429 --- /dev/null +++ b/src/modules/overseas-master-data/api/fleetQuoteCandidates.ts @@ -0,0 +1,30 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { PageResult } from './overseasMasterData' +import { fleetMoney, fleetNumber, fleetPage, fleetVersion } from './fleetQuotes' + +export interface FleetSaleQuoteCriteria { + customerId: string; originCity: string; destinationCode: string; currency: 'USD' | 'CNY' + effectiveOn: string; priceBasis: 'ORDER' | 'PALLET'; page?: number; size?: number +} +export interface FleetQuoteReference { quoteNo: string; recordVersion: string; priceItem: 'FULL_DELIVERY' | 'LOOSE_DELIVERY' } +export interface FleetQuoteSourceSnapshot extends FleetQuoteReference { + customerId: string; customerName: string; originCity: string; destinationCode: string; distanceMiles: number + currency: 'USD' | 'CNY'; priceBasis: 'ORDER' | 'PALLET'; quotedUnitPrice: number + validFrom: string; validTo: string; effectiveOn: string +} +type Numeric = string | number +export type WireSnapshot = Omit & { + recordVersion: Numeric; distanceMiles: Numeric; quotedUnitPrice: Numeric +} +export type WireQuotePage = Omit, 'page' | 'size' | 'total' | 'pages'> & { page: Numeric; size: Numeric; total: Numeric; pages: Numeric } +export const normalizeFleetQuoteSource = (row: WireSnapshot): FleetQuoteSourceSnapshot => ({ ...row, recordVersion: fleetVersion(row.recordVersion), distanceMiles: fleetNumber(row.distanceMiles), quotedUnitPrice: fleetMoney(row.quotedUnitPrice) }) +export const normalizeFleetQuotePage = (value: WireQuotePage): PageResult => fleetPage({ ...value, records: value.records.map(normalizeFleetQuoteSource) }) + +/** Only a candidate read: the booking writer must revalidate the reference and freeze its own source snapshot. */ +export async function listFleetSaleQuoteCandidates(criteria: FleetSaleQuoteCriteria): Promise> { + const response = unwrapApiResponse(await http.get, ApiEnvelope>('/platform/v1/last-leg/fleet/quotes/sale-candidates', { params: criteria })) + return normalizeFleetQuotePage(response) +} +export function fleetQuoteReference(source: FleetQuoteSourceSnapshot): FleetQuoteReference { + return { quoteNo: source.quoteNo, recordVersion: source.recordVersion, priceItem: source.priceItem } +} diff --git a/src/modules/overseas-master-data/api/tripBookings.test.ts b/src/modules/overseas-master-data/api/tripBookings.test.ts new file mode 100644 index 00000000..fa6a4436 --- /dev/null +++ b/src/modules/overseas-master-data/api/tripBookings.test.ts @@ -0,0 +1,21 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { listTripBookingQuoteCandidates, normalizeBooking, type WireBooking } from './tripBookings' +vi.mock('@/shared/api/http', async original => ({ ...(await original()), http: { get: vi.fn() } })) +const source = { quoteNo: 'FQ-1', 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.00', validFrom: '2026-10-01', validTo: '2026-10-31', effectiveOn: '2026-10-03' } +beforeEach(() => vi.clearAllMocks()) +it('uses trip-scoped candidate criteria without accepting browser-computed city or date', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [source], page: '1', size: '20', total: '1', pages: '1' } }) + const criteria = { customerId: '9', destinationKey: 'FBA:ONT8', currency: 'USD' as const, priceBasis: 'PALLET' as const } + const value = await listTripBookingQuoteCandidates('TR-1', criteria) + expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/trips/TR-1/bookings/quote-candidates', { params: criteria }) + expect(value.records[0]).toMatchObject({ recordVersion: source.recordVersion, quotedUnitPrice: 0 }) +}) +it('normalizes quote source without overwriting negotiated price and handles manual absence', () => { + const row: WireBooking = { bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户', status: 'DRAFT', destinationLabel: 'ONT8', agreedAmount: '25.00', recordVersion: '1', updatedAt: '', incomingNo: null, dispatchNo: null, receipt: null, quoteSource: source, + cargo: { destinationKey: 'FBA:ONT8', cargoName: null, packageCount: '1', palletSpaces: '2', weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: '12.50', currency: 'USD', contactName: null, contactPhone: null, remark: null } } + const value = normalizeBooking(row) + expect(value.quoteSource).toMatchObject({ quotedUnitPrice: 0, recordVersion: source.recordVersion }) + expect(value.cargo.unitPrice).toBe(12.5); expect(value.agreedAmount).toBe(25) + expect(normalizeBooking({ ...row, quoteSource: undefined }).quoteSource).toBeNull() +}) diff --git a/src/modules/overseas-master-data/api/tripBookings.ts b/src/modules/overseas-master-data/api/tripBookings.ts index 640d3c4c..3f3c242c 100644 --- a/src/modules/overseas-master-data/api/tripBookings.ts +++ b/src/modules/overseas-master-data/api/tripBookings.ts @@ -1,10 +1,11 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import { normalizeFleetQuoteSource, normalizeFleetQuotePage, type WireSnapshot, type WireQuotePage } from './fleetQuoteCandidates' import type { FleetCustomerOption, TripBooking, TripBookingCargo, TripBookingCreate, TripBookingUpdate, TripBookingCommand, TripBookingResult, TripBookingList, TripIncomingEvidence, TripIncomingPreview, TripIncomingStart, TripIncomingAccept, TripBookingBind, TripAcceptedReceipt } from '../bookingTypes' const base = '/platform/v1/last-leg/fleet/trips' const path = (tripNo: string) => `${base}/${encodeURIComponent(tripNo)}/bookings` type N = number | string type WireCargo = Omit & { packageCount: N; palletSpaces: N | null; weightKg: N | null; volumeCbm: N | null; unitPrice: N | null } -export type WireBooking = Omit & { recordVersion: N; agreedAmount: N | null; cargo: WireCargo; receipt: WireReceipt | null } +export type WireBooking = Omit & { recordVersion: N; agreedAmount: N | null; cargo: WireCargo; receipt: WireReceipt | null; quoteSource?: WireSnapshot | null } type WireIncoming = Omit & { receivedPackages: N; pallets: (Omit & { packages: N; weightKg: N | null; volumeCbm: N | null })[] } type WireReceipt = Omit & { source: WireIncoming; pallets: { warehousePalletId: string; palletSpaces: N }[] } type WireResult = { tripRecordVersion: N; booking: WireBooking; replayed: boolean } @@ -12,9 +13,13 @@ const number = (value: N) => { const result = Number(value); if (value == null | const integer = (value: N) => { const result = number(value); if (!Number.isSafeInteger(result)) throw new Error('外接预订版本或数量无效'); return result } const nullable = (value: N | null) => value == null ? null : number(value) const incoming = (value: WireIncoming): TripIncomingEvidence => ({ ...value, receivedPackages: integer(value.receivedPackages), pallets: value.pallets.map(row => ({ ...row, packages: integer(row.packages), weightKg: nullable(row.weightKg), volumeCbm: nullable(row.volumeCbm) })) }) -export const normalizeBooking = (value: WireBooking): TripBooking => ({ ...value, receipt: value.receipt == null ? null : { ...value.receipt, source: incoming(value.receipt.source), pallets: value.receipt.pallets.map(row => ({ ...row, palletSpaces: integer(row.palletSpaces) })) }, recordVersion: integer(value.recordVersion), agreedAmount: nullable(value.agreedAmount), cargo: { ...value.cargo, +export const normalizeBooking = (value: WireBooking): TripBooking => ({ ...value, quoteSource: value.quoteSource == null ? null : normalizeFleetQuoteSource(value.quoteSource), receipt: value.receipt == null ? null : { ...value.receipt, source: incoming(value.receipt.source), pallets: value.receipt.pallets.map(row => ({ ...row, palletSpaces: integer(row.palletSpaces) })) }, recordVersion: integer(value.recordVersion), agreedAmount: nullable(value.agreedAmount), cargo: { ...value.cargo, packageCount: integer(value.cargo.packageCount), palletSpaces: value.cargo.palletSpaces == null ? null : integer(value.cargo.palletSpaces), weightKg: nullable(value.cargo.weightKg), volumeCbm: nullable(value.cargo.volumeCbm), unitPrice: nullable(value.cargo.unitPrice) } }) const result = (value: WireResult): TripBookingResult => ({ tripRecordVersion: integer(value.tripRecordVersion), booking: normalizeBooking(value.booking), replayed: value.replayed === true }) +export interface TripBookingQuoteCriteria { customerId: string; destinationKey: string; currency: 'CNY' | 'USD'; priceBasis: 'PALLET' | 'ORDER'; page?: number; size?: number } +export async function listTripBookingQuoteCandidates(tripNo: string, criteria: TripBookingQuoteCriteria) { + return normalizeFleetQuotePage(unwrapApiResponse(await http.get, ApiEnvelope>(`${path(tripNo)}/quote-candidates`, { params: criteria }))) +} export async function listFleetCustomers(keyword?: string): Promise { return unwrapApiResponse(await http.get, ApiEnvelope>(`${base}/customer-options`, { params: { keyword } })) } diff --git a/src/modules/overseas-master-data/bookingTypes.ts b/src/modules/overseas-master-data/bookingTypes.ts index cd4123a2..d477877b 100644 --- a/src/modules/overseas-master-data/bookingTypes.ts +++ b/src/modules/overseas-master-data/bookingTypes.ts @@ -6,9 +6,9 @@ export interface TripBookingCargo { } export interface TripBooking { bookingNo: string; tripNo: string; customerId: string; customerCode: string; customerName: string; status: 'DRAFT' | 'CONFIRMED' | 'RECEIVED' | 'BOUND' | 'CANCELLED'; cargo: TripBookingCargo; destinationLabel: string; - agreedAmount: number | null; recordVersion: number; updatedAt: string; incomingNo: string | null; dispatchNo: string | null; receipt: TripAcceptedReceipt | null } -export interface TripBookingCreate { tripRecordVersion: number; customerId: string; cargo: TripBookingCargo; idempotencyKey: string } -export interface TripBookingUpdate { tripRecordVersion: number; recordVersion: number; cargo: TripBookingCargo } + agreedAmount: number | null; recordVersion: number; updatedAt: string; incomingNo: string | null; dispatchNo: string | null; receipt: TripAcceptedReceipt | null; quoteSource: import('./api/fleetQuoteCandidates').FleetQuoteSourceSnapshot | null } +export interface TripBookingCreate { tripRecordVersion: number; customerId: string; cargo: TripBookingCargo; idempotencyKey: string; quoteRef?: import('./api/fleetQuoteCandidates').FleetQuoteReference | null } +export interface TripBookingUpdate { tripRecordVersion: number; recordVersion: number; cargo: TripBookingCargo; quoteRef?: import('./api/fleetQuoteCandidates').FleetQuoteReference | null } export interface TripBookingCommand { tripRecordVersion: number; recordVersion: number; idempotencyKey: string; reason?: string } export interface TripBookingResult { tripRecordVersion: number; booking: TripBooking; replayed: boolean } export interface TripBookingList { tripRecordVersion: number; records: TripBooking[] } diff --git a/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.test.ts b/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.test.ts index 6b1e0028..cf335961 100644 --- a/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.test.ts +++ b/src/modules/overseas-master-data/components/TripBookingFulfillmentDialog.test.ts @@ -7,7 +7,7 @@ import type { TripBooking, TripIncomingPreview } from '../bookingTypes' vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn(), warning: vi.fn() } })) vi.mock('../api/tripBookings', () => ({ getTripIncoming: vi.fn(), startTripIncoming: vi.fn(), acceptTripIncoming: vi.fn(), bindTripBookingDispatch: vi.fn() })) const trip = { tripNo: 'TR-1', recordVersion: 4, plannedDepartureAt: '2026-10-03T10:00:00Z' } as FleetTrip -const booking: TripBooking = { bookingNo: 'FB-1', tripNo: 'TR-1', customerId: '9', customerCode: '00009', customerName: '客户甲', status: 'CONFIRMED', incomingNo: 'WT-1', dispatchNo: null, receipt: null, +const booking: TripBooking = { 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 }] } } diff --git a/src/modules/overseas-master-data/components/TripBookingPanel.test.ts b/src/modules/overseas-master-data/components/TripBookingPanel.test.ts index 2363f028..521ad9f4 100644 --- a/src/modules/overseas-master-data/components/TripBookingPanel.test.ts +++ b/src/modules/overseas-master-data/components/TripBookingPanel.test.ts @@ -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) +}) diff --git a/src/modules/overseas-master-data/components/TripBookingPanel.vue b/src/modules/overseas-master-data/components/TripBookingPanel.vue index b67c4890..cd9a48b4 100644 --- a/src/modules/overseas-master-data/components/TripBookingPanel.vue +++ b/src/modules/overseas-master-data/components/TripBookingPanel.vue @@ -8,7 +8,8 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { overseasMasterDataPermissions as permission } from '../permissions' -import { listFleetCustomers, listTripBookings, createTripBooking, updateTripBooking, confirmTripBooking, cancelTripBooking } from '../api/tripBookings' +import { listFleetCustomers, listTripBookings, createTripBooking, updateTripBooking, confirmTripBooking, cancelTripBooking, listTripBookingQuoteCandidates } from '../api/tripBookings' +import { fleetQuoteReference, type FleetQuoteReference, type FleetQuoteSourceSnapshot } from '../api/fleetQuoteCandidates' import { bookingStatusLabels, type TripBooking, type TripBookingCargo, type TripBookingCreate, type TripBookingCommand, type TripBookingResult, type FleetCustomerOption } from '../bookingTypes' import TripBookingFulfillmentDialog from './TripBookingFulfillmentDialog.vue' import type { FleetTrip } from '../tripTypes' @@ -25,6 +26,32 @@ const pendingCreate = ref(null), pendingCommand = ref< const version = ref(null), customerId = ref('') const blank = (): TripBookingCargo => ({ destinationKey: '', cargoName: null, packageCount: 1, palletSpaces: null, weightKg: null, volumeCbm: null, priceBasis: 'PALLET', unitPrice: null, currency: 'USD', contactName: null, contactPhone: null, remark: null }) const form = reactive(blank()) +const quoteSource = ref(null), quoteRef = ref(null) +const candidates = ref([]), quoteLoading = ref(false), quoteError = ref(null) +const quotePage = ref(1), quotePages = ref(0), quoteSearched = ref(false) +let quoteSequence = 0 +const canReadQuotes = computed(() => access.can(permission.fleetQuoteList)) +const quoteLocked = computed(() => readOnly.value || busy.value || !!pendingCreate.value || !canReadQuotes.value) +const canQueryQuotes = computed(() => !!customerId.value && form.destinationKey.startsWith('FBA:') && ['CNY', 'USD'].includes(form.currency || '')) +const sourceKey = (value: FleetQuoteSourceSnapshot | FleetQuoteReference) => `${value.quoteNo}:${value.recordVersion}:${value.priceItem}` +function resetCandidates() { quoteSequence++; candidates.value = []; quoteError.value = null; quoteLoading.value = false; quoteSearched.value = false; quotePage.value = 1; quotePages.value = 0 } +function clearQuote() { if (quoteLocked.value) return; quoteRef.value = null; quoteSource.value = null } +function selectQuote(key: string) { + if (quoteLocked.value) return + const source = candidates.value.find(row => sourceKey(row) === key) + if (!source) return + quoteSource.value = source; quoteRef.value = fleetQuoteReference(source); form.unitPrice = source.quotedUnitPrice +} +async function queryQuotes(page = 1) { + if (quoteLocked.value || !canQueryQuotes.value) return + const current = ++quoteSequence; quoteLoading.value = true; quoteError.value = null + try { + const result = await listTripBookingQuoteCandidates(props.trip.tripNo, { customerId: customerId.value, destinationKey: form.destinationKey, currency: form.currency as 'CNY' | 'USD', priceBasis: form.priceBasis, page, size: 20 }) + if (current === quoteSequence) { candidates.value = result.records; quotePage.value = result.page; quotePages.value = result.pages; quoteSearched.value = true } + } catch (cause) { if (current === quoteSequence) { candidates.value = []; quoteError.value = getApiErrorDetails(cause, '报价库查询未完成,可继续手工询价') } } + finally { if (current === quoteSequence) quoteLoading.value = false } +} +watch(() => [customerId.value, form.destinationKey, form.currency, form.priceBasis], () => { resetCandidates(); quoteSource.value = null; quoteRef.value = null }, { flush: 'sync' }) let generation = 0, searchSequence = 0 let searchTimer: ReturnType | undefined const planned = computed(() => props.trip.status === 'PLANNED') @@ -59,8 +86,9 @@ function edit(row?: TripBooking, viewOnly = false) { readOnly.value = viewOnly if (pendingCommand.value && !viewOnly) { ElMessage.warning('请先重试或刷新核对上一条操作'); return } editing.value = row || null - if (!row && pendingCreate.value) { customerId.value = pendingCreate.value.customerId; Object.assign(form, pendingCreate.value.cargo) } - else { customerId.value = row?.customerId || ''; Object.assign(form, row ? structuredClone({ ...row.cargo }) : blank()) } + resetCandidates() + if (!row && pendingCreate.value) { customerId.value = pendingCreate.value.customerId; Object.assign(form, pendingCreate.value.cargo); quoteRef.value = pendingCreate.value.quoteRef || null } + else { customerId.value = row?.customerId || ''; Object.assign(form, row ? structuredClone({ ...row.cargo }) : blank()); quoteSource.value = row?.quoteSource || null; quoteRef.value = quoteSource.value ? fleetQuoteReference(quoteSource.value) : null } open.value = true } function accept(value: TripBookingResult) { @@ -75,13 +103,14 @@ function failure(cause: unknown, fallback: string) { } async function save() { if (busy.value || readOnly.value || version.value == null) return + if (editing.value?.quoteSource && !canReadQuotes.value) { ElMessage.warning('编辑带报价来源的询价需要报价读取权限'); return } if (!customerId.value || !form.destinationKey || !form.packageCount) { ElMessage.warning('请选择客户、卸货站并填写箱数'); return } const current = generation; busy.value = true; error.value = null try { let value: TripBookingResult - if (editing.value) value = await updateTripBooking(props.trip.tripNo, editing.value.bookingNo, { tripRecordVersion: version.value, recordVersion: editing.value.recordVersion, cargo: structuredClone({ ...form }) }) + if (editing.value) value = await updateTripBooking(props.trip.tripNo, editing.value.bookingNo, { tripRecordVersion: version.value, recordVersion: editing.value.recordVersion, cargo: structuredClone({ ...form }), quoteRef: quoteRef.value ? { ...quoteRef.value } : null }) else { - pendingCreate.value ||= { tripRecordVersion: version.value, customerId: customerId.value, cargo: structuredClone({ ...form }), idempotencyKey: crypto.randomUUID() } + pendingCreate.value ||= { tripRecordVersion: version.value, customerId: customerId.value, cargo: structuredClone({ ...form }), quoteRef: quoteRef.value ? { ...quoteRef.value } : null, idempotencyKey: crypto.randomUUID() } value = await createTripBooking(props.trip.tripNo, pendingCreate.value) } if (current === generation) { accept(value); pendingCreate.value = null; open.value = false; ElMessage.success('外接询价已保存') } @@ -106,7 +135,9 @@ async function command(row: TripBooking, action: 'confirm' | 'cancel') { finally { busy.value = false } } watch(() => [props.trip.tripNo, props.trip.recordVersion], () => { if (version.value !== props.trip.recordVersion) void load() }, { immediate: true }) -onBeforeUnmount(() => { generation++; searchSequence++; clearTimeout(searchTimer) }) +watch(open, value => { if (!value) resetCandidates() }) +watch(() => [props.trip.tripNo, props.trip.recordVersion], resetCandidates) +onBeforeUnmount(() => { generation++; searchSequence++; quoteSequence++; clearTimeout(searchTimer) })