From e0d3c840a22eb7f4b3792942d338266135548fd8 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Wed, 22 Jul 2026 23:38:51 +0800 Subject: [PATCH] feat(ops): show order redeem records including FIFO secondary coupons Persist redeem allocations and surface them on HQ order detail with backfill for history. Co-authored-by: Cursor --- apps/admin-web/src/pages/OrdersPage.tsx | 224 +++++++++++++++- server/dukang-api/prisma/schema.prisma | 36 ++- .../src/modules/ops/admin-orders.service.ts | 246 +++++++++++++++++- .../src/modules/ops/admin-redeem.service.ts | 50 +++- .../src/modules/redeem/redeem.service.ts | 7 + 5 files changed, 542 insertions(+), 21 deletions(-) diff --git a/apps/admin-web/src/pages/OrdersPage.tsx b/apps/admin-web/src/pages/OrdersPage.tsx index e4958e3..fa88721 100644 --- a/apps/admin-web/src/pages/OrdersPage.tsx +++ b/apps/admin-web/src/pages/OrdersPage.tsx @@ -55,6 +55,28 @@ type WarehouseOption = { lat?: number | null; }; +type OrderRedeemRecord = { + id: string; + redeemNo: string; + amount: number; + settleAmount: number; + /** 本单权益券在该核销单中的分摊额 */ + couponAmount?: number; + role?: 'PRIMARY' | 'SECONDARY'; + createdAt: string; + store?: { id: string; name: string; cityName?: string | null } | null; +}; + +type OrderRedeemSummary = { + couponNo: string; + totalAmount: number; + usedAmount: number; + balance: number; + status: string; + redeemCount: number; + redeemRecordSum: number; +}; + type OrderDetail = AdminOrderRow & { receiverAddress?: string; receiverProvince?: string; @@ -81,6 +103,8 @@ type OrderDetail = AdminOrderRow & { payment?: Record | null; statusLogs?: Array<{ fromStatus: string | null; toStatus: string; createdAt: string }>; benefitCoupons?: Array>; + redeemSummary?: OrderRedeemSummary | null; + redeemRecords?: OrderRedeemRecord[]; fulfillmentWarehouse?: { id: string; name: string; @@ -135,6 +159,9 @@ export default function OrdersPage() { const [pageSize, setPageSize] = useState(20); const [detail, setDetail] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); + const [redeemDetail, setRedeemDetail] = useState | null>(null); + const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false); + const [redeemDetailLoading, setRedeemDetailLoading] = useState(false); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const [batchDeleteOpen, setBatchDeleteOpen] = useState(false); const [batchDeleting, setBatchDeleting] = useState(false); @@ -152,6 +179,20 @@ export default function OrdersPage() { [data?.items, selectedRowKeys], ); + async function openRedeemDetail(redeemId: string) { + setRedeemDetailLoading(true); + setRedeemDrawerOpen(true); + try { + const res = await request>(`/admin/redeem-records/${redeemId}`); + setRedeemDetail(res); + } catch (e) { + message.error(e instanceof Error ? e.message : '加载核销详情失败'); + setRedeemDrawerOpen(false); + } finally { + setRedeemDetailLoading(false); + } + } + const load = useCallback(async () => { setLoading(true); try { @@ -468,7 +509,7 @@ export default function OrdersPage() { setDrawerOpen(false)} extra={detail && ( @@ -570,6 +611,98 @@ export default function OrdersPage() { {detail.receiverAddress} + + 权益核销 + + {detail.redeemSummary ? ( + <> + + {detail.redeemSummary.couponNo} + {detail.redeemSummary.status} + + ¥{Number(detail.redeemSummary.totalAmount).toFixed(2)} + + + + ¥{Number(detail.redeemSummary.usedAmount).toFixed(2)} + + + ({detail.redeemSummary.redeemCount} 笔核销单) + + + + ¥{Number(detail.redeemSummary.balance).toFixed(2)} + + + ¥{Number(detail.redeemSummary.redeemRecordSum).toFixed(2)} + + (本券分摊合计) + + + + + v === 'SECONDARY' ? 次券 : 主券, + }, + { + title: '门店', + dataIndex: ['store', 'name'], + ellipsis: true, + render: (v: string | undefined, row) => + v ? `${v}${row.store?.cityName ? `(${row.store.cityName})` : ''}` : '—', + }, + { + title: '本券分摊', + dataIndex: 'couponAmount', + width: 95, + render: (v: number | undefined, row) => + `¥${Number(v ?? row.amount).toFixed(2)}`, + }, + { + title: '核销总额', + dataIndex: 'amount', + width: 90, + render: (v: number) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '结算额', + dataIndex: 'settleAmount', + width: 90, + render: (v: number) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '时间', + dataIndex: 'createdAt', + width: 150, + render: (v: string) => fmtTime(v), + }, + { + title: '操作', + width: 70, + render: (_, row) => ( + + ), + }, + ]} + /> + + ) : ( + 该订单尚未生成权益券,无核销记录 + )} + {detail.clientIp || '—'} {[detail.ipProvince, detail.ipCity, detail.ipDistrict].filter(Boolean).join(' / ') || '—'} @@ -618,6 +751,95 @@ export default function OrdersPage() { )} + { + setRedeemDrawerOpen(false); + setRedeemDetail(null); + }} + destroyOnClose + > + {redeemDetailLoading ? ( + 加载中… + ) : redeemDetail ? ( + + {String(redeemDetail.redeemNo ?? '—')} + + ¥{Number(redeemDetail.amount ?? 0).toFixed(2)} + + + ¥{Number(redeemDetail.settleAmount ?? 0).toFixed(2)} + + {fmtTime(String(redeemDetail.createdAt ?? ''))} + + {String((redeemDetail.user as { userNo?: string } | undefined)?.userNo ?? '—')} + {(redeemDetail.user as { phone?: string | null } | undefined)?.phone + ? ` / ${(redeemDetail.user as { phone?: string | null }).phone}` + : ''} + + + {String((redeemDetail.store as { name?: string } | undefined)?.name ?? '—')} + {(redeemDetail.store as { cityName?: string } | undefined)?.cityName + ? `(${(redeemDetail.store as { cityName?: string }).cityName})` + : ''} + + + {String((redeemDetail.store as { address?: string } | undefined)?.address ?? '—')} + + + {String( + (redeemDetail.store as { partnerAccount?: { companyName?: string } } | undefined) + ?.partnerAccount?.companyName ?? '—', + )} + + + {String((redeemDetail.coupon as { couponNo?: string } | undefined)?.couponNo ?? '—')} + + + {String( + (redeemDetail.coupon as { order?: { orderNo?: string } } | undefined)?.order?.orderNo ?? + '—', + )} + + {Array.isArray(redeemDetail.allocations) && + (redeemDetail.allocations as unknown[]).length > 0 ? ( + + {( + redeemDetail.allocations as Array<{ + couponNo?: string; + orderNo?: string | null; + amount?: number; + sortOrder?: number; + }> + ) + .map((a, idx) => { + const role = idx === 0 ? '主' : '次'; + return `${role} ${a.couponNo ?? '—'}¥${Number(a.amount ?? 0).toFixed(2)}${ + a.orderNo ? `(订单 ${a.orderNo})` : '' + }`; + }) + .join(';')} + + ) : null} + {redeemDetail.payout ? ( + + ¥{Number((redeemDetail.payout as { settleAmount?: number }).settleAmount ?? 0).toFixed(2)} + {' / '} + {String((redeemDetail.payout as { status?: string }).status ?? '—')} + + ) : null} + {redeemDetail.rating ? ( + + 服务 {(redeemDetail.rating as { serviceScore?: number }).serviceScore ?? '—'} 分 / 环境{' '} + {(redeemDetail.rating as { envScore?: number }).envScore ?? '—'} 分 + + ) : null} + + ) : null} + + { + const couponAmount = + r.allocations[0] != null + ? Number(r.allocations[0].amount) + : r.couponId === coupon!.id + ? Number(r.amount) + : 0; + const isPrimary = r.couponId === coupon!.id; + return { + id: r.id, + redeemNo: r.redeemNo, + amount: Number(r.amount), + settleAmount: Number(r.settleAmount), + couponAmount, + role: isPrimary ? 'PRIMARY' : 'SECONDARY', + createdAt: r.createdAt, + store: r.store, + }; + }); + const redeemRecordSum = redeemRecords.reduce((sum, r) => sum + r.couponAmount, 0); + + const { benefitCoupon: _coupon, ...orderRest } = order; + + 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: coupon + ? { + couponNo: coupon.couponNo, + totalAmount: Number(coupon.totalAmount), + usedAmount: Number(coupon.usedAmount), + balance: Number(coupon.balance), + status: coupon.status, + redeemCount: redeemRecords.length, + redeemRecordSum, + } + : null, + redeemRecords, + }), + ); } async updateStatusDebug(id: bigint, status: string) { @@ -309,7 +389,12 @@ export class AdminOrdersService { if (couponIds.length) { const redeemIds = ( await tx.redeemRecord.findMany({ - where: { couponId: { in: couponIds } }, + where: { + OR: [ + { couponId: { in: couponIds } }, + { allocations: { some: { couponId: { in: couponIds } } } }, + ], + }, select: { id: true }, }) ).map((r) => r.id); @@ -317,6 +402,7 @@ export class AdminOrdersService { 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 } } }); } @@ -332,4 +418,148 @@ export class AdminOrdersService { }); await tx.order.deleteMany({ where: { id: { in: orderIds } } }); } + + /** + * 补齐历史核销分摊: + * 1) 待审核销 pending.allocationsJson → allocation 表 + * 2) 权益流水 REDEEM(次券)→ 按用户/门店/时间窗匹配核销单 + * 3) 仅主券、尚无分摊行的核销单 → 写入单行分摊(全额) + */ + 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; + + // 优先挂到主券不是本券的核销单(跨券 FIFO 次券场景) + 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); + } } diff --git a/server/dukang-api/src/modules/ops/admin-redeem.service.ts b/server/dukang-api/src/modules/ops/admin-redeem.service.ts index 9bfb880..bb58fad 100644 --- a/server/dukang-api/src/modules/ops/admin-redeem.service.ts +++ b/server/dukang-api/src/modules/ops/admin-redeem.service.ts @@ -39,14 +39,56 @@ export class AdminRedeemService { const record = await this.prisma.redeemRecord.findUnique({ where: { id }, include: { - user: true, - store: { include: { partnerAccount: { select: { id: true, companyName: true } } } }, - coupon: true, + 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); + 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, + })), + }); } } diff --git a/server/dukang-api/src/modules/redeem/redeem.service.ts b/server/dukang-api/src/modules/redeem/redeem.service.ts index dcdb07b..395958a 100644 --- a/server/dukang-api/src/modules/redeem/redeem.service.ts +++ b/server/dukang-api/src/modules/redeem/redeem.service.ts @@ -181,6 +181,13 @@ export class RedeemService { storeId: account.storeId, amount, settleAmount, + allocations: { + create: normalizedAllocations.map((item, index) => ({ + couponId: BigInt(item.couponId), + amount: item.amount, + sortOrder: index, + })), + }, }, });