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: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 { expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http' import { http } from '@/shared/api/http'
import { listTripOperations, getTripOperationsRecord, saveTripOperationsRecord, listTripOperationsRecordHistory } from './tripOperations' 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() } })) 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 () => { 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 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 }] 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 query = { from: '2026-10-01T00:00:00Z', to: '2026-10-03T00:00:00Z', sourceType: 'EXTERNAL' as const }
const result = await listTripOperations(query) const result = await listTripOperations(query)
expect(result.summary.trips).toBe(20); expect(result.page.records).toHaveLength(1) 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.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.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.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(result.charts.physicalPallets[0]?.points[0]?.value).toBe(1)
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/fleet/operations', { params: query }) 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 () => { it('keeps measured versions and retries exact requests, without converting absent fuel into zero', async () => {
vi.clearAllMocks() 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 }) vi.mocked(http.get).mockResolvedValue({ code: '200', data: wire })
const record = await getTripOperationsRecord('TR1') const record = await getTripOperationsRecord('TR1')
expect(record.details).toEqual({ actualDistanceMiles: 0, actualFuelLitres: null, remark: null }) expect(record.details).toEqual({ actualDistanceMiles: 0, actualFuelLitres: null, remark: null, evidenceFiles: [] })
const request = { recordVersion: record.recordVersion, idempotencyKey: 'stable-key', details: record.details, reason: null } 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) 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) 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' } }) 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(history.records[0]?.recordVersion).toBe('90071992547409931')
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/fleet/operations/TR1/record/history', { params: { page: 1, size: 20 } }) 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 type { PageResult } from './overseasMasterData'
import { fleetCsv, fleetNumber, fleetMoney, fleetVersion, fleetPage } from './fleetQuotes' import { fleetCsv, fleetNumber, fleetMoney, fleetVersion, fleetPage } from './fleetQuotes'
import type { CostSummary, CostTotal } from './tripCosts' 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'> type OperationsCostTotal = Pick<CostTotal, 'currency' | 'directCost' | 'ownCost' | 'externalCost'>
export interface OperationsCosts { visible: boolean; unrecordedTrips: number | null; unallocatedTrips: number | null; totals: OperationsCostTotal[] } export interface OperationsCosts { visible: boolean; unrecordedTrips: number | null; unallocatedTrips: number | null; totals: OperationsCostTotal[] }
export type OperationsRowCosts = Pick<CostSummary, 'allocationBlockReason'> & { 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 TripOperationsSeries { label: string; unit: string; points: { label: string; value: number }[] }
export interface TripOperationsCharts { physicalPallets: TripOperationsSeries[]; destinationTrips: TripOperationsSeries[]; externalAgreedAmounts: TripOperationsSeries[]; externalPalletRates: TripOperationsSeries[] } export interface TripOperationsCharts { physicalPallets: TripOperationsSeries[]; destinationTrips: TripOperationsSeries[]; externalAgreedAmounts: TripOperationsSeries[]; externalPalletRates: TripOperationsSeries[] }
export interface TripOperationsRow { 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 } 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 WireNumbers<T> = { [K in keyof T]: T[K] extends number ? number | string : T[K] }
type WireMoney = { currency: string; amount: string | number } 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 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 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[] } } 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 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 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 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 WireSeries = Omit<TripOperationsSeries, 'points'> & { points: { label: string; value: string | number }[] }
type WireCharts = { [K in keyof TripOperationsCharts]: WireSeries[] } 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) })) })) 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 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 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 } export interface TripOperationsRecordHistory { recordVersion: string; reason: string; occurredAt: string; details: TripOperationsMeasured }
type WireRecord = Omit<TripOperationsRecord, 'recordVersion' | 'details'> & { recordVersion: string | number; details: WireMeasured } type WireRecord = Omit<TripOperationsRecord, 'recordVersion' | 'details'> & { recordVersion: string | number; details: WireMeasured }
type WireHistory = Omit<TripOperationsRecordHistory, '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 } })) 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) })) }) 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 { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
import TripOperationsRecordDialog from './TripOperationsRecordDialog.vue' import TripOperationsRecordDialog from './TripOperationsRecordDialog.vue'
import * as api from '../api/tripOperations' 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 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 wrappers: VueWrapper[] = []
const slot = { template: '<div><slot/></div>' } 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))" />' } 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(wrapper.findAll('button').find(b => b.text() === '保存记录')!.attributes('disabled')).toBeDefined()
expect(api.saveTripOperationsRecord).not.toHaveBeenCalled() 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 ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { isFleetCommandRejected } from '../api/fleetQuotes' 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 open = defineModel<boolean>({ default: false })
const props = defineProps<{ row: TripOperationsRow | null; readonly?: boolean }>(), emit = defineEmits<{ saved: [] }>() 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 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 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) const history = ref<TripOperationsRecordHistory[]>([]), total = ref(0), page = ref(1), size = ref(20)
let sequence = 0 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() { async function load() {
if (!open.value || !props.row) return if (!open.value || !props.row) return
const no = props.row.tripNo, current = ++sequence; loading.value = true; error.value = null; record.value = null 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 } catch (e) { if (current === sequence) error.value = getApiErrorDetails(e, '实测记录读取失败') } finally { if (current === sequence) loading.value = false }
} }
async function loadHistory() { 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 } 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() { async function save() {
if (!record.value || !props.row || props.readonly || saving.value) return if (!record.value || !props.row || props.readonly || saving.value || uploading.value || uploadUncertain.value || loading.value) return
const no = props.row.tripNo; saving.value = true; error.value = null const no = props.row.tripNo, current = sequence
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 (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) pending.set(no, request)
try { await saveTripOperationsRecord(no, request); pending.delete(no); uncertain.value = false; open.value = false; emit('saved') } 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) { error.value = getApiErrorDetails(e, '实测记录保存失败'); if (isFleetCommandRejected(e)) pending.delete(no); uncertain.value = pending.has(no) } 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 { saving.value = false } 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([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], () => { if (tab.value === 'history') void loadHistory() }); onBeforeUnmount(() => { sequence++ }) watch([tab, page, size], () => { clearPreview(); if (tab.value === 'history') void loadHistory(); else if (tab.value === 'measured') void load() }); onBeforeUnmount(() => { sequence++; clearPreview() })
</script> </script>
<template><AppDialog v-model="open" title="出车实测记录" width="820px" class="admin-density-dialog" :close-on-click-modal="false"> <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> <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> <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"> <el-tabs v-model="tab" class="density-element-tabs"><el-tab-pane label="实测记录" name="measured">
<p v-if="uncertain">上次保存结果未确认,将按原请求重试。</p> <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="本趟实际里程(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="本趟实际油耗(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 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-item v-if="!readonly" label="更正说明(选填)" class="full"><el-input v-model="reason" maxlength="1000" placeholder="选填,留空使用标准更正说明" /></el-form-item>
</el-form><p>未填写与明确零值分别保存。实测记录不改车辆累计里程、货物履约或财务费用。</p> <el-form-item label="实测凭证(选填)" class="full">
</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> <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" />
<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> <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> </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>
@@ -32,3 +32,5 @@
- 托盘原型操作列仅查看、编辑,工具栏仅新增;后端即使存在 DELETE,Admin 也不暴露。所有业务表格操作列保持第一列并固定左侧。 - 托盘原型操作列仅查看、编辑,工具栏仅新增;后端即使存在 DELETE,Admin 也不暴露。所有业务表格操作列保持第一列并固定左侧。
- 箱号和产品库不设独立正式 routes;海外出库、退货出库、海外派送、PDA、卸柜照片和库存离仓命令均按海外仓全流程的正式页面与接口归属验收,内部事件仍只作为服务端协作事实。 - 箱号和产品库不设独立正式 routes;海外出库、退货出库、海外派送、PDA、卸柜照片和库存离仓命令均按海外仓全流程的正式页面与接口归属验收,内部事件仍只作为服务端协作事实。
- 1920×1080、浏览器 100% 的人工截图仍待验收。 - 1920×1080、浏览器 100% 的人工截图仍待验收。
| 异常详情及处理 | `GET /exception-orders/{exceptionNo}`;`POST /{exceptionNo}/start-processing`、`/complete`、`/cancel`、`/evidence`(均在 `/platform/v1/last-leg/exception-orders` 下) | 显式customerId;命令携带expectedVersion和原idempotencyKey;complete携证据及服务端冻结来源对应本批实收、真实目标托盘/库位;分批后重新读取剩余量;version/数量规范化为安全整数,业务ID保留字符串;历史证据不自动提交,本批选用1–20份;目标采用一次托盘详情+一次`GET /platform/v1/overseas-warehouse-resources/{warehouseId}/locations/{id}`核对,不遍历全仓;普通说明选填并提交标准审计说明 | 详情沿list;处理及上传均 `platform:overseas-warehouse:exception-order:process`;既有居中详情内操作,未知结果锁定原载荷仅重试原键,明确版本拒绝刷新;无目标资源读取权限时显示错误,不猜库位 |
@@ -86,3 +86,4 @@
| 页面 | 操作 | 依据与承载 | 本次变更 | 验收 | | 页面 | 操作 | 依据与承载 | 本次变更 | 验收 |
|---|---|---|---|---| |---|---|---|---|---|
| 智能排车 | 退回来源选择、恢复草稿、预排 | 当前车辆异常退回与重派目标;沿`SmartDispatchDialog.vue`既有居中排车弹框 | 展示退回实收凭据号,按真实退回来源选用;草稿来源未全部恢复时保留选择、提示重试并阻止漏提交 | 组件交互覆盖恢复失败及重试后混合来源提交;1920×1080真实页面待验收 | | 智能排车 | 退回来源选择、恢复草稿、预排 | 当前车辆异常退回与重派目标;沿`SmartDispatchDialog.vue`既有居中排车弹框 | 展示退回实收凭据号,按真实退回来源选用;草稿来源未全部恢复时保留选择、提示重试并阻止漏提交 | 组件交互覆盖恢复失败及重试后混合来源提交;1920×1080真实页面待验收 |
| 异常订单 | 详情内开始处理、上传证据、分批实收、取消/完成 | 本轮完整异常退回流程接线授权;保持 `ExceptionOrdersView.vue` 既有760px居中详情弹框 | `ExceptionProcessingPanel.vue`读取权威详情、allowedActions/version;真实托盘库位选择,未知写结果保留原键与载荷,不新增撤拣或多车拆分 | 两批实收、未知结果重试、无权限、版本冲突及切单迟到响应组件交互覆盖;待人工1920×1080/100%截图确认 |
@@ -1,10 +1,33 @@
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http' import { http } from '@/shared/api/http'
import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation } from './exceptionOrders' import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation, getExceptionDetail, commandException, uploadExceptionEvidence, normalizeExceptionDetail, type ExceptionDetail } from './exceptionOrders'
vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn() } })) vi.mock('@/shared/api/http', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/shared/api/http')>()), http: { get: vi.fn(), post: vi.fn() } }))
describe('exception order API', () => { describe('exception order API', () => {
it('normalizes actual string versions and quantities without converting business references to numbers', () => {
const raw = { order: { version: '7', evidenceFileIds: ['190000000000000099'] }, returnSources: [{ outboundPalletItemId: '190000000000000098', inventoryPositionId: '190000000000000097', maxReturnPackages: '2' }] } as unknown as ExceptionDetail
const result = normalizeExceptionDetail(raw)
expect(result.order.version).toBe(7); expect(result.returnSources[0]?.maxReturnPackages).toBe(2)
expect(result.returnSources[0]?.outboundPalletItemId).toBe('190000000000000098')
for (const version of ['', '-1', '1.5', '9007199254740993', undefined]) expect(() => normalizeExceptionDetail({ ...raw, order: { ...raw.order, version } } as unknown as ExceptionDetail)).toThrow()
})
it('loads authoritative detail and scopes every versioned command and evidence upload to its customer and business number', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { order: { version: '7', allowedActions: ['COMPLETE_EXCEPTION'] }, returnSources: [] } })
await getExceptionDetail('100', 'EX/1')
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/exception-orders/EX%2F1', { params: { customerId: '100' } })
vi.mocked(http.post).mockResolvedValue({ code: '200', data: { exceptionNo: 'EX/1', version: '8', restoredPackages: '0' } })
for (const action of ['start-processing', 'cancel', 'complete'] as const) {
const body = { expectedVersion: 7, idempotencyKey: `original-${action}` }
await commandException('100', 'EX/1', action, body)
expect(http.post).toHaveBeenLastCalledWith(`/platform/v1/last-leg/exception-orders/EX%2F1/${action}`, body, { params: { customerId: '100' } })
}
const file = new File(['evidence'], 'receipt.txt')
await uploadExceptionEvidence('100', 'EX/1', file)
const call = vi.mocked(http.post).mock.calls.at(-1)!
expect(call[0]).toBe('/platform/v1/last-leg/exception-orders/EX%2F1/evidence')
expect((call[1] as FormData).get('file')).toBe(file); expect(call[2]).toEqual({ params: { customerId: '100' } })
})
beforeEach(() => { vi.mocked(http.get).mockReset(); vi.mocked(http.post).mockReset() }) beforeEach(() => { vi.mocked(http.get).mockReset(); vi.mocked(http.post).mockReset() })
it('queries the real paginated exception endpoint', async () => { it('queries the real paginated exception endpoint', async () => {
@@ -10,6 +10,37 @@ export interface ExceptionOrderRow {
deliveryOrderId?: string | null; deliveryNo?: string | null; outboundNo?: string | null deliveryOrderId?: string | null; deliveryNo?: string | null; outboundNo?: string | null
skuCode?: string | null; packageCount?: number | null; returnDirection?: string | null; returnLogisticsNo?: string | null skuCode?: string | null; packageCount?: number | null; returnDirection?: string | null; returnLogisticsNo?: string | null
handlingAction?: string | null; handlerName?: string | null; exceptionAt: string; updatedAt: string handlingAction?: string | null; handlerName?: string | null; exceptionAt: string; updatedAt: string
version?: number; allowedActions?: string[]; evidenceFileIds?: string[]; handlingResult?: string | null; completedAt?: string | null
}
export interface ExceptionReturnSource { outboundPalletItemId: string; inventoryPositionId: string; palletNo?: string | null; skuCode?: string | null; maxReturnPackages: number }
export interface ExceptionDetail { order: ExceptionOrderRow; returnSources: ExceptionReturnSource[] }
export interface ExceptionReceipt { outboundPalletItemId: string; inventoryPositionId: string; receivedPackages: number; targetPalletId: string; targetPalletNo: string; targetLocationId: string; targetLocationCode: string }
export interface ExceptionCommand { expectedVersion: number; idempotencyKey: string; handlingAction?: string; reason?: string; handlingResult?: string; evidenceFileIds?: string[]; receipts?: ExceptionReceipt[] }
export type ExceptionAction = 'start-processing' | 'complete' | 'cancel'
export interface ExceptionCommandResult { exceptionNo: string; status: string; version: number; restoredPackages: number; replayed: boolean }
const exceptionPath = (number: string) => `/platform/v1/last-leg/exception-orders/${encodeURIComponent(number)}`
function safeInteger(value: unknown, label: string) {
if ((typeof value !== 'number' && typeof value !== 'string') || !/^\d+$/.test(String(value)) || !Number.isSafeInteger(Number(value))) throw new Error(`${label}无效,请重新加载`)
return Number(value)
}
export function normalizeExceptionDetail(value: ExceptionDetail): ExceptionDetail {
return { ...value, order: { ...value.order, version: safeInteger(value.order.version, '异常版本'), evidenceFileIds: value.order.evidenceFileIds?.map(String) },
returnSources: value.returnSources.map(row => ({ ...row, outboundPalletItemId: String(row.outboundPalletItemId), inventoryPositionId: String(row.inventoryPositionId), maxReturnPackages: safeInteger(row.maxReturnPackages, '剩余可收数量') })) }
}
export async function getExceptionDetail(customerId: string, exceptionNo: string) {
const response = await http.get<ApiEnvelope<ExceptionDetail>, ApiEnvelope<ExceptionDetail>>(exceptionPath(exceptionNo), { params: { customerId } })
return normalizeExceptionDetail(unwrapApiResponse(response))
}
export async function commandException(customerId: string, exceptionNo: string, action: ExceptionAction, payload: ExceptionCommand) {
const response = await http.post<ApiEnvelope<ExceptionCommandResult>, ApiEnvelope<ExceptionCommandResult>>(`${exceptionPath(exceptionNo)}/${action}`, payload, { params: { customerId } })
const result = unwrapApiResponse(response)
return { ...result, version: safeInteger(result.version, '操作版本'), restoredPackages: safeInteger(result.restoredPackages, '实际入账数量') }
}
export async function uploadExceptionEvidence(customerId: string, exceptionNo: string, file: File) {
const form = new FormData(); form.append('file', file)
const response = await http.post<ApiEnvelope<{ fileId: string; fileName: string }>, ApiEnvelope<{ fileId: string; fileName: string }>>(`${exceptionPath(exceptionNo)}/evidence`, form, { params: { customerId } })
return unwrapApiResponse(response)
} }
export interface ExceptionRelation { export interface ExceptionRelation {
lastMileOrderId?: string | null; lastMileOrderNo?: string | null; inboundOrderId?: string | null; inboundCode?: string | null lastMileOrderId?: string | null; lastMileOrderNo?: string | null; inboundOrderId?: string | null; inboundCode?: string | null
@@ -0,0 +1,9 @@
import { expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { getOverseasLocation } from './locations'
vi.mock('@/shared/api/http', async original => ({ ...await original<typeof import('@/shared/api/http')>(), http: { get: vi.fn() } }))
it('reads one location through its warehouse-scoped detail endpoint without scanning pages', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', data: { id: '40', warehouseId: '92', locationCode: 'LOC' } })
expect(await getOverseasLocation('92', '40')).toEqual({ id: '40', warehouseId: '92', locationCode: 'LOC' })
expect(http.get).toHaveBeenCalledExactlyOnceWith('/platform/v1/overseas-warehouse-resources/92/locations/40')
})
@@ -35,6 +35,11 @@ export interface OverseasLocationQuery { page?: number; size?: number; keyword?:
function base(warehouseId: string) { return `/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations` } function base(warehouseId: string) { return `/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations` }
export async function getOverseasLocation(warehouseId: string, locationId: string) {
const response = await http.get<ApiEnvelope<OverseasLocationDto>, ApiEnvelope<OverseasLocationDto>>(`${base(warehouseId)}/${encodeURIComponent(locationId)}`)
return unwrapApiResponse(response)
}
export async function listOverseasLocations(warehouseId: string, query: OverseasLocationQuery = {}) { export async function listOverseasLocations(warehouseId: string, query: OverseasLocationQuery = {}) {
const response = await http.get<ApiEnvelope<OverseasPagePayload<OverseasLocationDto>>, ApiEnvelope<OverseasPagePayload<OverseasLocationDto>>>(base(warehouseId), { params: { page: query.page ?? 1, size: query.size ?? 20, ...query } }) const response = await http.get<ApiEnvelope<OverseasPagePayload<OverseasLocationDto>>, ApiEnvelope<OverseasPagePayload<OverseasLocationDto>>>(base(warehouseId), { params: { page: query.page ?? 1, size: query.size ?? 20, ...query } })
return normalizeOverseasPage(unwrapApiResponse(response)) return normalizeOverseasPage(unwrapApiResponse(response))
@@ -0,0 +1,202 @@
import { flushPromises, mount } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import { beforeEach, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import ExceptionProcessingPanel from './ExceptionProcessingPanel.vue'
import { getExceptionDetail, commandException, uploadExceptionEvidence, type ExceptionDetail, normalizeExceptionDetail } from '../api/exceptionOrders'
import { getPallet } from '../api/pallets'
import { getOverseasLocation } from '../api/locations'
const can = vi.hoisted(() => vi.fn(() => true))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('../api/exceptionOrders', async original => ({ ...await original<typeof import('../api/exceptionOrders')>(), getExceptionDetail: vi.fn(), commandException: vi.fn(), uploadExceptionEvidence: vi.fn() }))
vi.mock('../api/pallets', () => ({ getPallet: vi.fn(), listPallets: vi.fn() }))
vi.mock('../api/locations', () => ({ getOverseasLocation: vi.fn() }))
const shell = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
const field = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], setup(props, { emit }) {
return () => h('input', { value: props.modelValue, onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value) })
} })
const quantity = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], setup(props, { emit }) {
return () => h('input', { value: props.modelValue, onInput: (event: Event) => emit('update:modelValue', Number((event.target as HTMLInputElement).value)) })
} })
function snapshot(number = 'EX-1', version = 1, remaining = 4): ExceptionDetail {
return normalizeExceptionDetail({ order: { id: '1', exceptionNo: number, customerId: 'C', exceptionType: 'RETURN', returnDirection: 'OVERSEAS_WAREHOUSE',
overseasWarehouseId: '92', status: remaining ? 'PROCESSING' : 'COMPLETED', version: String(version), allowedActions: remaining ? ['COMPLETE_EXCEPTION'] : [],
reason: '拒收', exceptionAt: '', updatedAt: '', evidenceFileIds: ['91'] },
returnSources: remaining ? [{ outboundPalletItemId: '10', inventoryPositionId: '20', palletNo: 'ORIGINAL', skuCode: 'SKU', maxReturnPackages: String(remaining) }] : [] } as unknown as ExceptionDetail)
}
function open() { return mount(ExceptionProcessingPanel, { props: { customerId: 'C', exceptionNo: 'EX-1', active: true }, global: {
directives: { loading: () => {} }, stubs: { ElDescriptions: shell, ElDescriptionsItem: shell, ElForm: shell, ElFormItem: shell,
ElInput: field, ElInputNumber: quantity, ElSelect: field, ElOption: true, ElButton: { template: '<button><slot /></button>' } },
} }) }
async function fill(wrapper: ReturnType<typeof open>, amount = 2) {
if (wrapper.find('[data-test="history-0"]').exists()) await wrapper.get('[data-test="history-0"]').setValue(true)
await wrapper.get('[data-test="notes"]').setValue('本批实际收到')
await wrapper.get('[data-test="quantity-0"]').setValue(amount)
await wrapper.get('[data-test="target-0"]').setValue('30'); await flushPromises()
}
beforeEach(() => {
vi.resetAllMocks(); can.mockReturnValue(true)
vi.mocked(getExceptionDetail).mockResolvedValue(snapshot())
vi.mocked(getPallet).mockResolvedValue({ id: '30', warehouseId: '92', locationId: '40', palletCode: 'TARGET' } as Awaited<ReturnType<typeof getPallet>>)
vi.mocked(getOverseasLocation).mockResolvedValue({ id: '40', warehouseId: '92', locationCode: 'LOC' } as Awaited<ReturnType<typeof getOverseasLocation>>)
})
it('submits two actual batches using refreshed remaining sources and independent command keys', async () => {
vi.mocked(getExceptionDetail).mockResolvedValueOnce(snapshot()).mockResolvedValueOnce(snapshot('EX-1', 2, 2)).mockResolvedValueOnce(snapshot('EX-1', 3, 0))
vi.mocked(commandException).mockResolvedValueOnce({ exceptionNo: 'EX-1', version: 2, status: 'PROCESSING', restoredPackages: 2, replayed: false })
.mockResolvedValueOnce({ exceptionNo: 'EX-1', version: 3, status: 'COMPLETED', restoredPackages: 2, replayed: false })
const wrapper = open(); await flushPromises(); await fill(wrapper)
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('仍有待收货物'); expect(wrapper.text()).toContain('剩余可收 2 箱')
await fill(wrapper); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
const first = vi.mocked(commandException).mock.calls[0]!, second = vi.mocked(commandException).mock.calls[1]!
expect(first.slice(0, 3)).toEqual(['C', 'EX-1', 'complete'])
expect(first[3].receipts?.[0]).toEqual({ outboundPalletItemId: '10', inventoryPositionId: '20', receivedPackages: 2, targetPalletId: '30', targetPalletNo: 'TARGET', targetLocationId: '40', targetLocationCode: 'LOC' })
expect(first[3].expectedVersion).toBe(1); expect(second[3].expectedVersion).toBe(2)
expect(getPallet).toHaveBeenCalledTimes(2); expect(getOverseasLocation).toHaveBeenCalledTimes(2)
expect(getOverseasLocation).toHaveBeenNthCalledWith(1, '92', '40')
expect(second[3].idempotencyKey).not.toBe(first[3].idempotencyKey); expect(wrapper.find('[data-test="complete"]').exists()).toBe(false)
wrapper.unmount()
})
it('retains the exact command after unknown failure even if the visible form is changed', async () => {
vi.mocked(commandException).mockRejectedValueOnce(new Error('network lost')).mockResolvedValueOnce({ exceptionNo: 'EX-1', version: 2, status: 'PROCESSING', restoredPackages: 2, replayed: true })
const wrapper = open(); await flushPromises(); await fill(wrapper)
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
const payload = JSON.parse(JSON.stringify(vi.mocked(commandException).mock.calls[0]![3]))
expect(wrapper.text()).toContain('已保留原请求')
await wrapper.get('[data-test="notes"]').setValue('changed')
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(vi.mocked(commandException).mock.calls[1]![3]).toEqual(payload)
wrapper.unmount()
})
it('refreshes a definite stale version and requires a reviewed new submission', async () => {
vi.mocked(getExceptionDetail).mockResolvedValueOnce(snapshot()).mockResolvedValueOnce(snapshot('EX-1', 7, 2))
vi.mocked(commandException).mockRejectedValueOnce(new ApiResponseError('版本变化', 'A0409', 409))
const wrapper = open(); await flushPromises(); await fill(wrapper)
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('已重新读取当前版本'); expect(commandException).toHaveBeenCalledTimes(1)
await fill(wrapper); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(vi.mocked(commandException).mock.calls[1]![3].expectedVersion).toBe(7)
expect(vi.mocked(commandException).mock.calls[1]![3].idempotencyKey).not.toBe(vi.mocked(commandException).mock.calls[0]![3].idempotencyKey)
wrapper.unmount()
})
it('has no processing or upload controls without independent permission', async () => {
can.mockReturnValue(false); const wrapper = open(); await flushPromises()
expect(wrapper.find('[data-test="complete"]').exists()).toBe(false); expect(wrapper.find('[data-test="evidence"]').exists()).toBe(false)
expect(commandException).not.toHaveBeenCalled(); expect(uploadExceptionEvidence).not.toHaveBeenCalled(); wrapper.unmount()
})
it('ignores an old detail response after switching exceptions', async () => {
let resolve!: (value: ExceptionDetail) => void
vi.mocked(getExceptionDetail).mockImplementationOnce(() => new Promise(done => { resolve = done })).mockResolvedValueOnce(snapshot('EX-2', 2, 1))
const wrapper = open(); await wrapper.setProps({ exceptionNo: 'EX-2' }); await flushPromises()
resolve(snapshot('EX-1')); await flushPromises()
expect(wrapper.text()).toContain('EX-2'); expect(wrapper.text()).not.toContain('EX-1'); wrapper.unmount()
})
it('a late successful command never replaces the new exception detail', async () => {
let resolve!: (value: Awaited<ReturnType<typeof commandException>>) => void
vi.mocked(commandException).mockImplementationOnce(() => new Promise(done => { resolve = done }))
const wrapper = open(); await flushPromises(); await fill(wrapper); await wrapper.get('[data-test="complete"]').trigger('click')
vi.mocked(getExceptionDetail).mockResolvedValue(snapshot('EX-2', 2, 1))
await wrapper.setProps({ exceptionNo: 'EX-2' }); await flushPromises()
resolve({ exceptionNo: 'EX-1', version: 2, status: 'COMPLETED', restoredPackages: 2, replayed: false }); await flushPromises()
expect(wrapper.text()).toContain('EX-2'); expect(wrapper.emitted('changed')).toBeUndefined(); wrapper.unmount()
})
it('starts or cancels only when returned actions permit it, with the authoritative version', async () => {
const pending = snapshot(); pending.order.status = 'PENDING'; pending.order.allowedActions = ['START_PROCESSING', 'CANCEL_EXCEPTION']; pending.order.version = 4
vi.mocked(getExceptionDetail).mockResolvedValue(pending)
vi.mocked(commandException).mockResolvedValue({ exceptionNo: 'EX-1', version: 5, status: 'PROCESSING', restoredPackages: 0, replayed: false })
const wrapper = open(); await flushPromises()
await wrapper.get('[data-test="start"]').trigger('click'); await flushPromises()
expect(vi.mocked(commandException).mock.calls[0]![2]).toBe('start-processing')
expect(vi.mocked(commandException).mock.calls[0]![3].expectedVersion).toBe(4)
await wrapper.get('[data-test="notes"]').setValue('退回申请有误')
vi.mocked(commandException).mockResolvedValue({ exceptionNo: 'EX-1', version: 5, status: 'CANCELLED', restoredPackages: 0, replayed: false })
await wrapper.get('[data-test="cancel"]').trigger('click'); await flushPromises()
expect(vi.mocked(commandException).mock.calls[1]![3].reason).toBe('退回申请有误')
wrapper.unmount()
})
it('uploads owned evidence and includes the resulting file in the completion request', async () => {
vi.mocked(uploadExceptionEvidence).mockResolvedValue({ fileId: '991', fileName: 'receipt.txt' })
const wrapper = open(); await flushPromises()
const input = wrapper.get('[data-test="evidence"]'), file = new File(['proof'], 'receipt.txt')
Object.defineProperty(input.element, 'files', { value: [file], configurable: true })
await input.trigger('change'); await flushPromises()
expect(uploadExceptionEvidence).toHaveBeenCalledWith('C', 'EX-1', file)
await fill(wrapper); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(vi.mocked(commandException).mock.calls[0]![3].evidenceFileIds).toContain('991')
wrapper.unmount()
})
it('rejects a target pallet whose real warehouse differs from the exception warehouse', async () => {
vi.mocked(getPallet).mockResolvedValue({ id: '30', warehouseId: 'OTHER', locationId: '40', palletCode: 'WRONG' } as Awaited<ReturnType<typeof getPallet>>)
const wrapper = open(); await flushPromises(); await fill(wrapper)
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(commandException).not.toHaveBeenCalled(); expect(getOverseasLocation).not.toHaveBeenCalled()
wrapper.unmount()
})
it('uses standard audit text for actual warehouse receipt when the optional note is empty', async () => {
const wrapper = open(); await flushPromises(); await fill(wrapper)
await wrapper.get('[data-test="notes"]').setValue('')
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(vi.mocked(commandException).mock.calls[0]![3].handlingResult).toBe('确认本次退回入库')
wrapper.unmount()
})
it('allows cancellation and ordinary completion with standard audit text and still requires evidence', async () => {
const state = snapshot(); state.order.status = 'PENDING'; state.order.allowedActions = ['CANCEL_EXCEPTION']; state.order.exceptionType = 'DAMAGED'
vi.mocked(getExceptionDetail).mockResolvedValueOnce(state)
const wrapper = open(); await flushPromises(); await wrapper.get('[data-test="cancel"]').trigger('click'); await flushPromises()
expect(vi.mocked(commandException).mock.calls[0]![3].reason).toBe('取消异常处理')
const ordinary = snapshot('EX-2'); ordinary.order.exceptionType = 'DAMAGED'; ordinary.returnSources = []; ordinary.order.evidenceFileIds = []
vi.mocked(getExceptionDetail).mockResolvedValue(ordinary)
await wrapper.setProps({ exceptionNo: 'EX-2' }); await flushPromises(); await wrapper.get('[data-test="complete"]').trigger('click')
expect(commandException).toHaveBeenCalledTimes(1)
ordinary.order.evidenceFileIds = ['91']
await wrapper.setProps({ active: false }); await wrapper.setProps({ active: true }); await flushPromises()
await wrapper.get('[data-test="history-0"]').setValue(true)
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(vi.mocked(commandException).mock.calls[1]![3].handlingResult).toBe('确认异常处理完成')
expect(vi.mocked(commandException).mock.calls[1]![3].receipts).toEqual([])
wrapper.unmount()
})
it('closing and reopening cannot replay an in-flight command or lose a later unknown batch', async () => {
let rejectFirst!: (reason: Error) => void
vi.mocked(commandException).mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectFirst = reject }))
const wrapper = open(); await flushPromises(); await fill(wrapper)
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
const originalKey = vi.mocked(commandException).mock.calls[0]![3].idempotencyKey
await wrapper.setProps({ active: false }); await wrapper.setProps({ active: true }); await flushPromises()
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(commandException).toHaveBeenCalledTimes(1)
rejectFirst(new Error('R1 outcome unknown')); await flushPromises()
vi.mocked(getExceptionDetail).mockResolvedValue(snapshot('EX-1', 2, 2))
vi.mocked(commandException).mockResolvedValueOnce({ exceptionNo: 'EX-1', version: 2, status: 'PROCESSING', restoredPackages: 2, replayed: true })
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(vi.mocked(commandException).mock.calls[1]![3].idempotencyKey).toBe(originalKey)
await fill(wrapper)
vi.mocked(commandException).mockRejectedValueOnce(new Error('next batch unknown')).mockRejectedValueOnce(new Error('still unknown'))
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
const newKey = vi.mocked(commandException).mock.calls[2]![3].idempotencyKey
expect(newKey).not.toBe(originalKey)
await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(vi.mocked(commandException).mock.calls[3]![3].idempotencyKey).toBe(newKey)
wrapper.unmount()
})
it('twenty-one historical files do not get resubmitted automatically with the new batch evidence', async () => {
const history = snapshot(); history.order.exceptionType = 'DAMAGED'; history.returnSources = []
history.order.evidenceFileIds = Array.from({ length: 21 }, (_, index) => String(100 + index))
vi.mocked(getExceptionDetail).mockResolvedValue(history)
vi.mocked(uploadExceptionEvidence).mockResolvedValue({ fileId: '991', fileName: 'new-evidence.txt' })
const wrapper = open(); await flushPromises()
expect(wrapper.text()).toContain('21 份历史证据')
const input = wrapper.get('[data-test="evidence"]')
Object.defineProperty(input.element, 'files', { value: [new File(['proof'], 'new-evidence.txt')] })
await input.trigger('change'); await flushPromises(); await wrapper.get('[data-test="complete"]').trigger('click'); await flushPromises()
expect(vi.mocked(commandException).mock.calls[0]![3].evidenceFileIds).toEqual(['991'])
wrapper.unmount()
})
@@ -0,0 +1,197 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError, getApiErrorDetails } from '@/shared/api/http'
import { commandException, getExceptionDetail, uploadExceptionEvidence, type ExceptionAction, type ExceptionCommand, type ExceptionDetail, type ExceptionReturnSource } from '../api/exceptionOrders'
import { getPallet, listPallets, type PalletDto } from '../api/pallets'
import { getOverseasLocation } from '../api/locations'
import { formatOverseasDateTime } from '../lastMilePresentation'
const props = defineProps<{ customerId: string; exceptionNo: string; active: boolean }>()
const emit = defineEmits<{ changed: [] }>()
const access = useAccessPreviewStore()
const canProcess = computed(() => access.can('platform:overseas-warehouse:exception-order:process'))
const identity = computed(() => `${props.customerId}:${props.exceptionNo}`)
const detail = ref<ExceptionDetail | null>(null), loading = ref(false), busy = ref(false), error = ref(''), message = ref('')
type ReceiptInput = ExceptionReturnSource & { sourcePalletNo: string; quantity: number | undefined; target: string; palletNo: string; locationId: string; locationCode: string }
const receipts = ref<ReceiptInput[]>([]), targets = ref<PalletDto[]>([]), notes = ref(''), evidence = ref<{ fileId: string; fileName: string }[]>([])
type Attempt = { action: ExceptionAction; payload: ExceptionCommand }
const pendingByScope = reactive(new Map<string, Attempt>())
const unknownUploads = reactive(new Set<string>())
const savedEvidence = new Map<string, { fileId: string; fileName: string }[]>()
const selectedHistory = ref<string[]>([])
const inFlight = reactive(new Map<string, string>())
const uploadingScopes = reactive(new Set<string>())
const pending = computed(() => pendingByScope.get(identity.value))
const locked = computed(() => busy.value || inFlight.has(identity.value) || uploadingScopes.has(identity.value) || !!pending.value || unknownUploads.has(identity.value))
const warehouseReturn = computed(() => detail.value?.order.exceptionType === 'RETURN' && detail.value.order.returnDirection === 'OVERSEAS_WAREHOUSE')
const allowed = (action: string) => canProcess.value && detail.value?.order.allowedActions?.includes(action)
let sequence = 0
const targetSequences = new Map<number, number>()
const current = (ticket: number) => props.active && ticket === sequence
function definite(cause: unknown) { return cause instanceof ApiResponseError && /^A0[34]\d{2}$/.test(cause.code) && (!cause.status || cause.status < 500) }
async function load() {
const ticket = ++sequence; loading.value = true; error.value = ''; detail.value = null; receipts.value = []; targets.value = []; evidence.value = []; selectedHistory.value = []; notes.value = ''; targetSequences.clear()
try {
const result = await getExceptionDetail(props.customerId, props.exceptionNo)
if (!current(ticket)) return
if (result.order.exceptionNo !== props.exceptionNo || result.order.customerId !== props.customerId || !Number.isSafeInteger(result.order.version)) throw new Error('异常详情归属或版本无效,请重新加载')
detail.value = result
receipts.value = result.returnSources.map(row => ({ ...row, sourcePalletNo: row.palletNo || '—', quantity: undefined, target: '', palletNo: '', locationId: '', locationCode: '' }))
evidence.value = [...(savedEvidence.get(identity.value) || [])]
} catch (cause) { if (current(ticket)) error.value = getApiErrorDetails(cause, '异常详情加载失败').message }
finally { if (current(ticket)) loading.value = false }
}
async function searchTargets(keyword: string) {
const ticket = sequence, warehouse = detail.value?.order.overseasWarehouseId
if (!warehouse || locked.value || !canProcess.value) return
try {
const result = await listPallets(warehouse, { page: 1, size: 100, keyword })
if (current(ticket)) targets.value = result.records.filter(row => String(row.warehouseId) === String(warehouse) && !!row.locationId && row.status !== 'SCRAPPED')
} catch (cause) { if (current(ticket)) error.value = getApiErrorDetails(cause, '目标托盘加载失败').message }
}
async function chooseTarget(index: number, palletId: string) {
const ticket = sequence, row = receipts.value[index], warehouse = detail.value?.order.overseasWarehouseId
if (!row || !warehouse || locked.value) return
const targetTicket = (targetSequences.get(index) || 0) + 1; targetSequences.set(index, targetTicket)
row.target = palletId; row.palletNo = ''; row.locationId = ''; row.locationCode = ''
const valid = () => current(ticket) && targetSequences.get(index) === targetTicket
try {
const pallet = await getPallet(warehouse, palletId)
if (!valid()) return
if (String(pallet.warehouseId) !== String(warehouse) || !pallet.locationId || String(pallet.id) !== palletId) throw new Error('托盘仓库或库位不匹配')
const location = await getOverseasLocation(warehouse, String(pallet.locationId))
if (!valid()) return
if (String(location.id) !== String(pallet.locationId) || String(location.warehouseId) !== String(warehouse) || !location.locationCode)
throw new Error('目标托盘实际库位归属不匹配')
row.palletNo = pallet.palletCode; row.locationId = String(location.id); row.locationCode = location.locationCode
} catch (cause) { if (valid()) error.value = getApiErrorDetails(cause, '托盘库位核对失败').message }
}
async function upload(event: Event) {
const input = event.target as HTMLInputElement, file = input.files?.[0]
if (!file || !canProcess.value || locked.value) return
const ticket = sequence, scope = identity.value, customer = props.customerId, number = props.exceptionNo
uploadingScopes.add(scope)
busy.value = true; error.value = ''
try {
const result = await uploadExceptionEvidence(customer, number, file)
if (!result || !/^[1-9]\d*$/.test(String(result.fileId)) || !result.fileName) throw new Error('证据上传结果无法核对')
const fileResult = { fileId: String(result.fileId), fileName: result.fileName }
savedEvidence.set(scope, [...(savedEvidence.get(scope) || []), fileResult])
if (current(ticket)) evidence.value.push(fileResult)
} catch (cause) {
if (!definite(cause)) unknownUploads.add(scope)
if (current(ticket)) error.value = definite(cause) ? getApiErrorDetails(cause, '证据上传失败').message : '证据上传结果未知,请先人工核对;不会自动重复上传'
} finally { uploadingScopes.delete(scope); if (current(ticket)) busy.value = false; input.value = '' }
}
async function execute(action: ExceptionAction) {
const actionName = { 'start-processing': 'START_PROCESSING', complete: 'COMPLETE_EXCEPTION', cancel: 'CANCEL_EXCEPTION' }[action]
if (busy.value || inFlight.has(identity.value) || uploadingScopes.has(identity.value) || loading.value || !canProcess.value || (!pending.value && !allowed(actionName)) || unknownUploads.has(identity.value)) return
if (pending.value && pending.value.action !== action) return
if (!pending.value) {
const selectedFiles = [...new Set([...evidence.value.map(row => row.fileId), ...selectedHistory.value])]
if (action === 'complete' && (selectedFiles.length < 1 || selectedFiles.length > 20)) { error.value = '本次请选择或上传1–20份处理证据'; return }
const selected = receipts.value.filter(row => Number(row.quantity) > 0)
if (action === 'complete' && warehouseReturn.value && (!selected.length || selected.some(row => !Number.isSafeInteger(row.quantity) || Number(row.quantity) > row.maxReturnPackages || !row.palletNo || !row.locationId || !row.locationCode))) {
error.value = '请按剩余可收量填写本次实收,并选择已核对真实库位的目标托盘'; return
}
const payload: ExceptionCommand = { expectedVersion: detail.value!.order.version!, idempotencyKey: crypto.randomUUID() }
if (action === 'start-processing') payload.handlingAction = notes.value.trim() || undefined
if (action === 'cancel') payload.reason = notes.value.trim() || '取消异常处理'
if (action === 'complete') {
payload.handlingResult = notes.value.trim() || (warehouseReturn.value ? '确认本次退回入库' : '确认异常处理完成'); payload.evidenceFileIds = selectedFiles
payload.receipts = warehouseReturn.value ? selected.map(row => ({ outboundPalletItemId: row.outboundPalletItemId, inventoryPositionId: row.inventoryPositionId, receivedPackages: row.quantity!, targetPalletId: row.target, targetPalletNo: row.palletNo, targetLocationId: row.locationId, targetLocationCode: row.locationCode })) : []
}
pendingByScope.set(identity.value, { action, payload })
}
const ticket = sequence, scope = identity.value, attempt = pendingByScope.get(scope)!, customer = props.customerId, number = props.exceptionNo
let responseSequence = ticket
const requestKey = attempt.payload.idempotencyKey
const sameAttempt = () => pendingByScope.get(scope)?.payload.idempotencyKey === requestKey
inFlight.set(scope, requestKey)
busy.value = true; error.value = ''; message.value = ''
try {
const result = await commandException(customer, number, attempt.action, attempt.payload)
const statuses = attempt.action === 'complete' ? ['PROCESSING', 'COMPLETED'] : [attempt.action === 'cancel' ? 'CANCELLED' : 'PROCESSING']
if (!result || result.exceptionNo !== number || Number(result.version) !== attempt.payload.expectedVersion + 1 || !statuses.includes(result.status))
throw new Error('处理结果无法核对,请保留原请求重试')
if (sameAttempt()) { pendingByScope.delete(scope); savedEvidence.delete(scope) }
if (!current(ticket)) return
message.value = action === 'complete' && result.status === 'PROCESSING' ? `本批已实收 ${result.restoredPackages} 箱,仍有待收货物` : '操作已保存'
emit('changed'); responseSequence = sequence + 1; await load()
} catch (cause) {
if (definite(cause) && sameAttempt()) pendingByScope.delete(scope)
if (!current(ticket)) return
const text = getApiErrorDetails(cause, '处理请求失败').message
if (definite(cause)) { responseSequence = sequence + 1; await load(); if (current(responseSequence)) error.value = `${text};已重新读取当前版本,请核对后再操作` }
else error.value = `${text};结果未确认,已保留原请求,请仅重试原操作`
} finally {
if (inFlight.get(scope) === requestKey) inFlight.delete(scope)
if (current(responseSequence)) busy.value = false
}
}
function selectHistory(fileId: string, event: Event) {
if (locked.value) return
selectedHistory.value = (event.target as HTMLInputElement).checked ? [...new Set([...selectedHistory.value, fileId])] : selectedHistory.value.filter(id => id !== fileId)
}
function removeEvidence(index: number) {
if (locked.value) return
evidence.value.splice(index, 1); savedEvidence.set(identity.value, [...evidence.value])
}
watch(() => [props.customerId, props.exceptionNo, props.active], () => { ++sequence; busy.value = false; message.value = ''; if (props.active) void load() }, { immediate: true })
</script>
<template>
<section v-loading="loading" aria-label="异常处理详情">
<p v-if="error" role="alert">{{ error }}</p><p v-if="message" role="status">{{ message }}</p>
<el-button :disabled="busy" @click="load">重新读取详情</el-button>
<template v-if="detail">
<el-descriptions :column="2" border>
<el-descriptions-item label="异常单号">{{ detail.order.exceptionNo }}</el-descriptions-item>
<el-descriptions-item label="处理状态">{{ ({ PENDING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record<string, string>)[detail.order.status] || detail.order.status }}</el-descriptions-item>
<el-descriptions-item label="客户">{{ detail.order.customerName || '—' }}</el-descriptions-item>
<el-descriptions-item label="仓库">{{ detail.order.overseasWarehouseName || '—' }}</el-descriptions-item>
<el-descriptions-item label="尾程订单号">{{ detail.order.lastMileOrderNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="原入仓码">{{ detail.order.inboundCode || '—' }}</el-descriptions-item>
<el-descriptions-item label="派送单号">{{ detail.order.deliveryNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="出库单号">{{ detail.order.outboundNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="异常箱数">{{ detail.order.packageCount ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="SKU">{{ detail.order.skuCode || '—' }}</el-descriptions-item>
<el-descriptions-item label="退回物流单号">{{ detail.order.returnLogisticsNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="处理人">{{ detail.order.handlerName && !/^\d+$/.test(detail.order.handlerName) ? detail.order.handlerName : '—' }}</el-descriptions-item>
<el-descriptions-item label="异常时间">{{ formatOverseasDateTime(detail.order.exceptionAt) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatOverseasDateTime(detail.order.updatedAt) }}</el-descriptions-item>
<el-descriptions-item label="异常原因" :span="2">{{ detail.order.reason }}</el-descriptions-item>
<el-descriptions-item label="处理动作" :span="2">{{ detail.order.handlingAction || '—' }}</el-descriptions-item>
<el-descriptions-item label="处理结果" :span="2">{{ detail.order.handlingResult || '—' }}</el-descriptions-item>
</el-descriptions>
<p v-if="detail.order.evidenceFileIds?.length">已保存 {{ detail.order.evidenceFileIds.length }} 份历史证据;本次可按需选用,不自动全部提交。</p>
<template v-if="canProcess">
<el-form label-position="top" :disabled="locked">
<el-form-item label="处理说明 / 取消原因(选填)"><el-input v-model="notes" data-test="notes" type="textarea" :rows="2" maxlength="1000" placeholder="可补充实际情况;留空按本次操作记录标准说明" /></el-form-item>
<template v-if="allowed('COMPLETE_EXCEPTION')">
<label>处理证据 <input data-test="evidence" type="file" :disabled="locked" @change="upload" /></label>
<div v-for="(fileId, index) in detail.order.evidenceFileIds || []" :key="fileId"><label><input :data-test="`history-${index}`" type="checkbox" :checked="selectedHistory.includes(fileId)" :disabled="locked" @change="selectHistory(fileId, $event)" />选用历史证据 {{ index + 1 }}</label></div>
<ul><li v-for="(file, index) in evidence" :key="index">{{ file.fileName }} <el-button :disabled="locked" @click="removeEvidence(index)">移出本次提交</el-button></li></ul>
<template v-if="warehouseReturn">
<p>只填写本批实际收到的箱数;未收齐保持处理中。目标库位取所选托盘的真实库位。</p>
<div v-for="(row, index) in receipts" :key="`${row.outboundPalletItemId}:${row.inventoryPositionId}`">
<p>{{ row.sourcePalletNo }} · SKU {{ row.skuCode || '—' }} · 剩余可收 {{ row.maxReturnPackages }} 箱</p>
<el-form-item label="本批实收箱数"><el-input-number v-model="row.quantity" :data-test="`quantity-${index}`" :min="0" :max="row.maxReturnPackages" :precision="0" placeholder="未收到可留空" /></el-form-item>
<el-form-item label="实际收货目标托盘"><el-select :model-value="row.target" :data-test="`target-${index}`" filterable remote :remote-method="searchTargets" placeholder="输入托盘码查询" @visible-change="visible => visible && searchTargets('')" @update:model-value="value => chooseTarget(index, String(value))"><el-option v-for="target in targets" :key="target.id" :value="String(target.id)" :label="target.palletCode" /></el-select></el-form-item>
<p>目标托盘:{{ row.palletNo || '—' }};实际库位:{{ row.locationCode || '—' }}</p>
</div>
</template>
<p v-else-if="detail.order.exceptionType === 'RETURN'">关闭此异常只记录处理结果,不代表货物交接完成,也不会释放车辆或司机。</p>
</template>
</el-form>
<p v-if="pending">存在结果未确认的操作,表单已锁定;切换后仍保留该单原请求。</p>
<p v-if="unknownUploads.has(identity)">证据上传结果未知,需人工核对后继续。</p>
<el-button v-if="pending && !detail.order.allowedActions?.includes({ 'start-processing': 'START_PROCESSING', complete: 'COMPLETE_EXCEPTION', cancel: 'CANCEL_EXCEPTION' }[pending.action])" data-test="retry" :disabled="busy" @click="execute(pending.action)">重试原操作</el-button>
<el-button v-if="allowed('START_PROCESSING')" data-test="start" :disabled="busy || (!!pending && pending.action !== 'start-processing')" @click="execute('start-processing')">{{ pending ? '重试原开始请求' : '开始处理' }}</el-button>
<el-button v-if="allowed('CANCEL_EXCEPTION')" data-test="cancel" :disabled="busy || (!!pending && pending.action !== 'cancel')" @click="execute('cancel')">{{ pending ? '重试原取消请求' : '取消异常' }}</el-button>
<el-button v-if="allowed('COMPLETE_EXCEPTION')" data-test="complete" :disabled="busy || (!!pending && pending.action !== 'complete')" @click="execute('complete')">{{ pending ? '重试原处理请求' : warehouseReturn ? '提交本批实收' : '完成处理' }}</el-button>
</template>
</template>
</section>
</template>
@@ -1,19 +1,21 @@
import ElementPlus from 'element-plus' import ElementPlus from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils' import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation } from '../api/exceptionOrders' import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation, getExceptionDetail } from '../api/exceptionOrders'
import ExceptionOrdersView from './ExceptionOrdersView.vue' import ExceptionOrdersView from './ExceptionOrdersView.vue'
import { commonPhase5Stubs } from './phase5TestStubs' import { commonPhase5Stubs } from './phase5TestStubs'
const canPermission = vi.hoisted(() => vi.fn((_code: string) => false)) const canPermission = vi.hoisted(() => vi.fn((_code: string) => false))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: canPermission }) }))
vi.mock('../api/exceptionOrders', () => ({ listExceptionOrders: vi.fn(), resolveExceptionRelation: vi.fn(), createExceptionOrder: vi.fn() })) vi.mock('../api/exceptionOrders', () => ({ listExceptionOrders: vi.fn(), resolveExceptionRelation: vi.fn(), createExceptionOrder: vi.fn(), getExceptionDetail: vi.fn(), commandException: vi.fn(), uploadExceptionEvidence: vi.fn() }))
function mountView() { return mount(ExceptionOrdersView, { global: { plugins: [ElementPlus], stubs: commonPhase5Stubs } }) } function mountView() { return mount(ExceptionOrdersView, { global: { plugins: [ElementPlus], stubs: commonPhase5Stubs } }) }
describe('ExceptionOrdersView permissions and creation', () => { describe('ExceptionOrdersView permissions and creation', () => {
beforeEach(() => { beforeEach(() => {
canPermission.mockReset().mockReturnValue(false) canPermission.mockReset().mockReturnValue(false)
vi.mocked(getExceptionDetail).mockReset().mockResolvedValue({ order: { id: '19', exceptionNo: 'EXC-1', customerId: '100', customerName: '示例客户',
exceptionType: 'DAMAGED', status: 'PROCESSING', reason: '包装破损', handlerName: '1000001', version: 1, allowedActions: ['COMPLETE_EXCEPTION'], exceptionAt: '', updatedAt: '' }, returnSources: [] })
vi.mocked(listExceptionOrders).mockReset().mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 }) vi.mocked(listExceptionOrders).mockReset().mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 })
vi.mocked(resolveExceptionRelation).mockReset().mockResolvedValue({ inboundOrderId: '9', inboundCode: 'IN-1', lastMileOrderId: '8', lastMileOrderNo: 'LM-1', customerId: '100' }) vi.mocked(resolveExceptionRelation).mockReset().mockResolvedValue({ inboundOrderId: '9', inboundCode: 'IN-1', lastMileOrderId: '8', lastMileOrderNo: 'LM-1', customerId: '100' })
vi.mocked(createExceptionOrder).mockReset().mockResolvedValue({ id: '19', exceptionNo: 'EXC-1', status: 'PENDING' }) vi.mocked(createExceptionOrder).mockReset().mockResolvedValue({ id: '19', exceptionNo: 'EXC-1', status: 'PENDING' })
@@ -17,6 +17,7 @@ import { formatOverseasDateTime } from '../lastMilePresentation'
import { overseasWarehousePermissions as permission } from '../permissions' import { overseasWarehousePermissions as permission } from '../permissions'
import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation, type ExceptionOrderRow, type ExceptionRelation } from '../api/exceptionOrders' import { createExceptionOrder, listExceptionOrders, resolveExceptionRelation, type ExceptionOrderRow, type ExceptionRelation } from '../api/exceptionOrders'
import OrderReferenceText from '../components/OrderReferenceText.vue' import OrderReferenceText from '../components/OrderReferenceText.vue'
import ExceptionProcessingPanel from '../components/ExceptionProcessingPanel.vue'
const rows = ref<ExceptionOrderRow[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null) const rows = ref<ExceptionOrderRow[]>([]); const page = ref(1); const pageSize = ref(10); const total = ref(0); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null)
const access = useAccessPreviewStore() const access = useAccessPreviewStore()
@@ -24,7 +25,7 @@ const filters = ref<QueryModel>({ keyword: '', exceptionType: '', status: '' })
const fields: QueryField[] = [ const fields: QueryField[] = [
{ key: 'keyword', type: 'input', label: '关键词', placeholder: '异常单号 / 关联单号 / 客户 / 原因' }, { key: 'keyword', type: 'input', label: '关键词', placeholder: '异常单号 / 关联单号 / 客户 / 原因' },
{ key: 'exceptionType', type: 'select', label: '异常类型', placeholder: '全部类型', options: [{ label: '退货', value: 'RETURN' }, { label: '拒收', value: 'REJECT' }, { label: '破损', value: 'DAMAGED' }, { label: '其他', value: 'OTHER' }] }, { key: 'exceptionType', type: 'select', label: '异常类型', placeholder: '全部类型', options: [{ label: '退货', value: 'RETURN' }, { label: '拒收', value: 'REJECT' }, { label: '破损', value: 'DAMAGED' }, { label: '其他', value: 'OTHER' }] },
{ key: 'status', type: 'select', label: '处理状态', placeholder: '全部状态', options: [{ label: '待处理', value: 'PENDING' }, { label: '处理中', value: 'PROCESSING' }, { label: '已完成', value: 'COMPLETED' }] }, { key: 'status', type: 'select', label: '处理状态', placeholder: '全部状态', options: [{ label: '待处理', value: 'PENDING' }, { label: '处理中', value: 'PROCESSING' }, { label: '已完成', value: 'COMPLETED' }, { label: '已取消', value: 'CANCELLED' }] },
] ]
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' },
@@ -44,7 +45,7 @@ const columns: DataTableColumn[] = [
{ key: 'updatedAt', label: '更新时间', minWidth: 170, formatter: (row: ExceptionOrderRow) => formatOverseasDateTime(row.updatedAt) }, { key: 'updatedAt', label: '更新时间', minWidth: 170, formatter: (row: ExceptionOrderRow) => formatOverseasDateTime(row.updatedAt) },
] ]
const typeLabels: Record<string, string> = { RETURN: '退货', REJECT: '拒收', DAMAGED: '破损', OTHER: '其他' } const typeLabels: Record<string, string> = { RETURN: '退货', REJECT: '拒收', DAMAGED: '破损', OTHER: '其他' }
const statusLabels: Record<string, string> = { PENDING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成' } const statusLabels: Record<string, string> = { PENDING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消' }
const detailOpen = ref(false); const detailRow = ref<ExceptionOrderRow | null>(null) const detailOpen = ref(false); const detailRow = ref<ExceptionOrderRow | null>(null)
function openDetail(row: ExceptionOrderRow) { detailRow.value = row; detailOpen.value = true } function openDetail(row: ExceptionOrderRow) { detailRow.value = row; detailOpen.value = true }
function displayHandler(value: unknown) { function displayHandler(value: unknown) {
@@ -108,18 +109,7 @@ onMounted(load)
</el-form> </el-form>
</AppDialog> </AppDialog>
<AppDialog class="admin-density-dialog" v-model="detailOpen" title="异常订单详情" width="min(760px, calc(100vw - 28px))" :show-confirm="false" cancel-text="关闭"> <AppDialog class="admin-density-dialog" v-model="detailOpen" title="异常订单详情" width="min(760px, calc(100vw - 28px))" :show-confirm="false" cancel-text="关闭">
<el-descriptions class="density-descriptions" v-if="detailRow" :column="2" border> <ExceptionProcessingPanel v-if="detailRow" :customer-id="detailRow.customerId" :exception-no="detailRow.exceptionNo" :active="detailOpen" @changed="load()" />
<el-descriptions-item label="异常订单号">{{ detailRow.exceptionNo }}</el-descriptions-item>
<el-descriptions-item label="处理状态">{{ statusLabels[String(detailRow.status)] || detailRow.status }}</el-descriptions-item>
<el-descriptions-item label="异常类型">{{ typeLabels[detailRow.exceptionType] || detailRow.exceptionType }}</el-descriptions-item>
<el-descriptions-item label="客户">{{ detailRow.customerName || '—' }}</el-descriptions-item>
<el-descriptions-item label="海外仓">{{ detailRow.overseasWarehouseName || '—' }}</el-descriptions-item>
<el-descriptions-item label="处理人">{{ displayHandler(detailRow.handlerName) }}</el-descriptions-item>
<el-descriptions-item label="异常时间">{{ formatOverseasDateTime(detailRow.exceptionAt) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatOverseasDateTime(detailRow.updatedAt) }}</el-descriptions-item>
<el-descriptions-item label="异常原因" :span="2">{{ detailRow.reason || '—' }}</el-descriptions-item>
<el-descriptions-item label="处理动作" :span="2">{{ detailRow.handlingAction || '—' }}</el-descriptions-item>
</el-descriptions>
</AppDialog> </AppDialog>
</template> </template>