2639 lines
90 KiB
TypeScript
2639 lines
90 KiB
TypeScript
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);
|
||
}
|
||
}
|