feat(redeem): 核销记录标记扫码/手机号并支持方式统计
落库 RedeemChannel,门店记录页增加统计按钮,HQ 可按方式筛选。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -52,6 +52,12 @@ enum RedeemPendingStatus {
|
||||
REJECTED
|
||||
}
|
||||
|
||||
/// Redeem channel: SCAN or PHONE
|
||||
enum RedeemChannel {
|
||||
SCAN
|
||||
PHONE
|
||||
}
|
||||
|
||||
enum ResourceMediaType {
|
||||
IMAGE
|
||||
VIDEO
|
||||
@@ -1318,14 +1324,16 @@ model BenefitCoupon {
|
||||
}
|
||||
|
||||
model RedeemRecord {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
redeemNo String @unique @map("redeem_no") @db.VarChar(32)
|
||||
userId BigInt @map("user_id") @db.UnsignedBigInt
|
||||
couponId BigInt @map("coupon_id") @db.UnsignedBigInt
|
||||
storeId BigInt @map("store_id") @db.UnsignedBigInt
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
redeemNo String @unique @map("redeem_no") @db.VarChar(32)
|
||||
userId BigInt @map("user_id") @db.UnsignedBigInt
|
||||
couponId BigInt @map("coupon_id") @db.UnsignedBigInt
|
||||
storeId BigInt @map("store_id") @db.UnsignedBigInt
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2)
|
||||
/// SCAN=qrcode, PHONE=phone
|
||||
channel RedeemChannel @default(SCAN)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
coupon BenefitCoupon @relation(fields: [couponId], references: [id], onDelete: Restrict)
|
||||
@@ -1336,6 +1344,7 @@ model RedeemRecord {
|
||||
allocations RedeemRecordAllocation[]
|
||||
|
||||
@@index([storeId, createdAt])
|
||||
@@index([storeId, channel, createdAt])
|
||||
@@map("user_redeem_record")
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ export class AdminRedeemService {
|
||||
if (query.redeemNo) where.redeemNo = { contains: query.redeemNo };
|
||||
if (query.storeId) where.storeId = BigInt(query.storeId);
|
||||
if (query.userId) where.userId = BigInt(query.userId);
|
||||
if (query.channel === 'SCAN' || query.channel === 'PHONE') {
|
||||
where.channel = query.channel;
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.redeemRecord.findMany({
|
||||
|
||||
@@ -242,6 +242,11 @@ export class AdminRedeemRecordsQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userId?: string;
|
||||
|
||||
/** SCAN | PHONE */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
channel?: string;
|
||||
}
|
||||
|
||||
export class AdminStoreRatingsQueryDto extends PaginationQueryDto {
|
||||
|
||||
@@ -96,6 +96,16 @@ export class ShopRedeemController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
stats(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Query('range') range?: string,
|
||||
) {
|
||||
const normalized =
|
||||
range === '7d' || range === '30d' || range === 'today' ? range : 'today';
|
||||
return this.redeemService.getShopRedeemStats(user.actorId, user.storeId!, normalized);
|
||||
}
|
||||
|
||||
@Post('phone/send-lookup-sms')
|
||||
sendPhoneLookupSms(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneSendLookupSmsDto) {
|
||||
return this.redeemService.sendPhoneLookupSms(user.actorId, user.storeId!, body.phone);
|
||||
|
||||
@@ -178,6 +178,7 @@ export class RedeemService {
|
||||
) {
|
||||
const settlementRate = Number(account.store.settlementRate);
|
||||
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
|
||||
const redeemChannel = analyticsExtra?.channel === 'phone' ? 'PHONE' : 'SCAN';
|
||||
|
||||
let record;
|
||||
try {
|
||||
@@ -192,6 +193,7 @@ export class RedeemService {
|
||||
storeId: account.storeId,
|
||||
amount,
|
||||
settleAmount,
|
||||
channel: redeemChannel,
|
||||
allocations: {
|
||||
create: normalizedAllocations.map((item, index) => ({
|
||||
couponId: BigInt(item.couponId),
|
||||
@@ -1079,6 +1081,51 @@ export class RedeemService {
|
||||
return { list: serializeBigInt(list), total, page, pageSize };
|
||||
}
|
||||
|
||||
async getShopRedeemStats(
|
||||
storeAccountId: bigint,
|
||||
storeId: bigint,
|
||||
range: 'today' | '7d' | '30d' = 'today',
|
||||
) {
|
||||
await this.prisma.storeAccountStore.findUniqueOrThrow({
|
||||
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
||||
});
|
||||
const start = new Date();
|
||||
start.setHours(0, 0, 0, 0);
|
||||
if (range === '7d') start.setDate(start.getDate() - 6);
|
||||
if (range === '30d') start.setDate(start.getDate() - 29);
|
||||
|
||||
const records = await this.prisma.redeemRecord.findMany({
|
||||
where: { storeId, createdAt: { gte: start } },
|
||||
select: { channel: true, amount: true, settleAmount: true },
|
||||
});
|
||||
|
||||
const buckets: Record<'SCAN' | 'PHONE', { count: number; amount: number; settleAmount: number }> = {
|
||||
SCAN: { count: 0, amount: 0, settleAmount: 0 },
|
||||
PHONE: { count: 0, amount: 0, settleAmount: 0 },
|
||||
};
|
||||
for (const r of records) {
|
||||
const key = r.channel === 'PHONE' ? 'PHONE' : 'SCAN';
|
||||
buckets[key].count += 1;
|
||||
buckets[key].amount += Number(r.amount);
|
||||
buckets[key].settleAmount += Number(r.settleAmount);
|
||||
}
|
||||
|
||||
const byChannel = (['SCAN', 'PHONE'] as const).map((channel) => ({
|
||||
channel,
|
||||
count: buckets[channel].count,
|
||||
amount: Number(buckets[channel].amount.toFixed(2)),
|
||||
settleAmount: Number(buckets[channel].settleAmount.toFixed(2)),
|
||||
}));
|
||||
|
||||
return {
|
||||
range,
|
||||
totalCount: records.length,
|
||||
totalAmount: Number(byChannel.reduce((s, b) => s + b.amount, 0).toFixed(2)),
|
||||
totalSettleAmount: Number(byChannel.reduce((s, b) => s + b.settleAmount, 0).toFixed(2)),
|
||||
byChannel,
|
||||
};
|
||||
}
|
||||
|
||||
async getShopDashboard(storeAccountId: bigint, storeId: bigint) {
|
||||
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
|
||||
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
||||
@@ -1091,6 +1138,8 @@ export class RedeemService {
|
||||
});
|
||||
const todayCount = records.length;
|
||||
const todayAmount = records.reduce((sum, r) => sum + Number(r.amount), 0);
|
||||
const todayScanCount = records.filter((r) => r.channel !== 'PHONE').length;
|
||||
const todayPhoneCount = records.filter((r) => r.channel === 'PHONE').length;
|
||||
const recent = await this.prisma.redeemRecord.findMany({
|
||||
where: { storeId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -1100,6 +1149,8 @@ export class RedeemService {
|
||||
store: binding.store,
|
||||
todayCount,
|
||||
todayAmount,
|
||||
todayScanCount,
|
||||
todayPhoneCount,
|
||||
recentRecords: recent,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user