Files
dukang/server/dukang-api/src/modules/settlement/settlement.service.ts
T
2026-07-17 07:56:43 +08:00

791 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { WINERY_SETTLEMENT_RATE } from '@dukang/shared-types';
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)}`;
}
function csvEscape(value: string) {
if (/[",\n\r]/.test(value)) return `"${value.replace(/"/g, '""')}"`;
return value;
}
@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;
dateFrom?: string;
dateTo?: string;
}) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where = this.buildStorePayoutWhere(query);
const [items, total, aggregates] = await Promise.all([
this.prisma.storePayout.findMany({
where,
orderBy: [{ createdAt: 'desc' }, { 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, createdAt: true } },
},
}),
this.prisma.storePayout.count({ where }),
this.prisma.storePayout.aggregate({
where,
_sum: { redeemAmount: true, payoutAmount: true },
_count: true,
}),
]);
const summary = {
count: aggregates._count,
redeemAmount: Number(aggregates._sum.redeemAmount ?? 0),
payoutAmount: Number(aggregates._sum.payoutAmount ?? 0),
totalAmount: Number(aggregates._sum.payoutAmount ?? 0),
};
return serializeBigInt({ items, total, page, pageSize, summary });
}
/** 门店账单按日汇总(T+1:按核销日归集) */
async listAdminStoreDailyBills(query: {
page?: number;
pageSize?: number;
status?: string;
storeId?: string;
dateFrom?: string;
dateTo?: string;
}) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 50;
const where = this.buildStorePayoutWhere(query);
const payouts = await this.prisma.storePayout.findMany({
where,
include: {
store: { select: { id: true, name: true, cityName: true, phone: true } },
redeemRecord: { select: { createdAt: true } },
},
orderBy: { createdAt: 'desc' },
});
type DailyAgg = {
billDate: string;
storeId: string;
storeName: string;
storePhone?: string;
cityName?: string;
redeemCount: number;
redeemAmount: number;
settlementRate: number;
payoutAmount: number;
pendingCount: number;
paidCount: number;
};
const map = new Map<string, DailyAgg>();
for (const p of payouts) {
const billDate = (p.redeemRecord?.createdAt ?? p.createdAt).toISOString().slice(0, 10);
const storeId = p.storeId.toString();
const key = `${billDate}:${storeId}`;
const existing = map.get(key);
if (!existing) {
map.set(key, {
billDate,
storeId,
storeName: p.store?.name ?? '—',
storePhone: p.store?.phone ?? undefined,
cityName: p.store?.cityName ?? undefined,
redeemCount: 1,
redeemAmount: Number(p.redeemAmount),
settlementRate: Number(p.settlementRate),
payoutAmount: Number(p.payoutAmount),
pendingCount: p.status === 'PENDING' ? 1 : 0,
paidCount: p.status === 'PAID' ? 1 : 0,
});
} else {
existing.redeemCount += 1;
existing.redeemAmount += Number(p.redeemAmount);
existing.payoutAmount += Number(p.payoutAmount);
if (p.status === 'PENDING') existing.pendingCount += 1;
if (p.status === 'PAID') existing.paidCount += 1;
}
}
const all = Array.from(map.values()).sort((a, b) => {
if (a.billDate === b.billDate) return a.storeName.localeCompare(b.storeName, 'zh-CN');
return b.billDate.localeCompare(a.billDate);
});
const summary = {
count: all.length,
redeemAmount: Math.round(all.reduce((s, r) => s + r.redeemAmount, 0) * 100) / 100,
payoutAmount: Math.round(all.reduce((s, r) => s + r.payoutAmount, 0) * 100) / 100,
totalAmount: Math.round(all.reduce((s, r) => s + r.payoutAmount, 0) * 100) / 100,
};
const start = (page - 1) * pageSize;
const items = all.slice(start, start + pageSize).map((row) => ({
...row,
redeemAmount: Math.round(row.redeemAmount * 100) / 100,
payoutAmount: Math.round(row.payoutAmount * 100) / 100,
}));
return { items, total: all.length, page, pageSize, summary };
}
async exportAdminStorePayouts(query: {
status?: string;
storeId?: string;
dateFrom?: string;
dateTo?: string;
}) {
const where = this.buildStorePayoutWhere(query);
const payouts = await this.prisma.storePayout.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
store: { select: { name: true, phone: true, cityName: true } },
redeemRecord: { select: { redeemNo: true, createdAt: true } },
},
});
const header = [
'核销日期',
'门店',
'登录手机',
'城市',
'核销单号',
'核销面额',
'结算比例',
'应付金额',
'状态',
'预计打款',
'实际打款',
].join(',');
const rows = payouts.map((p) =>
[
(p.redeemRecord?.createdAt ?? p.createdAt).toISOString().slice(0, 10),
csvEscape(p.store?.name ?? ''),
csvEscape(p.store?.phone ?? ''),
csvEscape(p.store?.cityName ?? ''),
csvEscape(p.redeemRecord?.redeemNo ?? ''),
Number(p.redeemAmount),
`${Math.round(Number(p.settlementRate) * 100)}%`,
Number(p.payoutAmount),
p.status,
p.expectedPayAt.toISOString().slice(0, 10),
p.paidAt ? p.paidAt.toISOString().slice(0, 10) : '',
].join(','),
);
return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: payouts.length };
}
private buildStorePayoutWhere(query: {
status?: string;
storeId?: string;
dateFrom?: string;
dateTo?: string;
}): Prisma.StorePayoutWhereInput {
const where: Prisma.StorePayoutWhereInput = {};
if (query.status) where.status = query.status as Prisma.EnumStorePayoutStatusFilter['equals'];
if (query.storeId) where.storeId = BigInt(query.storeId);
if (query.dateFrom || query.dateTo) {
const createdAt: Prisma.DateTimeFilter = {};
if (query.dateFrom) createdAt.gte = new Date(`${query.dateFrom}T00:00:00.000`);
if (query.dateTo) createdAt.lte = new Date(`${query.dateTo}T23:59:59.999`);
where.createdAt = createdAt;
}
return where;
}
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;
year?: number;
month?: number;
}) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where = this.buildPartnerBillWhere(query);
const [rawItems, total, aggregates] = await Promise.all([
this.prisma.partnerBill.findMany({
where,
orderBy: { periodStart: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
partnerAccount: {
select: { id: true, companyName: true, name: true, phone: true },
},
},
}),
this.prisma.partnerBill.count({ where }),
this.prisma.partnerBill.aggregate({
where,
_sum: { orderCommission: true, redeemCommission: true, totalAmount: true },
_count: true,
}),
]);
const items = rawItems.map((b) => ({
...b,
partner: b.partnerAccount,
}));
const summary = {
count: aggregates._count,
orderCommission: Number(aggregates._sum.orderCommission ?? 0),
redeemCommission: Number(aggregates._sum.redeemCommission ?? 0),
totalAmount: Number(aggregates._sum.totalAmount ?? 0),
};
return serializeBigInt({ items, total, page, pageSize, summary });
}
private buildPartnerBillWhere(query: {
status?: string;
partnerId?: string;
year?: number;
month?: number;
}): Prisma.PartnerBillWhereInput {
const where: Prisma.PartnerBillWhereInput = {};
if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals'];
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
if (query.year && query.month) {
const periodStart = new Date(query.year, query.month - 1, 1);
const periodEnd = new Date(query.year, query.month, 0, 23, 59, 59, 999);
where.periodStart = { gte: periodStart, lte: periodEnd };
}
return where;
}
/** 为所有主合伙人生成指定自然月账单 */
async generateAllPartnerBills(body: { year: number; month: number }) {
const partners = await this.prisma.partnerAccount.findMany({
where: { isPrimary: 1, status: 'ACTIVE' },
select: { id: true, companyName: true },
});
const results: Array<{ partnerId: string; companyName: string; ok: boolean; message?: string }> = [];
for (const p of partners) {
try {
await this.generatePartnerBill({
partnerId: p.id.toString(),
year: body.year,
month: body.month,
});
results.push({ partnerId: p.id.toString(), companyName: p.companyName ?? '', ok: true });
} catch (e) {
results.push({
partnerId: p.id.toString(),
companyName: p.companyName ?? '',
ok: false,
message: e instanceof Error ? e.message : '失败',
});
}
}
return {
total: partners.length,
success: results.filter((r) => r.ok).length,
failed: results.filter((r) => !r.ok).length,
results,
};
}
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' && bill.status !== 'REJECTED') {
throw new BadRequestException('仅待确认或已驳回账单可确认');
}
const updated = await this.prisma.partnerBill.update({
where: { id },
data: {
status: 'CONFIRMED',
confirmedAt: new Date(),
rejectReason: null,
},
});
return serializeBigInt(updated);
}
/** 合伙人端:确认账单并申请打款(DRAFT/REJECTED → CONFIRMED */
async partnerConfirmBill(partnerAccountId: bigint, billId: bigint) {
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const bill = await this.prisma.partnerBill.findUnique({ where: { id: billId } });
if (!bill || bill.partnerAccountId !== primary.id) {
throw new NotFoundException('账单不存在');
}
if (bill.status !== 'DRAFT' && bill.status !== 'REJECTED') {
throw new BadRequestException('当前账单不可再次申请打款');
}
const updated = await this.prisma.partnerBill.update({
where: { id: billId },
data: {
status: 'CONFIRMED',
confirmedAt: new Date(),
rejectReason: null,
},
});
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerAccountId: primary.id,
eventName: 'partner_bill_confirm',
refType: 'PARTNER_BILL',
refId: billId,
extraJson: { billNo: bill.billNo },
});
return serializeBigInt(updated);
}
async rejectPartnerBill(id: bigint, reason: string) {
const rejectReason = reason.trim();
if (!rejectReason) throw new BadRequestException('请填写驳回理由');
if (rejectReason.length > 500) throw new BadRequestException('驳回理由不能超过 500 字');
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: 'REJECTED',
rejectReason,
},
});
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(), rejectReason: null },
});
return serializeBigInt(updated);
}
async exportPartnerBills(query: {
partnerId?: string;
status?: string;
year?: number;
month?: number;
}) {
const where = this.buildPartnerBillWhere(query);
const bills = await this.prisma.partnerBill.findMany({
where,
include: { partnerAccount: { select: { companyName: true, phone: true } } },
orderBy: { periodStart: 'desc' },
});
const header = [
'账单号',
'合伙人',
'登录手机',
'账期起',
'账期止',
'酒单佣金',
'核销佣金',
'合计应付',
'状态',
'确认时间',
'打款时间',
'驳回理由',
].join(',');
const rows = bills.map((b) =>
[
csvEscape(b.billNo),
csvEscape(b.partnerAccount.companyName ?? ''),
csvEscape(b.partnerAccount.phone ?? ''),
b.periodStart.toISOString().slice(0, 10),
b.periodEnd.toISOString().slice(0, 10),
Number(b.orderCommission),
Number(b.redeemCommission),
Number(b.totalAmount),
b.status,
b.confirmedAt ? b.confirmedAt.toISOString().slice(0, 10) : '',
b.paidAt ? b.paidAt.toISOString().slice(0, 10) : '',
csvEscape(b.rejectReason ?? ''),
].join(','),
);
return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: bills.length };
}
/** 酒厂账单:T+30,按自然月列出已付酒单,应付 = 实付 × WINERY_SETTLEMENT_RATE */
async listAdminWineryBills(query: {
page?: number;
pageSize?: number;
year?: number;
month?: number;
deliveryType?: string;
}) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where = this.buildWineryOrderWhere(query);
const [orders, total, aggregates] = await Promise.all([
this.prisma.order.findMany({
where,
orderBy: { paidAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: { city: { select: { name: true } } },
}),
this.prisma.order.count({ where }),
this.prisma.order.aggregate({
where,
_sum: { payAmount: true },
_count: true,
}),
]);
const rate = WINERY_SETTLEMENT_RATE;
const items = orders.map((o) => {
const payAmount = Number(o.payAmount);
const wineryAmount = Math.round(payAmount * rate * 100) / 100;
return {
orderId: o.id.toString(),
orderNo: o.orderNo,
deliveryType: o.deliveryType,
cityName: o.city?.name,
payAmount,
wineryRate: rate,
wineryAmount,
paidAt: o.paidAt?.toISOString() ?? '',
receiverCity: o.receiverCity,
productName: o.productName,
quantity: o.quantity,
};
});
const orderAmount = Number(aggregates._sum.payAmount ?? 0);
const summary = {
count: aggregates._count,
orderAmount,
wineryAmount: Math.round(orderAmount * rate * 100) / 100,
totalAmount: Math.round(orderAmount * rate * 100) / 100,
};
return { items, total, page, pageSize, summary };
}
async exportAdminWineryBills(query: {
year?: number;
month?: number;
deliveryType?: string;
}) {
const where = this.buildWineryOrderWhere(query);
const orders = await this.prisma.order.findMany({
where,
orderBy: { paidAt: 'desc' },
include: { city: { select: { name: true } } },
});
const rate = WINERY_SETTLEMENT_RATE;
const header = [
'订单号',
'配送类型',
'开城城市',
'收货城市',
'商品',
'数量',
'实付金额',
'酒厂比例',
'酒厂应付',
'支付时间',
].join(',');
const rows = orders.map((o) => {
const payAmount = Number(o.payAmount);
return [
csvEscape(o.orderNo),
o.deliveryType === 'LOCAL' ? '同城' : o.deliveryType === 'CROSS_CITY' ? '跨城' : o.deliveryType,
csvEscape(o.city?.name ?? ''),
csvEscape(o.receiverCity ?? ''),
csvEscape(o.productName ?? ''),
o.quantity,
payAmount,
`${Math.round(rate * 100)}%`,
Math.round(payAmount * rate * 100) / 100,
o.paidAt ? o.paidAt.toISOString().slice(0, 19).replace('T', ' ') : '',
].join(',');
});
return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: orders.length };
}
private buildWineryOrderWhere(query: {
year?: number;
month?: number;
deliveryType?: string;
}): Prisma.OrderWhereInput {
const where: Prisma.OrderWhereInput = {
payStatus: 'PAID',
deliveryType: { in: ['LOCAL', 'CROSS_CITY'] },
};
if (query.deliveryType === 'LOCAL' || query.deliveryType === 'CROSS_CITY') {
where.deliveryType = query.deliveryType;
}
if (query.year && query.month) {
const start = new Date(query.year, query.month - 1, 1);
const end = new Date(query.year, query.month, 0, 23, 59, 59, 999);
where.paidAt = { gte: start, lte: end };
} else {
where.paidAt = { not: null };
}
return where;
}
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 });
}
}