Files
dukang/server/dukang-api/src/modules/trade/trade.service.ts
T
jacy 53e0db6a98 feat(promo): promo metric event logs and HQ timeline charts (v3.4.13)
Add log_promo_event for scan/attribution/register/order with IP; admin metrics APIs and ECharts on promo detail page.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 00:06:52 +08:00

2313 lines
78 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,
NotFoundException,
forwardRef,
} from '@nestjs/common';
import type { FreightPayType } from '@prisma/client';
import {
calcBenefitAmount,
generateOrderNo,
orderTabToStatuses,
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 } 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 } 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 type { Request } from 'express';
@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,
) {}
async preview(
userId: bigint,
body: { productId: string; quantity: number; addressId?: string; onSitePickup?: boolean },
) {
const viewerPhone = await this.catalogService.resolveUserPhone(userId);
const product = await this.catalogService.getProduct(BigInt(body.productId), { phone: viewerPhone });
if (!product || product.status !== 'ON_SALE') {
throw new BadRequestException('商品不可购买');
}
const city = await this.prisma.commonCity.findFirst({ where: { status: 'ACTIVE' } });
if (!city) throw new BadRequestException('暂无开城城市');
const onSitePickup = !!body.onSitePickup;
if (onSitePickup && !product.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 = product.allowOnlinePurchase !== false;
const allowCross = product.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 check = validateMinPurchase(
deliveryType,
body.quantity,
city.localMinQty,
city.crossMinQty,
);
const unitPrice = Number(product.price);
const productAmount = unitPrice * body.quantity;
const benefitPerUnit = calcBenefitAmount({
price: unitPrice,
benefitAmount: product.benefitAmount ? Number(product.benefitAmount) : null,
});
const freightPayType: FreightPayType | null = deliveryType === 'CROSS_CITY' ? 'COD' : null;
const minQty =
deliveryType === 'ON_SITE_PICKUP'
? city.localMinQty
: deliveryType === 'LOCAL'
? city.localMinQty
: city.crossMinQty;
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: product.allowCrossCityDelivery !== false,
allowOnlinePurchase: product.allowOnlinePurchase !== false,
};
}
async createOrder(
userId: bigint,
body: {
productId: string;
quantity: number;
addressId?: string;
onSitePickup?: boolean;
clientLocation?: unknown;
},
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 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,
barcode69: product.barcode69,
productName: product.name,
productSpec: product.spec,
imageResourceId: product.coverResourceId,
quantity: body.quantity,
listUnitPrice: product.price,
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,
},
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,
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 } });
if (!order) return;
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 },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.order.count({ where }),
]);
return { list: serializeBigInt(list.map(mapOrderCompat)), 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 } },
},
});
if (!order) throw new NotFoundException('订单不存在');
const statusLogs = await this.prisma.commonEvent.findMany({
where: orderStatusLogWhere(orderId),
orderBy: { createdAt: 'desc' },
});
const mapped = mapOrderCompat({ ...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: {
titleType: string;
invoiceKind: string;
titleName: string;
taxNo?: string;
addressPhone?: string;
bankAccount?: string;
email: string;
phone: string;
remark?: string;
},
) {
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('该订单已有进行中或已开具的发票申请');
if (body.titleType === 'ENTERPRISE' && !body.taxNo?.trim()) {
throw new BadRequestException('企业抬头须填写税号');
}
if (body.invoiceKind === 'SPECIAL') {
if (body.titleType !== 'ENTERPRISE') {
throw new BadRequestException('专用发票仅支持企业抬头');
}
if (!body.taxNo?.trim() || !body.addressPhone?.trim() || !body.bankAccount?.trim()) {
throw new BadRequestException('专用发票须填写税号、地址电话与开户行账号');
}
}
const invoice = await this.prisma.userInvoice.create({
data: {
invoiceNo: this.generateInvoiceNo(),
orderId,
userId,
titleType: body.titleType as never,
invoiceKind: body.invoiceKind as never,
titleName: body.titleName.trim(),
taxNo: body.taxNo?.trim() || null,
addressPhone: body.addressPhone?.trim() || null,
bankAccount: body.bankAccount?.trim() || null,
email: body.email.trim(),
phone: body.phone.trim(),
remark: body.remark?.trim() || null,
},
});
return serializeBigInt({ ...invoice, orderNo: order.orderNo });
}
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;
titleType: string;
invoiceKind: 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);
}
async adminListInvoices(query: { status?: string; page?: number; pageSize?: number }) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: { status?: never } = {};
if (query.status) where.status = query.status as never;
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,
},
}),
]);
return serializeBigInt({
products: products.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 }).mainImageUrl ?? null,
allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean }).allowOnSitePickup,
allowOnlinePurchase: (p as { allowOnlinePurchase?: boolean }).allowOnlinePurchase !== false,
allowCrossCityDelivery:
(p as { allowCrossCityDelivery?: boolean }).allowCrossCityDelivery !== false,
})),
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;
},
viewer?: { phone?: string | null; bypassWhitelist?: boolean },
) {
const product = await this.catalogService.getProduct(BigInt(body.productId), viewer ?? {});
if (!product || product.status !== 'ON_SALE') {
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 (!product.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 = product.allowOnlinePurchase !== false;
const allowCross = product.allowCrossCityDelivery !== false;
if (deliveryType === 'LOCAL' && !allowOnline) {
throw new BadRequestException('该商品不支持线上购买');
}
if (deliveryType === 'CROSS_CITY') {
if (!allowOnline) {
throw new BadRequestException('该商品不支持线上购买');
}
if (!allowCross) {
throw new BadRequestException('该商品不支持跨城配送');
}
}
}
const check = validateMinPurchase(
deliveryType === 'CROSS_CITY' ? 'CROSS_CITY' : 'LOCAL',
body.quantity,
city.localMinQty,
city.crossMinQty,
);
if (!check.ok) throw new BadRequestException(check.message);
const unitPrice = Number(product.price);
const productAmount = unitPrice * body.quantity;
const benefitPerUnit = calcBenefitAmount({
price: unitPrice,
benefitAmount: product.benefitAmount ? Number(product.benefitAmount) : null,
});
return {
productAmount,
payAmount: productAmount,
benefitAmount: benefitPerUnit * body.quantity,
deliveryType,
unitPrice,
};
}
/** 合伙人代下单预览:按合伙人手机号遵守商品白名单 */
async previewPartnerProxyOrderForPartner(
partnerAccountId: bigint,
body: {
productId: string;
quantity: number;
deliveryMode?: 'ADDRESS' | 'ON_SITE_PICKUP';
storeId?: string;
receiverCity?: string;
receiverDistrict?: 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;
},
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,
},
{ 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,
barcode69: product.barcode69,
productName: product.name,
productSpec: product.spec,
imageResourceId: product.coverResourceId,
quantity: body.quantity,
listUnitPrice: product.price,
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' });
}
/** HQ 代下单:商品/推广码选项(运营侧可看白名单测试酒) */
async getHqProxyOrderOptions() {
const [products, promoCodes] = await Promise.all([
this.catalogService.listProducts(undefined, undefined, { bypassWhitelist: true }),
this.promoCodeService.listActiveOptions(),
]);
return serializeBigInt({
products: products.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 }).mainImageUrl ?? null,
allowOnSitePickup: !!(p as { allowOnSitePickup?: boolean }).allowOnSitePickup,
allowOnlinePurchase: (p as { allowOnlinePurchase?: boolean }).allowOnlinePurchase !== false,
allowCrossCityDelivery:
(p as { allowCrossCityDelivery?: boolean }).allowCrossCityDelivery !== false,
})),
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;
},
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,
},
{ 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,
barcode69: product.barcode69,
productName: product.name,
productSpec: product.spec,
imageResourceId: product.coverResourceId,
quantity: body.quantity,
listUnitPrice: product.price,
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 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;
}
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,
};
}
await this.markProxyOrderPaid(order.id, payResult.externalNo, 'PARTNER_PROXY_MOCK_PAY');
return {
mode: 'mock' as const,
orderId: order.id.toString(),
orderNo: order.orderNo,
payExpireAt: order.payExpireAt?.toISOString() ?? null,
};
}
/** 总部代下单支付:仅 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('订单已超时未支付');
}
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;
}
if (payResult.mode !== 'native') {
this.payRedeemAnomaly.onPayFail('无法生成收款码', {
orderNo: order.orderNo,
userId: order.userId,
});
throw new BadRequestException('无法生成收款码');
}
return {
mode: 'native' as const,
orderId: order.id.toString(),
orderNo: order.orderNo,
codeUrl: payResult.codeUrl,
payExpireAt: order.payExpireAt?.toISOString() ?? null,
};
}
/** 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 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);
}
}