import axios from 'axios' import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http' import type { VehicleCareDraft, VehicleCareRecord, VehicleCareType, VehicleIncident } from '../vehicleCareTypes' import type { PageResult } from './overseasMasterData' type Numeric = number | string type Wire = Omit & { recordVersion: Numeric details: Pick & Partial> & { odometer?: Numeric | null; lastServiceOdometer?: Numeric | null; nextServiceOdometer?: Numeric | null; fuelQuantity?: Numeric | null incident?: (Pick & Partial> & { reportedCost?: Numeric | null }) | null } files: Array & { sizeBytes: Numeric }> } const path = (vehicleId: string, recordNo?: string) => `/platform/v1/last-leg/fleet/vehicles/${encodeURIComponent(vehicleId)}/records${recordNo ? `/${encodeURIComponent(recordNo)}` : ''}` export const vehicleCareFinanceSubmitPermission = 'platform:overseas-master-data:vehicle:record-finance-submit' export type VehicleCareFinanceState = 'UNLINKED' | 'PENDING_FINANCE' | 'FINANCE_ATTENTION' | 'PENDING' | 'APPROVED' | 'PARTIALLY_PAID' | 'PAID' | 'VOIDED' export interface VehicleCareFinanceStatus { recordNo: string; status: VehicleCareFinanceState; sourceVersion: string | null; submittedRecordVersion: string | null finance: { costNo: string; status: string; currency: 'CNY'; supplierName: string; paidAmount: number; outstandingAmount: number | null; source: { originalCurrency: 'CNY' | 'USD'; originalAmount: number; amount: number; businessDate: string; exchangeRate: number; exchangeRateMonth: string } } | null } export interface VehicleCareFinanceSubmit { recordVersion: number; supplierId: string; businessDate: string; idempotencyKey: string } export interface VehicleCareFinanceSubmitted { recordNo: string; sourceVersion: string; submittedRecordVersion: string; replayed: boolean; status: 'PENDING_FINANCE' } export const vehicleCareFinanceLabels: Record = { UNLINKED: '未提交财务', PENDING_FINANCE: '待财务接收', FINANCE_ATTENTION: '财务接收需处理', PENDING: '待财务入账', APPROVED: '已入账 · 未付款', PARTIALLY_PAID: '部分付款', PAID: '已付款', VOIDED: '已作废', } function financeVersion(value: Numeric): string { if (!/^(0|[1-9]\d*)$/.test(String(value)) || (typeof value === 'number' && !Number.isSafeInteger(value))) throw new Error('财务版本无效') return String(value) } function financeAmount(value: Numeric): number { if ((typeof value !== 'string' && typeof value !== 'number') || !/^\d+(\.\d{1,2})?$/.test(String(value))) throw new Error('财务金额无效') const [whole, fraction = ''] = String(value).split('.') const cents = BigInt(whole!) * 100n + BigInt(fraction.padEnd(2, '0')) if (cents > BigInt(Number.MAX_SAFE_INTEGER) || BigInt(Number(value).toFixed(2).replace('.', '')) !== cents) throw new Error('财务金额超过精确展示范围') return Number(value) } export async function listVehicleCareFinance(vehicleId: string, recordNos: string[]): Promise { if (!recordNos.length) return [] type Cost = NonNullable type StatusWire = Omit & { sourceVersion?: Numeric | null; submittedRecordVersion?: Numeric | null finance?: (Omit & { paidAmount: Numeric; outstandingAmount?: Numeric | null; source: Omit & { originalAmount: Numeric; amount: Numeric; exchangeRate: Numeric } }) | null } const response = unwrapApiResponse(await http.post, ApiEnvelope>(`${path(vehicleId)}/finance-status`, { recordNos })) const seen = new Set() const result = response.map(value => { if (!recordNos.includes(value.recordNo) || seen.has(value.recordNo) || !Object.hasOwn(vehicleCareFinanceLabels, value.status)) throw new Error('财务状态归属或内容无效') seen.add(value.recordNo) const cost = value.finance if (!['UNLINKED', 'PENDING_FINANCE', 'FINANCE_ATTENTION'].includes(value.status) && (!cost || cost.status !== value.status)) throw new Error('财务入账或付款状态缺少真实依据') if (cost && cost.currency !== 'CNY') throw new Error('财务结算币种无效') return { ...value, sourceVersion: value.sourceVersion == null ? null : financeVersion(value.sourceVersion), submittedRecordVersion: value.submittedRecordVersion == null ? null : financeVersion(value.submittedRecordVersion), finance: cost ? { ...cost, paidAmount: financeAmount(cost.paidAmount), outstandingAmount: cost.outstandingAmount == null ? null : financeAmount(cost.outstandingAmount), source: { ...cost.source, originalAmount: financeAmount(cost.source.originalAmount), amount: financeAmount(cost.source.amount), exchangeRate: decimal(cost.source.exchangeRate) } } : null } }) if (seen.size !== new Set(recordNos).size) throw new Error('财务状态未返回全部所选记录') return result } export async function submitVehicleCareFinance(vehicleId: string, recordNo: string, request: VehicleCareFinanceSubmit): Promise { type Wire = Omit & { sourceVersion: Numeric; submittedRecordVersion: Numeric } const response = unwrapApiResponse(await http.post, ApiEnvelope>(`${path(vehicleId, recordNo)}/finance`, request)) if (response.recordNo !== recordNo || response.status !== 'PENDING_FINANCE') throw new Error('财务提交响应不匹配,请使用原请求重试') return { ...response, sourceVersion: financeVersion(response.sourceVersion), submittedRecordVersion: financeVersion(response.submittedRecordVersion) } } export async function listVehicleCareSuppliers(keyword = ''): Promise> { type Supplier = { id: Numeric; name: string; status: string } const response = unwrapApiResponse(await http.get, ApiEnvelope<{ records: Supplier[] }>>('/platform/v1/suppliers', { params: { page: 1, size: 50, status: 'ACTIVE', keyword } })) return response.records.filter(row => row.status === 'ACTIVE').map(row => ({ id: financeVersion(row.id), name: row.name })) } function integer(value: Numeric): number { const result = Number(value) if (!Number.isSafeInteger(result) || result < 0) throw new Error('运维接口返回了无效的分页或版本') return result } function decimal(value: Numeric): number { const result = Number(value) if (value === '' || !Number.isFinite(result) || result < 0) throw new Error('运维接口返回了无效数值') return result } function record(value: Wire): VehicleCareRecord { const optional = (number: Numeric | null | undefined) => number == null ? null : decimal(number) const details = value.details, incident = details.incident return { ...value, recordVersion: integer(value.recordVersion), paymentStatus: value.paymentStatus ?? null, details: { recordType: details.recordType, occurredAt: details.occurredAt, distanceUnit: details.distanceUnit ?? null, odometer: optional(details.odometer), lastServiceOdometer: optional(details.lastServiceOdometer), nextServiceOdometer: optional(details.nextServiceOdometer), fuelQuantity: optional(details.fuelQuantity), fuelUnit: details.fuelUnit ?? null, verificationStatus: details.verificationStatus ?? null, remark: details.remark ?? null, incident: incident ? { description: incident.description, processingStatus: incident.processingStatus, item: incident.item ?? null, place: incident.place ?? null, handler: incident.handler ?? null, exceptionType: incident.exceptionType ?? null, reportedCost: optional(incident.reportedCost), currency: incident.currency ?? null } : null }, files: value.files.map(file => ({ ...file, sizeBytes: integer(file.sizeBytes) })) } } export async function listVehicleCare(vehicleId: string, recordType: VehicleCareType, page = 1, size = 10): Promise> { type PageWire = { records: Wire[]; page: Numeric; size: Numeric; total: Numeric; pages: Numeric } const result = unwrapApiResponse(await http.get, ApiEnvelope>(path(vehicleId), { params: { recordType, page, size } })) return { records: result.records.map(record), page: integer(result.page), size: integer(result.size), total: integer(result.total), pages: integer(result.pages) } } export async function createVehicleCare(vehicleId: string, idempotencyKey: string, details: VehicleCareDraft): Promise { return record(unwrapApiResponse(await http.post, ApiEnvelope>(path(vehicleId), { idempotencyKey, details }))) } export async function getVehicleCare(vehicleId: string, recordNo: string): Promise { return record(unwrapApiResponse(await http.get, ApiEnvelope>(path(vehicleId, recordNo)))) } export async function updateVehicleCare(vehicleId: string, recordNo: string, recordVersion: number, reason: string, details: VehicleCareDraft): Promise { return record(unwrapApiResponse(await http.put, ApiEnvelope>(path(vehicleId, recordNo), { recordVersion, reason, details }))) } export async function uploadVehicleCarePhoto(vehicleId: string, recordNo: string, recordVersion: number, idempotencyKey: string, file: File): Promise { const form = new FormData(); form.append('recordVersion', String(recordVersion)); form.append('idempotencyKey', idempotencyKey); form.append('file', file) return record(unwrapApiResponse(await http.post, ApiEnvelope>(`${path(vehicleId, recordNo)}/files`, form))) } async function downloadError(value: unknown, status: number): Promise { if (value instanceof Blob && value.type.includes('json')) { try { value = JSON.parse(await value.text()) as unknown } catch { throw new Error('图片接口返回了无法识别的错误') } } if (value && typeof value === 'object' && 'code' in value) { const error = value as ApiEnvelope throw new ApiResponseError(error.message || '图片读取失败', error.code, status, error.data, error.requestId, error.traceId) } } export async function downloadVehicleCarePhoto(vehicleId: string, recordNo: string, fileId: string): Promise { try { const blob = await http.get(`${path(vehicleId, recordNo)}/files/${encodeURIComponent(fileId)}`, { responseType: 'blob' }) await downloadError(blob, 200) if (!(blob instanceof Blob) || !blob.size || !['image/png', 'image/jpeg'].includes(blob.type)) throw new Error('照片为空或格式无效') return blob } catch (cause) { if (axios.isAxiosError(cause)) await downloadError(cause.response?.data, cause.response?.status || 0) throw cause } }