优化
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { getOutsourcedBusiness, listOutsourcedBusiness, listOutsourcedBusinessHistory, saveOutsourcedBusinessOperations, uploadOutsourcedBusinessPhoto, bindOutsourcedBusinessPhoto, getOutsourcedBusinessPhotoContent } from './outsourcedBusiness'
|
||||
import { outsourcedBusinessFixture } from '../components/OutsourcedBusinessFixtures'
|
||||
import type { OutsourcedBusinessPhoto, OutsourcedBusinessPhotoSlot, OutsourcedBusinessSave } from '../outsourcedBusinessTypes'
|
||||
|
||||
vi.mock('@/shared/api/http', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('@/shared/api/http')>()
|
||||
return { ...actual, http: { get: vi.fn(), put: vi.fn(), post: vi.fn() }, unwrapApiResponse: (value: { data: unknown }) => value.data }
|
||||
})
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
const base = '/platform/v1/last-leg/fleet/outsourced-business'
|
||||
const photo: OutsourcedBusinessPhoto = { slot: 'LOADING_SCENE', category: 'LOADING', fileId: '9007199254740993', fileName: 'loading.png', contentType: 'image/png', sizeBytes: '10', uploadedAt: '2026-10-20T10:00:00Z', savedAt: null }
|
||||
describe('outsourced business HTTP contract', () => {
|
||||
it('uses one list response for matched statistics and keeps physical count independent of cargo rows', async () => {
|
||||
const detail = outsourcedBusinessFixture()
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { records: [detail.row], page: '1', size: '20', total: '41', summary: { dispatchCount: '41', afterLoadingCount: '40', deliveringCount: '1', deliveredCount: '0' } } })
|
||||
const query = { page: 1, size: 20, plannedDepartureFrom: '2026-10-20T00:00:00Z', plannedDepartureTo: '2026-10-21T00:00:00Z', keyword: 'FBA-1' }
|
||||
const value = await listOutsourcedBusiness(query)
|
||||
expect(http.get).toHaveBeenCalledExactlyOnceWith(base, { params: query })
|
||||
expect(value.summary.dispatchCount).toBe(41); expect(value.records).toHaveLength(1); expect(value.records[0]!.actualPalletCount).toBe(1)
|
||||
})
|
||||
it('encodes the dispatch business number and preserves auxiliary/main versions independently', async () => {
|
||||
const detail = outsourcedBusinessFixture(); detail.row.recordVersion = '0'; detail.row.dispatchRecordVersion = '9007199254740993'
|
||||
detail.row.dispatchNo = 'DS/1'; detail.dispatch.dispatchNo = 'DS/1'
|
||||
vi.mocked(http.get).mockResolvedValue({ data: detail })
|
||||
expect((await getOutsourcedBusiness('DS/1')).row.dispatchRecordVersion).toBe('9007199254740993')
|
||||
expect(http.get).toHaveBeenCalledWith(`${base}/DS%2F1`, { params: undefined })
|
||||
detail.dispatch.dispatchNo = 'another'; await expect(getOutsourcedBusiness('DS/1')).rejects.toThrow('不一致')
|
||||
})
|
||||
it('does not convert money text, calculate a client unit price or submit a dispatch version', async () => {
|
||||
const body: OutsourcedBusinessSave = { recordVersion: '0', idempotencyKey: 'original-key', operations: { ...outsourcedBusinessFixture().row.operations, money: { currency: 'USD', amount: '9999999999999999.99' } }, reason: null }
|
||||
const detail = outsourcedBusinessFixture(); detail.row.operations.money = body.operations.money
|
||||
vi.mocked(http.put).mockResolvedValue({ data: detail })
|
||||
const saved = await saveOutsourcedBusinessOperations('DS-OUT-1', body)
|
||||
expect(http.put).toHaveBeenCalledExactlyOnceWith(`${base}/DS-OUT-1/operations`, body)
|
||||
expect(body.operations.money!.amount).toBe('9999999999999999.99'); expect('dispatchRecordVersion' in body).toBe(false)
|
||||
expect(saved.row.operations.money!.amount).toBe('9999999999999999.99')
|
||||
})
|
||||
it('keeps upload and versioned file binding separate with the same prepared file identity', async () => {
|
||||
vi.mocked(http.post).mockResolvedValue({ data: photo })
|
||||
const file = new File(['image'], 'loading.png', { type: 'image/png' })
|
||||
await uploadOutsourcedBusinessPhoto('DS/1', 'LOADING_SCENE', file, 'upload-key')
|
||||
const [url, form] = vi.mocked(http.post).mock.calls[0]!
|
||||
expect(url).toBe(`${base}/DS%2F1/photos/LOADING_SCENE/uploads`); expect((form as FormData).get('file')).toBe(file); expect((form as FormData).get('idempotencyKey')).toBe('upload-key')
|
||||
const detail = outsourcedBusinessFixture(); detail.row.dispatchNo = 'DS/1'; detail.dispatch.dispatchNo = 'DS/1'
|
||||
vi.mocked(http.put).mockResolvedValue({ data: detail })
|
||||
await bindOutsourcedBusinessPhoto('DS/1', 'LOADING_SCENE', { recordVersion: '0', idempotencyKey: 'bind-key', fileId: photo.fileId })
|
||||
expect(http.put).toHaveBeenCalledExactlyOnceWith(`${base}/DS%2F1/photos/LOADING_SCENE`, { recordVersion: '0', idempotencyKey: 'bind-key', fileId: photo.fileId })
|
||||
})
|
||||
it('retains the two history streams and reads historical files only at their explicit revision', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { records: [{ eventSource: 'DISPATCH', recordVersion: null, dispatchRecordVersion: '9', operations: null, photos: [] }, { eventSource: 'OPERATIONS', recordVersion: '1', dispatchRecordVersion: null, operations: outsourcedBusinessFixture().row.operations, photos: [] }], page: '1', size: '20', total: '2' } })
|
||||
const value = await listOutsourcedBusinessHistory('DS/1', 1, 20)
|
||||
expect(value.records.map(record => [record.eventSource, record.recordVersion, record.dispatchRecordVersion])).toEqual([['DISPATCH',null,'9'],['OPERATIONS','1',null]])
|
||||
vi.mocked(http.get).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
|
||||
await getOutsourcedBusinessPhotoContent('DS/1', photo, '1')
|
||||
expect(http.get).toHaveBeenLastCalledWith(`${base}/DS%2F1/photos/LOADING_SCENE/files/${photo.fileId}/content`, { responseType: 'blob', params: { revisionVersion: '1' } })
|
||||
})
|
||||
it('never renders JSON errors or empty bytes as a photo', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue(new Blob([JSON.stringify({ code: 'A0403', message: '拒绝读取', requestId: 'request', traceId: 'trace' })], { type: 'application/json' }))
|
||||
await expect(getOutsourcedBusinessPhotoContent('DS-OUT-1', photo)).rejects.toMatchObject({ code: 'A0403', requestId: 'request', traceId: 'trace' })
|
||||
vi.mocked(http.get).mockResolvedValue(new Blob([], { type: 'image/png' })); await expect(getOutsourcedBusinessPhotoContent('DS-OUT-1', photo)).rejects.toThrow('为空')
|
||||
await expect(uploadOutsourcedBusinessPhoto('DS-OUT-1','LOADING_SCENE',new File(['text'],'bad.txt',{type:'text/plain'}),'key')).rejects.toThrow('JPG')
|
||||
})
|
||||
it.each(['2', null])('accepts authoritative external vehicle identity %s without looking up the current master', async vehicleId => {
|
||||
const detail = outsourcedBusinessFixture(); detail.row.vehicle.vehicleId = vehicleId; detail.row.vehicle.carrierName = null
|
||||
vi.mocked(http.get).mockResolvedValue({ data: detail })
|
||||
const value = await getOutsourcedBusiness('DS-OUT-1')
|
||||
expect(value.row.vehicle).toMatchObject({ vehicleId, ownership: 'DISPATCHED', carrierName: null })
|
||||
expect(http.get).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('rejects owned vehicle facts and unsupported fuel or mileage writes before any request', async () => {
|
||||
const detail = outsourcedBusinessFixture()
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { ...detail, row: { ...detail.row, vehicle: { ...detail.row.vehicle, ownership: 'OWNED' } } } })
|
||||
await expect(getOutsourcedBusiness('DS-OUT-1')).rejects.toThrow('来源或阶段')
|
||||
for (const field of ['actualDistanceMiles','fuelConsumptionLitresPer100Km','actualFuelLitres']) {
|
||||
const body = { recordVersion:'0', idempotencyKey:'original-key', operations:{ ...detail.row.operations, [field]:'1' }, reason:null }
|
||||
await expect(saveOutsourcedBusinessOperations('DS-OUT-1', body)).rejects.toThrow('不支持里程或油耗')
|
||||
}
|
||||
expect(http.put).not.toHaveBeenCalled()
|
||||
for (const slot of ['FUEL_START','FUEL_END']) {
|
||||
await expect(uploadOutsourcedBusinessPhoto('DS-OUT-1',slot as OutsourcedBusinessPhotoSlot,new File(['image'],'loading.png',{type:'image/png'}),'upload-key')).rejects.toThrow('不支持该照片分类')
|
||||
await expect(bindOutsourcedBusinessPhoto('DS-OUT-1',slot as OutsourcedBusinessPhotoSlot,{recordVersion:'0',idempotencyKey:'bind-key',fileId:photo.fileId})).rejects.toThrow('不支持该照片分类')
|
||||
}
|
||||
expect(http.post).not.toHaveBeenCalled(); expect(http.put).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import axios from 'axios'
|
||||
import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http'
|
||||
import { outsourcedBusinessDecimal, outsourcedBusinessVersion, outsourcedBusinessPhotoRoles, type OutsourcedBusinessOperations, type OutsourcedBusinessRow, type OutsourcedBusinessDetail, type OutsourcedBusinessList, type OutsourcedBusinessQuery, type OutsourcedBusinessSave, type OutsourcedBusinessBind, type OutsourcedBusinessPhoto, type OutsourcedBusinessPhotoSlot, type OutsourcedBusinessCommand, type OutsourcedBusinessHistory, type OutsourcedBusinessPage } from '../outsourcedBusinessTypes'
|
||||
|
||||
const base = '/platform/v1/last-leg/fleet/outsourced-business'
|
||||
const path = (no: string) => `${base}/${encodeURIComponent(no)}`
|
||||
async function get<T>(url: string, params?: object): Promise<T> { return unwrapApiResponse(await http.get<ApiEnvelope<T>, ApiEnvelope<T>>(url, { params })) }
|
||||
function count(value: unknown): number { const text = outsourcedBusinessVersion(value); const result = Number(text); if (!Number.isSafeInteger(result)) throw new Error('运营记录数量超出精确范围'); return result }
|
||||
function decimal(value: string | null, scale: number, precision: number): string | null {
|
||||
if (value == null) return null
|
||||
if (typeof value !== 'string' || outsourcedBusinessDecimal(value, scale, precision) !== value) throw new Error('运营金额或测量必须使用精确十进制字符串')
|
||||
return value
|
||||
}
|
||||
function operations(value: OutsourcedBusinessOperations): OutsourcedBusinessOperations {
|
||||
if ('actualDistanceMiles' in value || 'fuelConsumptionLitresPer100Km' in value || 'actualFuelLitres' in value) throw new Error('外配运营信息不支持里程或油耗')
|
||||
if (value.money && !['CNY','USD'].includes(value.money.currency)) throw new Error('运营金额币种无效')
|
||||
const amount = value.money ? decimal(value.money.amount, 2, 18) : null
|
||||
if (value.money && amount == null) throw new Error('承运金额与币种必须同时登记')
|
||||
return { money: value.money ? { currency: value.money.currency, amount: amount! } : null, arrivals: value.arrivals }
|
||||
}
|
||||
function requireSlot(slot: OutsourcedBusinessPhotoSlot) { if (!outsourcedBusinessPhotoRoles.some(role => role.slot === slot)) throw new Error('外配业务不支持该照片分类') }
|
||||
function row(value: OutsourcedBusinessRow): OutsourcedBusinessRow {
|
||||
if (!value.dispatchNo || !['AFTER_LOADING','DELIVERING','DELIVERED'].includes(value.stage) || value.vehicle?.ownership !== 'DISPATCHED') throw new Error('外配排车来源或阶段不完整,请重新读取')
|
||||
return { ...value, operations: operations(value.operations), unitPrice: decimal(value.unitPrice,2,18), recordVersion: outsourcedBusinessVersion(value.recordVersion), dispatchRecordVersion: outsourcedBusinessVersion(value.dispatchRecordVersion), actualPalletCount: value.actualPalletCount == null ? null : count(value.actualPalletCount), photoSummary: { loadingCount: count(value.photoSummary.loadingCount) } }
|
||||
}
|
||||
function detail(value: OutsourcedBusinessDetail): OutsourcedBusinessDetail { if (value.row.dispatchNo !== value.dispatch.dispatchNo) throw new Error('运营资料与原排车单不一致'); return { ...value, row: row(value.row) } }
|
||||
function command(value: OutsourcedBusinessCommand): OutsourcedBusinessCommand { return { ...value, recordVersion: value.recordVersion == null ? null : outsourcedBusinessVersion(value.recordVersion) } }
|
||||
export async function listOutsourcedBusiness(query: OutsourcedBusinessQuery): Promise<OutsourcedBusinessList> {
|
||||
const value = await get<OutsourcedBusinessList>(base, query)
|
||||
return { ...value, records: value.records.map(row), page: count(value.page), size: count(value.size), total: count(value.total), summary: { dispatchCount: count(value.summary.dispatchCount), afterLoadingCount: count(value.summary.afterLoadingCount), deliveringCount: count(value.summary.deliveringCount), deliveredCount: count(value.summary.deliveredCount) } }
|
||||
}
|
||||
export async function getOutsourcedBusiness(no: string) { const value = detail(await get<OutsourcedBusinessDetail>(path(no))); if (value.row.dispatchNo !== no) throw new Error('运营资料与当前排车业务号不一致'); return value }
|
||||
export async function listOutsourcedBusinessHistory(no: string, page: number, size: number): Promise<OutsourcedBusinessPage<OutsourcedBusinessHistory>> {
|
||||
const value = await get<OutsourcedBusinessPage<OutsourcedBusinessHistory>>(`${path(no)}/history`, { page, size })
|
||||
return { ...value, records: value.records.map(record => ({ ...record, operations: record.operations ? operations(record.operations) : null, recordVersion: record.recordVersion == null ? null : outsourcedBusinessVersion(record.recordVersion), dispatchRecordVersion: record.dispatchRecordVersion == null ? null : outsourcedBusinessVersion(record.dispatchRecordVersion) })), page: count(value.page), size: count(value.size), total: count(value.total) }
|
||||
}
|
||||
export const getOutsourcedBusinessCommand = (no: string, key: string) => get<OutsourcedBusinessCommand>(`${path(no)}/commands/${encodeURIComponent(key)}`).then(command)
|
||||
export async function saveOutsourcedBusinessOperations(no: string, body: OutsourcedBusinessSave) {
|
||||
outsourcedBusinessVersion(body.recordVersion)
|
||||
operations(body.operations)
|
||||
const value = detail(unwrapApiResponse(await http.put<ApiEnvelope<OutsourcedBusinessDetail>, ApiEnvelope<OutsourcedBusinessDetail>>(`${path(no)}/operations`, body)))
|
||||
if (value.row.dispatchNo !== no) throw new Error('运营保存结果与原排车单不一致')
|
||||
return value
|
||||
}
|
||||
export async function uploadOutsourcedBusinessPhoto(no: string, slot: OutsourcedBusinessPhotoSlot, file: File, key: string) {
|
||||
requireSlot(slot)
|
||||
if (!['image/jpeg','image/png'].includes(file.type) || file.size <= 0) throw new Error('请选择有效的JPG或PNG图片')
|
||||
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key)
|
||||
const value = unwrapApiResponse(await http.post<ApiEnvelope<OutsourcedBusinessPhoto>, ApiEnvelope<OutsourcedBusinessPhoto>>(`${path(no)}/photos/${slot}/uploads`, form))
|
||||
if (value.slot !== slot || !value.fileId) throw new Error('上传文件与原照片分类不一致')
|
||||
return value
|
||||
}
|
||||
export async function bindOutsourcedBusinessPhoto(no: string, slot: OutsourcedBusinessPhotoSlot, body: OutsourcedBusinessBind) {
|
||||
requireSlot(slot)
|
||||
outsourcedBusinessVersion(body.recordVersion)
|
||||
const value = detail(unwrapApiResponse(await http.put<ApiEnvelope<OutsourcedBusinessDetail>, ApiEnvelope<OutsourcedBusinessDetail>>(`${path(no)}/photos/${slot}`, body)))
|
||||
if (value.row.dispatchNo !== no) throw new Error('照片绑定结果与原排车单不一致')
|
||||
return value
|
||||
}
|
||||
async function downloadError(payload: unknown, status: number) {
|
||||
let value = payload
|
||||
if (payload instanceof Blob && payload.type.includes('json')) { try { value = JSON.parse(await payload.text()) as unknown } catch { throw new Error('图片接口返回了无法识别的错误') } }
|
||||
if (value && typeof value === 'object' && 'code' in value) { const error = value as ApiEnvelope<unknown>; throw new ApiResponseError(error.message || '图片读取失败', error.code, status, error.data, error.requestId, error.traceId) }
|
||||
}
|
||||
export async function getOutsourcedBusinessPhotoContent(no: string, photo: OutsourcedBusinessPhoto, revisionVersion?: string): Promise<Blob> {
|
||||
requireSlot(photo.slot)
|
||||
if (revisionVersion != null && BigInt(outsourcedBusinessVersion(revisionVersion)) < 1n) throw new Error('历史图片版本必须大于零')
|
||||
try {
|
||||
const blob = await http.get<Blob, Blob>(`${path(no)}/photos/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob', params: revisionVersion == null ? undefined : { revisionVersion: outsourcedBusinessVersion(revisionVersion) } })
|
||||
await downloadError(blob, 200)
|
||||
if (!(blob instanceof Blob) || !['image/jpeg','image/png'].includes(blob.type) || !blob.size) throw new Error('图片内容为空或格式无效')
|
||||
return blob
|
||||
} catch (cause) { if (axios.isAxiosError(cause)) await downloadError(cause.response?.data, cause.response?.status || 0); throw cause }
|
||||
}
|
||||
Reference in New Issue
Block a user