feat(promo): HQ 推广码订单只计已完成并补扫码订单快链

列表与详情的订单数/转化率按 COMPLETED 实时统计;扫码、订单、事件 ID 可跳到对应页。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-28 12:55:19 +08:00
parent 737efb89a3
commit fccfd7abbe
15 changed files with 421 additions and 124 deletions
@@ -151,7 +151,7 @@ export class PromoCodeService {
});
}
private mapRow(row: PromoRow) {
private mapRow(row: PromoRow, orderCount?: number) {
return serializeBigInt({
id: row.id,
code: row.code,
@@ -161,7 +161,7 @@ export class PromoCodeService {
status: row.status,
remark: row.remark,
scanCount: row.scanCount,
orderCount: row.orderCount,
orderCount: orderCount ?? row.orderCount,
landingUrl: buildLandingUrl(row.code, row.qrcodeId),
qrcodeUrl: row.qrcodeResource?.url ?? null,
ownerUser: this.mapOwnerUser(row.ownerUser),
@@ -207,9 +207,10 @@ export class PromoCodeService {
}),
this.prisma.commonPromoCode.count({ where }),
]);
const completedByPromo = await this.completedOrderCounts(items.map((r) => r.id));
return serializeBigInt({
items: items.map((r) => this.mapRow(r)),
items: items.map((r) => this.mapRow(r, completedByPromo.get(r.id.toString()) ?? 0)),
total,
page,
pageSize,
@@ -223,7 +224,7 @@ export class PromoCodeService {
});
if (!row) throw new NotFoundException('推广码不存在');
const stats = await this.statsFromRow(row);
return serializeBigInt({ ...this.mapRow(row), stats });
return serializeBigInt({ ...this.mapRow(row, stats.orderCount), stats });
}
private async resolveOwnerUserId(ownerUserId?: string) {
@@ -519,6 +520,40 @@ export class PromoCodeService {
this.logPromoMetric(promoCodeId, 'ORDER', { clientIp }, { userId, orderId });
}
/** 订单完成时计入推广码订单数(下单/待付款不计入) */
async recordCompletedOrder(
promoCodeId: bigint,
orderId: bigint,
userId: bigint,
clientIp?: string,
): Promise<void> {
const already = await this.prisma.logPromoEvent.findFirst({
where: { orderId, eventType: 'ORDER' },
select: { id: true },
});
if (already) return;
await this.prisma.commonPromoCode.update({
where: { id: promoCodeId },
data: { orderCount: { increment: 1 } },
});
this.logPromoOrderEvent(promoCodeId, orderId, userId, clientIp);
}
private async completedOrderCounts(promoIds: bigint[]): Promise<Map<string, number>> {
const map = new Map<string, number>();
if (!promoIds.length) return map;
const rows = await this.prisma.order.groupBy({
by: ['promoCodeId'],
where: { promoCodeId: { in: promoIds }, status: 'COMPLETED' },
_count: { _all: true },
});
for (const row of rows) {
if (row.promoCodeId == null) continue;
map.set(row.promoCodeId.toString(), row._count._all);
}
return map;
}
private logPromoMetric(
promoCodeId: bigint,
eventType: PromoMetricEventType,
@@ -610,12 +645,37 @@ export class PromoCodeService {
this.prisma.logPromoEvent.count({ where }),
]);
const userIds = [...new Set(items.map((row) => row.userId).filter((id): id is bigint => id != null))];
const orderIds = [...new Set(items.map((row) => row.orderId).filter((id): id is bigint => id != null))];
const [users, orders] = await Promise.all([
userIds.length
? this.prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, userNo: true },
})
: [],
orderIds.length
? this.prisma.order.findMany({
where: { id: { in: orderIds } },
select: { id: true, orderNo: true },
})
: [],
]);
const userNoById = new Map<string, string | null>(
users.map((u): [string, string | null] => [u.id.toString(), u.userNo]),
);
const orderNoById = new Map<string, string>(
orders.map((o): [string, string] => [o.id.toString(), o.orderNo]),
);
return serializeBigInt({
items: items.map((row) => ({
id: row.id,
eventType: row.eventType,
userId: row.userId,
userNo: row.userId ? userNoById.get(row.userId.toString()) ?? null : null,
orderId: row.orderId,
orderNo: row.orderId ? orderNoById.get(row.orderId.toString()) ?? null : null,
sessionId: row.sessionId,
clientIp: row.clientIp,
ipProvince: row.ipProvince,
@@ -636,12 +696,9 @@ export class PromoCodeService {
if (!promo) throw new NotFoundException('推广码不存在');
}
private async statsFromRow(row: { id: bigint; scanCount: number; orderCount: number }) {
private async statsFromRow(row: { id: bigint; scanCount: number }) {
const scanCount = row.scanCount;
const orderCount = row.orderCount;
const conversionRate =
scanCount > 0 ? Math.round((orderCount / scanCount) * 1000) / 10 : 0;
const [attributionCount, sourceMarkedCount] = await Promise.all([
const [attributionCount, sourceMarkedCount, orderCount] = await Promise.all([
this.prisma.userPromoAttribution.count({
where: { promoCodeId: row.id },
}),
@@ -652,7 +709,12 @@ export class PromoCodeService {
mergedIntoUserId: null,
},
}),
this.prisma.order.count({
where: { promoCodeId: row.id, status: 'COMPLETED' },
}),
]);
const conversionRate =
scanCount > 0 ? Math.round((orderCount / scanCount) * 1000) / 10 : 0;
return {
scanCount,
orderCount,
@@ -695,7 +757,7 @@ export class PromoCodeService {
sourceRefId: true,
createdAt: true,
promoTouch: { select: { firstTouchAt: true, promoCodeId: true } },
_count: { select: { orders: true } },
_count: { select: { orders: { where: { status: 'COMPLETED' } } } },
},
}),
this.prisma.user.count({ where }),