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:
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user