import { BadRequestException, Injectable, Logger, NotFoundException, Inject, forwardRef } from '@nestjs/common'; import type { CityWarehouse, FulfillmentProvider, Order } from '@prisma/client'; import { isXfxProviderCode } from '@dukang/shared-types'; import { BOTTLES_PER_BOX, XFX_AUTO_DISPATCH_MAX_BOXES, calcOrderBoxCount, shouldHoldAutoCourierDispatch, } from '@dukang/domain'; import { PrismaService } from '../../common/prisma/prisma.module'; import { CourierService } from '../../integrations/courier/courier.service'; 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'; export type ManualShipInput = { logisticsCompany: string; trackingNo: string; manualQueryUrl?: string; }; export type HqLogisticsShipInput = ManualShipInput; export const FULFILLMENT_HOLD_LARGE_ORDER = 'LARGE_ORDER_GE_10_BOXES'; @Injectable() export class FulfillmentService { private readonly logger = new Logger(FulfillmentService.name); constructor( 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, ) {} 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; } // 大单拦截:≥10 箱不自动推小飞侠,待总部确认后推单或自配送 if (shouldHoldAutoCourierDispatch(order.quantity)) { const boxes = calcOrderBoxCount(order.quantity); this.logger.warn( `大单拦截自动推单:${order.orderNo} quantity=${order.quantity} bottles≈${boxes}箱(阈值 ${XFX_AUTO_DISPATCH_MAX_BOXES}箱/${BOTTLES_PER_BOX}瓶)`, ); await this.prisma.order.update({ where: { id: orderId }, data: { fulfillmentHold: true, fulfillmentHoldReason: FULFILLMENT_HOLD_LARGE_ORDER, }, }); await this.ensureDeliveryRecord(orderId, 'MANUAL', provider.id); await this.prisma.logThirdParty.create({ data: { provider: 'XFX', scene: 'ORDER_DISPATCH_HOLD', refType: 'ORDER', refId: order.id, status: 'PENDING', errorMessage: `大单拦截:${order.quantity}瓶(约${boxes}箱),需总部确认后推小飞侠或自配送`.slice( 0, 512, ), }, }); return; } await this.dispatchApiAuto(order, warehouse, provider); } } async clearFulfillmentHold(orderId: bigint) { await this.prisma.order.update({ where: { id: orderId }, data: { fulfillmentHold: false, fulfillmentHoldReason: null }, }); } async dispatchApiAuto(order: Order, warehouse: CityWarehouse, provider: FulfillmentProvider) { if (!isXfxProviderCode(provider.code)) { this.logger.warn(`承运商 ${provider.code} 自动推单尚未实现,订单 ${order.orderNo} 保持待发货`); await this.ensureDeliveryRecord(order.id, 'MANUAL', provider.id); return; } let xfxConfig: XiaofeixiaConfig; try { xfxConfig = await this.fulfillmentProviderService.resolveXiaofeixiaConfig(provider.id); } 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); return; } const fromLng = warehouse.lng != null ? Number(warehouse.lng) : 113.665; const fromLat = warehouse.lat != null ? Number(warehouse.lat) : 34.757; 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: 2, payMode: CourierPayMode.SENDER, remark: `仓配自动发货 ${order.orderNo}`, }, { xiaofeixia: xfxConfig }, ); 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.order.update({ where: { id: order.id }, data: { fulfillmentHold: false, fulfillmentHoldReason: null }, }); 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('订单不存在'); // HQ 可对任意待发货单填快递单号(含仓配单手动填单) 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: { 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 base; } 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 ? { xiaofeixia: await this.fulfillmentProviderService.resolveXiaofeixiaConfig( order.delivery.fulfillmentProviderId, ), } : undefined; 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 { ...base, manualQueryUrl: order.delivery.manualQueryUrl, provider: order.delivery.provider, trackingNo: order.delivery.trackingNo, logisticsCompany: order.delivery.logisticsCompany, }; } 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 { 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' }, ) { 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 tx.order.update({ where: { id: order.id }, data: { fulfillmentHold: false, fulfillmentHoldReason: null }, }); }); 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)); } }