Files
dukang/server/dukang-api/src/modules/trade/trade.service.ts
T
jacy 62e61a9e63
CI / verify (pull_request) Waiting to run
v3.5.4版本提交,修改api
2026-08-21 16:22:53 +08:00

2639 lines
90 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown>,
sale: {
skuId: bigint | null;
skuCode: string;
specText: string;
price: unknown;
benefitAmount: unknown;
allowOnSitePickup: boolean;
allowOnlinePurchase: boolean;
allowCrossCityDelivery: boolean;
saleUnit: ProductSaleUnit;
bottlesPerUnit: number;
},
): Record<string, unknown> {
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<string, unknown>, 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<string, unknown>) {
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<string, unknown> = { 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<string, unknown> = { status: targetStatus };
const deliveryData: Record<string, unknown> = {};
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<number> {
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);
}
}