1a0afb6d39
Open weappOrderConfirm in mini-user so users confirm in-app instead of service notice; verify via get_order before completing. Show operator/remark on admin order status timeline. Co-authored-by: Cursor <cursoragent@cursor.com>
1387 lines
48 KiB
TypeScript
1387 lines
48 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, SmsScene, 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 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,
|
|
) {}
|
|
|
|
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';
|
|
}
|
|
}
|
|
|
|
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),
|
|
minQty,
|
|
onSitePickup,
|
|
};
|
|
}
|
|
|
|
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 || '购买数量不满足起购要求');
|
|
}
|
|
|
|
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;
|
|
});
|
|
|
|
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 user = await this.prisma.user.findUnique({ where: { id: userId } });
|
|
const openId = user?.wxOpenId ?? undefined;
|
|
const appConfig = loadAppConfig();
|
|
if (!appConfig.mockPay && !openId) {
|
|
throw new BadRequestException(WECHAT_AUTH_REQUIRED);
|
|
}
|
|
const payPlatform = clientApp === ClientApp.USER_MINI ? 'mini' : 'h5';
|
|
const payResult = await this.payProvider.payOrder(orderId, openId, payPlatform);
|
|
|
|
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,
|
|
);
|
|
const toStatus =
|
|
order.deliveryType === 'ON_SITE_PICKUP' ? 'PENDING_RECEIVE' : '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.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 expectedFen = Math.round(Number(order.payAmount) * 100);
|
|
if (params.amountFen > 0 && params.amountFen !== expectedFen) {
|
|
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,
|
|
);
|
|
const toStatus =
|
|
order.deliveryType === 'ON_SITE_PICKUP' ? 'PENDING_RECEIVE' : '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.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 };
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
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, { bypassWhitelist: true }),
|
|
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,
|
|
})),
|
|
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;
|
|
}) {
|
|
const product = await this.catalogService.getProduct(BigInt(body.productId), {
|
|
bypassWhitelist: true,
|
|
});
|
|
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 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 sendPartnerProxyCustomerSms(phone: string) {
|
|
const normalizedPhone = phone.trim();
|
|
await this.authService.sendSms(normalizedPhone, SmsScene.PARTNER_PROXY_CUSTOMER, {
|
|
clientApp: ClientApp.PARTNER_H5,
|
|
});
|
|
const masked =
|
|
normalizedPhone.length >= 7
|
|
? `${normalizedPhone.slice(0, 3)}****${normalizedPhone.slice(-4)}`
|
|
: normalizedPhone;
|
|
return { ok: true, maskedPhone: masked };
|
|
}
|
|
|
|
/** @deprecated 兼容旧前端:转发为客户短信 */
|
|
async sendPartnerProxyOrderSms(phone: string) {
|
|
return this.sendPartnerProxyCustomerSms(phone);
|
|
}
|
|
|
|
async sendPartnerProxyPartnerSms(partnerAccountId: bigint) {
|
|
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
|
const partnerPhone = primary.phone?.trim();
|
|
if (!partnerPhone || !/^1\d{10}$/.test(partnerPhone)) {
|
|
throw new BadRequestException('合伙人手机号无效,无法发送确认验证码');
|
|
}
|
|
await this.authService.sendSms(partnerPhone, SmsScene.PARTNER_PROXY_ORDER, {
|
|
clientApp: ClientApp.PARTNER_H5,
|
|
});
|
|
const masked =
|
|
partnerPhone.length >= 7
|
|
? `${partnerPhone.slice(0, 3)}****${partnerPhone.slice(-4)}`
|
|
: partnerPhone;
|
|
return { ok: true, maskedPhone: masked };
|
|
}
|
|
|
|
async createPartnerProxyOrder(
|
|
partnerAccountId: bigint,
|
|
body: {
|
|
phone: string;
|
|
customerSmsCode: string;
|
|
partnerSmsCode: 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();
|
|
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
|
const partnerPhone = primary.phone?.trim();
|
|
if (!partnerPhone || !/^1\d{10}$/.test(partnerPhone)) {
|
|
throw new BadRequestException('合伙人手机号无效');
|
|
}
|
|
|
|
await this.authService.verifySmsCode(
|
|
normalizedPhone,
|
|
body.customerSmsCode.trim(),
|
|
SmsScene.PARTNER_PROXY_CUSTOMER,
|
|
);
|
|
await this.authService.verifySmsCode(
|
|
partnerPhone,
|
|
body.partnerSmsCode.trim(),
|
|
SmsScene.PARTNER_PROXY_ORDER,
|
|
);
|
|
|
|
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,
|
|
});
|
|
|
|
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);
|
|
|
|
let promoCodeId: bigint | undefined;
|
|
if (body.promoCodeId?.trim()) {
|
|
promoCodeId = BigInt(body.promoCodeId.trim());
|
|
await this.promoCodeService.attributeUserToPromo(user.id, promoCodeId);
|
|
}
|
|
|
|
const orderNo = generateOrderNo();
|
|
const now = new Date();
|
|
const location = buildOrderClientLocationSnapshot(
|
|
req,
|
|
this.ipGeoService.resolve(extractClientIp(req)),
|
|
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: 'COMPLETED',
|
|
payStatus: 'PAID',
|
|
deliveryType: preview.deliveryType,
|
|
channelSource: 'OFFLINE_PROXY',
|
|
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,
|
|
paidAt: now,
|
|
shippedAt: now,
|
|
completedAt: now,
|
|
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.orderDelivery.create({
|
|
data: {
|
|
orderId: created.id,
|
|
provider: 'MANUAL',
|
|
outWarehouseAt: now,
|
|
shippingAt: now,
|
|
deliveredAt: now,
|
|
},
|
|
});
|
|
|
|
await tx.commonEvent.create({
|
|
data: buildOrderStatusEvent({
|
|
orderId: created.id,
|
|
fromStatus: 'PENDING_PAY',
|
|
toStatus: 'COMPLETED',
|
|
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;
|
|
});
|
|
|
|
await this.benefitService.grantOnOrderPaid(order.id);
|
|
|
|
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 this.getPartnerOrder(partnerAccountId, order.id);
|
|
}
|
|
}
|