import { maskContactPhone } from '@dukang/domain'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { orderStatusLogWhere } from '../../common/event/event.helpers'; import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat'; import { TradeService } from '../trade/trade.service'; import { AdminXiaofeixiaService } from './admin-xiaofeixia.service'; import type { AdminOrdersExportDto, AdminOrdersQueryDto } from './dto/admin-query.dto'; import type { AdminShipOrderDto, HqLogisticsShipDto } from './dto/admin-mutate.dto'; import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto'; import { FulfillmentService } from '../fulfillment/fulfillment.service'; import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service'; import { buildXfxGoodsPayload } from '../fulfillment/xfx-goods.util'; import { calcDeliveryFreightAmount } from '../fulfillment/delivery-freight.util'; import { AdminRedeemService } from './admin-redeem.service'; import { buildExportFilename, buildOrdersPdf, buildOrdersXlsx, mapOrderToExportRow, } from './admin-order-export.util'; const ORDER_EXPORT_MAX = 5000; type OrderFilterInput = Pick< AdminOrdersQueryDto, | 'orderNo' | 'status' | 'orderType' | 'userId' | 'cityId' | 'receiverPhone' | 'fulfillmentHold' | 'createdFrom' | 'createdTo' | 'excludeTest' | 'deliveryType' >; @Injectable() export class AdminOrdersService { constructor( private readonly prisma: PrismaService, private readonly tradeService: TradeService, private readonly xiaofeixiaService: AdminXiaofeixiaService, private readonly fulfillmentService: FulfillmentService, private readonly fulfillmentProviderService: FulfillmentProviderService, private readonly adminRedeemService: AdminRedeemService, ) {} /** v3.5.1 #1:发布会大屏,仅当日已付款订单;按付款时间倒序 */ async listBigScreen(limit?: string) { const take = Math.min(Math.max(Number(limit) || 2000, 1), 2000); const startOfToday = new Date(); startOfToday.setHours(0, 0, 0, 0); const orders = await this.prisma.order.findMany({ where: { payStatus: 'PAID', paidAt: { gte: startOfToday }, }, orderBy: [{ paidAt: 'desc' }, { createdAt: 'desc' }], take, include: { user: { select: { phone: true } } }, }); return { items: orders.map((o) => { const spec = o.productSpec ? ` ${o.productSpec}` : ''; const phone = o.user?.phone || o.receiverPhone; const paidAt = o.paidAt?.toISOString() ?? o.createdAt.toISOString(); return { id: o.id.toString(), orderNo: o.orderNo, payAmount: Number(o.payAmount), items: `${o.productName}${spec} × ${o.quantity}瓶`, createdAt: paidAt, paidAt, userPhoneMasked: phone ? maskContactPhone(phone) : null, }; }), }; } async list(query: AdminOrdersQueryDto) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where = this.buildOrderWhere(query); const [items, total] = await Promise.all([ this.prisma.order.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, include: { user: { select: { id: true, userNo: true, phone: true, nickname: true } }, delivery: { select: { provider: true, trackingNo: true, providerOrderNo: true, logisticsCompany: true, manualQueryUrl: true, fulfillmentProvider: { select: { code: true, pricingRulesJson: true } }, }, }, city: { select: { id: true, name: true, code: true } }, fulfillmentWarehouse: { select: { id: true, name: true } }, benefitCoupon: { select: { couponNo: true, totalAmount: true, usedAmount: true, balance: true, status: true }, }, }, }), this.prisma.order.count({ where }), ]); return serializeBigInt({ items: items.map((row) => this.withDeliveryLogisticsFee(row)), total, page, pageSize, }); } async previewExport(dto: AdminOrdersExportDto) { const count = await this.countExportOrders(dto); return { count, max: ORDER_EXPORT_MAX, exceeds: count > ORDER_EXPORT_MAX }; } async exportOrders(dto: AdminOrdersExportDto) { const count = await this.countExportOrders(dto); if (count > ORDER_EXPORT_MAX) { throw new BadRequestException(`超过 ${ORDER_EXPORT_MAX} 条,请缩小日期或筛选条件`); } if (count === 0) { throw new BadRequestException('没有可导出的订单'); } const orders = await this.prisma.order.findMany({ where: this.buildExportWhere(dto), orderBy: { createdAt: 'desc' }, take: ORDER_EXPORT_MAX, include: { city: { select: { name: true } }, delivery: { select: { trackingNo: true } }, benefitCoupon: { select: { totalAmount: true, usedAmount: true, balance: true }, }, }, }); const rows = orders.map((order) => mapOrderToExportRow(order)); const buffer = dto.format === 'pdf' ? await buildOrdersPdf(rows) : await buildOrdersXlsx(rows); const filename = buildExportFilename(dto.format, rows.length); const mimeType = dto.format === 'pdf' ? 'application/pdf' : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; return { filename, mimeType, contentBase64: buffer.toString('base64'), count: rows.length, }; } private buildOrderWhere(query: OrderFilterInput): Prisma.OrderWhereInput { const where: Prisma.OrderWhereInput = {}; if (query.orderNo) where.orderNo = { contains: query.orderNo }; if (query.status?.length) { where.status = { in: query.status as Prisma.EnumOrderStatusFilter['in'] }; } if (query.orderType) where.orderType = query.orderType as Prisma.EnumOrderTypeFilter['equals']; if (query.userId) where.userId = BigInt(query.userId); if (query.cityId) where.cityId = BigInt(query.cityId); if (query.receiverPhone) where.receiverPhone = { contains: query.receiverPhone }; if (query.deliveryType) { where.deliveryType = query.deliveryType as Prisma.EnumDeliveryTypeFilter['equals']; } if (query.fulfillmentHold === true || query.fulfillmentHold === 'true') { where.fulfillmentHold = true; } if (query.createdFrom || query.createdTo) { where.createdAt = {}; if (query.createdFrom) where.createdAt.gte = new Date(query.createdFrom); if (query.createdTo) where.createdAt.lte = this.endOfDay(query.createdTo); } if (query.excludeTest) where.isTest = false; return where; } private buildExportWhere(dto: AdminOrdersExportDto): Prisma.OrderWhereInput { if (dto.scope === 'selected') { const ids = (dto.ids ?? []).map((id) => BigInt(id)); if (!ids.length) { throw new BadRequestException('请先勾选要导出的订单'); } return { id: { in: ids } }; } const normalized = this.normalizeExportFilter(dto); return this.buildOrderWhere(normalized); } private async countExportOrders(dto: AdminOrdersExportDto): Promise { return this.prisma.order.count({ where: this.buildExportWhere(dto) }); } private normalizeExportFilter(dto: AdminOrdersExportDto): OrderFilterInput { const createdFrom = dto.createdFrom; const createdTo = dto.createdTo; if (!createdFrom && !createdTo) { const from = new Date(); from.setDate(from.getDate() - 30); from.setHours(0, 0, 0, 0); return { ...dto, createdFrom: from.toISOString().slice(0, 10), createdTo: new Date().toISOString().slice(0, 10), }; } return { ...dto, createdFrom, createdTo }; } private endOfDay(dateStr: string): Date { const end = new Date(dateStr); end.setHours(23, 59, 59, 999); return end; } async getOrderTrack(id: bigint) { const order = await this.prisma.order.findUnique({ where: { id }, select: { id: true }, }); if (!order) throw new NotFoundException('订单不存在'); return serializeBigInt(await this.fulfillmentService.getOrderTrack(id)); } async detail(id: bigint) { const order = await this.prisma.order.findUnique({ where: { id }, include: { user: { select: { id: true, userNo: true, phone: true, nickname: true, deviceKey: true, phoneVerifiedAt: true, }, }, delivery: { include: { fulfillmentProvider: { select: { code: true, pricingRulesJson: true } }, }, }, benefitCoupon: { select: { id: true, couponNo: true, totalAmount: true, usedAmount: true, balance: true, status: true, }, }, city: { select: { id: true, name: true, code: true } }, product: { select: { id: true, name: true, skuCode: true, barcode69: true } }, imageResource: { select: { url: true } }, fulfillmentWarehouse: { select: { id: true, name: true, contactName: true, contactPhone: true, address: true, lng: true, lat: true, fulfillmentMode: true, }, }, }, }); if (!order) throw new NotFoundException('订单不存在'); const statusLogs = await this.prisma.commonEvent.findMany({ where: orderStatusLogWhere(id), orderBy: { createdAt: 'asc' }, }); const coupon = order.benefitCoupon; const redeemTrace = coupon ? await this.adminRedeemService.buildCouponRedeemTrace(coupon) : { redeemSummary: null, redeemRecords: [] }; const withFee = this.withDeliveryLogisticsFee(order); const { benefitCoupon: _coupon, ...orderRest } = withFee; return serializeBigInt( mapOrderCompat({ ...orderRest, statusLogs: mapStatusLogCompat(statusLogs), benefitCoupons: coupon ? [ { id: coupon.id, couponNo: coupon.couponNo, totalAmount: Number(coupon.totalAmount), usedAmount: Number(coupon.usedAmount), balance: Number(coupon.balance), status: coupon.status, }, ] : [], redeemSummary: redeemTrace.redeemSummary, redeemRecords: redeemTrace.redeemRecords, }), ); } async updateStatusDebug(id: bigint, status: string) { const order = await this.prisma.order.findUnique({ where: { id } }); if (!order) throw new NotFoundException('订单不存在'); await this.tradeService.applyStatusTransition(id, order.status, status, 'HQ_DEBUG'); return this.detail(id); } async shipOrder(id: bigint, dto: AdminShipOrderDto) { if (dto.provider !== 'XFX') { throw new BadRequestException('暂仅支持小飞侠配送'); } let order = await this.prisma.order.findUnique({ where: { id }, include: { delivery: true, fulfillmentWarehouse: true, product: { select: { spec: true } }, }, }); if (!order) throw new NotFoundException('订单不存在'); if (!['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(order.status)) { throw new BadRequestException('当前订单状态不可发货'); } if (order.delivery?.trackingNo) { throw new BadRequestException('该订单已有运单号,请勿重复发货'); } if (dto.warehouseId) { const warehouseId = BigInt(dto.warehouseId); const warehouseRow = await this.prisma.cityWarehouse.findFirst({ where: { id: warehouseId, status: 'ACTIVE' }, }); if (!warehouseRow) { throw new BadRequestException('仓库不存在或已停用'); } if (order.fulfillmentWarehouseId !== warehouseId) { await this.prisma.order.update({ where: { id }, data: { fulfillmentWarehouseId: warehouseId }, }); order = await this.prisma.order.findUniqueOrThrow({ where: { id }, include: { delivery: true, fulfillmentWarehouse: true, product: { select: { spec: true } } }, }); } } const warehouse = order.fulfillmentWarehouse; if (!warehouse) { throw new BadRequestException('请先选择履约仓库'); } const providerId = order.delivery?.fulfillmentProviderId ?? warehouse?.fulfillmentProviderId ?? null; let xfxConfig; if (providerId) { xfxConfig = await this.fulfillmentProviderService.resolveXiaofeixiaConfig(providerId); } else { xfxConfig = await this.fulfillmentProviderService.resolveDefaultXiaofeixiaConfig(); if (!xfxConfig) { throw new BadRequestException('请先在仓配管理中注册并配置小飞侠承运商'); } } const defaults = this.getShipDefaults(warehouse); const { goodsName, goodsNum } = buildXfxGoodsPayload({ productName: order.productName, productSpec: order.productSpec, physicalSpec: order.product?.spec, quantity: order.quantity, bottlesPerUnit: order.bottlesPerUnit, }); const shipmentDto: XiaofeixiaCreateShipmentDto = { outNumber: order.orderNo, fromName: dto.fromName || defaults.fromName, fromMobile: dto.fromMobile || defaults.fromMobile, fromAddress: dto.fromAddress || defaults.fromAddress, fromAddressDetail: dto.fromAddressDetail || defaults.fromAddressDetail, fromLng: dto.fromLng ?? defaults.fromLng, fromLat: dto.fromLat ?? defaults.fromLat, toName: order.receiverName, toMobile: order.receiverPhone, toAddress: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}`, toAddressDetail: order.receiverAddress, goodsName, goodsNum, weight: dto.weight ?? defaults.weight, payMode: dto.payMode || defaults.payMode, remark: dto.remark || `HQ发货 ${order.orderNo}`, }; const result = await this.xiaofeixiaService.createShipment(shipmentDto, xfxConfig); if (!result.ok || !result.data) { throw new BadRequestException(result.error || '小飞侠创建运单失败'); } const { providerShipmentId, trackingNumber } = result.data; const now = new Date(); const resolvedProviderId = providerId; await this.prisma.$transaction(async (tx) => { if (order.delivery) { await tx.orderDelivery.update({ where: { orderId: id }, data: { provider: 'XFX', fulfillmentProviderId: resolvedProviderId, trackingNo: trackingNumber, providerOrderNo: String(providerShipmentId), shippingAt: now, }, }); } else { await tx.orderDelivery.create({ data: { orderId: id, provider: 'XFX', fulfillmentProviderId: resolvedProviderId, trackingNo: trackingNumber, providerOrderNo: String(providerShipmentId), shippingAt: now, }, }); } await tx.order.update({ where: { id }, data: { fulfillmentHold: false, fulfillmentHoldReason: null }, }); }); await this.tradeService.applyStatusTransition(id, order.status, 'SHIPPING', 'HQ_SHIP'); return this.detail(id); } private withDeliveryLogisticsFee< T extends { quantity: number; bottlesPerUnit: number; deliveryType: string; delivery?: { provider: string; fulfillmentProvider?: { code: string; pricingRulesJson: string | null } | null; } | null; }, >(order: T): T { if (!order.delivery) return order; const fp = order.delivery.fulfillmentProvider; const logisticsFee = calcDeliveryFreightAmount({ quantity: order.quantity, bottlesPerUnit: order.bottlesPerUnit, deliveryType: order.deliveryType, provider: order.delivery.provider, providerCode: fp?.code, pricing: this.fulfillmentProviderService.parsePricingRules(fp?.pricingRulesJson ?? null), }); const { fulfillmentProvider: _fp, ...deliveryRest } = order.delivery; return { ...order, delivery: { ...deliveryRest, logisticsFee }, }; } getShipDefaults(warehouse?: { contactName: string; contactPhone: string; address: string; name: string; lng: { toNumber?: () => number } | number | null; lat: { toNumber?: () => number } | number | null; } | null) { const lng = warehouse?.lng != null ? typeof warehouse.lng === 'object' && warehouse.lng && 'toNumber' in warehouse.lng ? Number(warehouse.lng) : Number(warehouse.lng) : 113.665; const lat = warehouse?.lat != null ? typeof warehouse.lat === 'object' && warehouse.lat && 'toNumber' in warehouse.lat ? Number(warehouse.lat) : Number(warehouse.lat) : 34.757; return { provider: 'XFX', providerLabel: '小飞侠', fromName: warehouse?.contactName || '杜康仓库', fromMobile: warehouse?.contactPhone || '13800000000', fromAddress: warehouse?.address || '河南省郑州市金水区', fromAddressDetail: warehouse?.name || '杜康酒业仓', fromLng: lng, fromLat: lat, weight: 2, payMode: '1', }; } /** 总部传统快递填单(同城无仓 / 跨城) */ async shipLogistics(id: bigint, dto: HqLogisticsShipDto) { await this.fulfillmentService.shipHqLogistics(id, dto); return this.detail(id); } async batchDeleteOrders(ids: bigint[]) { const uniqueIds = [...new Set(ids)]; if (!uniqueIds.length) { return { ok: true, deleted: 0, message: '未选择订单' }; } const orders = await this.prisma.order.findMany({ where: { id: { in: uniqueIds } }, select: { id: true, orderNo: true }, }); if (!orders.length) throw new NotFoundException('订单不存在'); const orderIds = orders.map((o) => o.id); await this.prisma.$transaction(async (tx) => { await this.deleteOrdersInTx(tx, orderIds); }); return { ok: true, deleted: orderIds.length, orderNos: orders.map((o) => o.orderNo), message: '订单及关联业务数据已删除,状态流转等业务日志已保留', }; } async deleteOrder(id: bigint) { const order = await this.prisma.order.findUnique({ where: { id }, select: { id: true, orderNo: true }, }); if (!order) throw new NotFoundException('订单不存在'); await this.prisma.$transaction(async (tx) => { await this.deleteOrdersInTx(tx, [id]); }); return { ok: true, deleted: 1, orderNo: order.orderNo, message: `订单 ${order.orderNo} 及关联业务数据已删除`, }; } private async deleteOrdersInTx(tx: Prisma.TransactionClient, orderIds: bigint[]) { if (!orderIds.length) return; const couponIds = ( await tx.benefitCoupon.findMany({ where: { orderId: { in: orderIds } }, select: { id: true }, }) ).map((c) => c.id); if (couponIds.length) { const redeemIds = ( await tx.redeemRecord.findMany({ where: { OR: [ { couponId: { in: couponIds } }, { allocations: { some: { couponId: { in: couponIds } } } }, ], }, select: { id: true }, }) ).map((r) => r.id); if (redeemIds.length) { await tx.storePayout.deleteMany({ where: { redeemRecordId: { in: redeemIds } } }); await tx.storeRating.deleteMany({ where: { redeemRecordId: { in: redeemIds } } }); await tx.redeemRecordAllocation.deleteMany({ where: { redeemRecordId: { in: redeemIds } } }); await tx.redeemRecord.deleteMany({ where: { id: { in: redeemIds } } }); } await tx.benefitCoupon.deleteMany({ where: { id: { in: couponIds } } }); } await tx.userInvoice.deleteMany({ where: { orderId: { in: orderIds } } }); await tx.wineryBillItem.deleteMany({ where: { orderId: { in: orderIds } } }); await tx.logisticsBillItem.deleteMany({ where: { orderId: { in: orderIds } } }); await tx.order.updateMany({ where: { originOrderId: { in: orderIds } }, data: { originOrderId: null }, }); await tx.commonTicket.deleteMany({ where: { refType: 'ORDER', refId: { in: orderIds } }, }); await tx.order.deleteMany({ where: { id: { in: orderIds } } }); } }