53e0db6a98
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>
2313 lines
78 KiB
TypeScript
2313 lines
78 KiB
TypeScript
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);
|
||
}
|
||
}
|