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,8 +9,10 @@ import {
} from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { CourierService } from '../../integrations/courier/courier.service';
import { CourierPayMode } from '../../integrations/courier/courier.types';
import { CourierPayMode, type TrackNode } from '../../integrations/courier/courier.types';
import type { XiaofeixiaConfig } from '../../integrations/courier/courier.config';
import { OSS_PROVIDER } from '../../integrations/integrations.constants';
import type { IOssProvider } from '../../integrations/oss/oss.interface';
import { TradeService } from '../trade/trade.service';
import { FulfillmentProviderService } from './fulfillment-provider.service';
@@ -32,6 +34,7 @@ export class FulfillmentService {
private readonly prisma: PrismaService,
private readonly courier: CourierService,
private readonly fulfillmentProviderService: FulfillmentProviderService,
@Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
@Inject(forwardRef(() => TradeService))
private readonly tradeService: TradeService,
) {}
@@ -246,13 +249,30 @@ export class FulfillmentService {
async getOrderTrack(orderId: bigint) {
const order = await this.prisma.order.findUnique({
where: { id: orderId },
include: { delivery: true },
include: {
delivery: {
include: { signPhotoResource: true },
},
},
});
const base = {
nodes: [] as TrackNode[],
signPhotoUrls: [] as string[],
estimatedArrival: null as { arriveTime: string; siteName?: string } | null,
manualQueryUrl: order?.delivery?.manualQueryUrl ?? null,
provider: order?.delivery?.provider,
trackingNo: order?.delivery?.trackingNo ?? null,
logisticsCompany: order?.delivery?.logisticsCompany ?? null,
};
if (!order?.delivery) {
return { nodes: [], manualQueryUrl: null };
return base;
}
if (order.delivery.provider === 'XFX' && (order.delivery.trackingNo || order.orderNo)) {
const isXfx =
order.delivery.provider === 'XFX' || isXfxProviderCode(String(order.delivery.provider || ''));
const canQueryCourier = isXfx && (order.delivery.trackingNo || order.orderNo);
if (canQueryCourier) {
try {
const options = order.delivery.fulfillmentProviderId
? {
@@ -261,27 +281,44 @@ export class FulfillmentService {
),
}
: undefined;
const nodes = await this.courier.getTrack(
{
trackingNumber: order.delivery.trackingNo ?? undefined,
outNumber: order.orderNo,
},
options,
);
return {
nodes,
manualQueryUrl: order.delivery.manualQueryUrl,
provider: order.delivery.provider,
trackingNo: order.delivery.trackingNo,
logisticsCompany: order.delivery.logisticsCompany,
const shipmentQuery = {
trackingNumber: order.delivery.trackingNo ?? undefined,
outNumber: order.orderNo,
};
const [nodes, signPhotoDataUris] = await Promise.all([
this.courier.getTrack(shipmentQuery, options).catch(() => [] as TrackNode[]),
this.courier.getSignPhotos(shipmentQuery, options).catch(() => [] as string[]),
]);
base.nodes = this.sortTrackNodesOldestFirst(nodes);
base.signPhotoUrls = await this.resolveSignPhotoUrls(order, signPhotoDataUris);
if (this.shouldFetchEstimatedArrival(order.status, base.nodes)) {
const toAddress = this.buildReceiverAddress(order);
if (toAddress) {
try {
const coverage = await this.courier.checkDeliveryCoverage(toAddress, options);
base.estimatedArrival = {
arriveTime: coverage.arriveTime,
siteName: coverage.siteName,
};
} catch {
// 预估送达失败不阻断
}
}
}
} catch {
// fall through
}
}
if (base.signPhotoUrls.length === 0 && order.delivery.signPhotoResource?.url) {
base.signPhotoUrls = [order.delivery.signPhotoResource.url];
}
return {
nodes: [],
...base,
manualQueryUrl: order.delivery.manualQueryUrl,
provider: order.delivery.provider,
trackingNo: order.delivery.trackingNo,
@@ -289,6 +326,119 @@ export class FulfillmentService {
};
}
private sortTrackNodesOldestFirst(nodes: TrackNode[]): TrackNode[] {
return [...nodes].sort((a, b) => {
const ta = new Date(a.createTime).getTime();
const tb = new Date(b.createTime).getTime();
if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
if (Number.isNaN(ta)) return 1;
if (Number.isNaN(tb)) return -1;
return ta - tb;
});
}
private buildReceiverAddress(order: {
receiverProvince?: string | null;
receiverCity?: string | null;
receiverDistrict?: string | null;
receiverAddress?: string | null;
}) {
const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict]
.filter(Boolean)
.join('');
const detail = (order.receiverAddress || '').trim();
if (!region && !detail) return '';
if (region && detail.startsWith(region)) return detail;
return `${region}${detail}`;
}
private shouldFetchEstimatedArrival(status: string, nodes: TrackNode[]) {
if (!['OUT_WAREHOUSE', 'SHIPPING', 'SHIPPED'].includes(status)) return false;
if (nodes.length === 0) return true;
return !nodes.some(
(node) =>
node.statusName?.includes('签收') ||
node.trackInfo?.includes('签收') ||
node.statusName?.includes('派件'),
);
}
private async resolveSignPhotoUrls(
order: {
id: bigint;
delivery: {
id: bigint;
signPhotoResourceId: bigint | null;
signPhotoResource: { url: string } | null;
} | null;
},
dataUris: string[],
): Promise<string[]> {
if (!order.delivery || dataUris.length === 0) {
return order.delivery?.signPhotoResource?.url ? [order.delivery.signPhotoResource.url] : [];
}
const urls: string[] = [];
let firstResourceId: bigint | null = order.delivery.signPhotoResourceId;
for (let i = 0; i < dataUris.length; i += 1) {
const parsed = this.parseDataUri(dataUris[i]);
if (!parsed) continue;
const result = await this.oss.putObject({
bizType: 'SIGN_PHOTO',
mediaType: 'IMAGE',
fileName: `sign-${order.id}-${i + 1}.${parsed.ext}`,
buffer: parsed.buffer,
mimeType: parsed.mimeType,
});
urls.push(result.url);
if (!firstResourceId) {
const resource = await this.prisma.commonResource.create({
data: {
ownerType: 'ORDER',
ownerId: order.id,
bizType: 'SIGN_PHOTO',
mediaType: 'IMAGE',
ossBucket: result.bucket,
ossKey: result.ossKey,
url: result.url,
fileName: `sign-${order.id}-${i + 1}.${parsed.ext}`,
fileSize: BigInt(parsed.buffer.length),
mimeType: parsed.mimeType,
status: 'ACTIVE',
},
});
firstResourceId = resource.id;
}
}
if (firstResourceId && firstResourceId !== order.delivery.signPhotoResourceId) {
await this.prisma.orderDelivery.update({
where: { id: order.delivery.id },
data: { signPhotoResourceId: firstResourceId },
});
}
return urls;
}
private parseDataUri(dataUri: string): { buffer: Buffer; mimeType: string; ext: string } | null {
const trimmed = dataUri.trim();
const match = /^data:([^;]+);base64,(.+)$/i.exec(trimmed);
const base64 = match ? match[2] : trimmed;
const mimeType = match?.[1] || 'image/png';
try {
const buffer = Buffer.from(base64, 'base64');
if (!buffer.length) return null;
const ext = mimeType.includes('jpeg') || mimeType.includes('jpg') ? 'jpg' : 'png';
return { buffer, mimeType, ext };
} catch {
return null;
}
}
private async applyManualShip(
order: Order & { delivery: { trackingNo: string | null } | null },
input: ManualShipInput & { operator: string; provider?: 'MANUAL' | 'LOGISTICS' },