feat(fleet): complete exception processing and trip evidence interfaces

This commit is contained in:
2026-10-03 16:40:36 +08:00
parent 9fa73f50d6
commit 4dc497c8ca
15 changed files with 716 additions and 49 deletions
@@ -51,7 +51,8 @@
| 入口 | 原型依据 | 当前承载 | 核对边界 |
| --- | --- | --- | --- |
| 车队报价 | `新版.html:8989–9021`,通用菜单`1527` | 独立列表,780px居中编辑,1040px居中CSV导入;采购/销售分开 | 列表权限含详情历史,写入与导出独立;整批导入,未知价不转零;真实1920×1080截图待发布后确认 |
| 出车记录 | `新版.html:8941–8987`,车辆菜单`1457` | 独立列表与统计,同一实际发车筛选口径,详情链接沿车次权限 | 不用原型内存记录创建发车事实;成本/财务未查明时明确显示未查询;首版CSV导出不声称原生Excel工作簿,页面与请求量待真实验收 |
| 出车记录 | `新版.html:8941–8987`,车辆菜单`1457` | 独立列表与统计,同一实际发车筛选口径,详情链接沿车次权限 | 不用原型内存记录创建发车事实;成本/财务未查明时明确显示未查询;首版CSV导出不声称原生Excel工作簿,页面与请求量待1920×1080真实验收 |
| 出车实测凭证 | `新版.html:8941–8987`、`modal-vehicle-trip`(8966);车辆规划第4.1节的实测凭证补充 | 沿现有820px居中实测弹框,附件选填;PDF/JPG/PNG,单个不超过10MiB、最多20个 | 平台0+专用业务类型+车次归属校验;保存后当前及历史版本按需预览,不逐行GET;零附件合法,移除当前引用保留历史。上传不创建实测记录;未知上传结果停止重传并保留文件名及诊断,允许暂不绑定。迟到响应不串车次;1920×1080、100%缩放视觉与请求量待真实验收 |
## 新版司机管理接入矩阵
@@ -1,28 +1,28 @@
import { expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { listTripOperations, getTripOperationsRecord, saveTripOperationsRecord, listTripOperationsRecordHistory } from './tripOperations'
vi.mock('@/shared/api/http', async original => ({ ...(await original<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), put: vi.fn() } }))
import { listTripOperations, getTripOperationsRecord, saveTripOperationsRecord, listTripOperationsRecordHistory, uploadTripOperationsEvidence, downloadTripOperationsEvidence } from './tripOperations'
vi.mock('@/shared/api/http', async original => ({ ...(await original<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), put: vi.fn(), post: vi.fn() } }))
it('uses one aggregate response, keeps currency groups and never invents finance/cost amounts', async () => {
const counts = { physicalPallets: '1', palletSpaces: '2', ownPackages: '11', externalPackages: '4', externalAgreedAmounts: [{ currency: 'USD', amount: '0' }, { currency: 'CNY', amount: '10.25' }] }
const totals = [{ currency: 'USD', directCost: '10.00', ownCost: null, externalCost: null }]
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { page: { records: [{ ...counts, tripNo: 'TR1', destinations: [{ key: 'FBA:ONT8', label: 'ONT8' }, { key: 'FBA:LAX9', label: 'LAX9' }], externalPalletRates: [], measured: { actualDistanceMiles: '0.0', actualFuelLitres: null, remark: null }, financeStatus: 'NOT_QUERIED', costStatus: 'AVAILABLE', costs: { allocationBlockReason: '路线不同', totals } }], total: '20', page: '1', size: '1', pages: '20' }, summary: { ...counts, trips: '20', inTransit: '12', completed: '8', costs: { visible: true, unrecordedTrips: '1', unallocatedTrips: '1', totals } }, charts: { physicalPallets: [{ label: '每日实运托盘', unit: '托', points: [{ label: '2026-10-01', value: '1' }] }], destinationTrips: [], externalAgreedAmounts: [], externalPalletRates: [] } } })
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { page: { records: [{ ...counts, tripNo: 'TR1', destinations: [{ key: 'FBA:ONT8', label: 'ONT8' }, { key: 'FBA:LAX9', label: 'LAX9' }], externalPalletRates: [], measured: { actualDistanceMiles: '0.0', actualFuelLitres: null, remark: null, evidenceFiles: [] }, financeStatus: 'NOT_QUERIED', costStatus: 'AVAILABLE', costs: { allocationBlockReason: '路线不同', totals } }], total: '20', page: '1', size: '1', pages: '20' }, summary: { ...counts, trips: '20', inTransit: '12', completed: '8', costs: { visible: true, unrecordedTrips: '1', unallocatedTrips: '1', totals } }, charts: { physicalPallets: [{ label: '每日实运托盘', unit: '托', points: [{ label: '2026-10-01', value: '1' }] }], destinationTrips: [], externalAgreedAmounts: [], externalPalletRates: [] } } })
const query = { from: '2026-10-01T00:00:00Z', to: '2026-10-03T00:00:00Z', sourceType: 'EXTERNAL' as const }
const result = await listTripOperations(query)
expect(result.summary.trips).toBe(20); expect(result.page.records).toHaveLength(1)
expect(result.summary.externalAgreedAmounts).toEqual([{ currency: 'USD', amount: 0 }, { currency: 'CNY', amount: 10.25 }])
expect(result.page.records[0]).toMatchObject({ financeStatus: 'NOT_QUERIED', costStatus: 'AVAILABLE', costs: { totals: [{ directCost: 10, ownCost: null, externalCost: null }] } })
expect(result.summary.costs).toMatchObject({ unrecordedTrips: 1, unallocatedTrips: 1 })
expect(result.page.records[0]?.measured).toEqual({ actualDistanceMiles: 0, actualFuelLitres: null, remark: null })
expect(result.page.records[0]?.measured).toEqual({ actualDistanceMiles: 0, actualFuelLitres: null, remark: null, evidenceFiles: [] })
expect(result.charts.physicalPallets[0]?.points[0]?.value).toBe(1)
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/operations', { params: query })
})
it('keeps measured versions and retries exact requests, without converting absent fuel into zero', async () => {
vi.clearAllMocks()
const wire = { tripNo: 'TR1', recordVersion: '90071992547409931', details: { actualDistanceMiles: '0', actualFuelLitres: null, remark: null }, updatedAt: null }
const wire = { tripNo: 'TR1', recordVersion: '90071992547409931', details: { actualDistanceMiles: '0', actualFuelLitres: null, remark: null, evidenceFiles: [] }, updatedAt: null }
vi.mocked(http.get).mockResolvedValue({ code: '200', data: wire })
const record = await getTripOperationsRecord('TR1')
expect(record.details).toEqual({ actualDistanceMiles: 0, actualFuelLitres: null, remark: null })
const request = { recordVersion: record.recordVersion, idempotencyKey: 'stable-key', details: record.details, reason: null }
expect(record.details).toEqual({ actualDistanceMiles: 0, actualFuelLitres: null, remark: null, evidenceFiles: [] })
const request = { recordVersion: record.recordVersion, idempotencyKey: 'stable-key', details: { actualDistanceMiles: record.details.actualDistanceMiles, actualFuelLitres: record.details.actualFuelLitres, remark: record.details.remark, evidenceFileIds: [] }, reason: null }
vi.mocked(http.put).mockResolvedValue({ code: '200', data: wire }); await saveTripOperationsRecord('TR1', request)
expect(http.put).toHaveBeenCalledWith('/platform/v1/last-leg/fleet/operations/TR1/record', request)
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { records: [{ ...wire, occurredAt: '2026-10-03T00:00:00Z', reason: '更正' }], total: '1', page: '1', size: '20', pages: '1' } })
@@ -30,3 +30,19 @@ it('keeps measured versions and retries exact requests, without converting absen
expect(history.records[0]?.recordVersion).toBe('90071992547409931')
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/fleet/operations/TR1/record/history', { params: { page: 1, size: 20 } })
})
it('uploads multipart to the trip-scoped endpoint and preserves large file identities', async () => {
vi.clearAllMocks()
const file = new File(['pdf'], 'meter.pdf', { type: 'application/pdf' })
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { fileId: '9007199254740993', fileName: 'meter.pdf', contentType: 'application/pdf', sizeBytes: '3' } })
expect(await uploadTripOperationsEvidence('TR/1', file)).toMatchObject({ fileId: '9007199254740993', sizeBytes: 3 })
expect(vi.mocked(http.post).mock.calls[0]![0]).toBe('/platform/v1/last-leg/fleet/operations/TR%2F1/record/files')
expect((vi.mocked(http.post).mock.calls[0]![1] as FormData).get('file')).toBe(file)
await expect(uploadTripOperationsEvidence('TR1', new File(['html'], 'x.html', { type: 'text/html' }))).rejects.toThrow('PDF')
expect(http.post).toHaveBeenCalledTimes(1)
})
it('rejects JSON download errors and preserves correlation evidence instead of previewing them', async () => {
const blob = new Blob(['pdf'], { type: 'application/pdf' }); vi.mocked(http.get).mockResolvedValue(blob)
expect(await downloadTripOperationsEvidence('TR1', '81')).toBe(blob)
vi.mocked(http.get).mockResolvedValue({ code: 'A0403', message: '无权读取', requestId: 'request-evidence', traceId: 'trace-evidence' })
await expect(downloadTripOperationsEvidence('TR1', '81')).rejects.toMatchObject({ code: 'A0403', requestId: 'request-evidence', traceId: 'trace-evidence' })
})
@@ -1,4 +1,5 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import axios from 'axios'
import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http'
import type { PageResult } from './overseasMasterData'
import { fleetCsv, fleetNumber, fleetMoney, fleetVersion, fleetPage } from './fleetQuotes'
import type { CostSummary, CostTotal } from './tripCosts'
@@ -7,7 +8,9 @@ export interface TripOperationsMoney { currency: string; amount: number }
type OperationsCostTotal = Pick<CostTotal, 'currency' | 'directCost' | 'ownCost' | 'externalCost'>
export interface OperationsCosts { visible: boolean; unrecordedTrips: number | null; unallocatedTrips: number | null; totals: OperationsCostTotal[] }
export type OperationsRowCosts = Pick<CostSummary, 'allocationBlockReason'> & { totals: OperationsCostTotal[] }
export interface TripOperationsMeasured { actualDistanceMiles: number | null; actualFuelLitres: number | null; remark: string | null }
export interface TripOperationsEvidence { fileId: string; fileName: string; contentType: string; sizeBytes: number }
export interface TripOperationsMeasured { actualDistanceMiles: number | null; actualFuelLitres: number | null; remark: string | null; evidenceFiles: TripOperationsEvidence[] }
export type TripOperationsMeasuredInput = Omit<TripOperationsMeasured, 'evidenceFiles'> & { evidenceFileIds: string[] }
export interface TripOperationsSeries { label: string; unit: string; points: { label: string; value: number }[] }
export interface TripOperationsCharts { physicalPallets: TripOperationsSeries[]; destinationTrips: TripOperationsSeries[]; externalAgreedAmounts: TripOperationsSeries[]; externalPalletRates: TripOperationsSeries[] }
export interface TripOperationsRow {
@@ -19,14 +22,19 @@ export interface TripOperationsSummary { trips: number; inTransit: number; compl
export interface TripOperationsResult { page: PageResult<TripOperationsRow>; summary: TripOperationsSummary; charts: TripOperationsCharts }
type WireNumbers<T> = { [K in keyof T]: T[K] extends number ? number | string : T[K] }
type WireMoney = { currency: string; amount: string | number }
type WireMeasured = { actualDistanceMiles: string | number | null; actualFuelLitres: string | number | null; remark: string | null }
type WireEvidence = Omit<TripOperationsEvidence, 'sizeBytes'> & { sizeBytes: number | string }
type WireMeasured = { actualDistanceMiles: string | number | null; actualFuelLitres: string | number | null; remark: string | null; evidenceFiles?: WireEvidence[] }
type WireCostTotal = { currency: CostTotal['currency']; directCost: string | number; ownCost: string | number | null; externalCost: string | number | null }
type WireRow = Omit<WireNumbers<TripOperationsRow>, 'externalAgreedAmounts' | 'measured' | 'externalPalletRates' | 'costs'> & { externalAgreedAmounts: WireMoney[]; measured: WireMeasured | null; externalPalletRates: { currency: string; pricedPalletSpaces: number | string; agreedAmount: number | string }[]; costs: { allocationBlockReason: string | null; totals: WireCostTotal[] } | null }
type WireSummary = Omit<WireNumbers<TripOperationsSummary>, 'externalAgreedAmounts' | 'costs'> & { externalAgreedAmounts: WireMoney[]; costs: { visible: boolean; unrecordedTrips: string | number | null; unallocatedTrips: string | number | null; totals: WireCostTotal[] } }
const costTotals = (rows: WireCostTotal[]): OperationsCostTotal[] => rows.map(r => ({ ...r, directCost: fleetMoney(r.directCost), ownCost: r.ownCost == null ? null : fleetMoney(r.ownCost), externalCost: r.externalCost == null ? null : fleetMoney(r.externalCost) }))
const money = (rows: WireMoney[]) => rows.map(r => ({ ...r, amount: fleetMoney(r.amount) }))
const counts = (r: WireRow | WireSummary) => ({ physicalPallets: fleetNumber(r.physicalPallets), palletSpaces: fleetNumber(r.palletSpaces), ownPackages: fleetNumber(r.ownPackages), externalPackages: fleetNumber(r.externalPackages), externalAgreedAmounts: money(r.externalAgreedAmounts) })
const measured = (r: WireMeasured): TripOperationsMeasured => ({ ...r, actualDistanceMiles: r.actualDistanceMiles == null ? null : fleetNumber(r.actualDistanceMiles), actualFuelLitres: r.actualFuelLitres == null ? null : fleetNumber(r.actualFuelLitres) })
const evidence = (r: WireEvidence): TripOperationsEvidence => {
if (typeof r.fileId !== 'string' || !/^[1-9]\d*$/.test(r.fileId) || !r.fileName || !['application/pdf', 'image/jpeg', 'image/png'].includes(r.contentType)) throw new Error('实测凭证信息无效')
return { ...r, sizeBytes: fleetNumber(r.sizeBytes) }
}
const measured = (r: WireMeasured): TripOperationsMeasured => ({ ...r, evidenceFiles: (r.evidenceFiles || []).map(evidence), actualDistanceMiles: r.actualDistanceMiles == null ? null : fleetNumber(r.actualDistanceMiles), actualFuelLitres: r.actualFuelLitres == null ? null : fleetNumber(r.actualFuelLitres) })
type WireSeries = Omit<TripOperationsSeries, 'points'> & { points: { label: string; value: string | number }[] }
type WireCharts = { [K in keyof TripOperationsCharts]: WireSeries[] }
const series = (values: WireSeries[]) => values.map(s => ({ ...s, points: s.points.map(p => ({ ...p, value: fleetNumber(p.value) })) }))
@@ -36,7 +44,7 @@ export async function listTripOperations(query: TripOperationsQuery): Promise<Tr
}
export const exportTripOperations = (query: TripOperationsQuery) => fleetCsv('/platform/v1/last-leg/fleet/operations/export', query)
export interface TripOperationsRecord { tripNo: string; recordVersion: string; details: TripOperationsMeasured; updatedAt: string | null }
export interface TripOperationsRecordSave { recordVersion: string; idempotencyKey: string; details: TripOperationsMeasured; reason: string | null }
export interface TripOperationsRecordSave { recordVersion: string; idempotencyKey: string; details: TripOperationsMeasuredInput; reason: string | null }
export interface TripOperationsRecordHistory { recordVersion: string; reason: string; occurredAt: string; details: TripOperationsMeasured }
type WireRecord = Omit<TripOperationsRecord, 'recordVersion' | 'details'> & { recordVersion: string | number; details: WireMeasured }
type WireHistory = Omit<TripOperationsRecordHistory, 'recordVersion' | 'details'> & { recordVersion: string | number; details: WireMeasured }
@@ -48,3 +56,20 @@ export async function listTripOperationsRecordHistory(tripNo: string, page = 1,
const result = unwrapApiResponse(await http.get<ApiEnvelope<PageResult<WireHistory>>, ApiEnvelope<PageResult<WireHistory>>>(`${recordPath(tripNo)}/history`, { params: { page, size } }))
return fleetPage({ ...result, records: result.records.map(r => ({ ...r, recordVersion: fleetVersion(r.recordVersion), details: measured(r.details) })) })
}
export async function uploadTripOperationsEvidence(tripNo: string, file: File): Promise<TripOperationsEvidence> {
if (!file.size || file.size > 10 * 1024 * 1024 || !['application/pdf', 'image/jpeg', 'image/png'].includes(file.type)) throw new Error('请选择非空PDF、JPG或PNG文件,最大10MiB')
const body = new FormData(); body.append('file', file)
return evidence(unwrapApiResponse(await http.post<ApiEnvelope<WireEvidence>, ApiEnvelope<WireEvidence>>(`${recordPath(tripNo)}/files`, body)))
}
export async function downloadTripOperationsEvidence(tripNo: string, fileId: string): Promise<Blob> {
async function rejectEnvelope(value: unknown, status: number) {
if (value instanceof Blob && value.type.includes('json')) { try { value = JSON.parse(await value.text()) } catch { throw new Error('凭证接口返回了无法识别的错误') } }
if (value && typeof value === 'object' && 'code' in value) { const e = value as ApiEnvelope<unknown>; throw new ApiResponseError(e.message || '凭证读取失败', e.code, status, e.data, e.requestId, e.traceId) }
}
try {
const value = await http.get<Blob, Blob>(`${recordPath(tripNo)}/files/${encodeURIComponent(fileId)}/content`, { responseType: 'blob' })
await rejectEnvelope(value, 200)
if (!(value instanceof Blob) || !value.size || !['application/pdf', 'image/jpeg', 'image/png'].includes(value.type) || value.size > 10 * 1024 * 1024) throw new Error('实测凭证下载失败或返回了错误响应')
return value
} catch (cause) { if (axios.isAxiosError(cause)) await rejectEnvelope(cause.response?.data, cause.response?.status || 0); throw cause }
}
@@ -2,9 +2,10 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
import TripOperationsRecordDialog from './TripOperationsRecordDialog.vue'
import * as api from '../api/tripOperations'
vi.mock('../api/tripOperations', () => ({ getTripOperationsRecord: vi.fn(), saveTripOperationsRecord: vi.fn(), listTripOperationsRecordHistory: vi.fn() }))
import { ApiResponseError } from '@/shared/api/http'
vi.mock('../api/tripOperations', () => ({ getTripOperationsRecord: vi.fn(), saveTripOperationsRecord: vi.fn(), listTripOperationsRecordHistory: vi.fn(), uploadTripOperationsEvidence: vi.fn(), downloadTripOperationsEvidence: vi.fn() }))
const row: api.TripOperationsRow = { tripNo: 'TR1', status: 'IN_TRANSIT', vehiclePlate: 'TEST-1', driverName: '司机', warehouseName: '起运仓', departedAt: '2026-10-01T00:00:00Z', completedAt: null, destinations: [], physicalPallets: 1, palletSpaces: 1, ownPackages: 1, externalPackages: 0, externalAgreedAmounts: [], externalPalletRates: [], measured: null, financeStatus: 'NOT_QUERIED', costStatus: 'FORBIDDEN', costs: null }
const record: api.TripOperationsRecord = { tripNo: 'TR1', recordVersion: '0', details: { actualDistanceMiles: null, actualFuelLitres: null, remark: null }, updatedAt: null }
const record: api.TripOperationsRecord = { tripNo: 'TR1', recordVersion: '0', details: { actualDistanceMiles: null, actualFuelLitres: null, remark: null, evidenceFiles: [] }, updatedAt: null }
const wrappers: VueWrapper[] = []
const slot = { template: '<div><slot/></div>' }
const input = { name: 'ElInputNumber', props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\',Number($event.target.value))" />' }
@@ -41,3 +42,113 @@ it('a failed initial read cannot save a guessed record version', async () => {
expect(wrapper.findAll('button').find(b => b.text() === '保存记录')!.attributes('disabled')).toBeDefined()
expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
})
const evidence: api.TripOperationsEvidence = { fileId: '9007199254740993', fileName: 'meter.pdf', contentType: 'application/pdf', sizeBytes: 4 }
async function choose(wrapper: VueWrapper, file = new File(['test'], 'meter.pdf', { type: 'application/pdf' })) {
const input = wrapper.find('[data-testid="evidence-input"]'); Object.defineProperty(input.element, 'files', { configurable: true, value: [file] }); await input.trigger('change'); await flushPromises(); return file
}
it('uploads optionally without creating records; saving binds only the exact IDs and unknown save retains them', async () => {
vi.mocked(api.uploadTripOperationsEvidence).mockResolvedValue(evidence)
vi.mocked(api.saveTripOperationsRecord).mockRejectedValueOnce(new Error('network')).mockResolvedValueOnce({ ...record, recordVersion: '1' })
const wrapper = await mount(); await choose(wrapper)
expect(api.saveTripOperationsRecord).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('meter.pdf')
expect(wrapper.text()).toContain('保存后可预览'); expect(wrapper.findAll('button').find(b => b.text() === 'meter.pdf')!.attributes('disabled')).toBeDefined()
await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click'); await flushPromises()
const request = vi.mocked(api.saveTripOperationsRecord).mock.calls[0]![1]
expect(request.details.evidenceFileIds).toEqual([evidence.fileId]); expect(request.details).not.toHaveProperty('evidenceFiles')
await wrapper.findAll('button').find(b => b.text() === '按原请求重试')!.trigger('click'); await flushPromises()
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[1]![1]).toEqual(request)
})
it('a committed upload with lost response never retransmits after reopen or reload and preserves bound evidence', async () => {
const existing = { ...evidence, fileId: '81', fileName: 'bound.pdf' }
vi.mocked(api.getTripOperationsRecord).mockResolvedValue({ ...record, details: { ...record.details, evidenceFiles: [existing] } })
let storedFiles = 0
vi.mocked(api.uploadTripOperationsEvidence).mockImplementation(async () => { storedFiles++; throw new Error('response lost after storage commit') })
const wrapper = await mount(); await choose(wrapper)
expect(storedFiles).toBe(1); expect(wrapper.text()).toContain('可能已上传但尚未绑定'); expect(wrapper.text()).toContain('bound.pdf')
expect(wrapper.text()).not.toContain('重试原文件'); expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect(wrapper.text()).toContain('meter.pdf'); expect(wrapper.text()).toContain('可能已上传但尚未绑定')
expect(wrapper.find('[data-testid="evidence-input"]').attributes('disabled')).toBeDefined()
await wrapper.findAll('button').find(b => b.text() === '重新读取')!.trigger('click'); await flushPromises()
await choose(wrapper)
expect(storedFiles).toBe(1); expect(api.uploadTripOperationsEvidence).toHaveBeenCalledTimes(1); expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
await wrapper.findAll('button').find(b => b.text() === '暂不绑定此附件')!.trigger('click'); await flushPromises()
vi.mocked(api.saveTripOperationsRecord).mockResolvedValue({ ...record, recordVersion: '1' })
await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click'); await flushPromises()
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[0]![1].details.evidenceFileIds).toEqual(['81'])
expect(storedFiles).toBe(1)
const refreshed = await mount(); expect(refreshed.text()).toContain('bound.pdf'); expect(storedFiles).toBe(1)
})
it('explicit upload rejection allows corrected file selection without creating a record', async () => {
vi.mocked(api.uploadTripOperationsEvidence).mockRejectedValueOnce(new ApiResponseError('文件格式不符', 'A0400', 400)).mockResolvedValueOnce(evidence)
const wrapper = await mount(); await choose(wrapper)
expect(wrapper.find('[data-testid="evidence-input"]').attributes('disabled')).toBeUndefined()
expect(wrapper.text()).not.toContain('可能已上传但尚未绑定')
const corrected = new File(['new'], 'corrected.pdf', { type: 'application/pdf' }); await choose(wrapper, corrected)
expect(api.uploadTripOperationsEvidence).toHaveBeenNthCalledWith(2, 'TR1', corrected)
expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
})
it('late upload from another trip cannot attach a file to the newly opened record', async () => {
let done!: (value: api.TripOperationsEvidence) => void
vi.mocked(api.uploadTripOperationsEvidence).mockReturnValue(new Promise(resolve => { done = resolve }))
const wrapper = await mount(); await choose(wrapper)
vi.mocked(api.getTripOperationsRecord).mockResolvedValue({ ...record, tripNo: 'TR2' })
await wrapper.setProps({ row: { ...row, tripNo: 'TR2' } }); await flushPromises(); done(evidence); await flushPromises()
expect(wrapper.text()).not.toContain('meter.pdf'); expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
vi.mocked(api.getTripOperationsRecord).mockResolvedValue(record)
await wrapper.setProps({ row }); await flushPromises()
expect(wrapper.text()).toContain('meter.pdf'); expect(wrapper.text()).toContain('保存后可预览')
})
it('upload completing on history tab remains recoverable on the same trip', async () => {
let done!: (value: api.TripOperationsEvidence) => void
vi.mocked(api.uploadTripOperationsEvidence).mockReturnValue(new Promise(resolve => { done = resolve }))
const wrapper = await mount(); await choose(wrapper)
wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'history'); await flushPromises()
done(evidence); await flushPromises()
wrapper.findComponent({ name: 'ElTabs' }).vm.$emit('update:modelValue', 'measured'); await flushPromises()
expect(wrapper.text()).toContain('meter.pdf'); expect(wrapper.text()).toContain('保存后可预览')
expect(wrapper.findAll('button').find(b => b.text() === '保存记录')!.attributes('disabled')).toBeUndefined()
expect(api.saveTripOperationsRecord).not.toHaveBeenCalled()
})
it('preview is fetched only on explicit click and stale response cannot appear on another trip', async () => {
vi.mocked(api.getTripOperationsRecord).mockResolvedValue({ ...record, details: { ...record.details, evidenceFiles: [evidence] } })
let done!: (value: Blob) => void
vi.mocked(api.downloadTripOperationsEvidence).mockReturnValue(new Promise(resolve => { done = resolve }))
const wrapper = await mount(true); expect(api.downloadTripOperationsEvidence).not.toHaveBeenCalled(); expect(wrapper.find('[data-testid="evidence-input"]').exists()).toBe(false)
await wrapper.findAll('button').find(b => b.text() === 'meter.pdf')!.trigger('click')
vi.mocked(api.getTripOperationsRecord).mockResolvedValue({ ...record, tripNo: 'TR2' })
await wrapper.setProps({ row: { ...row, tripNo: 'TR2' } }); await flushPromises(); done(new Blob(['pdf'], { type: 'application/pdf' })); await flushPromises()
expect(wrapper.find('iframe').exists()).toBe(false); expect(api.downloadTripOperationsEvidence).toHaveBeenCalledExactlyOnceWith('TR1', evidence.fileId)
})
it('late save cannot close another trip dialog', async () => {
let done!: (value: api.TripOperationsRecord) => void
vi.mocked(api.saveTripOperationsRecord).mockReturnValue(new Promise(resolve => { done = resolve }))
const wrapper = await mount(); await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click')
vi.mocked(api.getTripOperationsRecord).mockResolvedValue({ ...record, tripNo: 'TR2' }); await wrapper.setProps({ row: { ...row, tripNo: 'TR2' } }); await flushPromises()
done({ ...record, recordVersion: '1' }); await flushPromises()
expect(wrapper.emitted('update:modelValue')).toBeUndefined(); expect(wrapper.text()).toContain('TR2')
})
it('closing and reopening the same trip cannot start a concurrent save retry', async () => {
let reject!: (error: Error) => void
vi.mocked(api.saveTripOperationsRecord).mockReturnValue(new Promise((_, fail) => { reject = fail }))
const wrapper = await mount(); await wrapper.findAll('button').find(b => b.text() === '保存记录')!.trigger('click')
const original = vi.mocked(api.saveTripOperationsRecord).mock.calls[0]![1]
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
const retry = wrapper.findAll('button').find(b => b.text() === '按原请求重试')!
expect(retry.attributes('disabled')).toBeDefined(); await retry.trigger('click'); expect(api.saveTripOperationsRecord).toHaveBeenCalledTimes(1)
reject(new Error('unknown')); await flushPromises()
vi.mocked(api.saveTripOperationsRecord).mockResolvedValue({ ...record, recordVersion: '1' })
await wrapper.findAll('button').find(b => b.text() === '按原请求重试')!.trigger('click'); await flushPromises()
expect(vi.mocked(api.saveTripOperationsRecord).mock.calls[1]![1]).toEqual(original)
})
it('closing and reopening the same trip cannot duplicate an in-flight upload', async () => {
let done!: (value: api.TripOperationsEvidence) => void
vi.mocked(api.uploadTripOperationsEvidence).mockReturnValue(new Promise(resolve => { done = resolve }))
const wrapper = await mount(); await choose(wrapper)
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
expect(wrapper.find('[data-testid="evidence-input"]').attributes('disabled')).toBeDefined()
expect(wrapper.text()).not.toContain('重试原文件'); await choose(wrapper); expect(api.uploadTripOperationsEvidence).toHaveBeenCalledTimes(1)
done(evidence); await flushPromises()
expect(wrapper.text()).toContain('meter.pdf'); expect(wrapper.text()).toContain('保存后可预览')
expect(wrapper.findAll('button').find(b => b.text() === '保存记录')!.attributes('disabled')).toBeUndefined()
})
@@ -5,19 +5,59 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
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'
import { getTripOperationsRecord, saveTripOperationsRecord, listTripOperationsRecordHistory, uploadTripOperationsEvidence, downloadTripOperationsEvidence, type TripOperationsEvidence, 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: [] }>()
const form = reactive<TripOperationsMeasured>({ actualDistanceMiles: null, actualFuelLitres: null, remark: null }), reason = ref('')
const form = reactive<TripOperationsMeasured>({ actualDistanceMiles: null, actualFuelLitres: null, remark: null, evidenceFiles: [] }), reason = ref('')
const record = ref<TripOperationsRecord | null>(null), loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), tab = ref('measured')
const pending = new Map<string, TripOperationsRecordSave>(), uncertain = ref(false)
const pendingEvidence = new Map<string, TripOperationsEvidence[]>()
const activeSaves = new Set<string>()
const uploadedDrafts = new Map<string, TripOperationsEvidence[]>()
const savedFileIds = ref(new Set<string>())
const uploading = ref(false), uploadUncertain = ref(false), uploadName = ref('')
const pendingUploads = new Map<string, { name: string; diagnostic?: ApiErrorDetails }>()
const activeUploads = new Set<string>()
const previewUrl = ref(''), previewFile = ref<TripOperationsEvidence | null>(null), previewLoading = ref(false)
let previewSequence = 0
function clearPreview() { previewSequence++; if (previewUrl.value) URL.revokeObjectURL(previewUrl.value); previewUrl.value = ''; previewFile.value = null; previewLoading.value = false }
async function preview(file: TripOperationsEvidence) {
if (!props.row || !open.value) return
clearPreview(); const current = previewSequence, no = props.row.tripNo; previewLoading.value = true
try { const blob = await downloadTripOperationsEvidence(no, file.fileId); if (current !== previewSequence || props.row?.tripNo !== no || !open.value) return; previewUrl.value = URL.createObjectURL(blob); previewFile.value = file }
catch (cause) { if (current === previewSequence) error.value = getApiErrorDetails(cause, '凭证读取失败') }
finally { if (current === previewSequence) previewLoading.value = false }
}
async function upload(file: File) {
if (!props.row || !record.value || props.readonly || saving.value || uploading.value || uncertain.value || loading.value) return
const no = props.row.tripNo, selected = file
if (!selected || form.evidenceFiles.length >= 20 || activeUploads.has(no) || pendingUploads.has(no)) return
if (!selected.size || selected.size > 10 * 1024 * 1024 || !['application/pdf', 'image/jpeg', 'image/png'].includes(selected.type)) { error.value = { message: '请选择非空PDF、JPG或PNG文件,最大10MiB' }; return }
const current = sequence, attempt = { name: selected.name, diagnostic: undefined as ApiErrorDetails | undefined }; activeUploads.add(no); uploading.value = true; error.value = null; pendingUploads.set(no, attempt); uploadName.value = selected.name
try {
const result = await uploadTripOperationsEvidence(no, selected)
uploadedDrafts.set(no, [...(uploadedDrafts.get(no) || []).filter(file => file.fileId !== result.fileId), result])
if (pendingUploads.get(no) === attempt) pendingUploads.delete(no)
if (current !== sequence || props.row?.tripNo !== no || !open.value) return
form.evidenceFiles = [...form.evidenceFiles, result]; uploadUncertain.value = false; uploadName.value = ''
} catch (cause) {
attempt.diagnostic = getApiErrorDetails(cause, '凭证上传结果未确认,可能已上传但尚未绑定,请先核对');
if (isFleetCommandRejected(cause) && pendingUploads.get(no) === attempt) pendingUploads.delete(no)
if (current === sequence) { error.value = attempt.diagnostic; uploadUncertain.value = pendingUploads.has(no) }
} finally { activeUploads.delete(no); if (props.row?.tripNo === no) { uploading.value = false; if (current !== sequence && open.value && tab.value === 'measured') void load() } }
}
function chooseFile(event: Event) { const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''; if (file) void upload(file) }
function omitUpload() { if (!props.row || uploading.value || activeUploads.has(props.row.tripNo)) return; pendingUploads.delete(props.row.tripNo); uploadUncertain.value = false; uploadName.value = '' }
function removeFile(fileId: string) { if (props.readonly || saving.value || uncertain.value || uploading.value) return; form.evidenceFiles = form.evidenceFiles.filter(file => file.fileId !== fileId); if (props.row) uploadedDrafts.set(props.row.tripNo, (uploadedDrafts.get(props.row.tripNo) || []).filter(file => file.fileId !== fileId)); clearPreview() }
const history = ref<TripOperationsRecordHistory[]>([]), total = ref(0), page = ref(1), size = ref(20)
let sequence = 0
const columns: DataTableColumn[] = [{ key: 'recordVersion', label: '版本', width: 90 }, { key: 'occurredAt', label: '更正时间', minWidth: 190 }, { key: 'distance', label: '里程(mile)', width: 130, slotName: 'distance' }, { key: 'fuel', label: '油耗(L)', width: 120, slotName: 'fuel' }, { key: 'reason', label: '更正说明', minWidth: 180 }, { key: 'remark', label: '备注', minWidth: 200, slotName: 'remark' }]
const columns: DataTableColumn[] = [{ key: 'recordVersion', label: '版本', width: 90 }, { key: 'occurredAt', label: '更正时间', minWidth: 190 }, { key: 'distance', label: '里程(mile)', width: 130, slotName: 'distance' }, { key: 'fuel', label: '油耗(L)', width: 120, slotName: 'fuel' }, { key: 'reason', label: '更正说明', minWidth: 180 }, { key: 'remark', label: '备注', minWidth: 200, slotName: 'remark' }, { key: 'evidence', label: '凭证', minWidth: 170, slotName: 'evidence' }]
async function load() {
if (!open.value || !props.row) return
const no = props.row.tripNo, current = ++sequence; loading.value = true; error.value = null; record.value = null
try { const result = await getTripOperationsRecord(no); if (current !== sequence) return; record.value = result; const retry = pending.get(no); uncertain.value = !!retry; Object.assign(form, retry?.details || result.details); reason.value = retry?.reason || '' }
uploading.value = activeUploads.has(no)
saving.value = activeSaves.has(no)
try { const result = await getTripOperationsRecord(no); if (current !== sequence) return; record.value = result; savedFileIds.value = new Set(result.details.evidenceFiles.map(file => file.fileId)); const retry = pending.get(no); uncertain.value = !!retry; Object.assign(form, retry?.details || result.details); form.evidenceFiles = (retry ? pendingEvidence.get(no) || [] : [...result.details.evidenceFiles, ...(props.readonly ? [] : uploadedDrafts.get(no) || []).filter(file => !savedFileIds.value.has(file.fileId))]).map(file => ({ ...file })); reason.value = retry?.reason || ''; uploadUncertain.value = pendingUploads.has(no); uploadName.value = pendingUploads.get(no)?.name || ''; if (pendingUploads.get(no)?.diagnostic) error.value = pendingUploads.get(no)!.diagnostic! }
catch (e) { if (current === sequence) error.value = getApiErrorDetails(e, '实测记录读取失败') } finally { if (current === sequence) loading.value = false }
}
async function loadHistory() {
@@ -26,29 +66,41 @@ async function loadHistory() {
try { const result = await listTripOperationsRecordHistory(props.row.tripNo, page.value, size.value); if (current === sequence) { history.value = result.records; total.value = result.total } } catch (e) { if (current === sequence) error.value = getApiErrorDetails(e, '更正历史读取失败') } finally { if (current === sequence) loading.value = false }
}
async function save() {
if (!record.value || !props.row || props.readonly || saving.value) return
const no = props.row.tripNo; saving.value = true; error.value = null
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 }
if (!record.value || !props.row || props.readonly || saving.value || uploading.value || uploadUncertain.value || loading.value) return
const no = props.row.tripNo, current = sequence
if (activeSaves.has(no)) return
activeSaves.add(no); saving.value = true; error.value = null
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, evidenceFileIds: form.evidenceFiles.map(file => file.fileId) }, reason: reason.value || null }
if (!pending.has(no)) pendingEvidence.set(no, form.evidenceFiles.map(file => ({ ...file })))
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 (isFleetCommandRejected(e)) pending.delete(no); uncertain.value = pending.has(no) }
finally { saving.value = false }
try { await saveTripOperationsRecord(no, request); if (pending.get(no) === request) { pending.delete(no); pendingEvidence.delete(no) } uploadedDrafts.set(no, (uploadedDrafts.get(no) || []).filter(file => !request.details.evidenceFileIds.includes(file.fileId))); emit('saved'); if (current === sequence) { uncertain.value = false; open.value = false } }
catch (e) { if (isFleetCommandRejected(e) && pending.get(no) === request) { pending.delete(no); pendingEvidence.delete(no) } if (current === sequence) { error.value = getApiErrorDetails(e, '实测记录保存失败'); uncertain.value = pending.has(no) } }
finally { activeSaves.delete(no); if (props.row?.tripNo === no) { saving.value = false; if (current !== sequence && open.value && tab.value === 'measured') void load() } }
}
watch([open, () => props.row?.tripNo], () => { sequence++; tab.value = 'measured'; history.value = []; page.value = 1; total.value = 0; if (open.value) void load() })
watch([tab, page, size], () => { if (tab.value === 'history') void loadHistory() }); onBeforeUnmount(() => { sequence++ })
watch([open, () => props.row?.tripNo], () => { sequence++; clearPreview(); uploading.value = false; saving.value = false; uploadUncertain.value = false; uncertain.value = false; form.evidenceFiles = []; record.value = null; tab.value = 'measured'; history.value = []; page.value = 1; total.value = 0; if (open.value) void load() })
watch([tab, page, size], () => { clearPreview(); if (tab.value === 'history') void loadHistory(); else if (tab.value === 'measured') void load() }); onBeforeUnmount(() => { sequence++; clearPreview() })
</script>
<template><AppDialog v-model="open" title="出车实测记录" width="820px" class="admin-density-dialog" :close-on-click-modal="false">
<el-alert v-if="error" :title="error.message" type="error" :closable="false"><ApiErrorMeta :details="error" /><el-button @click="tab === 'history' ? loadHistory() : load()">重新读取</el-button></el-alert>
<p v-if="row">{{ row.tripNo }} · {{ row.vehiclePlate || '—' }} · 实际发车 {{ row.departedAt }} · 货物结案 {{ row.completedAt || '—' }}</p>
<el-tabs v-model="tab" class="density-element-tabs"><el-tab-pane label="实测记录" name="measured">
<p v-if="uncertain">上次保存结果未确认,将按原请求重试。</p>
<el-form label-position="top" class="density-form record-form" :disabled="readonly || !record || loading || saving || uncertain">
<el-form label-position="top" class="density-form record-form" :disabled="readonly || !record || loading || saving || uploading || uncertain">
<el-form-item label="本趟实际里程(mile)"><el-input-number :model-value="form.actualDistanceMiles" @update:model-value="form.actualDistanceMiles = $event ?? null" :min="0" :precision="1" placeholder="选填,本趟实际英里数" /></el-form-item>
<el-form-item label="本趟实际油耗(L)"><el-input-number :model-value="form.actualFuelLitres" @update:model-value="form.actualFuelLitres = $event ?? null" :min="0" :precision="1" placeholder="选填,本趟实际用油升数" /></el-form-item>
<el-form-item label="备注" class="full"><el-input :model-value="form.remark || ''" @update:model-value="form.remark = $event || null" type="textarea" maxlength="1000" placeholder="选填,补充实测情况" /></el-form-item>
<el-form-item v-if="!readonly" label="更正说明(选填)" class="full"><el-input v-model="reason" maxlength="1000" placeholder="选填,留空使用标准更正说明" /></el-form-item>
</el-form><p>未填写与明确零值分别保存。实测记录不改车辆累计里程、货物履约或财务费用。</p>
</el-tab-pane><el-tab-pane label="更正历史" name="history"><DataTable v-model:page="page" v-model:page-size="size" :data="history" :columns="columns" :total="total" :loading="loading" row-key="recordVersion" empty-text="暂无实测更正历史"><template #distance="{ row: item }">{{ item.details.actualDistanceMiles ?? '—' }}</template><template #fuel="{ row: item }">{{ item.details.actualFuelLitres ?? '—' }}</template><template #remark="{ row: item }">{{ item.details.remark || '—' }}</template></DataTable></el-tab-pane></el-tabs>
<template #footer><el-button :disabled="saving" @click="open = false">关闭</el-button><el-button v-if="!readonly && tab === 'measured'" type="primary" :loading="saving" :disabled="!record || loading" @click="save">{{ uncertain ? '按原请求重试' : '保存记录' }}</el-button></template>
<el-form-item label="实测凭证(选填)" class="full">
<input v-if="!readonly" data-testid="evidence-input" type="file" accept="application/pdf,image/jpeg,image/png" aria-label="选择实测凭证" :disabled="!record || loading || saving || uploading || uncertain || uploadUncertain || form.evidenceFiles.length >= 20" @change="chooseFile" />
<p>PDF、JPG、PNG,单个不超过10MiB,最多20个;不上传也可保存。</p>
</el-form-item>
</el-form>
<ul v-if="form.evidenceFiles.length" class="evidence-list"><li v-for="file in form.evidenceFiles" :key="file.fileId"><el-button :disabled="previewLoading || !savedFileIds.has(file.fileId)" @click="preview(file)">{{ file.fileName }}</el-button><span v-if="!savedFileIds.has(file.fileId)">(保存后可预览)</span><el-button v-if="!readonly" :disabled="saving || uploading || uncertain" @click="removeFile(file.fileId)">移除引用</el-button></li></ul>
<p v-if="uploading">正在上传 {{ uploadName }}…</p>
<p v-if="uploadUncertain">{{ uploadName }} 的上传结果未确认,可能已上传但尚未绑定。请先核对,不要重复上传。<el-button :disabled="uploading" @click="omitUpload">暂不绑定此附件</el-button></p>
<p>未填写与明确零值分别保存。实测记录不改车辆累计里程、货物履约或财务费用。</p>
</el-tab-pane><el-tab-pane label="更正历史" name="history"><DataTable v-model:page="page" v-model:page-size="size" :data="history" :columns="columns" :total="total" :loading="loading" row-key="recordVersion" empty-text="暂无实测更正历史"><template #distance="{ row: item }">{{ item.details.actualDistanceMiles ?? '—' }}</template><template #fuel="{ row: item }">{{ item.details.actualFuelLitres ?? '—' }}</template><template #remark="{ row: item }">{{ item.details.remark || '—' }}</template><template #evidence="{ row: item }"><el-button v-for="file in item.details.evidenceFiles" :key="file.fileId" :disabled="previewLoading" @click="preview(file)">{{ file.fileName }}</el-button><span v-if="!item.details.evidenceFiles.length">—</span></template></DataTable></el-tab-pane></el-tabs>
<section v-if="previewUrl && previewFile" aria-label="实测凭证预览"><el-button @click="clearPreview">关闭预览</el-button><iframe v-if="previewFile.contentType === 'application/pdf'" :src="previewUrl" :title="previewFile.fileName" class="evidence-preview" /><img v-else :src="previewUrl" :alt="previewFile.fileName" class="evidence-preview" /></section>
<template #footer><el-button :disabled="saving" @click="open = false">关闭</el-button><el-button v-if="!readonly && tab === 'measured'" type="primary" :loading="saving" :disabled="!record || loading || uploading || uploadUncertain" @click="save">{{ uncertain ? '按原请求重试' : '保存记录' }}</el-button></template>
</AppDialog></template>
<style scoped>.record-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:0 var(--admin-space-4); }.full { grid-column:1/-1; }</style>
<style scoped>.record-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:0 var(--admin-space-4); }.full { grid-column:1/-1; }.evidence-list{padding-left:var(--admin-space-4)}.evidence-preview{display:block;width:100%;max-height:360px;object-fit:contain}.evidence-preview:is(iframe){height:360px;border:0}</style>