feat(mini-user): logistics sign photos, dial, ETA and courier callbacks (v3.4.13)

Extend Courier adapter for XFX sign photos and route callbacks; mini-user logistics UI with timeline, phone dial, and estimated arrival.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-05 23:34:09 +08:00
parent 62652cd072
commit 345d5db9c9
19 changed files with 710 additions and 140 deletions
@@ -9,6 +9,7 @@ const CMD_SCENE: Record<string, string> = {
[XIAOFEIXIA_CMD.ESTIMATE_FREIGHT]: 'ESTIMATE_FREIGHT',
[XIAOFEIXIA_CMD.BATCH_GET_ORDER]: 'BATCH_GET_SHIPMENT',
[XIAOFEIXIA_CMD.DELIVERY_COVERAGE]: 'CHECK_COVERAGE',
[XIAOFEIXIA_CMD.SIGN_PHOTOS]: 'GET_SIGN_PHOTOS',
};
export type CourierLogRef = {
@@ -29,6 +29,6 @@ import type { ICourierProvider } from './courier.types';
},
CourierService,
],
exports: [CourierService, CourierConfigService, COURIER_PROVIDER],
exports: [CourierService, CourierConfigService, COURIER_PROVIDER, XiaofeixiaProvider],
})
export class CourierModule {}
@@ -12,6 +12,8 @@ import type {
ShipmentQuery,
TrackCallbackResponse,
TrackNode,
TrackCallbackPayload,
CourierMappedOrderStatus,
} from './courier.types';
/**
@@ -45,6 +47,10 @@ export class CourierService {
return this.provider.getTrack(query, options);
}
getSignPhotos(query: ShipmentQuery, options?: CourierCallOptions): Promise<string[]> {
return this.provider.getSignPhotos(query, options);
}
checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise<DeliveryCoverageResult> {
return this.provider.checkDeliveryCoverage(toAddress, options);
}
@@ -56,4 +62,12 @@ export class CourierService {
buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse {
return this.provider.buildTrackCallbackResponse(success);
}
parseTrackCallback(body: unknown): TrackCallbackPayload | null {
return this.provider.parseTrackCallback(body);
}
mapTrackStatus(status: string): CourierMappedOrderStatus | null {
return this.provider.mapTrackStatus(status);
}
}
@@ -109,6 +109,13 @@ export interface TrackCallbackResponse {
message: string;
}
/** 承运商路由回调映射后的订单履约状态 */
export type CourierMappedOrderStatus =
| 'OUT_WAREHOUSE'
| 'SHIPPING'
| 'PENDING_RECEIVE'
| 'COMPLETED';
export interface ICourierProvider {
readonly code: CourierProviderCode;
@@ -117,7 +124,10 @@ export interface ICourierProvider {
getShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail>;
batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail[]>;
getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise<TrackNode[]>;
getSignPhotos(query: ShipmentQuery, options?: CourierCallOptions): Promise<string[]>;
checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise<DeliveryCoverageResult>;
estimateFreight(weight: number, options?: CourierCallOptions): Promise<FreightEstimateResult>;
parseTrackCallback(body: unknown): TrackCallbackPayload | null;
mapTrackStatus(status: string): CourierMappedOrderStatus | null;
buildTrackCallbackResponse(success?: boolean): TrackCallbackResponse;
}
@@ -6,6 +6,7 @@ export const XIAOFEIXIA_CMD = {
GET_ORDER: '100104',
ESTIMATE_FREIGHT: '100105',
BATCH_GET_ORDER: '100106',
SIGN_PHOTOS: '100108',
DELIVERY_COVERAGE: '100301',
} as const;
@@ -12,7 +12,9 @@ import type {
ShipmentDetail,
ShipmentQuery,
TrackCallbackResponse,
TrackCallbackPayload,
TrackNode,
CourierMappedOrderStatus,
} from '../courier.types';
import { XiaofeixiaClient } from './xiaofeixia.client';
import { XIAOFEIXIA_CMD } from './xiaofeixia.constants';
@@ -121,6 +123,57 @@ export class XiaofeixiaProvider implements ICourierProvider {
return data ?? [];
}
async getSignPhotos(query: ShipmentQuery, options?: CourierCallOptions): Promise<string[]> {
this.assertShipmentQuery(query);
const data = await this.client.request<string[]>(
XIAOFEIXIA_CMD.SIGN_PHOTOS,
{
number: query.trackingNumber,
outNumber: query.outNumber,
},
options?.xiaofeixia,
);
return (data ?? []).filter((item) => typeof item === 'string' && item.trim().length > 0);
}
parseTrackCallback(body: unknown): TrackCallbackPayload | null {
if (!body || typeof body !== 'object') return null;
const raw = body as Record<string, unknown>;
const outNumber = String(raw.outNumber ?? '').trim();
const trackingNumber = String(raw.number ?? raw.trackingNumber ?? '').trim();
const status = String(raw.status ?? '').trim();
const statusName = String(raw.statusName ?? '').trim();
const trackInfo = String(raw.trackInfo ?? '').trim();
const createTime = String(raw.createTime ?? '').trim();
if (!outNumber && !trackingNumber) return null;
if (!status && !trackInfo) return null;
return {
outNumber,
trackingNumber,
status,
statusName,
trackInfo,
createTime,
};
}
mapTrackStatus(status: string): CourierMappedOrderStatus | null {
switch (String(status).trim()) {
case '1':
return 'OUT_WAREHOUSE';
case '2':
case '3':
case '4':
return 'SHIPPING';
case '5':
return 'PENDING_RECEIVE';
case '7':
return null;
default:
return null;
}
}
async checkDeliveryCoverage(toAddress: string, options?: CourierCallOptions): Promise<DeliveryCoverageResult> {
const data = await this.client.request<XiaofeixiaDeliveryCoverageData>(
XIAOFEIXIA_CMD.DELIVERY_COVERAGE,