后端增加发放好客权益券的接口

This commit is contained in:
2026-07-12 20:28:43 +08:00
parent b267e885be
commit b5235dedef
8 changed files with 303 additions and 34 deletions
+2 -2
View File
@@ -876,7 +876,7 @@ model BenefitCoupon {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
couponNo String @unique @map("coupon_no") @db.VarChar(32)
userId BigInt @map("user_id") @db.UnsignedBigInt
orderId BigInt @unique @map("order_id") @db.UnsignedBigInt
orderId BigInt? @unique @map("order_id") @db.UnsignedBigInt
totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
usedAmount Decimal @default(0) @map("used_amount") @db.Decimal(10, 2)
balance Decimal @db.Decimal(10, 2)
@@ -887,7 +887,7 @@ model BenefitCoupon {
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
order Order @relation(fields: [orderId], references: [id], onDelete: Restrict)
order Order? @relation(fields: [orderId], references: [id], onDelete: Restrict)
redeemRecords RedeemRecord[]
@@index([userId, status])
@@ -37,6 +37,7 @@ export const HqOperationAction = {
PRODUCT_TEMPLATE_CREATE: 'PRODUCT_TEMPLATE_CREATE',
PRODUCT_TEMPLATE_UPDATE: 'PRODUCT_TEMPLATE_UPDATE',
BENEFIT_COUPON_VOID: 'BENEFIT_COUPON_VOID',
BENEFIT_COUPON_GRANT: 'BENEFIT_COUPON_GRANT',
DELIVERY_UPDATE: 'DELIVERY_UPDATE',
TICKET_APPROVE: 'TICKET_APPROVE',
TICKET_REJECT: 'TICKET_REJECT',
@@ -95,6 +96,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.PRODUCT_TEMPLATE_CREATE]: '新增详情模板',
[HqOperationAction.PRODUCT_TEMPLATE_UPDATE]: '编辑详情模板',
[HqOperationAction.BENEFIT_COUPON_VOID]: '作废权益券',
[HqOperationAction.BENEFIT_COUPON_GRANT]: '手动发放权益',
[HqOperationAction.DELIVERY_UPDATE]: '编辑配送单',
[HqOperationAction.TICKET_APPROVE]: '工单通过',
[HqOperationAction.TICKET_REJECT]: '工单驳回',
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { calcBenefitAmount, calcBenefitSummary, generateCouponNo } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
@@ -50,6 +50,51 @@ export class BenefitService {
return serializeBigInt(coupon);
}
/** HQ 手动发放权益(无关联订单) */
async grantManual(params: {
userId: bigint;
amount: number;
remark?: string;
sourceProduct?: string;
}) {
const amount = Number(params.amount);
if (!Number.isFinite(amount) || amount <= 0) {
throw new BadRequestException('权益金额须大于 0');
}
if (amount > 999_999.99) {
throw new BadRequestException('权益金额超出上限');
}
const sourceProduct = params.sourceProduct?.trim() || '总部手动发放';
const remark = params.remark?.trim() || '总部手动发放';
const coupon = await this.prisma.$transaction(async (tx) => {
const created = await tx.benefitCoupon.create({
data: {
couponNo: generateCouponNo(),
userId: params.userId,
totalAmount: amount,
balance: amount,
sourceProduct,
},
});
await tx.commonEvent.create({
data: buildBenefitLedgerEvent({
userId: params.userId,
couponId: created.id,
type: 'GRANT',
amount,
balanceAfter: amount,
refType: 'ADMIN_GRANT',
remark,
}),
});
return created;
});
return serializeBigInt(coupon);
}
async listCoupons(userId: bigint) {
const list = await this.prisma.benefitCoupon.findMany({
where: { userId, status: { in: ['ACTIVE', 'USED_UP'] } },
@@ -1,9 +1,10 @@
import { Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { AdminBenefitService } from './admin-benefit.service';
import { AdminBenefitCouponsQueryDto, AdminBenefitLedgersQueryDto } from './dto/admin-query.dto';
import { AdminBenefitGrantDto } from './dto/admin-mutate.dto';
@Controller('admin/benefit/coupons')
@UseGuards(HqAuthGuard)
@@ -15,6 +16,17 @@ export class AdminBenefitCouponsController {
return this.service.listCoupons(query);
}
@Post('grant')
@HqOperation({
action: HqOperationAction.BENEFIT_COUPON_GRANT,
refType: 'BENEFIT_COUPON',
refIdField: 'id',
includeBody: true,
})
grant(@Body() dto: AdminBenefitGrantDto) {
return this.service.grantCoupon(dto);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detailCoupon(BigInt(id));
@@ -4,11 +4,16 @@ import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { buildBenefitLedgerEvent, benefitLedgerWhere } from '../../common/event/event.helpers';
import { mapBenefitLedgerCompat } from '../../common/compat/v31-compat';
import { BenefitService } from '../benefit/benefit.service';
import type { AdminBenefitCouponsQueryDto, AdminBenefitLedgersQueryDto } from './dto/admin-query.dto';
import type { AdminBenefitGrantDto } from './dto/admin-mutate.dto';
@Injectable()
export class AdminBenefitService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly benefitService: BenefitService,
) {}
async listCoupons(query: AdminBenefitCouponsQueryDto) {
const page = query.page ?? 1;
@@ -80,6 +85,32 @@ export class AdminBenefitService {
return serializeBigInt(updated);
}
async grantCoupon(dto: AdminBenefitGrantDto) {
const phone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) {
throw new BadRequestException('请输入正确的用户手机号');
}
const user = await this.prisma.user.findFirst({
where: { phone, status: 1, mergedIntoUserId: null },
select: { id: true, userNo: true, phone: true, nickname: true },
});
if (!user) {
throw new NotFoundException('未找到该手机号对应的用户');
}
const coupon = await this.benefitService.grantManual({
userId: user.id,
amount: dto.amount,
remark: dto.remark,
});
return serializeBigInt({
...coupon,
user,
});
}
async listLedgers(query: AdminBenefitLedgersQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
@@ -993,3 +993,19 @@ export class UpdatePromoCodeStatusDto {
@IsIn(['ACTIVE', 'DISABLED'])
status: 'ACTIVE' | 'DISABLED';
}
export class AdminBenefitGrantDto {
@IsString()
@IsNotEmpty()
phone: string;
@Type(() => Number)
@IsNumber()
@Min(0.01)
@Max(999999.99)
amount: number;
@IsOptional()
@IsString()
remark?: string;
}