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('SHIP_FROM_LNG') || 113.665), fromLat: Number(this.config.get('SHIP_FROM_LAT') || 34.757), weight: 2, payMode: CourierPayMode.SENDER, }; } }