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
@@ -100,6 +100,27 @@ export class WechatTradeManageService {
remark,
}),
});
if (order.promoCodeId) {
const already = await tx.logPromoEvent.findFirst({
where: { orderId: order.id, eventType: 'ORDER' },
select: { id: true },
});
if (!already) {
await tx.commonPromoCode.update({
where: { id: order.promoCodeId },
data: { orderCount: { increment: 1 } },
});
await tx.logPromoEvent.create({
data: {
promoCodeId: order.promoCodeId,
eventType: 'ORDER',
userId: order.userId,
orderId: order.id,
clientIp: order.clientIp,
},
});
}
}
});
await this.logEvent(evt, order.id, 'SUCCESS', methodLabel);
@@ -37,6 +37,7 @@ type OrderFilterInput = Pick<
| 'createdTo'
| 'excludeTest'
| 'deliveryType'
| 'promoCodeId'
>;
@Injectable()
@@ -94,7 +95,7 @@ export class AdminOrdersService {
skip: (page - 1) * pageSize,
take: pageSize,
include: {
user: { select: { id: true, userNo: true, phone: true, nickname: true } },
user: { select: { id: true, userNo: true, phone: true, nickname: true, hqRemark: true } },
delivery: {
select: {
provider: true,
@@ -190,6 +191,10 @@ export class AdminOrdersService {
if (query.createdTo) where.createdAt.lte = this.endOfDay(query.createdTo);
}
if (query.excludeTest) where.isTest = false;
const promoCodeId = query.promoCodeId?.trim();
if (promoCodeId && /^\d+$/.test(promoCodeId)) {
where.promoCodeId = BigInt(promoCodeId);
}
return where;
}
@@ -252,6 +257,7 @@ export class AdminOrdersService {
userNo: true,
phone: true,
nickname: true,
hqRemark: true,
deviceKey: true,
phoneVerifiedAt: true,
},
@@ -81,6 +81,13 @@ export class AdminUsersService {
if (query.phone) where.phone = { contains: query.phone };
if (query.userNo) where.userNo = { contains: query.userNo };
const userId = query.userId?.trim();
if (userId) {
if (!/^\d+$/.test(userId)) {
return serializeBigInt({ items: [], total: 0, page, pageSize });
}
where.id = BigInt(userId);
}
if (query.deviceKey) where.deviceKey = query.deviceKey;
if (query.status !== undefined) where.status = query.status;
if (query.phoneVerified === '1') where.phoneVerifiedAt = { not: null };
@@ -48,6 +48,11 @@ export class AdminUsersQueryDto extends PaginationQueryDto {
@IsString()
userNo?: string;
/** 精确匹配用户主键,供订单等页的用户快链带入筛选 */
@IsOptional()
@IsString()
userId?: string;
@IsOptional()
@IsString()
deviceKey?: string;
@@ -116,6 +121,11 @@ export class AdminOrdersQueryDto extends PaginationQueryDto {
@IsOptional()
@IsIn(['LOCAL', 'CROSS_CITY', 'ON_SITE_PICKUP'])
deliveryType?: string;
/** 按下单时绑定的推广码筛选 */
@IsOptional()
@IsString()
promoCodeId?: string;
}
/** HQ 订单导出(筛选 + 勾选范围) */
@@ -174,6 +184,10 @@ export class AdminOrdersExportDto {
@IsOptional()
@IsIn(['LOCAL', 'CROSS_CITY', 'ON_SITE_PICKUP'])
deliveryType?: string;
@IsOptional()
@IsString()
promoCodeId?: string;
}
/** 概览页用户/订单 ECharts 聚合筛选 */
@@ -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 }),
@@ -357,25 +357,9 @@ export class TradeService {
include: { product: true, imageResource: true },
});
if (promoCodeId) {
await tx.commonPromoCode.update({
where: { id: promoCodeId },
data: { orderCount: { increment: 1 } },
});
}
return created;
});
if (promoCodeId) {
this.promoCodeService.logPromoOrderEvent(
promoCodeId,
order.id,
userId,
location.clientIp ?? undefined,
);
}
this.analyticsService.trackOneSafe(userId, this.resolveTrackedUserClientApp(clientApp), {
eventName: 'order_submit',
refType: 'ORDER',
@@ -526,6 +510,15 @@ export class TradeService {
});
if (!order) return;
if (order.status === 'COMPLETED' && order.promoCodeId) {
await this.promoCodeService.recordCompletedOrder(
order.promoCodeId,
order.id,
order.userId,
order.clientIp ?? undefined,
);
}
if (!order.isTest) {
const unit = order.saleUnit === 'BOX' ? '箱' : '瓶';
const spec = (order.productSpec || '').trim();
@@ -1593,6 +1586,15 @@ export class TradeService {
});
});
if (targetStatus === 'COMPLETED' && currentStatus !== 'COMPLETED' && order.promoCodeId) {
await this.promoCodeService.recordCompletedOrder(
order.promoCodeId,
order.id,
order.userId,
order.clientIp ?? undefined,
);
}
// 发货信息管理:进入 SHIPPING 时向微信录入(解冻结算前置)
if (targetStatus === 'SHIPPING' && currentStatus !== 'SHIPPING') {
this.wechatOrderShipping.uploadForOrderSafe(orderId);
@@ -1934,25 +1936,9 @@ export class TradeService {
}),
});
if (promoCodeId) {
await tx.commonPromoCode.update({
where: { id: promoCodeId },
data: { orderCount: { increment: 1 } },
});
}
return created;
});
if (promoCodeId) {
this.promoCodeService.logPromoOrderEvent(
promoCodeId,
order.id,
user.id,
location.clientIp ?? undefined,
);
}
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerAccountId: primary.id,
eventName: 'partner_proxy_order_create',
@@ -2347,25 +2333,9 @@ export class TradeService {
}),
});
if (promoCodeId) {
await tx.commonPromoCode.update({
where: { id: promoCodeId },
data: { orderCount: { increment: 1 } },
});
}
return created;
});
if (promoCodeId) {
this.promoCodeService.logPromoOrderEvent(
promoCodeId,
order.id,
user.id,
location.clientIp ?? undefined,
);
}
return {
id: order.id.toString(),
orderNo: order.orderNo,