349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
|
import { Prisma } from '@prisma/client';
|
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
|
import { AnalyticsService } from '../analytics/analytics.service';
|
|
import { PartnerCityService } from '../city-scope/partner-city.service';
|
|
|
|
function generateBillNo() {
|
|
return `PB${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
|
|
}
|
|
|
|
@Injectable()
|
|
export class SettlementService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly analyticsService: AnalyticsService,
|
|
private readonly partnerCityService: PartnerCityService,
|
|
) {}
|
|
|
|
async createStorePayout(
|
|
redeemRecordId: bigint,
|
|
storeId: bigint,
|
|
redeemAmount: number,
|
|
payoutAmount: number,
|
|
settlementRate: number,
|
|
tx?: Prisma.TransactionClient,
|
|
) {
|
|
const expectedPayAt = new Date();
|
|
expectedPayAt.setDate(expectedPayAt.getDate() + 1);
|
|
const client = tx ?? this.prisma;
|
|
const payout = await client.storePayout.create({
|
|
data: {
|
|
redeemRecordId,
|
|
storeId,
|
|
redeemAmount,
|
|
payoutAmount,
|
|
settlementRate,
|
|
status: 'PENDING',
|
|
expectedPayAt,
|
|
},
|
|
});
|
|
this.analyticsService.trackStoreOneSafe(undefined, 'SHOP_H5', {
|
|
storeId,
|
|
eventName: 'store_payout_created',
|
|
refType: 'STORE_PAYOUT',
|
|
refId: payout.id,
|
|
extraJson: {
|
|
redeemRecordId: redeemRecordId.toString(),
|
|
payoutAmount,
|
|
redeemAmount,
|
|
settlementRate,
|
|
},
|
|
});
|
|
return serializeBigInt(payout);
|
|
}
|
|
|
|
async listShopPayouts(storeAccountId: bigint, storeId: bigint, page = 1, pageSize = 20) {
|
|
await this.prisma.storeAccountStore.findUniqueOrThrow({
|
|
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
|
});
|
|
const where = { storeId };
|
|
const [items, total] = await Promise.all([
|
|
this.prisma.storePayout.findMany({
|
|
where,
|
|
orderBy: { createdAt: 'desc' },
|
|
skip: (page - 1) * pageSize,
|
|
take: pageSize,
|
|
include: { redeemRecord: { select: { redeemNo: true, amount: true } } },
|
|
}),
|
|
this.prisma.storePayout.count({ where }),
|
|
]);
|
|
return serializeBigInt({ items, total, page, pageSize });
|
|
}
|
|
|
|
async listAdminStorePayouts(query: {
|
|
page?: number;
|
|
pageSize?: number;
|
|
status?: string;
|
|
storeId?: string;
|
|
}) {
|
|
const page = query.page ?? 1;
|
|
const pageSize = query.pageSize ?? 20;
|
|
const where: Prisma.StorePayoutWhereInput = {};
|
|
if (query.status) where.status = query.status as Prisma.EnumStorePayoutStatusFilter['equals'];
|
|
if (query.storeId) where.storeId = BigInt(query.storeId);
|
|
|
|
const [items, total] = await Promise.all([
|
|
this.prisma.storePayout.findMany({
|
|
where,
|
|
orderBy: { expectedPayAt: 'asc' },
|
|
skip: (page - 1) * pageSize,
|
|
take: pageSize,
|
|
include: {
|
|
store: { select: { id: true, name: true, cityName: true, phone: true } },
|
|
redeemRecord: { select: { redeemNo: true, amount: true, userId: true } },
|
|
},
|
|
}),
|
|
this.prisma.storePayout.count({ where }),
|
|
]);
|
|
return serializeBigInt({ items, total, page, pageSize });
|
|
}
|
|
|
|
async getAdminStorePayout(id: bigint) {
|
|
const payout = await this.prisma.storePayout.findUnique({
|
|
where: { id },
|
|
include: {
|
|
store: true,
|
|
redeemRecord: { include: { user: { select: { userNo: true, phone: true } } } },
|
|
},
|
|
});
|
|
if (!payout) throw new NotFoundException('打款记录不存在');
|
|
return serializeBigInt(payout);
|
|
}
|
|
|
|
async confirmStorePayout(id: bigint, dto: { paymentRef?: string; batchNo?: string; remark?: string }) {
|
|
const payout = await this.prisma.storePayout.findUnique({ where: { id } });
|
|
if (!payout) throw new NotFoundException('打款记录不存在');
|
|
if (payout.status === 'PAID') throw new BadRequestException('已打款');
|
|
|
|
const updated = await this.prisma.storePayout.update({
|
|
where: { id },
|
|
data: {
|
|
status: 'PAID',
|
|
paidAt: new Date(),
|
|
batchNo: dto.batchNo ?? payout.batchNo,
|
|
},
|
|
});
|
|
|
|
this.analyticsService.trackStoreOneSafe(undefined, 'HQ_WEB', {
|
|
storeId: payout.storeId,
|
|
eventName: 'store_payout_paid',
|
|
refType: 'STORE_PAYOUT',
|
|
refId: id,
|
|
extraJson: {
|
|
batchNo: dto.batchNo ?? payout.batchNo,
|
|
paymentRef: dto.paymentRef,
|
|
payoutAmount: Number(payout.payoutAmount),
|
|
},
|
|
});
|
|
|
|
return serializeBigInt(updated);
|
|
}
|
|
|
|
async batchConfirmStorePayouts(ids: string[], dto: { batchNo?: string }) {
|
|
const results: Array<{ id: string; ok: boolean; message?: string }> = [];
|
|
for (const id of ids) {
|
|
try {
|
|
await this.confirmStorePayout(BigInt(id), { batchNo: dto.batchNo });
|
|
results.push({ id, ok: true });
|
|
} catch (e) {
|
|
results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' });
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
async listPartnerBills(partnerAccountId: bigint) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
|
const bills = await this.prisma.partnerBill.findMany({
|
|
where: { partnerAccountId: primary.id },
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
|
|
partnerAccountId: primary.id,
|
|
eventName: 'partner_bill_view',
|
|
extraJson: { count: bills.length },
|
|
});
|
|
return serializeBigInt(bills);
|
|
}
|
|
|
|
async listAdminPartnerBills(query: {
|
|
page?: number;
|
|
pageSize?: number;
|
|
status?: string;
|
|
partnerId?: string;
|
|
}) {
|
|
const page = query.page ?? 1;
|
|
const pageSize = query.pageSize ?? 20;
|
|
const where: Prisma.PartnerBillWhereInput = {};
|
|
if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals'];
|
|
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
|
|
|
|
const [items, total] = await Promise.all([
|
|
this.prisma.partnerBill.findMany({
|
|
where,
|
|
orderBy: { createdAt: 'desc' },
|
|
skip: (page - 1) * pageSize,
|
|
take: pageSize,
|
|
include: { partnerAccount: { select: { companyName: true } } },
|
|
}),
|
|
this.prisma.partnerBill.count({ where }),
|
|
]);
|
|
return serializeBigInt({ items, total, page, pageSize });
|
|
}
|
|
|
|
async getAdminPartnerBill(id: bigint) {
|
|
const bill = await this.prisma.partnerBill.findUnique({
|
|
where: { id },
|
|
include: { partnerAccount: true },
|
|
});
|
|
if (!bill) throw new NotFoundException('账单不存在');
|
|
return serializeBigInt(bill);
|
|
}
|
|
|
|
async generatePartnerBill(body: { partnerId: string; year: number; month: number }) {
|
|
const partnerAccountId = BigInt(body.partnerId);
|
|
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
|
const periodStart = new Date(body.year, body.month - 1, 1);
|
|
const periodEnd = new Date(body.year, body.month, 0, 23, 59, 59, 999);
|
|
|
|
const existing = await this.prisma.partnerBill.findFirst({
|
|
where: {
|
|
partnerAccountId: primary.id,
|
|
periodStart,
|
|
status: { not: 'DRAFT' },
|
|
},
|
|
});
|
|
if (existing && existing.status !== 'DRAFT') {
|
|
throw new BadRequestException('该月账单已确认,不可重复生成');
|
|
}
|
|
|
|
if (!primary.cityId) {
|
|
throw new BadRequestException('合伙人未绑定开城城市');
|
|
}
|
|
|
|
const orderCommissionRate = Number(primary.orderCommissionRate ?? 0);
|
|
const redeemCommissionRate = Number(primary.redeemCommissionRate ?? 0.03);
|
|
|
|
const orders = await this.prisma.order.findMany({
|
|
where: {
|
|
cityId: primary.cityId,
|
|
payStatus: 'PAID',
|
|
paidAt: { gte: periodStart, lte: periodEnd },
|
|
},
|
|
});
|
|
const orderCommission = orders.reduce((sum, o) => {
|
|
if (o.partnerAccountIdAtPay) {
|
|
if (o.partnerAccountIdAtPay !== primary.id) return sum;
|
|
const rate = o.orderCommissionRateAtPay != null ? Number(o.orderCommissionRateAtPay) : 0;
|
|
return sum + Number(o.payAmount) * rate;
|
|
}
|
|
return sum + Number(o.payAmount) * orderCommissionRate;
|
|
}, 0);
|
|
|
|
const stores = await this.prisma.store.findMany({
|
|
where: { partnerAccountId: primary.id },
|
|
select: { id: true },
|
|
});
|
|
const storeIds = stores.map((s) => s.id);
|
|
const redeems = await this.prisma.redeemRecord.findMany({
|
|
where: {
|
|
storeId: { in: storeIds },
|
|
createdAt: { gte: periodStart, lte: periodEnd },
|
|
},
|
|
});
|
|
const redeemCommission = redeems.reduce(
|
|
(sum, r) => sum + Number(r.amount) * redeemCommissionRate,
|
|
0,
|
|
);
|
|
|
|
const totalAmount = Math.round((orderCommission + redeemCommission) * 100) / 100;
|
|
|
|
const draft = await this.prisma.partnerBill.findFirst({
|
|
where: { partnerAccountId: primary.id, periodStart, status: 'DRAFT' },
|
|
});
|
|
|
|
const bill = draft
|
|
? await this.prisma.partnerBill.update({
|
|
where: { id: draft.id },
|
|
data: { orderCommission, redeemCommission, totalAmount, periodEnd },
|
|
})
|
|
: await this.prisma.partnerBill.create({
|
|
data: {
|
|
billNo: generateBillNo(),
|
|
partnerAccountId: primary.id,
|
|
periodStart,
|
|
periodEnd,
|
|
orderCommission,
|
|
redeemCommission,
|
|
totalAmount,
|
|
status: 'DRAFT',
|
|
},
|
|
});
|
|
|
|
return serializeBigInt(bill);
|
|
}
|
|
|
|
async confirmPartnerBill(id: bigint) {
|
|
const bill = await this.prisma.partnerBill.findUnique({ where: { id } });
|
|
if (!bill) throw new NotFoundException('账单不存在');
|
|
if (bill.status !== 'DRAFT') throw new BadRequestException('仅草稿可确认');
|
|
|
|
const updated = await this.prisma.partnerBill.update({
|
|
where: { id },
|
|
data: { status: 'CONFIRMED', confirmedAt: new Date() },
|
|
});
|
|
|
|
return serializeBigInt(updated);
|
|
}
|
|
|
|
async markPartnerBillPaid(id: bigint, dto: { paymentRef?: string }) {
|
|
const bill = await this.prisma.partnerBill.findUnique({ where: { id } });
|
|
if (!bill) throw new NotFoundException('账单不存在');
|
|
if (bill.status !== 'CONFIRMED') throw new BadRequestException('仅已确认账单可标记打款');
|
|
|
|
const updated = await this.prisma.partnerBill.update({
|
|
where: { id },
|
|
data: { status: 'PAID', paidAt: new Date() },
|
|
});
|
|
|
|
return serializeBigInt(updated);
|
|
}
|
|
|
|
async exportPartnerBills(query: { partnerId?: string; status?: string }) {
|
|
const where: Prisma.PartnerBillWhereInput = {};
|
|
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
|
|
if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals'];
|
|
|
|
const bills = await this.prisma.partnerBill.findMany({
|
|
where,
|
|
include: { partnerAccount: { select: { companyName: true } } },
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
|
|
const header = 'billNo,partner,periodStart,periodEnd,orderCommission,redeemCommission,totalAmount,status';
|
|
const rows = bills.map((b) =>
|
|
[
|
|
b.billNo,
|
|
b.partnerAccount.companyName,
|
|
b.periodStart.toISOString().slice(0, 10),
|
|
b.periodEnd.toISOString().slice(0, 10),
|
|
Number(b.orderCommission),
|
|
Number(b.redeemCommission),
|
|
Number(b.totalAmount),
|
|
b.status,
|
|
].join(','),
|
|
);
|
|
return { csv: [header, ...rows].join('\n'), count: bills.length };
|
|
}
|
|
|
|
async scanDueStorePayouts() {
|
|
const now = new Date();
|
|
const due = await this.prisma.storePayout.findMany({
|
|
where: { status: 'PENDING', expectedPayAt: { lte: now } },
|
|
take: 100,
|
|
});
|
|
return serializeBigInt({ dueCount: due.length, items: due });
|
|
}
|
|
}
|