From 3440f9dd11fd84b284c0aec41028a3afef05ae18 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 3 Oct 2026 13:00:32 +0800 Subject: [PATCH] fix(fleet): preserve retry intent and restore inquiry editing --- .../api/fleetQuotes.test.ts | 9 ++- .../overseas-master-data/api/fleetQuotes.ts | 4 ++ .../components/FleetQuoteEditor.test.ts | 44 ++++++++++++++ .../components/FleetQuoteEditor.vue | 6 +- .../components/FleetQuoteImport.test.ts | 58 +++++++++++++++++++ .../components/FleetQuoteImport.vue | 19 ++++-- .../components/TripBookingPanel.test.ts | 19 +++++- .../components/TripBookingPanel.vue | 2 +- .../components/TripOperationsRecordDialog.vue | 5 +- 9 files changed, 152 insertions(+), 14 deletions(-) create mode 100644 src/modules/overseas-master-data/components/FleetQuoteEditor.test.ts create mode 100644 src/modules/overseas-master-data/components/FleetQuoteImport.test.ts diff --git a/src/modules/overseas-master-data/api/fleetQuotes.test.ts b/src/modules/overseas-master-data/api/fleetQuotes.test.ts index 8f185125..2b366b3a 100644 --- a/src/modules/overseas-master-data/api/fleetQuotes.test.ts +++ b/src/modules/overseas-master-data/api/fleetQuotes.test.ts @@ -1,6 +1,6 @@ import { beforeEach, expect, it, vi } from 'vitest' -import { http } from '@/shared/api/http' -import { listFleetQuotes, createFleetQuote, updateFleetQuote, importFleetQuotes, fleetCsv, fleetMoney, fleetVersion, type FleetQuoteDraft } from './fleetQuotes' +import { http, ApiResponseError } from '@/shared/api/http' +import { listFleetQuotes, createFleetQuote, updateFleetQuote, importFleetQuotes, fleetCsv, fleetMoney, fleetVersion, isFleetCommandRejected, type FleetQuoteDraft } from './fleetQuotes' vi.mock('@/shared/api/http', async original => ({ ...(await original()), http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } })) const details: FleetQuoteDraft = { kind: 'SALE', partyId: '90071992547409931', originCity: 'LA', destinationCode: 'ONT8', currency: 'USD', distanceMiles: 10, selfPickupPrice: 0, fullDeliveryPrice: null, looseDeliveryPrice: 2, validFrom: '2026-01-01', validTo: '2026-12-31', remark: null } const quote = { quoteNo: 'FQ-ONE', details: { ...details, selfPickupPrice: '0', looseDeliveryPrice: '2', distanceMiles: '10' }, partyName: '委托企业', status: 'ACTIVE', recordVersion: '90071992547409931', updatedAt: '2026-10-01T00:00:00Z' } @@ -33,3 +33,8 @@ it('rejects lossy money and numeric versions instead of rounding them', () => { expect(() => fleetVersion(9007199254740992)).toThrow('版本无效') expect(fleetVersion('90071992547409931')).toBe('90071992547409931') }) +it('recognizes deployed A04xx business rejection without treating S0500 or network failure as a definite abort', () => { + for (const code of ['A0400', 'A0409', 'A0422']) expect(isFleetCommandRejected(new ApiResponseError('业务拒绝', code, 200))).toBe(true) + expect(isFleetCommandRejected(new ApiResponseError('服务器结果不明', 'S0500', 200))).toBe(false) + expect(isFleetCommandRejected(new Error('Network error'))).toBe(false) +}) diff --git a/src/modules/overseas-master-data/api/fleetQuotes.ts b/src/modules/overseas-master-data/api/fleetQuotes.ts index d8e28581..08db06e8 100644 --- a/src/modules/overseas-master-data/api/fleetQuotes.ts +++ b/src/modules/overseas-master-data/api/fleetQuotes.ts @@ -28,6 +28,10 @@ export function fleetVersion(value: Numeric): string { if (!/^(0|[1-9]\d*)$/.test(String(value)) || (typeof value === 'number' && !Number.isSafeInteger(value))) throw new Error('接口版本无效') return String(value) } +export function isFleetCommandRejected(error: unknown): boolean { + return error instanceof ApiResponseError && ((error.status != null && error.status >= 400 && error.status < 500) + || /^A04\d\d$/.test(error.code) || ['400', '409', '422', 'VALIDATION_FAILED', 'CONFLICT'].includes(error.code)) +} export function fleetPage(value: { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }): PageResult { const integer = (n: Numeric) => { const result = fleetNumber(n); if (!Number.isSafeInteger(result)) throw new Error('接口分页超出有效范围'); return result } return { ...value, page: integer(value.page), size: integer(value.size), total: integer(value.total), pages: integer(value.pages) } diff --git a/src/modules/overseas-master-data/components/FleetQuoteEditor.test.ts b/src/modules/overseas-master-data/components/FleetQuoteEditor.test.ts new file mode 100644 index 00000000..175df81a --- /dev/null +++ b/src/modules/overseas-master-data/components/FleetQuoteEditor.test.ts @@ -0,0 +1,44 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils' +import FleetQuoteEditor from './FleetQuoteEditor.vue' +import { ApiResponseError } from '@/shared/api/http' +import * as api from '../api/fleetQuotes' +vi.mock('../api/fleetQuotes', async original => ({ ...(await original()), createFleetQuote: vi.fn(), updateFleetQuote: vi.fn() })) +const wrappers: VueWrapper[] = [] +const input = (name: string) => ({ name, props: ['modelValue'], emits: ['update:modelValue'], template: '' }) +const slot = { template: '
' } +async function mount() { + const wrapper = shallowMount(FleetQuoteEditor, { props: { modelValue: false, quote: null, kind: 'SALE' }, global: { stubs: { + AppDialog: { props: ['modelValue'], template: '
' }, ElForm: { name: 'ElForm', props: ['disabled'], template: '
' }, + ElFormItem: slot, ElInput: input('ElInput'), ElInputNumber: input('ElInputNumber'), ElDatePicker: input('ElDatePicker'), ElSelect: slot, ElOption: true, + FleetQuotePartySelect: input('FleetQuotePartySelect'), ElButton: { template: '' }, ElAlert: { props: ['title'], template: '

{{ title }}

' }, ApiErrorMeta: true, + } } }); wrappers.push(wrapper); await wrapper.setProps({ modelValue: true }); await flushPromises() + wrapper.findComponent({ name: 'FleetQuotePartySelect' }).vm.$emit('update:modelValue', '101') + wrapper.findAllComponents({ name: 'ElInput' })[1]!.vm.$emit('update:modelValue', 'QA-Origin') + wrapper.findAllComponents({ name: 'ElInput' })[2]!.vm.$emit('update:modelValue', 'QA-DEST') + wrapper.findAllComponents({ name: 'ElInputNumber' })[0]!.vm.$emit('update:modelValue', 10) + wrapper.findAllComponents({ name: 'ElInputNumber' })[2]!.vm.$emit('update:modelValue', 0) + wrapper.findAllComponents({ name: 'ElDatePicker' })[1]!.vm.$emit('update:modelValue', '2100-01-01') + await flushPromises(); return wrapper +} +async function clickSave(wrapper: VueWrapper) { await wrapper.findAll('button').find(b => ['保存报价', '按原请求重试'].includes(b.text()))!.trigger('click'); await flushPromises() } +beforeEach(() => vi.clearAllMocks()); afterEach(() => wrappers.splice(0).forEach(w => w.unmount())) +it('HTTP 200/A0409 unlocks the form so corrected content receives a new key', async () => { + const wrapper = await mount(); vi.mocked(api.createFleetQuote).mockRejectedValueOnce(new ApiResponseError('报价组合重复', 'A0409', 200)) + await clickSave(wrapper); const original = vi.mocked(api.createFleetQuote).mock.calls[0]! + expect(wrapper.findComponent({ name: 'ElForm' }).props('disabled')).toBe(false); expect(wrapper.text()).not.toContain('按原请求重试') + wrapper.findAllComponents({ name: 'ElInput' })[1]!.vm.$emit('update:modelValue', 'QA-Another-Origin') + vi.mocked(api.createFleetQuote).mockResolvedValue({ quoteNo: 'FQ-NEW', details: original[1], recordVersion: '0', partyName: '测试客户', status: 'ACTIVE', updatedAt: '' }) + await clickSave(wrapper) + const corrected = vi.mocked(api.createFleetQuote).mock.calls[1]! + expect(corrected[0]).not.toBe(original[0]); expect(corrected[1].originCity).toBe('QA-Another-Origin'); expect(corrected[1].fullDeliveryPrice).toBe(0) +}) +it('unknown creation retains its exact key after viewing another quote', async () => { + const wrapper = await mount(); vi.mocked(api.createFleetQuote).mockRejectedValueOnce(new Error('Network unavailable')) + await clickSave(wrapper); const original = vi.mocked(api.createFleetQuote).mock.calls[0]! + const other: api.FleetQuote = { quoteNo: 'FQ-OTHER', details: { ...original[1], originCity: 'OTHER' }, recordVersion: '2', partyName: '测试客户', status: 'ACTIVE', updatedAt: '' } + await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ quote: other, readonly: true, modelValue: true }); await flushPromises() + await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ quote: null, readonly: false, modelValue: true }); await flushPromises() + expect(wrapper.text()).toContain('按原请求重试'); vi.mocked(api.createFleetQuote).mockResolvedValue({ ...other, quoteNo: 'FQ-NEW', details: original[1] }) + await clickSave(wrapper); expect(vi.mocked(api.createFleetQuote).mock.calls[1]).toEqual(original) +}) diff --git a/src/modules/overseas-master-data/components/FleetQuoteEditor.vue b/src/modules/overseas-master-data/components/FleetQuoteEditor.vue index 9d517def..7aaac54a 100644 --- a/src/modules/overseas-master-data/components/FleetQuoteEditor.vue +++ b/src/modules/overseas-master-data/components/FleetQuoteEditor.vue @@ -2,9 +2,9 @@ import { reactive, ref, watch } from 'vue' import AppDialog from '@/shared/components/AppDialog.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' -import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' +import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import FleetQuotePartySelect from './FleetQuotePartySelect.vue' -import { createFleetQuote, updateFleetQuote, type FleetQuote, type FleetQuoteDraft, type FleetQuoteKind } from '../api/fleetQuotes' +import { createFleetQuote, updateFleetQuote, isFleetCommandRejected, type FleetQuote, type FleetQuoteDraft, type FleetQuoteKind } from '../api/fleetQuotes' const open = defineModel({ default: false }) const props = defineProps<{ quote: FleetQuote | null; readonly?: boolean; kind: FleetQuoteKind }>() const emit = defineEmits<{ saved: [] }>() @@ -31,7 +31,7 @@ async function save() { else { pending ??= { idempotencyKey: crypto.randomUUID(), details }; await createFleetQuote(pending.idempotencyKey, pending.details) } if (!editing) pending = null uncertain.value = false; open.value = false; emit('saved') - } catch (e) { error.value = getApiErrorDetails(e, '报价保存失败'); if (!editing && e instanceof ApiResponseError && ([400, 409, 422].includes(e.status || 0) || ['400', '409', '422', 'VALIDATION_FAILED', 'CONFLICT'].includes(e.code))) pending = null; uncertain.value = !editing && pending !== null } + } catch (e) { error.value = getApiErrorDetails(e, '报价保存失败'); if (!editing && isFleetCommandRejected(e)) pending = null; uncertain.value = !editing && pending !== null } finally { saving.value = false } } diff --git a/src/modules/overseas-master-data/components/FleetQuoteImport.test.ts b/src/modules/overseas-master-data/components/FleetQuoteImport.test.ts new file mode 100644 index 00000000..580b750d --- /dev/null +++ b/src/modules/overseas-master-data/components/FleetQuoteImport.test.ts @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils' +import FleetQuoteImport from './FleetQuoteImport.vue' +import { ApiResponseError } from '@/shared/api/http' +import { fleetQuoteCsvHeader } from '../fleetQuoteCsv' +import * as api from '../api/fleetQuotes' +vi.mock('../api/fleetQuotes', async original => ({ ...(await original()), importFleetQuotes: vi.fn() })) +const wrappers: VueWrapper[] = [] +async function mount() { + const wrapper = shallowMount(FleetQuoteImport, { props: { modelValue: true, kind: 'SALE' }, global: { stubs: { + AppDialog: { template: '
' }, + FleetQuotePartySelect: { name: 'FleetQuotePartySelect', props: ['modelValue', 'kind', 'disabled'], emits: ['update:modelValue'], template: '
' }, + ElButton: { props: ['disabled', 'loading'], template: '' }, + ElAlert: { props: ['title'], template: '

