import { Injectable, NotFoundException } from '@nestjs/common'; import type { DeliveryProvider } from '@prisma/client'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import type { AdminDeliveriesQueryDto, AdminRedeemRecordsQueryDto } from './dto/admin-query.dto'; import type { UpdateDeliveryDto } from './dto/admin-mutate.dto'; export type CouponRedeemTrace = { redeemSummary: { couponNo: string; totalAmount: number; usedAmount: number; balance: number; status: string; redeemCount: number; redeemRecordSum: number; }; redeemRecords: Array<{ id: bigint; redeemNo: string; amount: number; settleAmount: number; couponAmount: number; role: 'PRIMARY' | 'SECONDARY'; createdAt: Date; store: { id: bigint; name: string; cityName: string | null } | null; }>; }; @Injectable() export class AdminRedeemService { constructor(private readonly prisma: PrismaService) {} async listRecords(query: AdminRedeemRecordsQueryDto) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where: Prisma.RedeemRecordWhereInput = {}; if (query.redeemNo) where.redeemNo = { contains: query.redeemNo }; if (query.storeId) where.storeId = BigInt(query.storeId); if (query.userId) where.userId = BigInt(query.userId); const [items, total] = await Promise.all([ this.prisma.redeemRecord.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, include: { user: { select: { id: true, userNo: true, phone: true, nickname: true } }, store: { select: { id: true, name: true, cityName: true } }, coupon: { select: { id: true, couponNo: true, balance: true } }, }, }), this.prisma.redeemRecord.count({ where }), ]); return serializeBigInt({ items, total, page, pageSize }); } async detailRecord(id: bigint) { const record = await this.prisma.redeemRecord.findUnique({ where: { id }, include: { user: { select: { id: true, userNo: true, phone: true, nickname: true } }, store: { select: { id: true, name: true, cityName: true, address: true, partnerAccount: { select: { id: true, companyName: true } }, }, }, coupon: { select: { id: true, couponNo: true, totalAmount: true, usedAmount: true, balance: true, status: true, orderId: true, order: { select: { id: true, orderNo: true } }, }, }, allocations: { orderBy: { sortOrder: 'asc' }, include: { coupon: { select: { id: true, couponNo: true, order: { select: { id: true, orderNo: true } }, }, }, }, }, payout: true, rating: true, }, }); if (!record) throw new NotFoundException('核销记录不存在'); return serializeBigInt({ ...record, allocations: record.allocations.map((a) => ({ couponId: a.couponId, amount: Number(a.amount), sortOrder: a.sortOrder, couponNo: a.coupon.couponNo, orderId: a.coupon.order?.id ?? null, orderNo: a.coupon.order?.orderNo ?? null, })), }); } /** 按权益券聚合核销追溯(含跨券 FIFO 次券) */ async buildCouponRedeemTrace(coupon: { id: bigint; couponNo: string; totalAmount: Prisma.Decimal | number; usedAmount: Prisma.Decimal | number; balance: Prisma.Decimal | number; status: string; }): Promise { await this.ensureRedeemAllocationsForCoupon(coupon.id); const redeemRows = await this.prisma.redeemRecord.findMany({ where: { OR: [ { couponId: coupon.id }, { allocations: { some: { couponId: coupon.id } } }, ], }, orderBy: { createdAt: 'desc' }, include: { store: { select: { id: true, name: true, cityName: true } }, allocations: { where: { couponId: coupon.id }, select: { amount: true, sortOrder: true }, }, }, }); const redeemRecords = redeemRows.map((r) => { const couponAmount = r.allocations[0] != null ? Number(r.allocations[0].amount) : r.couponId === coupon.id ? Number(r.amount) : 0; return { id: r.id, redeemNo: r.redeemNo, amount: Number(r.amount), settleAmount: Number(r.settleAmount), couponAmount, role: (r.couponId === coupon.id ? 'PRIMARY' : 'SECONDARY') as 'PRIMARY' | 'SECONDARY', createdAt: r.createdAt, store: r.store, }; }); const redeemRecordSum = redeemRecords.reduce((sum, r) => sum + r.couponAmount, 0); return { redeemSummary: { couponNo: coupon.couponNo, totalAmount: Number(coupon.totalAmount), usedAmount: Number(coupon.usedAmount), balance: Number(coupon.balance), status: coupon.status, redeemCount: redeemRecords.length, redeemRecordSum, }, redeemRecords, }; } private async ensureRedeemAllocationsForCoupon(couponId: bigint) { const coupon = await this.prisma.benefitCoupon.findUnique({ where: { id: couponId }, select: { id: true, userId: true }, }); if (!coupon) return; const pendings = await this.prisma.redeemPendingRecord.findMany({ where: { userId: coupon.userId, redeemRecordId: { not: null } }, select: { redeemRecordId: true, allocationsJson: true }, }); for (const pending of pendings) { if (!pending.redeemRecordId) continue; const allocs = this.parseAllocationsJson(pending.allocationsJson); if (!allocs.some((a) => a.couponId === couponId.toString())) continue; const existing = await this.prisma.redeemRecordAllocation.count({ where: { redeemRecordId: pending.redeemRecordId }, }); if (existing > 0) continue; await this.prisma.redeemRecordAllocation.createMany({ data: allocs.map((a, index) => ({ redeemRecordId: pending.redeemRecordId!, couponId: BigInt(a.couponId), amount: a.amount, sortOrder: index, })), skipDuplicates: true, }); } const ledgers = await this.prisma.commonEvent.findMany({ where: { eventType: 'BENEFIT_LEDGER', param1: 'REDEEM', param2: couponId.toString(), actorType: 'USER', actorId: coupon.userId, refType: 'STORE', }, select: { id: true, refId: true, amount1: true, createdAt: true }, orderBy: { createdAt: 'asc' }, }); for (const ledger of ledgers) { if (ledger.refId == null || ledger.amount1 == null) continue; const allocAmount = Math.abs(Number(ledger.amount1)); if (!(allocAmount > 0)) continue; const already = await this.prisma.redeemRecordAllocation.findFirst({ where: { couponId, amount: allocAmount, redeemRecord: { userId: coupon.userId, storeId: ledger.refId, createdAt: { gte: new Date(ledger.createdAt.getTime() - 8000), lte: new Date(ledger.createdAt.getTime() + 8000), }, }, }, select: { id: true }, }); if (already) continue; const candidates = await this.prisma.redeemRecord.findMany({ where: { userId: coupon.userId, storeId: ledger.refId, createdAt: { gte: new Date(ledger.createdAt.getTime() - 8000), lte: new Date(ledger.createdAt.getTime() + 8000), }, amount: { gte: allocAmount }, allocations: { none: { couponId } }, }, orderBy: { createdAt: 'asc' }, take: 5, }); if (!candidates.length) continue; const target = candidates.find((r) => r.couponId !== couponId) ?? (candidates.length === 1 ? candidates[0] : null); if (!target) continue; await this.prisma.redeemRecordAllocation .create({ data: { redeemRecordId: target.id, couponId, amount: allocAmount, sortOrder: target.couponId === couponId ? 0 : 1, }, }) .catch(() => { /* unique 冲突忽略 */ }); } const primaryWithoutAlloc = await this.prisma.redeemRecord.findMany({ where: { couponId, allocations: { none: {} }, }, select: { id: true, amount: true }, }); if (primaryWithoutAlloc.length) { await this.prisma.redeemRecordAllocation.createMany({ data: primaryWithoutAlloc.map((r) => ({ redeemRecordId: r.id, couponId, amount: r.amount, sortOrder: 0, })), skipDuplicates: true, }); } } private parseAllocationsJson( value: Prisma.JsonValue, ): Array<{ couponId: string; amount: number }> { if (!Array.isArray(value)) return []; return value .map((item) => { if (!item || typeof item !== 'object') return null; const row = item as { couponId?: unknown; amount?: unknown }; const id = row.couponId != null ? String(row.couponId) : ''; const amount = Number(row.amount); if (!id || !Number.isFinite(amount) || amount <= 0) return null; return { couponId: id, amount }; }) .filter((item): item is { couponId: string; amount: number } => !!item); } } @Injectable() export class AdminDeliveriesService { constructor(private readonly prisma: PrismaService) {} async list(query: AdminDeliveriesQueryDto) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where: Prisma.OrderDeliveryWhereInput = {}; if (query.provider) where.provider = query.provider as DeliveryProvider; if (query.trackingNo) where.trackingNo = { contains: query.trackingNo }; if (query.orderNo) { where.order = { orderNo: { contains: query.orderNo } }; } const [items, total] = await Promise.all([ this.prisma.orderDelivery.findMany({ where, orderBy: { updatedAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, include: { order: { select: { id: true, orderNo: true, status: true, receiverName: true, receiverPhone: true, deliveryType: true, productName: true, quantity: true, }, }, }, }), this.prisma.orderDelivery.count({ where }), ]); return serializeBigInt({ items, total, page, pageSize }); } async detail(id: bigint) { const delivery = await this.prisma.orderDelivery.findUnique({ where: { id }, include: { order: { include: { user: { select: { id: true, userNo: true, phone: true } }, imageResource: { select: { url: true } }, }, }, }, }); if (!delivery) throw new NotFoundException('配送单不存在'); return serializeBigInt(delivery); } async update(id: bigint, dto: UpdateDeliveryDto) { const delivery = await this.prisma.orderDelivery.update({ where: { id }, data: { ...(dto.provider !== undefined ? { provider: dto.provider as DeliveryProvider } : {}), ...(dto.providerOrderNo !== undefined ? { providerOrderNo: dto.providerOrderNo } : {}), ...(dto.trackingNo !== undefined ? { trackingNo: dto.trackingNo } : {}), }, include: { order: { select: { orderNo: true, status: true } } }, }); return serializeBigInt(delivery); } }