import { BadRequestException, Inject, Injectable, Logger, NotFoundException, forwardRef, } from '@nestjs/common'; import type { FreightPayType } from '@prisma/client'; import { calcBenefitAmount, generateOrderNo, orderTabToStatuses, toMinSaleQuantity, validateMinPurchase, } from '@dukang/domain'; import { loadAppConfig, ClientApp, WECHAT_AUTH_REQUIRED } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { AnalyticsService } from '../analytics/analytics.service'; import { CatalogService } from '../catalog/catalog.service'; import { BenefitService } from '../benefit/benefit.service'; import { PartnerCityService } from '../city-scope/partner-city.service'; import { AuthService } from '../iam/auth.service'; import { PromoCodeService } from '../promo/promo-code.service'; import { TicketService } from '../common/ticket.service'; import { PAY_PROVIDER, DELIVERY_PROVIDER } from '../../integrations/integrations.constants'; import { IPayProvider, type PayOrderResult } from '../../integrations/pay/pay.interface'; import { IDeliveryProvider } from '../../integrations/delivery/delivery.interface'; import { IpGeoService } from '../../common/geo/ip-geo.service'; import { buildOrderClientLocationSnapshot } from '../../common/geo/client-location.util'; import { extractClientIp } from '../../common/geo/client-ip.util'; import { buildOrderStatusEvent, orderStatusLogWhere } from '../../common/event/event.helpers'; import { mapOrderCompat, mapStatusLogCompat, mapUserOrderWithInvoice } from '../../common/compat/v31-compat'; import { FulfillmentService } from '../fulfillment/fulfillment.service'; import { WechatOrderShippingService } from '../../integrations/wechat/wechat-order-shipping.service'; import { AlertService } from '../../common/alert/alert.service'; import { PayRedeemAnomalyService } from '../../common/alert/pay-redeem-anomaly.service'; import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service'; import type { Request } from 'express'; import type { ProductSaleUnit } from '@prisma/client'; @Injectable() export class TradeService { constructor( private readonly prisma: PrismaService, private readonly catalogService: CatalogService, private readonly benefitService: BenefitService, private readonly ticketService: TicketService, private readonly ipGeoService: IpGeoService, @Inject(PAY_PROVIDER) private readonly payProvider: IPayProvider, @Inject(DELIVERY_PROVIDER) private readonly deliveryProvider: IDeliveryProvider, private readonly analyticsService: AnalyticsService, private readonly partnerCityService: PartnerCityService, private readonly authService: AuthService, private readonly promoCodeService: PromoCodeService, @Inject(forwardRef(() => FulfillmentService)) private readonly fulfillmentService: FulfillmentService, private readonly wechatOrderShipping: WechatOrderShippingService, private readonly payRedeemAnomaly: PayRedeemAnomalyService, private readonly alert: AlertService, private readonly wecomPush: WecomMessagePushService, ) {} private readonly logger = new Logger(TradeService.name); private overlayProductWithSale( productDto: Record, sale: { skuId: bigint | null; skuCode: string; specText: string; price: unknown; benefitAmount: unknown; allowOnSitePickup: boolean; allowOnlinePurchase: boolean; allowCrossCityDelivery: boolean; saleUnit: ProductSaleUnit; bottlesPerUnit: number; }, ): Record { return { ...productDto, skuCode: sale.skuCode, spec: sale.specText || productDto.spec, price: Number(sale.price), benefitAmount: Number(sale.benefitAmount ?? sale.price), benefitDisplay: Number(sale.benefitAmount ?? sale.price), allowOnSitePickup: sale.allowOnSitePickup, allowOnlinePurchase: sale.allowOnlinePurchase, allowCrossCityDelivery: sale.allowCrossCityDelivery, saleUnit: sale.saleUnit, bottlesPerUnit: sale.bottlesPerUnit, ...(sale.skuId ? { selectedSkuId: sale.skuId.toString() } : {}), }; } async preview( userId: bigint, body: { productId: string; quantity: number; addressId?: string; onSitePickup?: boolean; skuId?: string; }, ) { const viewerPhone = await this.catalogService.resolveUserPhone(userId); const { product: spu, sale } = await this.catalogService.assertPurchasable( BigInt(body.productId), viewerPhone, body.skuId, ); const productDto = await this.catalogService.getProduct(spu.id, { phone: viewerPhone }); if (!productDto || productDto.status !== 'ON_SALE') { throw new BadRequestException('商品不可购买'); } const product = this.overlayProductWithSale(productDto as Record, sale); const city = await this.prisma.commonCity.findFirst({ where: { status: 'ACTIVE' } }); if (!city) throw new BadRequestException('暂无开城城市'); const onSitePickup = !!body.onSitePickup; if (onSitePickup && !sale.allowOnSitePickup) { throw new BadRequestException('该规格不支持现场取货'); } let deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP' = onSitePickup ? 'ON_SITE_PICKUP' : 'LOCAL'; if (!onSitePickup && body.addressId) { const address = await this.prisma.userAddress.findFirst({ where: { id: BigInt(body.addressId), userId }, }); if (address && address.city !== city.name && address.city !== '郑州市') { deliveryType = 'CROSS_CITY'; } } let addressOk = true; let addressMessage: string | null = null; if (!onSitePickup) { const allowOnline = sale.allowOnlinePurchase !== false; const allowCross = sale.allowCrossCityDelivery !== false; if (deliveryType === 'LOCAL' && !allowOnline) { addressOk = false; addressMessage = '该规格不支持线上购买'; } else if (deliveryType === 'CROSS_CITY') { if (!allowOnline) { addressOk = false; addressMessage = '该规格不支持线上购买'; } else if (!allowCross) { addressOk = false; addressMessage = '该规格不支持跨城配送,请更换为开城城市内的收货地址'; } } } const bottlesPerUnit = sale.bottlesPerUnit > 0 ? sale.bottlesPerUnit : 1; const check = validateMinPurchase( deliveryType, body.quantity, city.localMinQty, city.crossMinQty, { bottlesPerUnit, saleUnit: sale.saleUnit }, ); const unitPrice = Number(sale.price); const productAmount = unitPrice * body.quantity; const benefitPerUnit = calcBenefitAmount({ price: unitPrice, benefitAmount: sale.benefitAmount != null ? Number(sale.benefitAmount) : null, }); const freightPayType: FreightPayType | null = deliveryType === 'CROSS_CITY' ? 'COD' : null; const minBottleQty = deliveryType === 'ON_SITE_PICKUP' ? city.localMinQty : deliveryType === 'LOCAL' ? city.localMinQty : city.crossMinQty; const minQty = toMinSaleQuantity(minBottleQty, bottlesPerUnit); return { product, quantity: body.quantity, deliveryType, productAmount, freightAmount: 0, freightPayType, payAmount: productAmount, benefitAmount: benefitPerUnit * body.quantity, city: serializeBigInt(city), quantityOk: check.ok, quantityMessage: check.ok ? null : (check.message ?? null), addressOk, addressMessage, minQty, onSitePickup, allowCrossCityDelivery: sale.allowCrossCityDelivery !== false, allowOnlinePurchase: sale.allowOnlinePurchase !== false, skuId: sale.skuId?.toString(), barcode69: sale.barcode69, productSpec: sale.specText, unitPrice, saleUnit: sale.saleUnit, bottlesPerUnit, bottleQuantity: body.quantity * bottlesPerUnit, }; } async createOrder( userId: bigint, body: { productId: string; quantity: number; addressId?: string; onSitePickup?: boolean; clientLocation?: unknown; skuId?: string; }, req: Request, ) { const preview = await this.preview(userId, body); if (preview.quantityOk === false) { throw new BadRequestException(preview.quantityMessage || '购买数量不满足起购要求'); } if (preview.addressOk === false) { throw new BadRequestException(preview.addressMessage || '收货地址不可用'); } const onSitePickup = !!body.onSitePickup || preview.deliveryType === 'ON_SITE_PICKUP'; let receiverName = '现场取货'; let receiverPhone = '00000000000'; let receiverAddress = '现场取货'; let receiverProvince = ''; let receiverCity = ''; let receiverDistrict = ''; if (onSitePickup) { const user = await this.prisma.user.findUnique({ where: { id: userId } }); receiverPhone = user?.phone || '00000000000'; receiverName = (user?.nickname?.trim() || '现场取货').slice(0, 32); receiverProvince = '现场'; receiverCity = '现场'; receiverDistrict = '取货'; } else { if (!body.addressId) throw new BadRequestException('请选择收货地址'); const address = await this.prisma.userAddress.findFirst({ where: { id: BigInt(body.addressId), userId }, }); if (!address) throw new BadRequestException('请选择收货地址'); receiverName = address.receiverName; receiverPhone = address.phone; receiverAddress = `${address.province}${address.city}${address.district}${address.detail}`; receiverProvince = address.province; receiverCity = address.city; receiverDistrict = address.district; } const product = await this.prisma.commonProductItem.findUniqueOrThrow({ where: { id: BigInt(body.productId) }, }); const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } }); const orderNo = generateOrderNo(); const payExpireAt = new Date(Date.now() + 30 * 60 * 1000); const location = buildOrderClientLocationSnapshot( req, this.ipGeoService.resolve(extractClientIp(req)), body.clientLocation, ); const attribution = await this.prisma.userPromoAttribution.findUnique({ where: { userId }, include: { promoCode: true }, }); const promoCodeId = attribution?.promoCode?.status === 'ACTIVE' ? attribution.promoCodeId : undefined; const buyer = await this.prisma.user.findUnique({ where: { id: userId }, select: { isTest: true }, }); const order = await this.prisma.$transaction(async (tx) => { const created = await tx.order.create({ data: { orderNo, userId, cityId: city.id, status: 'PENDING_PAY', payStatus: 'UNPAID', deliveryType: preview.deliveryType as 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP', productId: product.id, skuId: preview.skuId ? BigInt(preview.skuId) : null, barcode69: preview.barcode69 || product.barcode69, productName: product.name, productSpec: preview.productSpec || product.spec, imageResourceId: product.coverResourceId, quantity: body.quantity, saleUnit: preview.saleUnit, bottlesPerUnit: preview.bottlesPerUnit, listUnitPrice: preview.unitPrice, listAmount: preview.productAmount, productAmount: preview.productAmount, receiverName, receiverPhone, receiverAddress, receiverProvince, receiverCity, receiverDistrict, clientIp: location.clientIp, ipProvince: location.ipProvince, ipCity: location.ipCity, ipDistrict: location.ipDistrict, gpsProvince: location.gpsProvince, gpsCity: location.gpsCity, gpsDistrict: location.gpsDistrict, gpsLatitude: location.gpsLatitude, gpsLongitude: location.gpsLongitude, gpsAddress: location.gpsAddress, freightAmount: preview.freightAmount, freightPayType: preview.freightPayType, payAmount: preview.payAmount, benefitAmount: preview.benefitAmount, payExpireAt, promoCodeId, isTest: !!buyer?.isTest, }, include: { product: true, imageResource: true }, }); if (promoCodeId) { await tx.commonPromoCode.update({ where: { id: promoCodeId }, data: { orderCount: { increment: 1 } }, }); } return created; }); if (promoCodeId) { this.promoCodeService.logPromoOrderEvent( promoCodeId, order.id, userId, location.clientIp ?? undefined, ); } this.analyticsService.trackOneSafe(userId, 'USER_H5', { eventName: 'order_submit', refType: 'ORDER', refId: order.id, extraJson: { orderId: order.id.toString(), productId: body.productId, skuId: preview.skuId, quantity: body.quantity, onSitePickup, }, }); return serializeBigInt(mapOrderCompat(order)); } async payOrder(userId: bigint, orderId: bigint, clientApp?: ClientApp) { const order = await this.prisma.order.findFirst({ where: { id: orderId, userId }, }); if (!order) throw new NotFoundException('订单不存在'); if (order.status !== 'PENDING_PAY') { throw new BadRequestException('订单状态不可支付'); } const payAmountYuan = Number(order.payAmount); this.payRedeemAnomaly.onPayAttempt(payAmountYuan, { orderNo: order.orderNo, userId, }); const user = await this.prisma.user.findUnique({ where: { id: userId } }); const openId = user?.wxOpenId ?? undefined; const appConfig = loadAppConfig(); if (!appConfig.mockPay && !openId) { this.payRedeemAnomaly.onPayFail(WECHAT_AUTH_REQUIRED, { orderNo: order.orderNo, userId, }); this.analyticsService.trackOneSafe(userId, clientApp ?? 'USER_H5', { eventName: 'pay_fail', refType: 'ORDER', refId: orderId, extraJson: { orderId: orderId.toString(), failReason: WECHAT_AUTH_REQUIRED, stage: 'auth' }, }); throw new BadRequestException(WECHAT_AUTH_REQUIRED); } const payPlatform = clientApp === ClientApp.USER_MINI ? 'mini' : 'h5'; let payResult; try { payResult = await this.payProvider.payOrder(orderId, openId, payPlatform); } catch (e) { const failReason = e instanceof Error ? e.message : '拉起支付失败'; this.payRedeemAnomaly.onPayFail(failReason, { orderNo: order.orderNo, userId, }); this.analyticsService.trackOneSafe(userId, clientApp ?? 'USER_H5', { eventName: 'pay_fail', refType: 'ORDER', refId: orderId, extraJson: { orderId: orderId.toString(), failReason, stage: 'prepay' }, }); throw e; } if (payResult.mode === 'jsapi') { return { mode: 'jsapi' as const, orderId: order.id.toString(), prepay: payResult.prepay, }; } const { externalNo } = payResult; const now = new Date(); const paySnapshot = await this.partnerCityService.resolveForOrder( order.cityId, order.receiverDistrict, ); // 现场提货:支付即完成(PRD SC-02 / REQ-U-008) const toStatus = order.deliveryType === 'ON_SITE_PICKUP' ? 'COMPLETED' : 'PENDING_SHIP'; await this.prisma.$transaction(async (tx) => { await tx.order.update({ where: { id: order.id }, data: { status: toStatus, payStatus: 'PAID', paidAt: now, payExternalNo: externalNo, partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? null, orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null, }, }); await tx.logThirdParty.create({ data: { provider: 'WECHAT_PAY', scene: 'ORDER_PAY', refType: 'ORDER', refId: order.id, externalNo, amount: order.payAmount, status: 'SUCCESS', }, }); await tx.commonEvent.create({ data: buildOrderStatusEvent({ orderId: order.id, fromStatus: 'PENDING_PAY', toStatus, operator: 'MOCK_PAY', }), }); await tx.orderDelivery.create({ data: { orderId: order.id, provider: 'MANUAL' }, }); }); await this.afterOrderPaid(order.id); this.payRedeemAnomaly.onPaySuccess(payAmountYuan, { orderNo: order.orderNo, userId, }); this.analyticsService.trackOneSafe(userId, 'USER_H5', { eventName: 'pay_success', refType: 'ORDER', refId: order.id, extraJson: { orderId: order.id.toString(), mode: 'mock' }, }); return this.getOrder(userId, orderId); } private async afterOrderPaid(orderId: bigint) { await this.benefitService.grantOnOrderPaid(orderId); const order = await this.prisma.order.findUnique({ where: { id: orderId }, include: { city: { select: { name: true } }, user: { select: { phone: true } }, }, }); if (!order) return; if (!order.isTest) { const skuSummary = `${order.productName}×${order.quantity}`; const phone = order.user?.phone || ''; const phoneMasked = phone.length >= 7 ? `${phone.slice(0, 3)}****${phone.slice(-4)}` : phone || '—'; void this.wecomPush.dispatchEvent( 'order.paid', { orderNo: order.orderNo, payAmount: Number(order.payAmount).toFixed(2), cityName: order.city?.name || '—', skuSummary, phoneMasked, }, { handlePath: `/orders?orderNo=${encodeURIComponent(order.orderNo)}` }, ); } const delivery = await this.prisma.orderDelivery.findUnique({ where: { orderId } }); if (!delivery) { await this.prisma.orderDelivery.create({ data: { orderId, provider: 'MANUAL' }, }); } if (order.deliveryType === 'ON_SITE_PICKUP') { // 现场取货:支付后即向微信录入「用户自提」发货信息 this.wechatOrderShipping.uploadForOrderSafe(orderId); return; } await this.fulfillmentService.dispatchAfterPay(orderId); const refreshed = await this.prisma.order.findUnique({ where: { id: orderId } }); if (refreshed?.status === 'PENDING_SHIP') { await this.deliveryProvider.scheduleAutoAdvance(orderId); } } /** 微信支付回调:幂等更新订单为已支付并发券 */ async handlePaySuccess(params: { orderNo: string; transactionId: string; amountFen: number; }) { const order = await this.prisma.order.findUnique({ where: { orderNo: params.orderNo } }); if (!order) { throw new NotFoundException('订单不存在'); } if (order.payStatus === 'PAID') { return { orderId: order.id.toString(), alreadyPaid: true }; } const payAmountYuan = Number(order.payAmount); const expectedFen = Math.round(payAmountYuan * 100); if (params.amountFen > 0 && params.amountFen !== expectedFen) { const reason = `支付金额与订单不符 expected=${expectedFen} got=${params.amountFen}`; this.payRedeemAnomaly.onPayFail(reason, { orderNo: order.orderNo, userId: order.userId }); this.alert.notify({ level: 'P0', category: 'pay', title: '支付金额不一致', detail: `订单 ${order.orderNo}\n期望 ${expectedFen} 分,回调 ${params.amountFen} 分`, dedupeKey: `pay_amount_mismatch|${order.orderNo}`, }); throw new BadRequestException('支付金额与订单不符'); } const existingLog = await this.prisma.logThirdParty.findFirst({ where: { provider: 'WECHAT_PAY', externalNo: params.transactionId, status: 'SUCCESS', }, }); if (existingLog) { return { orderId: order.id.toString(), alreadyPaid: true }; } const now = new Date(); const paySnapshot = await this.partnerCityService.resolveForOrder( order.cityId, order.receiverDistrict, ); // 现场提货:支付即完成(PRD SC-02 / REQ-U-008) const toStatus = order.deliveryType === 'ON_SITE_PICKUP' ? 'COMPLETED' : 'PENDING_SHIP'; await this.prisma.$transaction(async (tx) => { const current = await tx.order.findUnique({ where: { id: order.id } }); if (!current || current.payStatus === 'PAID') return; await tx.order.update({ where: { id: order.id }, data: { status: toStatus, payStatus: 'PAID', paidAt: now, payExternalNo: params.transactionId, partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? null, orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null, }, }); await tx.logThirdParty.create({ data: { provider: 'WECHAT_PAY', scene: 'ORDER_PAY', refType: 'ORDER', refId: order.id, externalNo: params.transactionId, amount: order.payAmount, status: 'SUCCESS', }, }); await tx.commonEvent.create({ data: buildOrderStatusEvent({ orderId: order.id, fromStatus: 'PENDING_PAY', toStatus, operator: 'WECHAT_PAY', }), }); const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } }); if (!delivery) { await tx.orderDelivery.create({ data: { orderId: order.id, provider: 'MANUAL' }, }); } }); const refreshed = await this.prisma.order.findUnique({ where: { id: order.id } }); if (refreshed?.payStatus === 'PAID') { await this.afterOrderPaid(order.id); this.payRedeemAnomaly.onPaySuccess(payAmountYuan, { orderNo: order.orderNo, userId: order.userId, }); this.analyticsService.trackOneSafe(order.userId, 'USER_H5', { eventName: 'pay_success', refType: 'ORDER', refId: order.id, extraJson: { orderId: order.id.toString(), mode: 'wechat_callback' }, }); } return { orderId: order.id.toString(), alreadyPaid: false }; } /** 总部审批退款 / 协同取回后退款:发起微信原路退(Mock 同步完成) */ async initiateRefund( orderId: bigint, ticketId: bigint, remark: string, actorType = 'HQ', ) { const order = await this.prisma.order.findUnique({ where: { id: orderId } }); if (!order) throw new NotFoundException('订单不存在'); if (order.payStatus === 'REFUNDED' || order.status === 'REFUNDED') { return { orderId: order.id.toString(), alreadyRefunded: true }; } if (order.payStatus === 'REFUNDING' || order.status === 'REFUNDING') { throw new BadRequestException('订单已在退款处理中'); } if (order.payStatus !== 'PAID') { throw new BadRequestException('订单未支付,无法退款'); } const outRefundNo = `RF-${order.orderNo}-${ticketId}`; const pendingLog = await this.prisma.logThirdParty.findFirst({ where: { provider: 'WECHAT_REFUND', externalNo: outRefundNo, status: { in: ['PENDING', 'SUCCESS'] }, }, }); if (pendingLog?.status === 'SUCCESS') { return { orderId: order.id.toString(), alreadyRefunded: true }; } const fromStatus = order.status; await this.prisma.order.update({ where: { id: orderId }, data: { status: 'REFUNDING', payStatus: 'REFUNDING' }, }); await this.prisma.commonEvent.create({ data: buildOrderStatusEvent({ orderId, fromStatus, toStatus: 'REFUNDING', operator: actorType, remark, }), }); let refundResult; try { refundResult = await this.payProvider.refundOrder(orderId, outRefundNo, remark); } catch (err) { await this.prisma.order.update({ where: { id: orderId }, data: { status: fromStatus, payStatus: 'PAID' }, }); await this.prisma.commonEvent.create({ data: buildOrderStatusEvent({ orderId, fromStatus: 'REFUNDING', toStatus: fromStatus, operator: actorType, remark: `退款发起失败,已恢复:${err instanceof Error ? err.message : String(err)}`.slice( 0, 500, ), }), }); this.alert.notify({ level: 'P0', category: 'pay', title: '微信退款发起失败', detail: `订单 ${order.orderNo}\n${err instanceof Error ? err.message : String(err)}`, dedupeKey: `refund_init_fail|${order.orderNo}`, dedupeTtlSec: 120, }); throw err; } if (refundResult.mode === 'mock' || refundResult.status === 'SUCCESS') { await this.handleRefundSuccess({ outRefundNo, refundId: refundResult.mode === 'wechat' ? refundResult.refundId : `MOCK-REF-${outRefundNo}`, amountFen: Math.round(Number(order.payAmount) * 100), remark, actorType, }); return { orderId: order.id.toString(), alreadyRefunded: false, completed: true }; } await this.prisma.logThirdParty.create({ data: { provider: 'WECHAT_REFUND', scene: 'ORDER_REFUND', refType: 'ORDER', refId: orderId, externalNo: refundResult.refundId ?? outRefundNo, status: 'PENDING', amount: order.payAmount, }, }); return { orderId: order.id.toString(), alreadyRefunded: false, completed: false, outRefundNo }; } /** 微信退款回调 / Mock 同步:幂等更新订单为已退款并作废权益 */ async handleRefundSuccess(params: { outRefundNo: string; refundId: string; amountFen: number; remark?: string; actorType?: string; }) { const existingLog = await this.prisma.logThirdParty.findFirst({ where: { provider: 'WECHAT_REFUND', externalNo: params.refundId, status: 'SUCCESS', }, }); if (existingLog) { return { orderId: existingLog.refId?.toString() ?? '', alreadyRefunded: true, }; } const match = params.outRefundNo.match(/^RF-(.+)-(\d+)$/); if (!match) { throw new BadRequestException('退款单号格式无效'); } const [, orderNo] = match; const order = await this.prisma.order.findUnique({ where: { orderNo } }); if (!order) throw new NotFoundException('订单不存在'); if (order.payStatus === 'REFUNDED') { return { orderId: order.id.toString(), alreadyRefunded: true }; } const expectedFen = Math.round(Number(order.payAmount) * 100); if (params.amountFen > 0 && params.amountFen !== expectedFen) { this.alert.notify({ level: 'P0', category: 'pay', title: '退款金额不一致', detail: `订单 ${order.orderNo}\n期望 ${expectedFen} 分,回调 ${params.amountFen} 分`, dedupeKey: `refund_amount_mismatch|${order.orderNo}`, }); throw new BadRequestException('退款金额与订单不符'); } const fromStatus = order.status; const operator = params.actorType ?? 'WECHAT_REFUND'; await this.prisma.$transaction(async (tx) => { const current = await tx.order.findUnique({ where: { id: order.id } }); if (!current || current.payStatus === 'REFUNDED') return; await tx.order.update({ where: { id: order.id }, data: { status: 'REFUNDED', payStatus: 'REFUNDED' }, }); await tx.logThirdParty.create({ data: { provider: 'WECHAT_REFUND', scene: 'ORDER_REFUND', refType: 'ORDER', refId: order.id, externalNo: params.refundId, status: 'SUCCESS', amount: order.payAmount, }, }); await tx.commonEvent.create({ data: buildOrderStatusEvent({ orderId: order.id, fromStatus, toStatus: 'REFUNDED', operator, remark: params.remark ?? '退款成功', }), }); }); await this.benefitService.voidCouponsOnRefund(order.id); return { orderId: order.id.toString(), alreadyRefunded: false }; } async listOrders(userId: bigint, tab = 'all', page = 1, pageSize = 20) { const statuses = orderTabToStatuses(tab); const where = { userId, ...(statuses ? { status: { in: statuses as never[] } } : {}), }; const [list, total] = await Promise.all([ this.prisma.order.findMany({ where, include: { benefitCoupon: true, imageResource: true, invoices: { select: { status: true }, orderBy: { createdAt: 'desc' }, take: 1 }, }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }), this.prisma.order.count({ where }), ]); return { list: serializeBigInt(list.map(mapUserOrderWithInvoice)), total, page, pageSize, }; } async getOrder(userId: bigint, orderId: bigint) { const order = await this.prisma.order.findFirst({ where: { id: orderId, userId }, include: { delivery: true, benefitCoupon: true, imageResource: true, product: true, fulfillmentWarehouse: { select: { id: true, name: true } }, invoices: { select: { status: true }, orderBy: { createdAt: 'desc' }, take: 1 }, }, }); if (!order) throw new NotFoundException('订单不存在'); const statusLogs = await this.prisma.commonEvent.findMany({ where: orderStatusLogWhere(orderId), orderBy: { createdAt: 'desc' }, }); const mapped = mapUserOrderWithInvoice({ ...order, statusLogs: mapStatusLogCompat(statusLogs), }); return serializeBigInt({ ...mapped, wechatConfirm: this.wechatOrderShipping.buildConfirmPayload(order), }); } async getOrderTrack(userId: bigint, orderId: bigint) { const order = await this.prisma.order.findFirst({ where: { id: orderId, userId }, select: { id: true }, }); if (!order) throw new NotFoundException('订单不存在'); return this.fulfillmentService.getOrderTrack(orderId); } async updateAddress(userId: bigint, orderId: bigint, body: Record) { const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } }); if (!order) throw new NotFoundException('订单不存在'); if (!['PENDING_PAY', 'PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) { throw new BadRequestException('当前状态不可修改地址'); } const updated = await this.prisma.order.update({ where: { id: orderId }, data: { receiverName: String(body.receiverName ?? order.receiverName), receiverPhone: String(body.receiverPhone ?? order.receiverPhone), receiverProvince: String(body.receiverProvince ?? order.receiverProvince), receiverCity: String(body.receiverCity ?? order.receiverCity), receiverDistrict: String(body.receiverDistrict ?? order.receiverDistrict), receiverAddress: String(body.receiverAddress ?? order.receiverAddress), }, }); await this.prisma.commonEvent.create({ data: buildOrderStatusEvent({ orderId, fromStatus: order.status, toStatus: order.status, operator: 'USER', remark: '修改收货地址', }), }); return serializeBigInt(updated); } async confirmReceive( userId: bigint, orderId: bigint, opts?: { onSitePickup?: boolean; source?: 'USER' | 'WECHAT_COMPONENT' }, ) { const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } }); if (!order) throw new NotFoundException('订单不存在'); if (!['PENDING_RECEIVE', 'DELIVERED'].includes(order.status)) { throw new BadRequestException('当前状态不可确认收货'); } const viaWechat = opts?.source === 'WECHAT_COMPONENT'; if (viaWechat) { await this.wechatOrderShipping.assertWechatUserConfirmed(orderId); } const operator = viaWechat ? 'USER_WECHAT_CONFIRM' : order.deliveryType === 'ON_SITE_PICKUP' || opts?.onSitePickup ? 'USER_ON_SITE' : 'USER'; const remark = viaWechat ? '用户经微信确认收货组件确认' : order.deliveryType === 'ON_SITE_PICKUP' || opts?.onSitePickup ? '用户现场取货确认收货' : undefined; await this.applyStatusTransition(order.id, order.status, 'COMPLETED', operator, remark); return this.getOrder(userId, orderId); } async createRefundRequest(userId: bigint, orderId: bigint, remark?: string) { return this.createAfterSaleTicket(userId, orderId, { ticketType: 'REFUND', remark: remark ?? '用户申请退款', }); } async createAfterSaleTicket( userId: bigint, orderId: bigint, body: { ticketType: string; remark?: string; evidenceUrls?: string[] }, ) { const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } }); if (!order) throw new NotFoundException('订单不存在'); if (order.status === 'PENDING_PAY' || order.status === 'CANCELLED') { throw new BadRequestException('当前订单不可申请售后'); } if (['REFUNDING', 'REFUNDED'].includes(order.status)) { throw new BadRequestException('订单已在退款流程中'); } const pending = await this.prisma.commonTicket.findFirst({ where: { ticketType: body.ticketType as never, refType: 'ORDER', refId: orderId, status: { in: ['PENDING', 'OPEN'] }, }, }); if (pending) throw new BadRequestException('该类型售后工单已在处理中'); const evidenceUrls = (body.evidenceUrls ?? []).filter((u) => typeof u === 'string' && u.trim()); return this.ticketService.create({ ticketType: body.ticketType, refType: 'ORDER', refId: orderId.toString(), remark: body.remark ?? '', extraJson: evidenceUrls.length ? { evidenceUrls } : undefined, }); } async listAfterSaleTickets(userId: bigint, page = 1, pageSize = 20) { const orders = await this.prisma.order.findMany({ where: { userId }, select: { id: true, orderNo: true }, }); const orderIds = orders.map((o) => o.id); const orderNoMap = new Map(orders.map((o) => [o.id.toString(), o.orderNo])); if (!orderIds.length) { return serializeBigInt({ items: [], total: 0, page, pageSize }); } const where = { refType: 'ORDER', refId: { in: orderIds }, ticketType: { in: ['REFUND', 'RESHIPMENT', 'DAMAGE_RETURN', 'RETURN_REFUND'] as never[] }, }; const [items, total] = await Promise.all([ this.prisma.commonTicket.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }), this.prisma.commonTicket.count({ where }), ]); return serializeBigInt({ items: items.map((t) => ({ ...t, orderNo: orderNoMap.get(t.refId.toString()) ?? null, })), total, page, pageSize, }); } async getAfterSaleTicket(userId: bigint, ticketId: bigint) { const ticket = await this.prisma.commonTicket.findUnique({ where: { id: ticketId } }); if (!ticket || ticket.refType !== 'ORDER') throw new NotFoundException('工单不存在'); const order = await this.prisma.order.findFirst({ where: { id: ticket.refId, userId }, select: { id: true, orderNo: true, status: true }, }); if (!order) throw new NotFoundException('工单不存在'); return serializeBigInt({ ...ticket, orderNo: order.orderNo, orderStatus: order.status }); } async createPackageDispute( userId: bigint, body: { storeId: string; remark?: string; redeemRecordId?: string }, ) { const storeId = BigInt(body.storeId); const store = await this.prisma.store.findFirst({ where: { id: storeId, status: 'OPEN' }, }); if (!store) throw new NotFoundException('门店不存在'); const pending = await this.prisma.commonTicket.findFirst({ where: { ticketType: 'PACKAGE_DISPUTE', refType: 'STORE', refId: storeId, status: { in: ['PENDING', 'OPEN'] }, }, }); if (pending) throw new BadRequestException('该门店套餐异议已在处理中'); const extraJson: Record = { userId: userId.toString() }; if (body.redeemRecordId) { extraJson.redeemRecordId = body.redeemRecordId; } return this.ticketService.create({ ticketType: 'PACKAGE_DISPUTE', refType: 'STORE', refId: storeId.toString(), remark: body.remark ?? '用户对门店套餐有异议', extraJson, }); } private generateInvoiceNo() { return `INV${Date.now()}${Math.floor(Math.random() * 900 + 100)}`; } async createInvoice( userId: bigint, orderId: bigint, body: { titleId?: string; titleType?: string; invoiceKind?: string; invoiceCategory?: string; titleName?: string; taxNo?: string | null; addressPhone?: string | null; bankAccount?: string | null; email?: string; phone?: string; remark?: string; }, opts?: { allowSpecialKind?: boolean }, ) { const order = await this.prisma.order.findFirst({ where: { id: orderId, userId } }); if (!order) throw new NotFoundException('订单不存在'); if (order.status !== 'COMPLETED') { throw new BadRequestException('仅已完成订单可申请发票'); } const existing = await this.prisma.userInvoice.findFirst({ where: { orderId, status: { in: ['PENDING', 'ISSUED'] } }, }); if (existing) throw new BadRequestException('该订单已有进行中或已开具的发票申请'); let resolved = { titleType: body.titleType || '', titleName: body.titleName || '', taxNo: body.taxNo ?? null, addressPhone: body.addressPhone ?? null, bankAccount: body.bankAccount ?? null, email: body.email || '', phone: body.phone || '', }; if (body.titleId) { const title = await this.prisma.userInvoiceTitle.findFirst({ where: { id: BigInt(body.titleId), userId }, }); if (!title) throw new BadRequestException('发票抬头不存在'); resolved = { titleType: title.titleType, titleName: title.titleName, taxNo: title.taxNo, addressPhone: title.addressPhone, bankAccount: title.bankAccount, email: title.email || body.email || '', phone: title.phone || body.phone || '', }; } if (!resolved.titleName.trim()) { throw new BadRequestException('请填写抬头名称'); } if (!resolved.email.trim()) { throw new BadRequestException('请填写接收邮箱'); } if (!resolved.phone.trim()) { throw new BadRequestException('请填写联系电话'); } if (resolved.titleType === 'ENTERPRISE' && !resolved.taxNo?.trim()) { throw new BadRequestException('企业抬头须填写税号'); } const invoiceKind = opts?.allowSpecialKind && body.invoiceKind === 'SPECIAL' ? 'SPECIAL' : 'NORMAL'; if (invoiceKind === 'SPECIAL') { if (resolved.titleType !== 'ENTERPRISE') { throw new BadRequestException('专用发票仅支持企业抬头'); } if (!resolved.taxNo?.trim() || !resolved.addressPhone?.trim() || !resolved.bankAccount?.trim()) { throw new BadRequestException('专用发票须填写税号、地址电话与开户行账号'); } } const invoiceCategory = body.invoiceCategory === 'CATERING' ? 'CATERING' : 'LIQUOR'; const invoice = await this.prisma.userInvoice.create({ data: { invoiceNo: this.generateInvoiceNo(), orderId, userId, titleType: resolved.titleType as never, invoiceKind: invoiceKind as never, invoiceCategory: invoiceCategory as never, titleName: resolved.titleName.trim(), taxNo: resolved.taxNo?.trim() || null, addressPhone: resolved.addressPhone?.trim() || null, bankAccount: resolved.bankAccount?.trim() || null, email: resolved.email.trim(), phone: resolved.phone.trim(), remark: body.remark?.trim() || null, } as never, }); if (!order.isTest) { const phone = resolved.phone.trim(); const phoneMasked = phone.length >= 7 ? `${phone.slice(0, 3)}****${phone.slice(-4)}` : phone || '—'; void this.wecomPush.dispatchEvent( 'invoice.pending', { invoiceNo: invoice.invoiceNo, orderNo: order.orderNo, payAmount: Number(order.payAmount).toFixed(2), titleName: invoice.titleName, phoneMasked, }, { handlePath: `/invoices?status=PENDING&invoiceNo=${encodeURIComponent(invoice.invoiceNo)}`, }, ); } return serializeBigInt({ ...invoice, orderNo: order.orderNo }); } /** v3.5.1 #3:查询某订单是否已申请发票 */ async getOrderInvoiceStatus(userId: bigint, orderId: bigint) { const inv = await this.prisma.userInvoice.findFirst({ where: { orderId, userId }, select: { id: true, status: true }, }); return { exists: !!inv, status: inv?.status ?? null }; } async listInvoices(userId: bigint, page = 1, pageSize = 20) { const where = { userId }; const [items, total] = await Promise.all([ this.prisma.userInvoice.findMany({ where, include: { order: { select: { orderNo: true } } }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }), this.prisma.userInvoice.count({ where }), ]); return serializeBigInt({ items: items.map((inv) => ({ ...inv, orderNo: inv.order.orderNo, order: undefined, })), total, page, pageSize, }); } async getInvoice(userId: bigint, invoiceId: bigint) { const invoice = await this.prisma.userInvoice.findFirst({ where: { id: invoiceId, userId }, include: { order: { select: { orderNo: true, payAmount: true } } }, }); if (!invoice) throw new NotFoundException('发票不存在'); return serializeBigInt({ ...invoice, orderNo: invoice.order.orderNo, payAmount: invoice.order.payAmount, order: undefined, }); } /** 工作日差(粗略:排除周六日) */ private businessDaysSince(from: Date, to = new Date()): number { let days = 0; const cur = new Date(from); cur.setHours(0, 0, 0, 0); const end = new Date(to); end.setHours(0, 0, 0, 0); while (cur < end) { cur.setDate(cur.getDate() + 1); const w = cur.getDay(); if (w !== 0 && w !== 6) days += 1; } return days; } async adminCreateInvoice( body: { orderNo: string; titleId?: string; titleType?: string; invoiceKind?: string; invoiceCategory?: string; titleName?: string; taxNo?: string; addressPhone?: string; bankAccount?: string; email?: string; phone?: string; remark?: string; }, ) { const orderNo = body.orderNo?.trim(); if (!orderNo) throw new BadRequestException('请填写订单号'); const order = await this.prisma.order.findFirst({ where: { orderNo } }); if (!order) throw new NotFoundException('订单不存在'); return this.createInvoice(order.userId, order.id, body, { allowSpecialKind: true }); } async adminListInvoices(query: { status?: string; invoiceNo?: string; page?: number; pageSize?: number; }) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where: { status?: never; invoiceNo?: { contains: string } } = {}; if (query.status) where.status = query.status as never; if (query.invoiceNo?.trim()) where.invoiceNo = { contains: query.invoiceNo.trim() }; const [items, total] = await Promise.all([ this.prisma.userInvoice.findMany({ where, include: { order: { select: { orderNo: true, payAmount: true } }, user: { select: { phone: true } } }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }), this.prisma.userInvoice.count({ where }), ]); return serializeBigInt({ items: items.map((inv) => ({ ...inv, orderNo: inv.order.orderNo, payAmount: inv.order.payAmount, userPhone: inv.user.phone, overdue: inv.status === 'PENDING' && this.businessDaysSince(inv.createdAt) > 2, order: undefined, user: undefined, })), total, page, pageSize, }); } async adminGetInvoice(id: bigint) { const invoice = await this.prisma.userInvoice.findUnique({ where: { id }, include: { order: { select: { orderNo: true, payAmount: true, productName: true } }, user: { select: { phone: true, nickname: true } } }, }); if (!invoice) throw new NotFoundException('发票不存在'); return serializeBigInt({ ...invoice, orderNo: invoice.order.orderNo, payAmount: invoice.order.payAmount, productName: invoice.order.productName, userPhone: invoice.user.phone, overdue: invoice.status === 'PENDING' && this.businessDaysSince(invoice.createdAt) > 2, }); } async adminIssueInvoice( id: bigint, operatorId: bigint, body: { fileUrl: string; resourceId?: string; remark?: string }, ) { const invoice = await this.prisma.userInvoice.findUnique({ where: { id } }); if (!invoice) throw new NotFoundException('发票不存在'); if (invoice.status !== 'PENDING') throw new BadRequestException('当前状态不可开票'); const updated = await this.prisma.userInvoice.update({ where: { id }, data: { status: 'ISSUED', fileUrl: body.fileUrl, resourceId: body.resourceId ? BigInt(body.resourceId) : null, issuedAt: new Date(), operatorId, remark: body.remark ?? invoice.remark, }, }); return serializeBigInt(updated); } async adminRejectInvoice(id: bigint, operatorId: bigint, remark?: string) { const invoice = await this.prisma.userInvoice.findUnique({ where: { id } }); if (!invoice) throw new NotFoundException('发票不存在'); if (invoice.status !== 'PENDING') throw new BadRequestException('当前状态不可驳回'); const updated = await this.prisma.userInvoice.update({ where: { id }, data: { status: 'REJECTED', operatorId, remark: remark ?? '驳回', issuedAt: new Date(), }, }); return serializeBigInt(updated); } async listPartnerReshipments(partnerAccountId: bigint) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); const orderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id); const orders = await this.prisma.order.findMany({ where: orderWhere, select: { id: true }, }); const tickets = await this.prisma.commonTicket.findMany({ where: { ticketType: 'RESHIPMENT', refType: 'ORDER', refId: { in: orders.map((o) => o.id) }, }, orderBy: { createdAt: 'desc' }, }); return serializeBigInt(tickets); } async listPartnerOrders(partnerAccountId: bigint, page = 1, pageSize = 20) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); const hasWarehouseAccess = await this.partnerCityService.hasManagedWarehouse(primary.id); const where = await this.partnerCityService.buildPartnerOrderWhere(primary.id); const [list, total] = await Promise.all([ this.prisma.order.findMany({ where, include: { delivery: true, imageResource: true, user: { select: { phone: true, nickname: true } }, fulfillmentWarehouse: { select: { id: true, name: true } } }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }), this.prisma.order.count({ where }), ]); return { list: serializeBigInt(list.map(mapOrderCompat)), total, page, pageSize, hasWarehouseAccess, message: hasWarehouseAccess ? undefined : '未配置仓库管理权限,购酒订单由总部履约', }; } async getPartnerOrder(partnerAccountId: bigint, orderId: bigint) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); const partnerOrderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id); const order = await this.prisma.order.findFirst({ where: { id: orderId, ...partnerOrderWhere }, include: { delivery: true, user: true, imageResource: true, fulfillmentWarehouse: { select: { id: true, name: true } } }, }); if (!order) throw new NotFoundException('订单不存在'); const statusLogs = await this.prisma.commonEvent.findMany({ where: orderStatusLogWhere(orderId), orderBy: { createdAt: 'desc' }, }); return serializeBigInt(mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) })); } async partnerManualShip( partnerAccountId: bigint, orderId: bigint, input: { logisticsCompany: string; trackingNo: string; manualQueryUrl?: string }, ) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); const warehouseIds = await this.partnerCityService.resolveManagedWarehouseIds(primary.id); if (!warehouseIds.length) throw new BadRequestException('当前账号未绑定仓库'); await this.fulfillmentService.shipManualByWarehouse(orderId, warehouseIds, input); this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', { partnerAccountId: primary.id, eventName: 'partner_order_ship', refType: 'ORDER', refId: orderId, extraJson: { mode: 'manual' }, }); return this.getPartnerOrder(partnerAccountId, orderId); } async getPartnerOrderTrack(partnerAccountId: bigint, orderId: bigint) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); const partnerOrderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id); const order = await this.prisma.order.findFirst({ where: { id: orderId, ...partnerOrderWhere }, select: { id: true }, }); if (!order) throw new NotFoundException('订单不存在'); return this.fulfillmentService.getOrderTrack(orderId); } async advanceDelivery(partnerAccountId: bigint, orderId: bigint, targetStatus: string) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); const partnerOrderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id); const order = await this.prisma.order.findFirst({ where: { id: orderId, ...partnerOrderWhere, }, include: { delivery: true }, }); if (!order) throw new NotFoundException('订单不存在'); await this.applyStatusTransition(order.id, order.status, targetStatus); if (targetStatus === 'SHIPPING') { this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', { partnerAccountId: primary.id, eventName: 'partner_order_ship', refType: 'ORDER', refId: orderId, extraJson: { fromStatus: order.status }, }); } this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', { partnerAccountId: primary.id, eventName: 'partner_delivery_advance', refType: 'ORDER', refId: orderId, extraJson: { fromStatus: order.status, targetStatus, }, }); return this.getPartnerOrder(partnerAccountId, orderId); } async applyStatusTransition( orderId: bigint, fromStatus: string, targetStatus: string, operator = 'MOCK', remark?: string, ) { const order = await this.prisma.order.findUnique({ where: { id: orderId } }); if (!order) return; const currentStatus = fromStatus || order.status; const now = new Date(); const data: Record = { status: targetStatus }; const deliveryData: Record = {}; if (targetStatus === 'OUT_WAREHOUSE') deliveryData.outWarehouseAt = now; if (targetStatus === 'SHIPPING') { deliveryData.shippingAt = now; data.shippedAt = now; } if (targetStatus === 'COMPLETED') { deliveryData.deliveredAt = now; data.completedAt = now; } await this.prisma.$transaction(async (tx) => { await tx.order.update({ where: { id: orderId }, data: data as never }); if (Object.keys(deliveryData).length) { await tx.orderDelivery.updateMany({ where: { orderId }, data: deliveryData as never }); } await tx.commonEvent.create({ data: buildOrderStatusEvent({ orderId, fromStatus: currentStatus, toStatus: targetStatus, operator, remark, }), }); }); // 发货信息管理:进入 SHIPPING 时向微信录入(解冻结算前置) if (targetStatus === 'SHIPPING' && currentStatus !== 'SHIPPING') { this.wechatOrderShipping.uploadForOrderSafe(orderId); } } async getPartnerProxyOrderOptions(partnerAccountId: bigint) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); const [products, promoCodes, stores] = await Promise.all([ // 遵守商品白名单:未开放的测试酒不对合伙人可见 this.catalogService.listProducts(undefined, undefined, { phone: primary.phone }), this.promoCodeService.listActiveOptions(), this.prisma.store.findMany({ where: { partnerAccountId: primary.id }, orderBy: { createdAt: 'desc' }, select: { id: true, name: true, address: true, phone: true, province: true, cityName: true, district: true, status: true, }, }), ]); const details = await Promise.all( products.map((p) => this.catalogService.getProduct(BigInt(String(p.id)), { phone: primary.phone }), ), ); return serializeBigInt({ products: details.filter(Boolean).map((p) => ({ id: p!.id, name: p!.name, spec: p!.spec, price: Number(p!.price), benefitAmount: p!.benefitAmount != null ? Number(p!.benefitAmount) : null, coverUrl: (p as { mainImageUrl?: string | null } | null)?.mainImageUrl ?? null, allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean } | null)?.allowOnSitePickup, allowOnlinePurchase: (p as { allowOnlinePurchase?: boolean } | null)?.allowOnlinePurchase !== false, allowCrossCityDelivery: (p as { allowCrossCityDelivery?: boolean } | null)?.allowCrossCityDelivery !== false, specEnabled: !!(p as { specEnabled?: boolean } | null)?.specEnabled, saleUnit: (p as { saleUnit?: string } | null)?.saleUnit, skus: (p as { skus?: unknown[] } | null)?.skus ?? [], defaultSkuId: (p as { defaultSkuId?: string } | null)?.defaultSkuId, specAttrs: (p as { specAttrs?: unknown[] } | null)?.specAttrs ?? [], })), promoCodes, stores: stores.map((s) => ({ id: s.id, name: s.name, address: s.address, phone: s.phone, province: s.province, cityName: s.cityName, district: s.district, })), }); } async previewPartnerProxyOrder( body: { productId: string; quantity: number; deliveryMode?: 'ADDRESS' | 'ON_SITE_PICKUP'; storeId?: string; receiverCity?: string; receiverDistrict?: string; skuId?: string; }, viewer?: { phone?: string | null; bypassWhitelist?: boolean }, ) { const { product: spu, sale } = await this.catalogService.assertPurchasable( BigInt(body.productId), viewer?.phone, body.skuId, { bypassWhitelist: !!viewer?.bypassWhitelist }, ); if (spu.status !== 'ON_SALE') { throw new BadRequestException('商品不可购买'); } // 非 bypass 时再走可见性(与 getProduct 一致) if (!viewer?.bypassWhitelist) { const dto = await this.catalogService.getProduct(spu.id, viewer ?? {}); if (!dto) throw new BadRequestException('商品不可购买'); } const city = await this.prisma.commonCity.findFirst({ where: { status: 'ACTIVE' } }); if (!city) throw new BadRequestException('暂无开城城市'); const deliveryMode = body.deliveryMode ?? 'ADDRESS'; let deliveryType: 'LOCAL' | 'CROSS_CITY' | 'ON_SITE_PICKUP' = 'LOCAL'; if (deliveryMode === 'ON_SITE_PICKUP') { if (!sale.allowOnSitePickup) { throw new BadRequestException('该规格不支持现场提货'); } deliveryType = 'ON_SITE_PICKUP'; } else { const receiverCity = body.receiverCity?.trim(); if (receiverCity && receiverCity !== city.name && receiverCity !== '郑州市') { deliveryType = 'CROSS_CITY'; } const allowOnline = sale.allowOnlinePurchase !== false; const allowCross = sale.allowCrossCityDelivery !== false; if (deliveryType === 'LOCAL' && !allowOnline) { throw new BadRequestException('该规格不支持线上购买'); } if (deliveryType === 'CROSS_CITY') { if (!allowOnline) { throw new BadRequestException('该规格不支持线上购买'); } if (!allowCross) { throw new BadRequestException('该规格不支持跨城配送'); } } } const bottlesPerUnit = sale.bottlesPerUnit > 0 ? sale.bottlesPerUnit : 1; const check = validateMinPurchase( deliveryType === 'CROSS_CITY' ? 'CROSS_CITY' : deliveryType === 'ON_SITE_PICKUP' ? 'ON_SITE_PICKUP' : 'LOCAL', body.quantity, city.localMinQty, city.crossMinQty, { bottlesPerUnit, saleUnit: sale.saleUnit }, ); if (!check.ok) throw new BadRequestException(check.message); const unitPrice = Number(sale.price); const productAmount = unitPrice * body.quantity; const benefitPerUnit = calcBenefitAmount({ price: unitPrice, benefitAmount: sale.benefitAmount != null ? Number(sale.benefitAmount) : null, }); return { productAmount, payAmount: productAmount, benefitAmount: benefitPerUnit * body.quantity, deliveryType, unitPrice, skuId: sale.skuId?.toString(), barcode69: sale.barcode69, productSpec: sale.specText, saleUnit: sale.saleUnit, bottlesPerUnit, bottleQuantity: body.quantity * bottlesPerUnit, minQuantity: toMinSaleQuantity( deliveryType === 'CROSS_CITY' ? city.crossMinQty : city.localMinQty, bottlesPerUnit, ), }; } /** 合伙人代下单预览:按合伙人手机号遵守商品白名单 */ async previewPartnerProxyOrderForPartner( partnerAccountId: bigint, body: { productId: string; quantity: number; deliveryMode?: 'ADDRESS' | 'ON_SITE_PICKUP'; storeId?: string; receiverCity?: string; receiverDistrict?: string; skuId?: string; }, ) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); return this.previewPartnerProxyOrder(body, { phone: primary.phone }); } async createPartnerProxyOrder( partnerAccountId: bigint, body: { phone: string; deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP'; autoReceive?: boolean; storeId?: string; receiverName?: string; province?: string; city?: string; district?: string; addressDetail?: string; productId: string; quantity: number; promoCodeId?: string; skuId?: string; }, req: Request, ) { const normalizedPhone = body.phone.trim(); if (!/^1\d{10}$/.test(normalizedPhone)) { throw new BadRequestException('请输入有效手机号'); } const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); const partnerPhone = primary.phone?.trim(); if (!partnerPhone || !/^1\d{10}$/.test(partnerPhone)) { throw new BadRequestException('合伙人手机号无效'); } if (body.deliveryMode === 'ADDRESS' && body.autoReceive !== true) { throw new BadRequestException('配送到址须勾选同意自动收货'); } const maskedPartnerPhone = partnerPhone.length >= 7 ? `${partnerPhone.slice(0, 3)}****${partnerPhone.slice(-4)}` : partnerPhone; const user = await this.authService.findOrCreateUserByPhone(normalizedPhone, { sourceType: 'PARTNER_PROXY', sourceRefId: primary.id, sourceLabel: `代下单·${maskedPartnerPhone}`, }); const preview = await this.previewPartnerProxyOrder( { productId: body.productId, quantity: body.quantity, deliveryMode: body.deliveryMode, storeId: body.storeId, receiverCity: body.city, receiverDistrict: body.district, skuId: body.skuId, }, { phone: primary.phone }, ); const product = await this.prisma.commonProductItem.findUniqueOrThrow({ where: { id: BigInt(body.productId) }, }); const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } }); let receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`; let receiverProvince = body.province?.trim() || ''; let receiverCity = body.city?.trim() || ''; let receiverDistrict = body.district?.trim() || ''; let receiverAddress = ''; let commissionDistrict = receiverDistrict; if (body.deliveryMode === 'ON_SITE_PICKUP') { receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`; receiverProvince = '现场'; receiverCity = '现场'; receiverDistrict = '取货'; receiverAddress = '现场提货'; commissionDistrict = ''; } else { if (!receiverProvince || !receiverCity || !receiverDistrict) { throw new BadRequestException('请选择省市区'); } if (!body.addressDetail?.trim()) { throw new BadRequestException('请填写详细地址'); } receiverAddress = `${receiverProvince}${receiverCity}${receiverDistrict}${body.addressDetail.trim()}`; } const paySnapshot = await this.partnerCityService.resolveForOrder(city.id, commissionDistrict); const orderNo = generateOrderNo(); const payExpireAt = new Date(Date.now() + 30 * 60 * 1000); const location = buildOrderClientLocationSnapshot( req, this.ipGeoService.resolve(extractClientIp(req)), undefined, ); let promoCodeId: bigint | undefined; if (body.promoCodeId?.trim()) { promoCodeId = BigInt(body.promoCodeId.trim()); await this.promoCodeService.attributeUserToPromo(user.id, promoCodeId, { clientIp: location.clientIp ?? undefined, }); } const order = await this.prisma.$transaction(async (tx) => { const created = await tx.order.create({ data: { orderNo, orderType: 'PROXY', userId: user.id, cityId: city.id, status: 'PENDING_PAY', payStatus: 'UNPAID', deliveryType: preview.deliveryType, channelSource: 'PROXY_ONLINE', promoCodeId, productId: product.id, skuId: preview.skuId ? BigInt(preview.skuId) : null, barcode69: preview.barcode69 || product.barcode69, productName: product.name, productSpec: preview.productSpec || product.spec, imageResourceId: product.coverResourceId, quantity: body.quantity, saleUnit: preview.saleUnit, bottlesPerUnit: preview.bottlesPerUnit, listUnitPrice: preview.unitPrice, listAmount: preview.productAmount, productAmount: preview.productAmount, payAmount: preview.payAmount, benefitAmount: preview.benefitAmount, freightAmount: 0, freightPayType: preview.deliveryType === 'CROSS_CITY' ? 'COD' : null, receiverName, receiverPhone: normalizedPhone, receiverAddress, receiverProvince, receiverCity, receiverDistrict, clientIp: location.clientIp, ipProvince: location.ipProvince, ipCity: location.ipCity, ipDistrict: location.ipDistrict, payExpireAt, partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? primary.id, orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null, proxyPartnerAccountId: primary.id, proxyPartnerName: primary.name, proxyPartnerPhone: partnerPhone, remark: `合伙人代下单 partnerAccountId=${primary.id} deliveryMode=${body.deliveryMode} customer=${normalizedPhone}`, }, include: { product: true, imageResource: true }, }); await tx.commonEvent.create({ data: buildOrderStatusEvent({ orderId: created.id, fromStatus: 'PENDING_PAY', toStatus: 'PENDING_PAY', operator: 'PARTNER_PROXY', remark: `合伙人代下单待支付 mode=${body.deliveryMode} customer=${normalizedPhone} partner=${primary.id}`, }), }); if (promoCodeId) { await tx.commonPromoCode.update({ where: { id: promoCodeId }, data: { orderCount: { increment: 1 } }, }); } return created; }); if (promoCodeId) { this.promoCodeService.logPromoOrderEvent( promoCodeId, order.id, user.id, location.clientIp ?? undefined, ); } this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', { partnerAccountId: primary.id, eventName: 'partner_proxy_order_create', refType: 'ORDER', refId: order.id, extraJson: { orderId: order.id.toString(), userId: user.id.toString(), productId: body.productId, quantity: body.quantity, deliveryMode: body.deliveryMode, promoCodeId: promoCodeId?.toString() ?? null, }, }); return { id: order.id.toString(), orderNo: order.orderNo, status: order.status, payStatus: order.payStatus, payAmount: Number(order.payAmount), benefitAmount: Number(order.benefitAmount), deliveryType: order.deliveryType, payExpireAt: order.payExpireAt?.toISOString() ?? null, proxyPartnerName: primary.name, }; } /** 合伙人代下单列表:按 proxyPartnerAccountId 归属,与管仓订单无关 */ async listPartnerProxyOrders( partnerAccountId: bigint, page = 1, pageSize = 20, keyword?: string, ) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); const kw = keyword?.trim(); const where = { orderType: 'PROXY' as const, proxyPartnerAccountId: primary.id, ...(kw ? { OR: [ { orderNo: { contains: kw } }, { receiverPhone: { contains: kw } }, { receiverName: { contains: kw } }, { productName: { contains: kw } }, ], } : {}), }; const take = Math.min(Math.max(pageSize, 1), 50); const skip = (Math.max(page, 1) - 1) * take; const [list, total] = await Promise.all([ this.prisma.order.findMany({ where, include: { delivery: true, imageResource: true, user: { select: { phone: true, nickname: true } }, }, orderBy: { createdAt: 'desc' }, skip, take, }), this.prisma.order.count({ where }), ]); return { list: serializeBigInt(list.map(mapOrderCompat)), total, page: Math.max(page, 1), pageSize: take, }; } async getPartnerProxyOrder(partnerAccountId: bigint, orderId: bigint) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); const order = await this.prisma.order.findFirst({ where: { id: orderId, orderType: 'PROXY', proxyPartnerAccountId: primary.id, }, include: { delivery: true, user: true, imageResource: true, }, }); if (!order) throw new NotFoundException('代下单不存在'); const statusLogs = await this.prisma.commonEvent.findMany({ where: orderStatusLogWhere(orderId), orderBy: { createdAt: 'desc' }, }); return serializeBigInt({ ...mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) }), proxyPayMethod: this.parseProxyPayMethod(order.channelSource), }); } private parseProxyPayMethod(channelSource: string | null | undefined): 'NATIVE' | 'JSAPI' | null { if (!channelSource) return null; const m = channelSource.match(/^PROXY_ONLINE:(NATIVE|JSAPI)$/); return m ? (m[1] as 'NATIVE' | 'JSAPI') : null; } private proxyChannelWithMethod(method: 'NATIVE' | 'JSAPI'): string { return `PROXY_ONLINE:${method}`; } /** 合伙人取消代下单支付(关闭待支付订单,可重新下单并选择其他支付方式) */ async cancelPartnerProxyOrder(partnerAccountId: bigint, orderId: bigint) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); const order = await this.prisma.order.findFirst({ where: { id: orderId, orderType: 'PROXY', proxyPartnerAccountId: primary.id, }, }); if (!order) throw new NotFoundException('代下单不存在'); if (order.status !== 'PENDING_PAY' || order.payStatus !== 'UNPAID') { throw new BadRequestException('仅待支付订单可取消'); } await this.prisma.$transaction(async (tx) => { await tx.order.update({ where: { id: orderId }, data: { status: 'CANCELLED', cancelledAt: new Date() }, }); await tx.commonEvent.create({ data: buildOrderStatusEvent({ orderId, fromStatus: 'PENDING_PAY', toStatus: 'CANCELLED', operator: 'PARTNER_PROXY', remark: '合伙人取消代下单支付', }), }); }); return serializeBigInt({ id: orderId.toString(), status: 'CANCELLED' }); } /** * v3.5.1 #8:订单 30 分钟未支付自动取消。 * 下单时已写入 payExpireAt = now+30min;此处将已过期且仍为待支付的订单翻为 CANCELLED。 * 建单/取消均无库存占用或权益发放,故仅翻状态 + 写状态日志,不回滚权益券。 */ async cancelExpiredPendingOrders(limit = 200): Promise { const now = new Date(); const expired = await this.prisma.order.findMany({ where: { status: 'PENDING_PAY', payStatus: 'UNPAID', payExpireAt: { lt: now }, isTest: false, }, take: limit, select: { id: true }, orderBy: { payExpireAt: 'asc' }, }); if (expired.length === 0) return 0; let cancelled = 0; for (const o of expired) { try { await this.prisma.$transaction(async (tx) => { const updated = await tx.order.updateMany({ where: { id: o.id, status: 'PENDING_PAY' }, data: { status: 'CANCELLED', cancelledAt: now }, }); if (updated.count === 0) return; // 已被并发处理 await tx.commonEvent.create({ data: buildOrderStatusEvent({ orderId: o.id, fromStatus: 'PENDING_PAY', toStatus: 'CANCELLED', operator: 'SYSTEM_AUTO_EXPIRE', remark: '30 分钟未支付自动取消', }), }); }); cancelled++; } catch (e) { this.logger.error( `cancelExpiredPendingOrders failed for order ${o.id}`, e instanceof Error ? e.stack : e, ); } } if (cancelled > 0) { this.logger.log(`Auto-cancelled ${cancelled} expired pending orders`); } return cancelled; } /** HQ 代下单:商品/推广码选项(运营侧可看白名单测试酒) */ async getHqProxyOrderOptions() { const [products, promoCodes] = await Promise.all([ this.catalogService.listProducts(undefined, undefined, { bypassWhitelist: true }), this.promoCodeService.listActiveOptions(), ]); const details = await Promise.all( products.map((p) => this.catalogService.getProduct(BigInt(String(p.id)), { bypassWhitelist: true }), ), ); return serializeBigInt({ products: details.filter(Boolean).map((p) => ({ id: p!.id, name: p!.name, spec: p!.spec, price: Number(p!.price), benefitAmount: p!.benefitAmount != null ? Number(p!.benefitAmount) : null, coverUrl: (p as { mainImageUrl?: string | null } | null)?.mainImageUrl ?? null, allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean } | null)?.allowOnSitePickup, allowOnlinePurchase: (p as { allowOnlinePurchase?: boolean } | null)?.allowOnlinePurchase !== false, allowCrossCityDelivery: (p as { allowCrossCityDelivery?: boolean } | null)?.allowCrossCityDelivery !== false, specEnabled: !!(p as { specEnabled?: boolean } | null)?.specEnabled, saleUnit: (p as { saleUnit?: string } | null)?.saleUnit, skus: (p as { skus?: unknown[] } | null)?.skus ?? [], defaultSkuId: (p as { defaultSkuId?: string } | null)?.defaultSkuId, specAttrs: (p as { specAttrs?: unknown[] } | null)?.specAttrs ?? [], })), promoCodes, stores: [], }); } async createHqProxyOrder( hqAccountId: bigint, body: { phone: string; deliveryMode: 'ADDRESS' | 'ON_SITE_PICKUP'; autoReceive?: boolean; receiverName?: string; province?: string; city?: string; district?: string; addressDetail?: string; productId: string; quantity: number; promoCodeId?: string; skuId?: string; }, req: Request, ) { const normalizedPhone = body.phone.trim(); if (!/^1\d{10}$/.test(normalizedPhone)) { throw new BadRequestException('请输入有效手机号'); } const hq = await this.prisma.hqAccount.findUnique({ where: { id: hqAccountId } }); if (!hq || hq.status !== 'ACTIVE') { throw new BadRequestException('总部账号无效'); } const operatorPhone = hq.phone?.trim(); if (!operatorPhone || !/^1\d{10}$/.test(operatorPhone)) { throw new BadRequestException('总部账号手机号无效'); } if (body.deliveryMode === 'ADDRESS' && body.autoReceive !== true) { throw new BadRequestException('配送到址须勾选同意自动收货'); } const maskedOperatorPhone = operatorPhone.length >= 7 ? `${operatorPhone.slice(0, 3)}****${operatorPhone.slice(-4)}` : operatorPhone; const proxyDisplayName = `总部·${hq.name}`; const user = await this.authService.findOrCreateUserByPhone(normalizedPhone, { sourceType: 'PARTNER_PROXY', sourceRefId: hq.id, sourceLabel: `总部代下单·${maskedOperatorPhone}`, }); const preview = await this.previewPartnerProxyOrder( { productId: body.productId, quantity: body.quantity, deliveryMode: body.deliveryMode, receiverCity: body.city, receiverDistrict: body.district, skuId: body.skuId, }, { bypassWhitelist: true }, ); const product = await this.prisma.commonProductItem.findUniqueOrThrow({ where: { id: BigInt(body.productId) }, }); const city = await this.prisma.commonCity.findFirstOrThrow({ where: { status: 'ACTIVE' } }); let receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`; let receiverProvince = body.province?.trim() || ''; let receiverCity = body.city?.trim() || ''; let receiverDistrict = body.district?.trim() || ''; let receiverAddress = ''; let commissionDistrict = receiverDistrict; if (body.deliveryMode === 'ON_SITE_PICKUP') { receiverName = body.receiverName?.trim() || `用户${normalizedPhone.slice(-4)}`; receiverProvince = '现场'; receiverCity = '现场'; receiverDistrict = '取货'; receiverAddress = '现场提货'; commissionDistrict = ''; } else { if (!receiverProvince || !receiverCity || !receiverDistrict) { throw new BadRequestException('请选择省市区'); } if (!body.addressDetail?.trim()) { throw new BadRequestException('请填写详细地址'); } receiverAddress = `${receiverProvince}${receiverCity}${receiverDistrict}${body.addressDetail.trim()}`; } const paySnapshot = await this.partnerCityService.resolveForOrder(city.id, commissionDistrict); const orderNo = generateOrderNo(); const payExpireAt = new Date(Date.now() + 30 * 60 * 1000); const location = buildOrderClientLocationSnapshot( req, this.ipGeoService.resolve(extractClientIp(req)), undefined, ); let promoCodeId: bigint | undefined; if (body.promoCodeId?.trim()) { promoCodeId = BigInt(body.promoCodeId.trim()); await this.promoCodeService.attributeUserToPromo(user.id, promoCodeId, { clientIp: location.clientIp ?? undefined, }); } const order = await this.prisma.$transaction(async (tx) => { const created = await tx.order.create({ data: { orderNo, orderType: 'PROXY', userId: user.id, cityId: city.id, status: 'PENDING_PAY', payStatus: 'UNPAID', deliveryType: preview.deliveryType, channelSource: 'PROXY_ONLINE', promoCodeId, productId: product.id, skuId: preview.skuId ? BigInt(preview.skuId) : null, barcode69: preview.barcode69 || product.barcode69, productName: product.name, productSpec: preview.productSpec || product.spec, imageResourceId: product.coverResourceId, quantity: body.quantity, saleUnit: preview.saleUnit, bottlesPerUnit: preview.bottlesPerUnit, listUnitPrice: preview.unitPrice, listAmount: preview.productAmount, productAmount: preview.productAmount, payAmount: preview.payAmount, benefitAmount: preview.benefitAmount, freightAmount: 0, freightPayType: preview.deliveryType === 'CROSS_CITY' ? 'COD' : null, receiverName, receiverPhone: normalizedPhone, receiverAddress, receiverProvince, receiverCity, receiverDistrict, clientIp: location.clientIp, ipProvince: location.ipProvince, ipCity: location.ipCity, ipDistrict: location.ipDistrict, payExpireAt, partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? null, orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null, proxyPartnerAccountId: null, proxyPartnerName: proxyDisplayName, proxyPartnerPhone: operatorPhone, remark: `总部代下单 hqAccountId=${hq.id} deliveryMode=${body.deliveryMode} customer=${normalizedPhone}`, }, include: { product: true, imageResource: true }, }); await tx.commonEvent.create({ data: buildOrderStatusEvent({ orderId: created.id, fromStatus: 'PENDING_PAY', toStatus: 'PENDING_PAY', operator: 'HQ_PROXY', remark: `总部代下单待支付 mode=${body.deliveryMode} customer=${normalizedPhone} hq=${hq.id}`, }), }); if (promoCodeId) { await tx.commonPromoCode.update({ where: { id: promoCodeId }, data: { orderCount: { increment: 1 } }, }); } return created; }); if (promoCodeId) { this.promoCodeService.logPromoOrderEvent( promoCodeId, order.id, user.id, location.clientIp ?? undefined, ); } return { id: order.id.toString(), orderNo: order.orderNo, status: order.status, payStatus: order.payStatus, payAmount: Number(order.payAmount), benefitAmount: Number(order.benefitAmount), deliveryType: order.deliveryType, payExpireAt: order.payExpireAt?.toISOString() ?? null, proxyPartnerName: proxyDisplayName, }; } /** 合伙人代下单支付:NATIVE 商家码 / JSAPI 合伙人微信代付 */ async payPartnerProxyOrder( partnerAccountId: bigint, orderId: bigint, payMethod: 'NATIVE' | 'JSAPI', ) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); const order = await this.prisma.order.findFirst({ where: { id: orderId, orderType: 'PROXY', proxyPartnerAccountId: primary.id, }, }); if (!order) throw new NotFoundException('代下单不存在'); if (order.status !== 'PENDING_PAY' || order.payStatus !== 'UNPAID') { throw new BadRequestException('订单状态不可支付'); } if (order.payExpireAt && order.payExpireAt.getTime() < Date.now()) { throw new BadRequestException('订单已超时未支付'); } const mockPaid = await this.tryMockPayProxyOrder(order, 'PARTNER_PROXY_MOCK_PAY'); if (mockPaid) return mockPaid; const lockedMethod = this.parseProxyPayMethod(order.channelSource); if (lockedMethod && lockedMethod !== payMethod) { throw new BadRequestException( lockedMethod === 'JSAPI' ? '该订单已发起微信代付,请先取消支付后重新下单' : '该订单已生成收款码,请先取消支付后重新下单', ); } if (!lockedMethod) { await this.prisma.order.update({ where: { id: orderId }, data: { channelSource: this.proxyChannelWithMethod(payMethod) }, }); } this.payRedeemAnomaly.onPayAttempt(Number(order.payAmount), { orderNo: order.orderNo, userId: order.userId, }); let openId: string | undefined; if (payMethod === 'JSAPI') { openId = primary.wxOpenId ?? undefined; const appConfig = loadAppConfig(); if (!appConfig.mockPay && !openId) { this.payRedeemAnomaly.onPayFail('代付未绑定微信', { orderNo: order.orderNo, userId: order.userId, }); throw new BadRequestException('请先在微信内登录并绑定微信后再代付'); } } let payResult; try { payResult = await this.payProvider.payOrder(orderId, openId, 'h5', payMethod); } catch (e) { this.payRedeemAnomaly.onPayFail(e instanceof Error ? e.message : '代付拉起失败', { orderNo: order.orderNo, userId: order.userId, }); throw e; } return this.finishProxyPayResult(order, payResult, 'PARTNER_PROXY_MOCK_PAY'); } /** 总部代下单支付:仅 Native 收款码 */ async payHqProxyOrder(orderId: bigint, payMethod: 'NATIVE' | 'JSAPI' = 'NATIVE') { if (payMethod !== 'NATIVE') { throw new BadRequestException('总部代下单仅支持收款码支付'); } const order = await this.prisma.order.findFirst({ where: { id: orderId, orderType: 'PROXY' }, }); if (!order) throw new NotFoundException('代下单不存在'); if (order.status !== 'PENDING_PAY' || order.payStatus !== 'UNPAID') { throw new BadRequestException('订单状态不可支付'); } if (order.payExpireAt && order.payExpireAt.getTime() < Date.now()) { throw new BadRequestException('订单已超时未支付'); } const mockPaid = await this.tryMockPayProxyOrder(order, 'HQ_PROXY_MOCK_PAY'); if (mockPaid) return mockPaid; this.payRedeemAnomaly.onPayAttempt(Number(order.payAmount), { orderNo: order.orderNo, userId: order.userId, }); let payResult; try { payResult = await this.payProvider.payOrder(orderId, undefined, 'h5', 'NATIVE'); } catch (e) { this.payRedeemAnomaly.onPayFail(e instanceof Error ? e.message : '总部代付拉起失败', { orderNo: order.orderNo, userId: order.userId, }); throw e; } const result = await this.finishProxyPayResult(order, payResult, 'HQ_PROXY_MOCK_PAY'); if (result.mode !== 'native' && result.mode !== 'mock') { this.payRedeemAnomaly.onPayFail('无法生成收款码', { orderNo: order.orderNo, userId: order.userId, }); throw new BadRequestException('无法生成收款码'); } return result; } /** Mock 环境确认代下单支付 */ async mockConfirmProxyPay( orderId: bigint, opts: { partnerAccountId?: bigint; hq?: boolean }, ) { const appConfig = loadAppConfig(); if (!appConfig.mockPay) { throw new BadRequestException('仅 MOCK_PAY 环境可用'); } let order; if (opts.partnerAccountId != null) { const primary = await this.partnerCityService.resolvePrimaryAccount(opts.partnerAccountId); order = await this.prisma.order.findFirst({ where: { id: orderId, orderType: 'PROXY', proxyPartnerAccountId: primary.id, }, }); } else { order = await this.prisma.order.findFirst({ where: { id: orderId, orderType: 'PROXY' }, }); } if (!order) throw new NotFoundException('代下单不存在'); if (order.payStatus === 'PAID') { return this.getProxyPayStatus(order.id); } if (order.status !== 'PENDING_PAY') { throw new BadRequestException('订单状态不可支付'); } await this.markProxyOrderPaid( order.id, `MOCK-CONFIRM-${Date.now()}`, opts.hq ? 'HQ_PROXY_MOCK_PAY' : 'PARTNER_PROXY_MOCK_PAY', ); return this.getProxyPayStatus(order.id); } async getProxyPayStatus(orderId: bigint) { const order = await this.prisma.order.findUnique({ where: { id: orderId }, select: { id: true, orderNo: true, status: true, payStatus: true, payAmount: true, deliveryType: true, payExpireAt: true, paidAt: true, }, }); if (!order) throw new NotFoundException('订单不存在'); return { id: order.id.toString(), orderNo: order.orderNo, status: order.status, payStatus: order.payStatus, payAmount: Number(order.payAmount), deliveryType: order.deliveryType, payExpireAt: order.payExpireAt?.toISOString() ?? null, paidAt: order.paidAt?.toISOString() ?? null, }; } async getPartnerProxyOrderTrack(partnerAccountId: bigint, orderId: bigint) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); const order = await this.prisma.order.findFirst({ where: { id: orderId, orderType: 'PROXY', proxyPartnerAccountId: primary.id, }, select: { id: true, deliveryType: true }, }); if (!order) throw new NotFoundException('代下单不存在'); return this.fulfillmentService.getOrderTrack(orderId); } private async tryMockPayProxyOrder( order: { id: bigint; orderNo: string; payExpireAt: Date | null }, operator: string, ) { if (!loadAppConfig().mockPay) return null; await this.markProxyOrderPaid(order.id, `MOCK-${Date.now()}`, operator); return { mode: 'mock' as const, orderId: order.id.toString(), orderNo: order.orderNo, payExpireAt: order.payExpireAt?.toISOString() ?? null, }; } /** 代下单支付结果:Mock 环境直接落单,真实环境返回收款码/JSAPI */ private async finishProxyPayResult( order: { id: bigint; orderNo: string; payExpireAt: Date | null }, payResult: PayOrderResult, operator: string, ) { const mockPay = loadAppConfig().mockPay; const isMockNative = payResult.mode === 'native' && (mockPay || payResult.codeUrl.startsWith('mock://')); if (payResult.mode === 'mock' || isMockNative) { await this.markProxyOrderPaid(order.id, payResult.externalNo, operator); return { mode: 'mock' as const, orderId: order.id.toString(), orderNo: order.orderNo, payExpireAt: order.payExpireAt?.toISOString() ?? null, }; } if (payResult.mode === 'native') { return { mode: 'native' as const, orderId: order.id.toString(), orderNo: order.orderNo, codeUrl: payResult.codeUrl, payExpireAt: order.payExpireAt?.toISOString() ?? null, }; } if (payResult.mode === 'jsapi') { return { mode: 'jsapi' as const, orderId: order.id.toString(), orderNo: order.orderNo, prepay: payResult.prepay, payExpireAt: order.payExpireAt?.toISOString() ?? null, }; } throw new BadRequestException('支付发起失败'); } private async markProxyOrderPaid(orderId: bigint, externalNo: string, operator: string) { const order = await this.prisma.order.findUnique({ where: { id: orderId } }); if (!order) throw new NotFoundException('订单不存在'); if (order.payStatus === 'PAID') return; const now = new Date(); const paySnapshot = await this.partnerCityService.resolveForOrder( order.cityId, order.receiverDistrict, ); // 现场提货:支付即完成(代下单同 C 端 PRD) const toStatus = order.deliveryType === 'ON_SITE_PICKUP' ? 'COMPLETED' : 'PENDING_SHIP'; await this.prisma.$transaction(async (tx) => { await tx.order.update({ where: { id: order.id }, data: { status: toStatus, payStatus: 'PAID', paidAt: now, payExternalNo: externalNo, partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? order.partnerAccountIdAtPay, orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? order.orderCommissionRateAtPay, }, }); await tx.logThirdParty.create({ data: { provider: 'WECHAT_PAY', scene: 'ORDER_PAY', refType: 'ORDER', refId: order.id, externalNo, amount: order.payAmount, status: 'SUCCESS', }, }); await tx.commonEvent.create({ data: buildOrderStatusEvent({ orderId: order.id, fromStatus: 'PENDING_PAY', toStatus, operator, }), }); const delivery = await tx.orderDelivery.findUnique({ where: { orderId: order.id } }); if (!delivery) { await tx.orderDelivery.create({ data: { orderId: order.id, provider: 'MANUAL' }, }); } }); await this.afterOrderPaid(order.id); } }