{{ title }}

' }, ApiErrorMeta: true, + } } }); wrappers.push(wrapper) + wrapper.findComponent({ name: 'FleetQuotePartySelect' }).vm.$emit('update:modelValue', '101'); await flushPromises(); return wrapper +} +async function pick(wrapper: VueWrapper, city = 'QA-FIRST', size = 100, text?: () => Promise) { + Object.defineProperty(wrapper.find('input[type="file"]').element, 'files', { configurable: true, value: [{ size, text: text || (async () => `${fleetQuoteCsvHeader.join(',')}\n${city},QA-DEST,10,USD,,0,12,2026-01-01,2100-01-01,`) }] }) + await wrapper.find('input[type="file"]').trigger('change'); await flushPromises() +} +async function save(wrapper: VueWrapper) { await wrapper.findAll('button').find(b => ['确认导入', '按原请求重试'].includes(b.text()))!.trigger('click'); await flushPromises() } +beforeEach(() => vi.clearAllMocks()); afterEach(() => wrappers.splice(0).forEach(w => w.unmount())) +it('definite import rejection unlocks file selection and corrected rows use a new key', async () => { + const wrapper = await mount(); await pick(wrapper) + vi.mocked(api.importFleetQuotes).mockRejectedValueOnce(new ApiResponseError('组合重复', 'A0409', 200)); await save(wrapper) + const first = vi.mocked(api.importFleetQuotes).mock.calls[0]! + expect(wrapper.find('input[type="file"]').attributes('disabled')).toBeUndefined() + await pick(wrapper, 'QA-CORRECTED'); vi.mocked(api.importFleetQuotes).mockResolvedValue({ importedRows: 1 }); await save(wrapper) + const second = vi.mocked(api.importFleetQuotes).mock.calls[1]! + expect(second[0]).not.toBe(first[0]); expect(second[1][0]?.originCity).toBe('QA-CORRECTED') +}) +it('unknown result preserves original purpose, key and rows across closing and category changes', async () => { + const wrapper = await mount(); await pick(wrapper) + vi.mocked(api.importFleetQuotes).mockRejectedValueOnce(new Error('network')); await save(wrapper) + const call = vi.mocked(api.importFleetQuotes).mock.calls[0]! + const first = [call[0], call[1].map(row => ({ ...row }))] + await wrapper.setProps({ modelValue: false, kind: 'PURCHASE' }); await wrapper.setProps({ modelValue: true }); await flushPromises() + expect(wrapper.findComponent({ name: 'FleetQuotePartySelect' }).props('kind')).toBe('SALE') + expect(wrapper.find('input[type="file"]').attributes('disabled')).toBeDefined() + vi.mocked(api.importFleetQuotes).mockResolvedValue({ importedRows: 1 }); await save(wrapper) + expect(vi.mocked(api.importFleetQuotes).mock.calls[1]).toEqual(first) +}) +it('selecting an oversized replacement file cannot accidentally submit the previous valid file', async () => { + const wrapper = await mount(); await pick(wrapper); await pick(wrapper, 'TOO-LARGE', 500001) + expect(wrapper.findAll('button').find(b => b.text() === '确认导入')!.attributes('disabled')).toBeDefined() + expect(wrapper.text()).not.toContain('QA-FIRST'); expect(api.importFleetQuotes).not.toHaveBeenCalled() +}) +it('a late old file read cannot replace the most recently selected CSV', async () => { + const wrapper = await mount(); let release!: (value: string) => void + const slow = new Promise(resolve => { release = resolve }) + await pick(wrapper, 'SLOW', 100, () => slow) + await pick(wrapper, 'QA-LATEST') + release(`${fleetQuoteCsvHeader.join(',')}\nQA-OLD,QA-DEST,10,USD,,0,12,2026-01-01,2100-01-01,`); await flushPromises() + expect(wrapper.text()).toContain('QA-LATEST'); expect(wrapper.text()).not.toContain('QA-OLD') + vi.mocked(api.importFleetQuotes).mockResolvedValue({ importedRows: 1 }); await save(wrapper) + expect(vi.mocked(api.importFleetQuotes).mock.calls[0]?.[1][0]?.originCity).toBe('QA-LATEST') +}) diff --git a/src/modules/overseas-master-data/components/FleetQuoteImport.vue b/src/modules/overseas-master-data/components/FleetQuoteImport.vue index 92e16de4..10a3b8f4 100644 --- a/src/modules/overseas-master-data/components/FleetQuoteImport.vue +++ b/src/modules/overseas-master-data/components/FleetQuoteImport.vue @@ -1,20 +1,29 @@