核销代码
This commit is contained in:
@@ -0,0 +1,100 @@
|
|||||||
|
import '../src/load-env';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
import { PrismaService } from '../src/common/prisma/prisma.module';
|
||||||
|
import { RedeemService } from '../src/modules/redeem/redeem.service';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn'] });
|
||||||
|
const prisma = app.get(PrismaService);
|
||||||
|
const redeem = app.get(RedeemService);
|
||||||
|
|
||||||
|
let coupon = await prisma.benefitCoupon.findFirst({
|
||||||
|
where: { status: 'ACTIVE', balance: { gt: 10 } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
if (!coupon) {
|
||||||
|
const user = await prisma.user.findFirst({ orderBy: { id: 'asc' } });
|
||||||
|
const city = await prisma.commonCity.findFirst();
|
||||||
|
const product = await prisma.commonProductItem.findFirst();
|
||||||
|
if (!user || !city || !product) throw new Error('seed base data missing');
|
||||||
|
const order = await prisma.order.create({
|
||||||
|
data: {
|
||||||
|
orderNo: `T${Date.now()}`,
|
||||||
|
userId: user.id,
|
||||||
|
cityId: city.id,
|
||||||
|
status: 'COMPLETED',
|
||||||
|
payStatus: 'PAID',
|
||||||
|
deliveryType: 'LOCAL',
|
||||||
|
productId: product.id,
|
||||||
|
barcode69: product.barcode69,
|
||||||
|
productName: product.name,
|
||||||
|
productSpec: product.spec ?? '500ml',
|
||||||
|
quantity: 2,
|
||||||
|
listUnitPrice: 100,
|
||||||
|
listAmount: 200,
|
||||||
|
productAmount: 200,
|
||||||
|
payAmount: 200,
|
||||||
|
benefitAmount: 200,
|
||||||
|
receiverName: 'test',
|
||||||
|
receiverPhone: user.phone ?? '13800000001',
|
||||||
|
receiverProvince: '河南',
|
||||||
|
receiverCity: '郑州',
|
||||||
|
receiverDistrict: '金水',
|
||||||
|
receiverAddress: 'test addr',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
coupon = await prisma.benefitCoupon.create({
|
||||||
|
data: {
|
||||||
|
couponNo: `TEST${Date.now()}`,
|
||||||
|
userId: user.id,
|
||||||
|
orderId: order.id,
|
||||||
|
totalAmount: 200,
|
||||||
|
usedAmount: 0,
|
||||||
|
balance: 200,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
sourceProduct: product.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('created test coupon', coupon.id.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeAccount = await prisma.storeAccount.findFirst({
|
||||||
|
where: { status: 'ACTIVE', store: { status: 'OPEN' } },
|
||||||
|
include: { store: true },
|
||||||
|
});
|
||||||
|
if (!storeAccount) throw new Error('no active store account on OPEN store');
|
||||||
|
|
||||||
|
console.log('userId', coupon.userId.toString());
|
||||||
|
console.log('storeId', storeAccount.storeId.toString());
|
||||||
|
console.log('coupon balance', coupon.balance.toString());
|
||||||
|
|
||||||
|
const tokenRes = await redeem.createToken(coupon.userId, {
|
||||||
|
amount: 10,
|
||||||
|
storeId: storeAccount.storeId.toString(),
|
||||||
|
});
|
||||||
|
console.log('token created', tokenRes.token);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const preview = await redeem.previewRedeem(storeAccount.id, tokenRes.token);
|
||||||
|
console.log('preview ok', JSON.stringify(preview));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('preview failed:', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const record = await redeem.confirmRedeem(storeAccount.id, { token: tokenRes.token });
|
||||||
|
console.log('confirm ok', JSON.stringify(record));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('confirm failed:', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -5,6 +5,23 @@ import {
|
|||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
function prismaErrorMessage(exception: Prisma.PrismaClientKnownRequestError): string {
|
||||||
|
switch (exception.code) {
|
||||||
|
case 'P2002':
|
||||||
|
return '数据冲突,请刷新后重试';
|
||||||
|
case 'P2003':
|
||||||
|
return '关联数据不存在,请检查门店/权益配置';
|
||||||
|
case 'P2021':
|
||||||
|
case 'P2022':
|
||||||
|
return '数据库表结构未同步,请在服务器执行 prisma db push';
|
||||||
|
case 'P2025':
|
||||||
|
return '记录不存在或已被删除';
|
||||||
|
default:
|
||||||
|
return exception.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Catch()
|
@Catch()
|
||||||
export class HttpExceptionFilter implements ExceptionFilter {
|
export class HttpExceptionFilter implements ExceptionFilter {
|
||||||
@@ -27,10 +44,29 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (exception instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
console.error(exception);
|
||||||
|
response.status(HttpStatus.BAD_REQUEST).json({
|
||||||
|
code: 400,
|
||||||
|
message: prismaErrorMessage(exception),
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exception instanceof SyntaxError && /BigInt/i.test(exception.message)) {
|
||||||
|
response.status(HttpStatus.BAD_REQUEST).json({
|
||||||
|
code: 400,
|
||||||
|
message: 'ID 格式无效',
|
||||||
|
data: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
console.error(exception);
|
console.error(exception);
|
||||||
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
|
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
|
||||||
code: 500,
|
code: 500,
|
||||||
message: 'Internal server error',
|
message: exception instanceof Error ? exception.message : 'Internal server error',
|
||||||
data: null,
|
data: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,10 @@ export class BenefitService {
|
|||||||
const coupon = await tx.benefitCoupon.findUniqueOrThrow({
|
const coupon = await tx.benefitCoupon.findUniqueOrThrow({
|
||||||
where: { id: BigInt(alloc.couponId) },
|
where: { id: BigInt(alloc.couponId) },
|
||||||
});
|
});
|
||||||
const allocAmount = alloc.amount;
|
const allocAmount = Number(alloc.amount);
|
||||||
|
if (!Number.isFinite(allocAmount) || allocAmount <= 0) {
|
||||||
|
throw new Error('BENEFIT_ALLOC_INVALID');
|
||||||
|
}
|
||||||
const updated = await tx.benefitCoupon.updateMany({
|
const updated = await tx.benefitCoupon.updateMany({
|
||||||
where: { id: coupon.id, version: coupon.version, balance: { gte: allocAmount } },
|
where: { id: coupon.id, version: coupon.version, balance: { gte: allocAmount } },
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { RedeemService } from '../redeem/redeem.service';
|
import { RedeemService } from '../redeem/redeem.service';
|
||||||
import type {
|
import type {
|
||||||
@@ -13,9 +13,17 @@ export class AdminRedeemDebugService {
|
|||||||
private readonly redeemService: RedeemService,
|
private readonly redeemService: RedeemService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private parseId(value: string, label: string): bigint {
|
||||||
|
const normalized = value?.trim();
|
||||||
|
if (!normalized || !/^\d+$/.test(normalized)) {
|
||||||
|
throw new BadRequestException(`${label}格式无效`);
|
||||||
|
}
|
||||||
|
return BigInt(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
private async resolveStoreAccountId(storeId: string): Promise<bigint> {
|
private async resolveStoreAccountId(storeId: string): Promise<bigint> {
|
||||||
const account = await this.prisma.storeAccount.findFirst({
|
const account = await this.prisma.storeAccount.findFirst({
|
||||||
where: { storeId: BigInt(storeId), status: 'ACTIVE' },
|
where: { storeId: this.parseId(storeId, '门店 ID'), status: 'ACTIVE' },
|
||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
select: { id: true, store: { select: { name: true } } },
|
select: { id: true, store: { select: { name: true } } },
|
||||||
});
|
});
|
||||||
@@ -26,7 +34,7 @@ export class AdminRedeemDebugService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createToken(dto: AdminRedeemDebugCreateTokenDto) {
|
async createToken(dto: AdminRedeemDebugCreateTokenDto) {
|
||||||
return this.redeemService.createToken(BigInt(dto.userId), {
|
return this.redeemService.createToken(this.parseId(dto.userId, '用户 ID'), {
|
||||||
amount: dto.amount,
|
amount: dto.amount,
|
||||||
couponId: dto.couponId,
|
couponId: dto.couponId,
|
||||||
storeId: dto.storeId,
|
storeId: dto.storeId,
|
||||||
|
|||||||
@@ -150,23 +150,38 @@ export class RedeemService {
|
|||||||
throw new BadRequestException('核销码数据异常');
|
throw new BadRequestException('核销码数据异常');
|
||||||
}
|
}
|
||||||
|
|
||||||
const allocSum = allocations.reduce((sum, item) => sum + item.amount, 0);
|
const normalizedAllocations = allocations.map((item) => ({
|
||||||
if (Math.abs(allocSum - cached.amount) > 0.001) {
|
couponId: String(item.couponId),
|
||||||
|
amount: Number(item.amount),
|
||||||
|
}));
|
||||||
|
const tokenAmount = Number(cached.amount);
|
||||||
|
if (!Number.isFinite(tokenAmount) || tokenAmount <= 0) {
|
||||||
throw new BadRequestException('核销码数据异常');
|
throw new BadRequestException('核销码数据异常');
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const alloc of allocations) {
|
const allocSum = normalizedAllocations.reduce((sum, item) => sum + item.amount, 0);
|
||||||
|
if (Math.abs(allocSum - tokenAmount) > 0.001) {
|
||||||
|
throw new BadRequestException('核销码数据异常');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const alloc of normalizedAllocations) {
|
||||||
|
let couponId: bigint;
|
||||||
|
try {
|
||||||
|
couponId = BigInt(alloc.couponId);
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException('核销码数据异常');
|
||||||
|
}
|
||||||
const coupon = await this.prisma.benefitCoupon.findUnique({
|
const coupon = await this.prisma.benefitCoupon.findUnique({
|
||||||
where: { id: BigInt(alloc.couponId) },
|
where: { id: couponId },
|
||||||
});
|
});
|
||||||
if (!coupon) throw new BadRequestException('券不存在');
|
if (!coupon) throw new BadRequestException('券不存在');
|
||||||
const check = validateRedeemAmount(Number(coupon.balance), alloc.amount, Number(coupon.balance));
|
const check = validateRedeemAmount(Number(coupon.balance), alloc.amount, Number(coupon.balance));
|
||||||
if (!check.ok) throw new BadRequestException(check.message);
|
if (!check.ok) throw new BadRequestException(check.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const amount = Number(cached.amount);
|
const amount = tokenAmount;
|
||||||
const cityRule = await this.prisma.commonCityCommissionRule.findFirst({
|
const cityRule = await this.prisma.commonCityCommissionRule.findUnique({
|
||||||
where: { city: { stores: { some: { id: account.storeId } } } },
|
where: { cityId: account.store.cityId },
|
||||||
});
|
});
|
||||||
const settlementRate = cityRule ? Number(cityRule.storeSettlementRate) : 0.6;
|
const settlementRate = cityRule ? Number(cityRule.storeSettlementRate) : 0.6;
|
||||||
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
|
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
|
||||||
@@ -174,29 +189,40 @@ export class RedeemService {
|
|||||||
let record;
|
let record;
|
||||||
try {
|
try {
|
||||||
record = await this.prisma.$transaction(async (tx) => {
|
record = await this.prisma.$transaction(async (tx) => {
|
||||||
await this.benefitService.deductCoupons(tx, allocations, 'STORE', account.storeId);
|
await this.benefitService.deductCoupons(tx, normalizedAllocations, 'STORE', account.storeId);
|
||||||
|
|
||||||
const redeemRecord = await tx.redeemRecord.create({
|
const redeemRecord = await tx.redeemRecord.create({
|
||||||
data: {
|
data: {
|
||||||
redeemNo: generateRedeemNo(),
|
redeemNo: generateRedeemNo(),
|
||||||
userId: BigInt(cached.userId),
|
userId: BigInt(cached.userId),
|
||||||
couponId: BigInt(allocations[0].couponId),
|
couponId: BigInt(normalizedAllocations[0].couponId),
|
||||||
storeId: account.storeId,
|
storeId: account.storeId,
|
||||||
amount,
|
amount,
|
||||||
settleAmount,
|
settleAmount,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.settlementService.createStorePayout(
|
||||||
|
redeemRecord.id,
|
||||||
|
account.storeId,
|
||||||
|
amount,
|
||||||
|
settleAmount,
|
||||||
|
settlementRate,
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
|
||||||
return redeemRecord;
|
return redeemRecord;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error && e.message === 'BENEFIT_DEDUCT_CONFLICT') {
|
if (e instanceof Error && e.message === 'BENEFIT_DEDUCT_CONFLICT') {
|
||||||
throw new BadRequestException('核销失败,请重试');
|
throw new BadRequestException('核销失败,请重试');
|
||||||
}
|
}
|
||||||
|
if (e instanceof Error && e.message === 'BENEFIT_ALLOC_INVALID') {
|
||||||
|
throw new BadRequestException('核销码数据异常');
|
||||||
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.settlementService.createStorePayout(record.id, account.storeId, amount, settleAmount, settlementRate);
|
|
||||||
await this.redis.del(`redeem:token:${body.token}`);
|
await this.redis.del(`redeem:token:${body.token}`);
|
||||||
|
|
||||||
this.analyticsService.trackOneSafe(BigInt(cached.userId), 'SHOP_H5', {
|
this.analyticsService.trackOneSafe(BigInt(cached.userId), 'SHOP_H5', {
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ export class SettlementService {
|
|||||||
redeemAmount: number,
|
redeemAmount: number,
|
||||||
payoutAmount: number,
|
payoutAmount: number,
|
||||||
settlementRate: number,
|
settlementRate: number,
|
||||||
|
tx?: Prisma.TransactionClient,
|
||||||
) {
|
) {
|
||||||
const expectedPayAt = new Date();
|
const expectedPayAt = new Date();
|
||||||
expectedPayAt.setDate(expectedPayAt.getDate() + 1);
|
expectedPayAt.setDate(expectedPayAt.getDate() + 1);
|
||||||
const payout = await this.prisma.storePayout.create({
|
const client = tx ?? this.prisma;
|
||||||
|
const payout = await client.storePayout.create({
|
||||||
data: {
|
data: {
|
||||||
redeemRecordId,
|
redeemRecordId,
|
||||||
storeId,
|
storeId,
|
||||||
|
|||||||
Reference in New Issue
Block a user