fix(fleet): preserve retry intent and restore inquiry editing

This commit is contained in:
2026-10-03 13:00:32 +08:00
parent 0b5094dd8f
commit 3440f9dd11
9 changed files with 152 additions and 14 deletions
@@ -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<typeof import('@/shared/api/http')>()), 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)
})
@@ -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<T>(value: { records: T[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric }): PageResult<T> {
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) }
@@ -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<typeof import('../api/fleetQuotes')>()), createFleetQuote: vi.fn(), updateFleetQuote: vi.fn() }))
const wrappers: VueWrapper[] = []
const input = (name: string) => ({ name, props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" />' })
const slot = { template: '<div><slot/></div>' }
async function mount() {
const wrapper = shallowMount(FleetQuoteEditor, { props: { modelValue: false, quote: null, kind: 'SALE' }, global: { stubs: {
AppDialog: { props: ['modelValue'], template: '<div v-if="modelValue"><slot/><slot name="footer"/></div>' }, ElForm: { name: 'ElForm', props: ['disabled'], template: '<div><slot/></div>' },
ElFormItem: slot, ElInput: input('ElInput'), ElInputNumber: input('ElInputNumber'), ElDatePicker: input('ElDatePicker'), ElSelect: slot, ElOption: true,
FleetQuotePartySelect: input('FleetQuotePartySelect'), ElButton: { template: '<button><slot/></button>' }, ElAlert: { props: ['title'], template: '<p>{{ title }}<slot/></p>' }, 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)
})
@@ -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<boolean>({ 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 }
}
</script>
@@ -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<typeof import('../api/fleetQuotes')>()), importFleetQuotes: vi.fn() }))
const wrappers: VueWrapper[] = []
async function mount() {
const wrapper = shallowMount(FleetQuoteImport, { props: { modelValue: true, kind: 'SALE' }, global: { stubs: {
AppDialog: { template: '<div><slot/><slot name="footer"/></div>' },
FleetQuotePartySelect: { name: 'FleetQuotePartySelect', props: ['modelValue', 'kind', 'disabled'], emits: ['update:modelValue'], template: '<div/>' },
ElButton: { props: ['disabled', 'loading'], template: '<button :disabled="disabled || loading"><slot/></button>' },
ElAlert: { props: ['title'], template: '<p>{{ title }}<slot/></p>' }, 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<string>) {
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<string>(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')
})
@@ -1,20 +1,29 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, onBeforeUnmount, 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 { importFleetQuotes, saveFleetCsv, type FleetQuoteDraft, type FleetQuoteKind } from '../api/fleetQuotes'
import { importFleetQuotes, saveFleetCsv, isFleetCommandRejected, type FleetQuoteDraft, type FleetQuoteKind } from '../api/fleetQuotes'
import { fleetQuoteCsvHeader, parseFleetQuoteCsv } from '../fleetQuoteCsv'
const open = defineModel<boolean>({ default: false }), props = defineProps<{ kind: FleetQuoteKind }>(), emit = defineEmits<{ saved: [] }>()
const partyId = ref(''), rows = ref<FleetQuoteDraft[]>([]), source = ref(''), saving = ref(false), error = ref<ApiErrorDetails | null>(null)
const pending = ref<{ key: string; rows: FleetQuoteDraft[] } | null>(null)
const activeKind = computed(() => pending.value?.rows[0]?.kind || props.kind)
let fileSequence = 0
watch([partyId, () => props.kind], () => { if (!pending.value) preview() })
function preview() { try { rows.value = source.value ? parseFleetQuoteCsv(source.value, props.kind, partyId.value) : []; error.value = null } catch (e) { rows.value = []; error.value = getApiErrorDetails(e, 'CSV校验失败') } }
async function pick(event: Event) { const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return; if (file.size > 500000) { error.value = getApiErrorDetails(new Error('CSV不能超过500KB')); return }; source.value = await file.text(); preview() }
async function pick(event: Event) {
if (saving.value || pending.value) return
const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return
const current = ++fileSequence; rows.value = []; source.value = ''; error.value = null
if (file.size > 500000) { error.value = getApiErrorDetails(new Error('CSV不能超过500KB')); return }
try { const text = await file.text(); if (current !== fileSequence) return; source.value = text; preview() }
catch (e) { if (current === fileSequence) error.value = getApiErrorDetails(e, 'CSV读取失败,请重新选择文件') }
}
function template() { saveFleetCsv(new Blob(['\uFEFF' + fleetQuoteCsvHeader.join(',') + '\r\n'], { type: 'text/csv;charset=utf-8' }), '车队报价模板.csv') }
async function save() { saving.value = true; error.value = null; try { pending.value ??= { key: crypto.randomUUID(), rows: rows.value.map(r => ({ ...r })) }; await importFleetQuotes(pending.value.key, pending.value.rows); pending.value = null; rows.value = []; source.value = ''; open.value = false; emit('saved') } catch (e) { error.value = getApiErrorDetails(e, '整批导入失败,请核对报价库与目录后重试'); if (e instanceof ApiResponseError && ([400, 409, 422].includes(e.status || 0) || ['400', '409', '422', 'VALIDATION_FAILED', 'CONFLICT'].includes(e.code))) pending.value = null } finally { saving.value = false } }
async function save() { if (saving.value || (!pending.value && (!rows.value.length || !partyId.value))) return; fileSequence++; saving.value = true; error.value = null; try { pending.value ??= { key: crypto.randomUUID(), rows: rows.value.map(r => ({ ...r })) }; await importFleetQuotes(pending.value.key, pending.value.rows); pending.value = null; rows.value = []; source.value = ''; open.value = false; emit('saved') } catch (e) { error.value = getApiErrorDetails(e, '整批导入失败,请核对报价库与目录后重试'); if (isFleetCommandRejected(e)) pending.value = null } finally { saving.value = false } }
onBeforeUnmount(() => { fileSequence++ })
</script>
<template><AppDialog v-model="open" title="批量导入车队报价" width="1040px" class="admin-density-dialog" :close-on-click-modal="false">
<p>本批{{ activeKind === 'PURCHASE' ? '采购' : '销售' }}报价使用同一往来单位;每批最多200条,服务端整批校验,任一失败全部不写入。</p>
@@ -16,7 +16,7 @@ function mount() {
const field = { props: ['modelValue'], emits: ['update:modelValue'], template: '<input />' }
const wrapper = shallowMount(TripBookingPanel, { props: { trip }, global: { stubs: {
DataTable: { props: ['data'], template: '<div><slot name="toolbar-actions"/><div v-for="row in data" :key="row.bookingNo"><slot name="actions" :row="row"/></div></div>' },
AppDialog: { name: 'AppDialog', props: ['modelValue'], emits: ['confirm'], template: '<div v-if="modelValue"><slot/></div>' },
AppDialog: { name: 'AppDialog', props: ['modelValue', 'showConfirm'], emits: ['confirm', 'update:modelValue'], template: '<div v-if="modelValue"><slot/></div>' },
ElButton: { props: ['disabled'], template: '<button :disabled="disabled"><slot/></button>' },
ElForm: { template: '<div><slot/></div>' }, ElFormItem: { template: '<div><slot/></div>' },
ElSelect: { ...field, name: 'ElSelect' }, ElInputNumber: { ...field, name: 'ElInputNumber' }, ElInput: field, ElOption: true,
@@ -55,3 +55,20 @@ it('opens reactive booking data for editing and read-only details without losing
wrapper.findComponent({ name: 'AppDialog' }).vm.$emit('confirm'); await flushPromises()
expect(api.updateTripBooking).not.toHaveBeenCalled(); expect(api.createTripBooking).not.toHaveBeenCalled()
})
it('can edit an inquiry after closing its read-only details', async () => {
const wrapper = mount(); await flushPromises()
await button(wrapper, '详情').trigger('click'); await flushPromises()
const dialog = wrapper.findComponent({ name: 'AppDialog' })
expect(dialog.props('showConfirm')).toBe(false)
dialog.vm.$emit('update:modelValue', false); await flushPromises()
expect(button(wrapper, '编辑').attributes('disabled')).toBeUndefined()
await button(wrapper, '编辑').trigger('click'); await flushPromises()
expect(dialog.props('showConfirm')).toBe(true)
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,
})
expect(api.listTripBookings).toHaveBeenCalledOnce()
})
@@ -114,7 +114,7 @@ onBeforeUnmount(() => { generation++; searchSequence++; clearTimeout(searchTimer
<LoadErrorState v-if="error" title="外接操作未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<DataTable :data="records" :columns="columns" :loading="loading" row-key="bookingNo" empty-text="暂无外接预订">
<template #toolbar-actions><el-button :disabled="busy" @click="load">刷新预订</el-button><el-button v-if="planned && access.can(permission.tripBookingCreate)" type="primary" :disabled="busy" @click="edit()">{{ pendingCreate ? '继续原登记' : '登记外接询价' }}</el-button></template>
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" :disabled="busy" @click="edit(row as TripBooking, true)">详情</el-button><el-button v-if="planned && row.status === 'DRAFT' && access.can(permission.tripBookingEdit)" class="density-action" :disabled="readOnly || busy || !!pendingCreate" @click="edit(row as TripBooking)">编辑</el-button><el-button v-if="planned && row.status === 'DRAFT' && access.can(permission.tripBookingConfirm)" class="density-action" :disabled="busy" @click="command(row as TripBooking, 'confirm')">确认承运</el-button><el-button v-if="planned && ['DRAFT','CONFIRMED','RECEIVED'].includes(row.status) && access.can(permission.tripBookingCancel)" class="density-action" data-tone="danger" :disabled="busy" @click="command(row as TripBooking, 'cancel')">取消预订</el-button><el-button v-if="row.incomingNo" class="density-action" @click="fulfill(row as TripBooking, 'view')">来货详情</el-button><el-button v-if="planned && row.status === 'CONFIRMED' && !row.incomingNo && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'incoming')">登记来货</el-button><el-button v-if="planned && row.status === 'CONFIRMED' && row.incomingNo && access.can(permission.tripBookingConfirm)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'receipt')">核对实收</el-button><el-button v-if="planned && row.status === 'RECEIVED' && access.can(permission.tripBookingEdit) && access.can(permission.tripEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'bind')">关联排车</el-button></div></template>
<template #actions="{ row }"><div class="density-actions"><el-button class="density-action" :disabled="busy" @click="edit(row as TripBooking, true)">详情</el-button><el-button v-if="planned && row.status === 'DRAFT' && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy || !!pendingCreate" @click="edit(row as TripBooking)">编辑</el-button><el-button v-if="planned && row.status === 'DRAFT' && access.can(permission.tripBookingConfirm)" class="density-action" :disabled="busy" @click="command(row as TripBooking, 'confirm')">确认承运</el-button><el-button v-if="planned && ['DRAFT','CONFIRMED','RECEIVED'].includes(row.status) && access.can(permission.tripBookingCancel)" class="density-action" data-tone="danger" :disabled="busy" @click="command(row as TripBooking, 'cancel')">取消预订</el-button><el-button v-if="row.incomingNo" class="density-action" @click="fulfill(row as TripBooking, 'view')">来货详情</el-button><el-button v-if="planned && row.status === 'CONFIRMED' && !row.incomingNo && access.can(permission.tripBookingEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'incoming')">登记来货</el-button><el-button v-if="planned && row.status === 'CONFIRMED' && row.incomingNo && access.can(permission.tripBookingConfirm)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'receipt')">核对实收</el-button><el-button v-if="planned && row.status === 'RECEIVED' && access.can(permission.tripBookingEdit) && access.can(permission.tripEdit)" class="density-action" :disabled="busy" @click="fulfill(row as TripBooking, 'bind')">关联排车</el-button></div></template>
<template #status="{ row }">{{ bookingStatusLabels[row.status as keyof typeof bookingStatusLabels] }}</template>
<template #cargo="{ row }">{{ row.receipt ? '实收' : '预计' }} · {{ row.cargo.packageCount }} 箱 · {{ row.cargo.palletSpaces ?? '—' }} 托位 · {{ row.cargo.weightKg ?? '—' }} kg · {{ row.cargo.volumeCbm ?? '—' }} m³</template>
<template #documents="{ row }"><div>{{ row.incomingNo || '尚未登记来货' }}</div><div>{{ row.dispatchNo || '尚未关联排车' }}</div></template>
@@ -3,7 +3,8 @@ import { onBeforeUnmount, reactive, ref, watch } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.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 { isFleetCommandRejected } from '../api/fleetQuotes'
import { getTripOperationsRecord, saveTripOperationsRecord, listTripOperationsRecordHistory, type TripOperationsRow, type TripOperationsRecord, type TripOperationsRecordHistory, type TripOperationsRecordSave, type TripOperationsMeasured } from '../api/tripOperations'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ row: TripOperationsRow | null; readonly?: boolean }>(), emit = defineEmits<{ saved: [] }>()
@@ -30,7 +31,7 @@ async function save() {
const request = pending.get(no) || { recordVersion: record.value.recordVersion, idempotencyKey: crypto.randomUUID(), details: { actualDistanceMiles: form.actualDistanceMiles ?? null, actualFuelLitres: form.actualFuelLitres ?? null, remark: form.remark || null }, reason: reason.value || null }
pending.set(no, request)
try { await saveTripOperationsRecord(no, request); pending.delete(no); uncertain.value = false; open.value = false; emit('saved') }
catch (e) { error.value = getApiErrorDetails(e, '实测记录保存失败'); if (e instanceof ApiResponseError && ([400, 409, 422].includes(e.status || 0) || ['400', '409', '422', 'VALIDATION_FAILED', 'CONFLICT'].includes(e.code))) pending.delete(no); uncertain.value = pending.has(no) }
catch (e) { error.value = getApiErrorDetails(e, '实测记录保存失败'); if (isFleetCommandRejected(e)) pending.delete(no); uncertain.value = pending.has(no) }
finally { saving.value = false }
}
watch([open, () => props.row?.tripNo], () => { sequence++; tab.value = 'measured'; history.value = []; page.value = 1; total.value = 0; if (open.value) void load() })