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) { 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 = { status: targetStatus }; const deliveryData: Record = {}; 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); } }