0692bebf07
Xiaofeixia posts multipart form fields; previously mis-read as urlencoded empty payload. Parse multipart text parts and map into order status transitions. Co-authored-by: Cursor <cursoragent@cursor.com>
282 lines
8.9 KiB
TypeScript
282 lines
8.9 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { CourierProviderCode } from '../courier.constants';
|
|
import { CourierApiError } from '../courier.error';
|
|
import type {
|
|
BatchShipmentQuery,
|
|
CourierCallOptions,
|
|
CreateShipmentInput,
|
|
CreateShipmentResult,
|
|
DeliveryCoverageResult,
|
|
FreightEstimateResult,
|
|
ICourierProvider,
|
|
ShipmentDetail,
|
|
ShipmentQuery,
|
|
TrackCallbackResponse,
|
|
TrackCallbackPayload,
|
|
TrackNode,
|
|
CourierMappedOrderStatus,
|
|
} from '../courier.types';
|
|
import { XiaofeixiaClient } from './xiaofeixia.client';
|
|
import { XIAOFEIXIA_CMD } from './xiaofeixia.constants';
|
|
import type {
|
|
XiaofeixiaCreateOrderData,
|
|
XiaofeixiaDeliveryCoverageData,
|
|
XiaofeixiaFreightEstimateData,
|
|
XiaofeixiaOrderDetail,
|
|
XiaofeixiaTrackNode,
|
|
} from './xiaofeixia.types';
|
|
|
|
@Injectable()
|
|
export class XiaofeixiaProvider implements ICourierProvider {
|
|
readonly code = CourierProviderCode.XIAOFEIXIA;
|
|
|
|
constructor(private readonly client: XiaofeixiaClient) {}
|
|
|
|
async createShipment(input: CreateShipmentInput, options?: CourierCallOptions): Promise<CreateShipmentResult> {
|
|
const data = await this.client.request<XiaofeixiaCreateOrderData>(
|
|
XIAOFEIXIA_CMD.CREATE_ORDER,
|
|
{
|
|
customerId: input.customerId,
|
|
outNumber: input.outNumber,
|
|
fromAddress: input.from.address,
|
|
fromAddressDetail: input.from.addressDetail,
|
|
fromCoordinate: this.formatCoordinate(input.from.coordinate),
|
|
fromMobile: input.from.mobile,
|
|
fromName: input.from.name,
|
|
toAddress: input.to.address,
|
|
toAddressDetail: input.to.addressDetail,
|
|
toCoordinate: this.formatCoordinate(input.to.coordinate),
|
|
toMobile: input.to.mobile,
|
|
toName: input.to.name,
|
|
goodsName: input.goodsName,
|
|
goodsNum: input.goodsNum,
|
|
weight: input.weight,
|
|
insuredSumPrice: input.insuredSumPrice,
|
|
collectionPrice: input.collectionPrice,
|
|
payMode: input.payMode,
|
|
remark: input.remark,
|
|
},
|
|
options?.xiaofeixia,
|
|
);
|
|
|
|
return {
|
|
providerShipmentId: data.id,
|
|
trackingNumber: data.number,
|
|
};
|
|
}
|
|
|
|
async cancelShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<void> {
|
|
this.assertShipmentQuery(query);
|
|
await this.client.request(
|
|
XIAOFEIXIA_CMD.CANCEL_ORDER,
|
|
{
|
|
number: query.trackingNumber,
|
|
outNumber: query.outNumber,
|
|
},
|
|
options?.xiaofeixia,
|
|
);
|
|
}
|
|
|
|
async getShipment(query: ShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail> {
|
|
this.assertShipmentQuery(query);
|
|
const data = await this.client.request<XiaofeixiaOrderDetail>(
|
|
XIAOFEIXIA_CMD.GET_ORDER,
|
|
{
|
|
number: query.trackingNumber,
|
|
outNumber: query.outNumber,
|
|
},
|
|
options?.xiaofeixia,
|
|
);
|
|
return this.mapOrderDetail(data);
|
|
}
|
|
|
|
async batchGetShipments(query: BatchShipmentQuery, options?: CourierCallOptions): Promise<ShipmentDetail[]> {
|
|
const number = query.trackingNumbers?.join(',');
|
|
const outNumber = query.outNumbers?.join(',');
|
|
|
|
if (!number && !outNumber) {
|
|
throw new CourierApiError('运单号与商家单号至少传一个', '300000', this.code);
|
|
}
|
|
|
|
const data = await this.client.request<XiaofeixiaOrderDetail[]>(
|
|
XIAOFEIXIA_CMD.BATCH_GET_ORDER,
|
|
{
|
|
number,
|
|
outNumber,
|
|
},
|
|
options?.xiaofeixia,
|
|
);
|
|
|
|
return (data ?? []).map((item) => this.mapOrderDetail(item));
|
|
}
|
|
|
|
async getTrack(query: ShipmentQuery, options?: CourierCallOptions): Promise<TrackNode[]> {
|
|
this.assertShipmentQuery(query);
|
|
const data = await this.client.request<XiaofeixiaTrackNode[]>(
|
|
XIAOFEIXIA_CMD.TRACK_ROUTE,
|
|
{
|
|
number: query.trackingNumber,
|
|
outNumber: query.outNumber,
|
|
},
|
|
options?.xiaofeixia,
|
|
);
|
|
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 root = body as Record<string, unknown>;
|
|
// 兼容顶层字段或 data/payload 包裹;data 若为 JSON 字符串也解开
|
|
const nestedObj = this.unwrapNestedObject(root.data) ?? this.unwrapNestedObject(root.payload);
|
|
const raw = nestedObj ? { ...nestedObj, ...root } : root;
|
|
const outNumber = this.firstString(raw, ['outNumber', 'out_number', 'outNo']);
|
|
const trackingNumber = this.firstString(raw, ['number', 'trackingNumber', 'trackingNo']);
|
|
const status = this.firstString(raw, ['status']);
|
|
const statusName = this.firstString(raw, ['statusName', 'status_name']);
|
|
const trackInfo = this.firstString(raw, ['trackInfo', 'track_info']);
|
|
const createTime = this.firstString(raw, ['createTime', 'create_time']);
|
|
if (!outNumber && !trackingNumber) return null;
|
|
if (!status && !trackInfo) return null;
|
|
return {
|
|
outNumber,
|
|
trackingNumber,
|
|
status,
|
|
statusName,
|
|
trackInfo,
|
|
createTime,
|
|
};
|
|
}
|
|
|
|
private unwrapNestedObject(value: unknown): Record<string, unknown> | null {
|
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
return value as Record<string, unknown>;
|
|
}
|
|
if (typeof value === 'string' && value.trim().startsWith('{')) {
|
|
try {
|
|
const parsed = JSON.parse(value) as unknown;
|
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
return parsed as Record<string, unknown>;
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private firstString(raw: Record<string, unknown>, keys: string[]): string {
|
|
for (const key of keys) {
|
|
const value = raw[key];
|
|
if (value == null) continue;
|
|
if (Array.isArray(value)) {
|
|
const last = value[value.length - 1];
|
|
if (last != null && String(last).trim()) return String(last).trim();
|
|
continue;
|
|
}
|
|
const text = String(value).trim();
|
|
if (text) return text;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
mapTrackStatus(status: string, statusName?: string): CourierMappedOrderStatus | null {
|
|
const name = (statusName || '').trim();
|
|
if (status === '5' || /签收/.test(name)) {
|
|
return 'COMPLETED';
|
|
}
|
|
switch (String(status).trim()) {
|
|
case '1':
|
|
return 'OUT_WAREHOUSE';
|
|
case '2':
|
|
case '3':
|
|
case '4':
|
|
return 'SHIPPING';
|
|
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,
|
|
{ toAddress },
|
|
options?.xiaofeixia,
|
|
);
|
|
|
|
return {
|
|
arriveTime: data.arriveTime,
|
|
siteName: data.name,
|
|
siteId: data.id,
|
|
};
|
|
}
|
|
|
|
async estimateFreight(weight: number, options?: CourierCallOptions): Promise<FreightEstimateResult> {
|
|
const data = await this.client.request<XiaofeixiaFreightEstimateData>(
|
|
XIAOFEIXIA_CMD.ESTIMATE_FREIGHT,
|
|
{ weight },
|
|
options?.xiaofeixia,
|
|
);
|
|
|
|
return { freightPrice: data.freightPrice };
|
|
}
|
|
|
|
buildTrackCallbackResponse(success = true): TrackCallbackResponse {
|
|
return {
|
|
code: success ? '100000' : '300000',
|
|
message: success ? 'success' : 'fail',
|
|
};
|
|
}
|
|
|
|
private assertShipmentQuery(query: ShipmentQuery): void {
|
|
if (!query.trackingNumber && !query.outNumber) {
|
|
throw new CourierApiError('运单号与商家单号至少传一个', '300000', this.code);
|
|
}
|
|
}
|
|
|
|
private formatCoordinate(coordinate?: { lng: number; lat: number }): string | undefined {
|
|
if (!coordinate) return undefined;
|
|
return JSON.stringify({ lng: coordinate.lng, lat: coordinate.lat });
|
|
}
|
|
|
|
private mapOrderDetail(data: XiaofeixiaOrderDetail): ShipmentDetail {
|
|
return {
|
|
trackingNumber: data.number,
|
|
toCarrierName: data.toCarrierName,
|
|
toSiteName: data.toSiteName,
|
|
toName: data.toName,
|
|
toMobile: data.toMobile,
|
|
toAddress: data.toAddress,
|
|
toAddressDetail: data.toAddressDetail,
|
|
fromCarrierName: data.fromCarrierName,
|
|
fromSiteName: data.fromSiteName,
|
|
fromName: data.fromName,
|
|
fromMobile: data.fromMobile,
|
|
fromAddress: data.fromAddress,
|
|
fromAddressDetail: data.fromAddressDetail,
|
|
weight: data.weight,
|
|
payModeName: data.payModeName,
|
|
freightPrice: data.freightPrice,
|
|
insuredPrice: data.insuredPrice,
|
|
collectionPrice: data.collectionPrice,
|
|
sumPrice: data.sumPrice,
|
|
goodsName: data.goodsName,
|
|
remark: data.remark,
|
|
};
|
|
}
|
|
}
|