Files
dukang/server/dukang-api/src/modules/fulfillment/fulfillment.service.ts
T
jacy f6d97b4ee8
CI / verify (pull_request) Has been cancelled
feat(trade): 仓配可调配履约与三分支推单
同城有仓按仓库绑定承运商自动推单或自管填单,无仓/跨城走总部快递;新增仓配注册表、FulfillmentService 及三端运单追踪。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 17:51:20 +08:00

307 lines
10 KiB
TypeScript

import { BadRequestException, Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { CityWarehouse, FulfillmentProvider, Order } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { CourierService } from '../../integrations/courier/courier.service';
import { CourierPayMode } from '../../integrations/courier/courier.types';
import { TradeService } from '../trade/trade.service';
export type ManualShipInput = {
logisticsCompany: string;
trackingNo: string;
manualQueryUrl?: string;
};
export type HqLogisticsShipInput = ManualShipInput;
const XFX_CODES = new Set(['XFX', 'XIAOFEIXIA']);
@Injectable()
export class FulfillmentService {
private readonly logger = new Logger(FulfillmentService.name);
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
private readonly courier: CourierService,
@Inject(forwardRef(() => TradeService))
private readonly tradeService: TradeService,
) {}
async dispatchAfterPay(orderId: bigint) {
const order = await this.prisma.order.findUnique({
where: { id: orderId },
include: { delivery: true },
});
if (!order || order.payStatus !== 'PAID') return;
if (order.deliveryType === 'CROSS_CITY') {
await this.ensureDeliveryRecord(orderId, 'MANUAL');
return;
}
const warehouse = await this.resolveWarehouseForLocalOrder(order.cityId);
if (!warehouse) {
await this.ensureDeliveryRecord(orderId, 'MANUAL');
return;
}
await this.prisma.order.update({
where: { id: orderId },
data: { fulfillmentWarehouseId: warehouse.id },
});
if (warehouse.fulfillmentMode === 'MANUAL') {
await this.ensureDeliveryRecord(orderId, 'MANUAL');
return;
}
if (warehouse.fulfillmentMode === 'API_AUTO' && warehouse.fulfillmentProviderId) {
const provider = await this.prisma.fulfillmentProvider.findUnique({
where: { id: warehouse.fulfillmentProviderId },
});
if (!provider || provider.status !== 'ACTIVE' || provider.type !== 'API') {
await this.ensureDeliveryRecord(orderId, 'MANUAL');
return;
}
await this.dispatchApiAuto(order, warehouse, provider);
}
}
async dispatchApiAuto(order: Order, warehouse: CityWarehouse, provider: FulfillmentProvider) {
if (!XFX_CODES.has(provider.code)) {
this.logger.warn(`承运商 ${provider.code} 自动推单尚未实现,订单 ${order.orderNo} 保持待发货`);
await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id);
return;
}
const defaults = this.getShipDefaults();
const fromLng = warehouse.lng != null ? Number(warehouse.lng) : defaults.fromLng;
const fromLat = warehouse.lat != null ? Number(warehouse.lat) : defaults.fromLat;
try {
const result = await this.courier.createShipment({
outNumber: order.orderNo,
from: {
name: warehouse.contactName,
mobile: warehouse.contactPhone,
address: warehouse.address,
addressDetail: warehouse.name,
coordinate: { lng: fromLng, lat: fromLat },
},
to: {
name: order.receiverName,
mobile: order.receiverPhone,
address: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`,
addressDetail: order.receiverAddress,
},
goodsName: order.productName,
goodsNum: order.quantity,
weight: defaults.weight,
payMode: defaults.payMode,
remark: `仓配自动发货 ${order.orderNo}`,
});
const now = new Date();
await this.prisma.$transaction(async (tx) => {
const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } });
const data = {
provider: 'XFX' as const,
fulfillmentProviderId: provider.id,
trackingNo: result.trackingNumber,
providerOrderNo: String(result.providerShipmentId),
shippingAt: now,
};
if (delivery) {
await tx.orderDelivery.update({ where: { orderId: order.id }, data });
} else {
await tx.orderDelivery.create({ data: { orderId: order.id, ...data } });
}
await tx.logThirdParty.create({
data: {
provider: 'XFX',
scene: 'ORDER_DISPATCH',
refType: 'ORDER',
refId: order.id,
externalNo: result.trackingNumber,
status: 'SUCCESS',
},
});
});
await this.tradeService.applyStatusTransition(order.id, order.status, 'SHIPPING', 'WAREHOUSE_AUTO');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
await this.logDispatchFailure(order, provider, message);
await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id);
}
}
async shipManualByWarehouse(orderId: bigint, warehouseIds: bigint[], input: ManualShipInput) {
const order = await this.prisma.order.findFirst({
where: { id: orderId, fulfillmentWarehouseId: { in: warehouseIds } },
include: { delivery: true },
});
if (!order) throw new NotFoundException('订单不存在或无权操作');
if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) {
throw new BadRequestException('当前订单状态不可发货');
}
const queryUrl =
input.manualQueryUrl?.trim() ||
(await this.buildQueryUrlFromTemplate(order.fulfillmentWarehouseId, input.trackingNo));
return this.applyManualShip(order, {
logisticsCompany: input.logisticsCompany.trim(),
trackingNo: input.trackingNo.trim(),
manualQueryUrl: queryUrl,
operator: 'WAREHOUSE_MANUAL',
});
}
async shipHqLogistics(orderId: bigint, input: HqLogisticsShipInput) {
const order = await this.prisma.order.findUnique({
where: { id: orderId },
include: { delivery: true },
});
if (!order) throw new NotFoundException('订单不存在');
const isHqQueue =
order.deliveryType === 'CROSS_CITY' ||
(order.deliveryType === 'LOCAL' && !order.fulfillmentWarehouseId);
if (!isHqQueue) throw new BadRequestException('该订单由仓配履约,请使用仓配发货');
if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) {
throw new BadRequestException('当前订单状态不可发货');
}
if (order.delivery?.trackingNo) throw new BadRequestException('该订单已有运单号');
return this.applyManualShip(order, {
logisticsCompany: input.logisticsCompany.trim(),
trackingNo: input.trackingNo.trim(),
manualQueryUrl: input.manualQueryUrl?.trim(),
operator: 'HQ_LOGISTICS',
provider: 'LOGISTICS',
});
}
async getOrderTrack(orderId: bigint) {
const order = await this.prisma.order.findUnique({
where: { id: orderId },
include: { delivery: true },
});
if (!order?.delivery) {
return { nodes: [], manualQueryUrl: null };
}
if (order.delivery.provider === 'XFX' && (order.delivery.trackingNo || order.orderNo)) {
try {
const nodes = await this.courier.getTrack({
trackingNumber: order.delivery.trackingNo ?? undefined,
outNumber: order.orderNo,
});
return {
nodes,
manualQueryUrl: order.delivery.manualQueryUrl,
provider: order.delivery.provider,
trackingNo: order.delivery.trackingNo,
logisticsCompany: order.delivery.logisticsCompany,
};
} catch {
// fall through to manual fields
}
}
return {
nodes: [],
manualQueryUrl: order.delivery.manualQueryUrl,
provider: order.delivery.provider,
trackingNo: order.delivery.trackingNo,
logisticsCompany: order.delivery.logisticsCompany,
};
}
private async applyManualShip(
order: Order & { delivery: { trackingNo: string | null } | null },
input: ManualShipInput & { operator: string; provider?: 'MANUAL' | 'LOGISTICS' },
) {
const now = new Date();
const provider = input.provider ?? 'MANUAL';
await this.prisma.$transaction(async (tx) => {
const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } });
const data = {
provider,
logisticsCompany: input.logisticsCompany,
trackingNo: input.trackingNo,
manualQueryUrl: input.manualQueryUrl || null,
shippingAt: now,
};
if (delivery) {
await tx.orderDelivery.update({ where: { orderId: order.id }, data });
} else {
await tx.orderDelivery.create({ data: { orderId: order.id, ...data } });
}
});
await this.tradeService.applyStatusTransition(order.id, order.status, 'SHIPPING', input.operator);
return this.prisma.order.findUnique({
where: { id: order.id },
include: { delivery: true, fulfillmentWarehouse: true },
});
}
private async resolveWarehouseForLocalOrder(cityId: bigint) {
return this.prisma.cityWarehouse.findFirst({
where: { cityId, status: 'ACTIVE' },
orderBy: { createdAt: 'asc' },
});
}
private async ensureDeliveryRecord(
orderId: bigint,
provider: 'MANUAL' | 'LOGISTICS' | 'XFX',
fulfillmentProviderId?: bigint,
) {
const existing = await this.prisma.orderDelivery.findUnique({ where: { orderId } });
if (existing) return;
await this.prisma.orderDelivery.create({
data: {
orderId,
provider,
...(fulfillmentProviderId ? { fulfillmentProviderId } : {}),
},
});
}
private async logDispatchFailure(order: Order, provider: FulfillmentProvider, error: string) {
await this.prisma.logThirdParty.create({
data: {
provider: 'XFX',
scene: 'ORDER_DISPATCH',
refType: 'ORDER',
refId: order.id,
status: 'FAILED',
errorMessage: `[${provider.code}] ${error}`.slice(0, 512),
},
});
}
private async buildQueryUrlFromTemplate(warehouseId: bigint | null, trackingNo: string) {
if (!warehouseId) return undefined;
const wh = await this.prisma.cityWarehouse.findUnique({ where: { id: warehouseId } });
const tpl = wh?.manualQueryUrlTemplate;
if (!tpl) return undefined;
return tpl.replace(/\{trackingNo\}/g, encodeURIComponent(trackingNo));
}
private getShipDefaults() {
return {
fromLng: Number(this.config.get<string>('SHIP_FROM_LNG') || 113.665),
fromLat: Number(this.config.get<string>('SHIP_FROM_LAT') || 34.757),
weight: 2,
payMode: CourierPayMode.SENDER,
};
}
}