diff --git a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md index 5dec8836..7d69750c 100644 --- a/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-master-data/PROTOTYPE_MATRIX.md @@ -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%缩放视觉与请求量待真实验收 | ## 新版司机管理接入矩阵 diff --git a/src/modules/overseas-master-data/api/tripOperations.test.ts b/src/modules/overseas-master-data/api/tripOperations.test.ts index 0374f520..2f5d598f 100644 --- a/src/modules/overseas-master-data/api/tripOperations.test.ts +++ b/src/modules/overseas-master-data/api/tripOperations.test.ts @@ -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()), 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()), 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' }) +}) diff --git a/src/modules/overseas-master-data/api/tripOperations.ts b/src/modules/overseas-master-data/api/tripOperations.ts index 25da0f2e..9c7aa87d 100644 --- a/src/modules/overseas-master-data/api/tripOperations.ts +++ b/src/modules/overseas-master-data/api/tripOperations.ts @@ -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 export interface OperationsCosts { visible: boolean; unrecordedTrips: number | null; unallocatedTrips: number | null; totals: OperationsCostTotal[] } export type OperationsRowCosts = Pick & { 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 & { 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; summary: TripOperationsSummary; charts: TripOperationsCharts } type WireNumbers = { [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 & { 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, '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, '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 & { 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 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 & { recordVersion: string | number; details: WireMeasured } type WireHistory = Omit & { recordVersion: string | number; details: WireMeasured } @@ -48,3 +56,20 @@ export async function listTripOperationsRecordHistory(tripNo: string, page = 1, const result = unwrapApiResponse(await http.get>, ApiEnvelope>>(`${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 { + 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>(`${recordPath(tripNo)}/files`, body))) +} +export async function downloadTripOperationsEvidence(tripNo: string, fileId: string): Promise { + 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; throw new ApiResponseError(e.message || '凭证读取失败', e.code, status, e.data, e.requestId, e.traceId) } + } + try { + const value = await http.get(`${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 } +} diff --git a/src/modules/overseas-master-data/components/TripOperationsRecordDialog.test.ts b/src/modules/overseas-master-data/components/TripOperationsRecordDialog.test.ts index 6c92f786..2e5ed9f6 100644 --- a/src/modules/overseas-master-data/components/TripOperationsRecordDialog.test.ts +++ b/src/modules/overseas-master-data/components/TripOperationsRecordDialog.test.ts @@ -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: '
' } const input = { name: 'ElInputNumber', props: ['modelValue'], emits: ['update:modelValue'], template: '' } @@ -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() +}) diff --git a/src/modules/overseas-master-data/components/TripOperationsRecordDialog.vue b/src/modules/overseas-master-data/components/TripOperationsRecordDialog.vue index 9522c0ed..fb84473c 100644 --- a/src/modules/overseas-master-data/components/TripOperationsRecordDialog.vue +++ b/src/modules/overseas-master-data/components/TripOperationsRecordDialog.vue @@ -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({ default: false }) const props = defineProps<{ row: TripOperationsRow | null; readonly?: boolean }>(), emit = defineEmits<{ saved: [] }>() -const form = reactive({ actualDistanceMiles: null, actualFuelLitres: null, remark: null }), reason = ref('') +const form = reactive({ actualDistanceMiles: null, actualFuelLitres: null, remark: null, evidenceFiles: [] }), reason = ref('') const record = ref(null), loading = ref(false), saving = ref(false), error = ref(null), tab = ref('measured') const pending = new Map(), uncertain = ref(false) +const pendingEvidence = new Map() +const activeSaves = new Set() +const uploadedDrafts = new Map() +const savedFileIds = ref(new Set()) +const uploading = ref(false), uploadUncertain = ref(false), uploadName = ref('') +const pendingUploads = new Map() +const activeUploads = new Set() +const previewUrl = ref(''), previewFile = ref(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([]), 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() })