This commit is contained in:
2026-10-19 10:23:38 +08:00
parent 4bb21641d9
commit 8ae1e5d0d4
18 changed files with 575 additions and 28 deletions
@@ -0,0 +1,51 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import { normalizeTripDetail } from './trips'
import type { TripDetail } from '../tripTypes'
export interface PickupSource { customerId: string; transferNo: string }
export interface PickupCandidate extends PickupSource { lastlegOrderNo: string; warehouseId: string; warehouseCode?: string | null; warehouseName?: string | null; pickupAddress: string; contactName: string; contactPhone: string; packageCount: number; expectedArrivalAt: string | null }
export interface PickupEvidence { fileId: string; fileName: string; contentType: string; sizeBytes: number }
export interface PickupTask extends Omit<PickupCandidate, 'expectedArrivalAt'> {
status: string; tripNo: string | null; vehiclePlate: string | null; driverName: string | null; driverPhone: string | null
plannedDepartureAt: string | null; actualPackages: number | null; pickedAt: string | null; arrivedAt: string | null; remark: string | null; evidence: PickupEvidence[]
}
export interface PickupPlan { sources: PickupSource[]; plannedDepartureAt: string; expectedDriverEndAt: string; remark?: string; idempotencyKey: string }
export interface PickupCommand { recordVersion: number; idempotencyKey: string; reason: string }
export interface PickupConfirmation { recordVersion: number; idempotencyKey: string; actualPackages: number; evidenceFileIds: string[]; remark?: string }
export const pickupStatusLabels: Record<string, string> = { PENDING_REVIEW: '待审核', WAITING: '待安排取货', ASSIGNED: '已安排取货', PICKED: '已取货', ARRIVED: '已送仓' }
const base = '/platform/v1/last-leg/fleet/pickups'
const tripPath = (tripNo: string) => `${base}/${encodeURIComponent(tripNo)}`
const taskPath = (tripNo: string, row: PickupSource) => `${tripPath(tripNo)}/tasks/${encodeURIComponent(row.customerId)}/${encodeURIComponent(row.transferNo)}`
type Page = { records: PickupCandidate[]; total: string | number }
type WireDetail = Parameters<typeof normalizeTripDetail>[0]
/** 待取货列表始终以服务端资格和总数为准。 */
export async function listPickupCandidates(page: number, size: number, keyword?: string) {
const value = unwrapApiResponse(await http.get<ApiEnvelope<Page>, ApiEnvelope<Page>>(base, { params: { page, size, keyword } }))
return { records: value.records, total: Number(value.total) }
}
/** 安排成功返回原有车次详情。 */
export async function createPickupTrip(body: PickupPlan): Promise<TripDetail> {
return normalizeTripDetail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(base, body)))
}
/** 车次成员一次读取,避免逐行加载。 */
export async function getPickupTasks(tripNo: string): Promise<PickupTask[]> {
return unwrapApiResponse(await http.get<ApiEnvelope<PickupTask[]>, ApiEnvelope<PickupTask[]>>(`${tripPath(tripNo)}/tasks`))
}
/** 运输发车与结案沿取货专用规则执行。 */
export async function commandPickupTrip(tripNo: string, action: 'depart' | 'complete', body: PickupCommand): Promise<TripDetail> {
return normalizeTripDetail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${tripPath(tripNo)}/${action}`, body)))
}
/** 取货和送仓逐票确认,保留请求键以支持未知结果重试。 */
export async function commandPickupTask(tripNo: string, row: PickupSource, action: 'picked' | 'arrive', body: PickupCommand | PickupConfirmation): Promise<PickupTask[]> {
return unwrapApiResponse(await http.post<ApiEnvelope<PickupTask[]>, ApiEnvelope<PickupTask[]>>(`${taskPath(tripNo, row)}/${action}`, body))
}
/** 文件上传绑定本票与当前车次。 */
export async function uploadPickupEvidence(tripNo: string, row: PickupSource, file: File): Promise<PickupEvidence> {
const body = new FormData(); body.append('file', file)
return unwrapApiResponse(await http.post<ApiEnvelope<PickupEvidence>, ApiEnvelope<PickupEvidence>>(`${taskPath(tripNo, row)}/files`, body))
}
/** 本票凭证通过授权入口读取,永不使用公共文件直链。 */
export async function downloadPickupEvidence(row: PickupSource, fileId: string): Promise<Blob> {
return http.get<Blob, Blob>(`/platform/v1/last-leg/warehouse-transfers/${encodeURIComponent(row.transferNo)}/pickup-evidence/${encodeURIComponent(fileId)}`, { params: { customerId: row.customerId }, responseType: 'blob' })
}
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError, http } from '@/shared/api/http'
import {
prepareTrip, listTrips, getTrip, createTrip, updateTripPlan, assignTrip, bindTripDispatch, unbindTripDispatch, cancelTrip,
prepareTrip, listTrips, getTrip, createTrip, updateTripPlan, assignTrip, getTripResourceOccupancy, bindTripDispatch, unbindTripDispatch, cancelTrip,
getTripLoading, confirmTripLoading, getTripCompletion, completeTrip, releaseTripResource, listTripHistory, reviewTripDispatch, reviewTripLoading, getTripCapacityPlan, confirmTripCapacityPlan,
} from './trips'
@@ -26,6 +26,15 @@ const envelope = <T>(data: T) => ({ code: '200', message: '成功', data })
describe('fleet trip API contract', () => {
beforeEach(() => vi.clearAllMocks())
it('batches occupancy queries without rounding long resource identifiers', async () => {
const occupancy = [{ resourceId: longId, occupiedTripNo: 'TR-OTHER', available: false }]
vi.mocked(http.get).mockResolvedValueOnce(envelope(occupancy))
expect(await getTripResourceOccupancy('TR/TEST', 'DRIVER', [longId, '9007199254740997'])).toEqual(occupancy)
expect(http.get).toHaveBeenCalledExactlyOnceWith(`${base}/TR%2FTEST/resource-occupancy`, { params: { kind: 'DRIVER', resourceIds: `${longId},9007199254740997` } })
expect(await getTripResourceOccupancy('TR/TEST', 'VEHICLE', [])).toEqual([])
expect(http.get).toHaveBeenCalledTimes(1)
})
it('uses one paged request and keeps long identifiers as exact strings', async () => {
vi.mocked(http.get).mockResolvedValueOnce(envelope({ records: [trip], page: '1', size: '20', total: '121', pages: '7' }))
const query = { page: 1, size: 20, keyword: 'TEST', warehouseId: longId }
+11 -5
View File
@@ -52,7 +52,7 @@ const trip = (value: WireTrip): FleetTrip => ({ ...value, recordVersion: integer
const review = (value: WireReview): TripReviewState => ({ ...value, recordVersion: value.recordVersion == null ? null : integer(value.recordVersion) })
const batchReferences = (values: TripBinding['appointmentBatches']) => values.map(reference => ({ ...reference, sources: reference.sources.map(source => ({ ...source, packageCount: integer(source.packageCount) })) }))
const binding = (value: WireBinding): TripBinding => ({ ...value, packageCount: integer(value.packageCount), appointmentBatches: batchReferences(value.appointmentBatches), review: review(value.review) })
const detail = (value: WireDetail): TripDetail => ({ ...value, trip: trip(value.trip), dispatches: value.dispatches.map(binding), previousDispatches: value.previousDispatches.map(row => ({ ...row, recordVersion: integer(row.recordVersion), appointmentBatches: batchReferences(row.appointmentBatches) })), loadingReview: review(value.loadingReview) })
export const normalizeTripDetail = (value: WireDetail): TripDetail => ({ ...value, trip: trip(value.trip), dispatches: value.dispatches.map(binding), previousDispatches: value.previousDispatches.map(row => ({ ...row, recordVersion: integer(row.recordVersion), appointmentBatches: batchReferences(row.appointmentBatches) })), loadingReview: review(value.loadingReview) })
const cargo = (value: WireCargo): TripCargo => ({ ...value, packages: integer(value.packages) })
const outcome = (value: WireOutcome): TripOutcome => ({ ...value, packages: integer(value.packages),
signedPackages: integer(value.signedPackages), returnedPackages: integer(value.returnedPackages), remainingPackages: integer(value.remainingPackages) })
@@ -71,13 +71,19 @@ export async function listTrips(query: TripQuery): Promise<PageResult<FleetTrip>
return page(unwrapApiResponse(await http.get<ApiEnvelope<WirePage<WireTrip>>, ApiEnvelope<WirePage<WireTrip>>>(base, { params: query })), trip)
}
export async function getTrip(tripNo: string): Promise<TripDetail> {
return detail(unwrapApiResponse(await http.get<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(path(tripNo))))
return normalizeTripDetail(unwrapApiResponse(await http.get<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(path(tripNo))))
}
async function postDetail(url: string, body: TripDraft | TripAssignment | TripBindingCommand | TripComplete | TripResourceRelease | TripReviewCommand | { recordVersion: number; reason: string }): Promise<TripDetail> {
return detail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(url, body)))
return normalizeTripDetail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(url, body)))
}
export const createTrip = (body: TripDraft) => postDetail(base, body)
export const assignTrip = (tripNo: string, body: TripAssignment) => postDetail(`${path(tripNo)}/assignment`, body)
export interface TripResourceOccupancy { resourceId: string; occupiedTripNo: string | null; available: boolean }
/** 当前下拉候选批量查询实际占用,长 ID 始终按字符串传递。 */
export async function getTripResourceOccupancy(tripNo: string, kind: 'VEHICLE' | 'DRIVER', resourceIds: string[]): Promise<TripResourceOccupancy[]> {
if (!resourceIds.length) return []
return unwrapApiResponse(await http.get<ApiEnvelope<TripResourceOccupancy[]>, ApiEnvelope<TripResourceOccupancy[]>>(`${path(tripNo)}/resource-occupancy`, { params: { kind, resourceIds: resourceIds.join(',') } }))
}
export const reviewTripDispatch = (tripNo: string, dispatchNo: string, body: TripReviewCommand) => postDetail(`${path(tripNo)}/dispatches/${encodeURIComponent(dispatchNo)}/review`, body)
export const reviewTripLoading = (tripNo: string, body: TripReviewCommand) => postDetail(`${path(tripNo)}/loading-review`, body)
export const bindTripDispatch = (tripNo: string, body: TripBindingCommand) => postDetail(`${path(tripNo)}/dispatches/bind`, body)
@@ -86,7 +92,7 @@ export const cancelTrip = (tripNo: string, body: { recordVersion: number; reason
export const completeTrip = (tripNo: string, body: TripComplete) => postDetail(`${path(tripNo)}/complete`, body)
export const releaseTripResource = (tripNo: string, body: TripResourceRelease) => postDetail(`${path(tripNo)}/resources/release`, body)
export async function updateTripPlan(tripNo: string, body: TripPlan): Promise<TripDetail> {
return detail(unwrapApiResponse(await http.put<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${path(tripNo)}/plan`, body)))
return normalizeTripDetail(unwrapApiResponse(await http.put<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${path(tripNo)}/plan`, body)))
}
const planning = (value: WirePlanning): TripCapacityPlanning => ({ ...value, recordVersion: integer(value.recordVersion), cargo: value.cargo.map(cargo), capacityPlan: confirmed(value.capacityPlan), planValid: value.planValid === true, availableSegments: value.availableSegments.map(row => ({ fromIndex: integer(row.fromIndex), toIndex: integer(row.toIndex), occupied: load(row.occupied), remaining: load(row.remaining) })) })
export async function getTripCapacityPlan(tripNo: string): Promise<TripCapacityPlanning> {
@@ -121,5 +127,5 @@ export async function listTripDispatchCandidates(tripNo: string, keyword?: strin
}
export interface TripPreparation { tripNo: string | null; recordVersion: number | null; plan: TripDraft | null; vehicleId: string | null; driverId: string | null; dispatchNos: string[]; idempotencyKey: string }
export async function prepareTrip(body: TripPreparation): Promise<TripDetail> {
return detail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${base}/prepare`, body)))
return normalizeTripDetail(unwrapApiResponse(await http.post<ApiEnvelope<WireDetail>, ApiEnvelope<WireDetail>>(`${base}/prepare`, body)))
}