Files
dukang/server/dukang-api/src/modules/settlement/settlement.service.ts
T
jacy b392c28787
CI / verify (pull_request) Has been cancelled
feat(settlement): redesign factory/partner/store statement bills
Add WineryBill/StoreBill tables, partner review-send-confirm flow, daily/monthly cron, and admin multi-select confirm with status filters.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 17:25:49 +08:00

1133 lines
37 KiB
TypeScript

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(prefix: string) {
return `${prefix}${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
}
function csvEscape(value: string) {
if (/[",\n\r]/.test(value)) return `"${value.replace(/"/g, '""')}"`;
return value;
}
/** 上海时区自然日 00:00(用本地 Date 构造;服务器需设 Asia/Shanghai 或等价) */
function startOfDay(d: Date) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
}
function dayWindow(anchor = new Date()) {
const end = startOfDay(anchor);
const start = new Date(end);
start.setDate(start.getDate() - 1);
return { start, end, billDate: start };
}
function round2(n: number) {
return Math.round(n * 100) / 100;
}
@Injectable()
export class SettlementService {
constructor(
private readonly prisma: PrismaService,
private readonly analyticsService: AnalyticsService,
private readonly partnerCityService: PartnerCityService,
) {}
// ─── Store payout (line) ─────────────────────────────
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 });
}
async getAdminStorePayout(id: bigint) {
const payout = await this.prisma.storePayout.findUnique({
where: { id },
include: {
store: true,
redeemRecord: 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 !== 'PENDING') throw new BadRequestException('仅未打款记录可确认');
const updated = await this.prisma.storePayout.update({
where: { id },
data: {
status: 'PAID',
paidAt: new Date(),
batchNo: dto.batchNo ?? payout.batchNo,
},
});
if (payout.storeBillId) {
const pending = await this.prisma.storePayout.count({
where: { storeBillId: payout.storeBillId, status: 'PENDING' },
});
if (pending === 0) {
await this.prisma.storeBill.update({
where: { id: payout.storeBillId },
data: { status: 'PAID', paidAt: new Date() },
});
}
}
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 exportAdminStorePayouts(query: {
status?: string;
storeId?: string;
dateFrom?: string;
dateTo?: string;
}) {
const where = this.buildStorePayoutWhere(query);
const items = await this.prisma.storePayout.findMany({
where,
include: {
store: { select: { name: true, phone: true, cityName: true } },
redeemRecord: { select: { redeemNo: true, createdAt: true } },
},
orderBy: { createdAt: 'desc' },
});
const header = ['核销单号', '门店', '城市', '核销金额', '结算比例', '应付金额', '状态', '核销时间', '打款时间'].join(',');
const rows = items.map((p) =>
[
csvEscape(p.redeemRecord.redeemNo ?? ''),
csvEscape(p.store.name),
csvEscape(p.store.cityName ?? ''),
Number(p.redeemAmount),
Number(p.settlementRate),
Number(p.payoutAmount),
p.status,
p.redeemRecord.createdAt.toISOString().slice(0, 19).replace('T', ' '),
p.paidAt ? p.paidAt.toISOString().slice(0, 19).replace('T', ' ') : '',
].join(','),
);
return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: items.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 });
}
private buildStorePayoutWhere(query: {
status?: string;
storeId?: string;
dateFrom?: string;
dateTo?: string;
}): Prisma.StorePayoutWhereInput {
const where: Prisma.StorePayoutWhereInput = {};
if (query.status === 'PENDING' || query.status === 'PAID') where.status = query.status;
if (query.storeId) where.storeId = BigInt(query.storeId);
if (query.dateFrom || query.dateTo) {
where.createdAt = {};
if (query.dateFrom) where.createdAt.gte = new Date(query.dateFrom);
if (query.dateTo) {
const end = new Date(query.dateTo);
end.setHours(23, 59, 59, 999);
where.createdAt.lte = end;
}
}
return where;
}
// ─── Store bills (daily header) ──────────────────────
/** 生成指定自然日窗口内的门店对账单(默认昨日) */
async generateStoreBillsForDay(anchor = new Date()) {
const { start, end, billDate } = dayWindow(anchor);
const payouts = await this.prisma.storePayout.findMany({
where: {
storeBillId: null,
createdAt: { gte: start, lt: end },
},
include: { store: { select: { id: true, settlementRate: true } } },
});
const byStore = new Map<string, typeof payouts>();
for (const p of payouts) {
const key = p.storeId.toString();
const list = byStore.get(key) ?? [];
list.push(p);
byStore.set(key, list);
}
let created = 0;
let skipped = 0;
for (const [storeIdStr, list] of byStore) {
const storeId = BigInt(storeIdStr);
const existing = await this.prisma.storeBill.findUnique({
where: { storeId_billDate: { storeId, billDate } },
});
if (existing) {
if (existing.status === 'PAID') {
skipped += 1;
continue;
}
const redeemAmount = round2(list.reduce((s, p) => s + Number(p.redeemAmount), 0));
const payoutAmount = round2(list.reduce((s, p) => s + Number(p.payoutAmount), 0));
const rate = Number(list[0].settlementRate);
await this.prisma.$transaction(async (tx) => {
await tx.storeBill.update({
where: { id: existing.id },
data: {
redeemCount: existing.redeemCount + list.length,
redeemAmount: round2(Number(existing.redeemAmount) + redeemAmount),
payoutAmount: round2(Number(existing.payoutAmount) + payoutAmount),
settlementRate: rate,
},
});
await tx.storePayout.updateMany({
where: { id: { in: list.map((p) => p.id) } },
data: { storeBillId: existing.id },
});
});
created += 1;
continue;
}
const redeemAmount = round2(list.reduce((s, p) => s + Number(p.redeemAmount), 0));
const payoutAmount = round2(list.reduce((s, p) => s + Number(p.payoutAmount), 0));
const rate = Number(list[0].settlementRate);
await this.prisma.$transaction(async (tx) => {
const bill = await tx.storeBill.create({
data: {
billNo: generateBillNo('SB'),
storeId,
billDate,
redeemCount: list.length,
redeemAmount,
settlementRate: rate,
payoutAmount,
status: 'UNPAID',
},
});
await tx.storePayout.updateMany({
where: { id: { in: list.map((p) => p.id) } },
data: { storeBillId: bill.id },
});
});
created += 1;
}
return { billDate: billDate.toISOString().slice(0, 10), created, skipped, payoutLinked: payouts.length };
}
async listAdminStoreBills(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.buildStoreBillWhere(query);
const [items, total, aggregates] = await Promise.all([
this.prisma.storeBill.findMany({
where,
orderBy: [{ billDate: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
include: {
store: { select: { id: true, name: true, cityName: true, phone: true } },
},
}),
this.prisma.storeBill.count({ where }),
this.prisma.storeBill.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 });
}
async getAdminStoreBill(id: bigint) {
const bill = await this.prisma.storeBill.findUnique({
where: { id },
include: {
store: { select: { id: true, name: true, cityName: true, phone: true } },
payouts: {
include: { redeemRecord: { select: { redeemNo: true, amount: true, createdAt: true } } },
orderBy: { createdAt: 'desc' },
},
},
});
if (!bill) throw new NotFoundException('门店对账单不存在');
return serializeBigInt(bill);
}
async confirmStoreBill(id: bigint) {
const bill = await this.prisma.storeBill.findUnique({ where: { id } });
if (!bill) throw new NotFoundException('门店对账单不存在');
if (bill.status !== 'UNPAID') throw new BadRequestException('仅未打款账单可确认打款');
const paidAt = new Date();
const updated = await this.prisma.$transaction(async (tx) => {
const b = await tx.storeBill.update({
where: { id },
data: { status: 'PAID', paidAt },
});
await tx.storePayout.updateMany({
where: { storeBillId: id, status: 'PENDING' },
data: { status: 'PAID', paidAt },
});
return b;
});
return serializeBigInt(updated);
}
async batchConfirmStoreBills(ids: string[]) {
const results: Array<{ id: string; ok: boolean; message?: string }> = [];
for (const id of ids) {
try {
await this.confirmStoreBill(BigInt(id));
results.push({ id, ok: true });
} catch (e) {
results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' });
}
}
return results;
}
async exportAdminStoreBills(query: {
status?: string;
storeId?: string;
dateFrom?: string;
dateTo?: string;
}) {
const where = this.buildStoreBillWhere(query);
const bills = await this.prisma.storeBill.findMany({
where,
include: { store: { select: { name: true, phone: true, cityName: true } } },
orderBy: { billDate: 'desc' },
});
const header = ['账单号', '账单日', '门店', '城市', '核销笔数', '核销金额', '结算比例', '应付金额', '状态', '打款时间'].join(',');
const rows = bills.map((b) =>
[
csvEscape(b.billNo),
b.billDate.toISOString().slice(0, 10),
csvEscape(b.store.name),
csvEscape(b.store.cityName ?? ''),
b.redeemCount,
Number(b.redeemAmount),
Number(b.settlementRate),
Number(b.payoutAmount),
b.status,
b.paidAt ? b.paidAt.toISOString().slice(0, 19).replace('T', ' ') : '',
].join(','),
);
return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: bills.length };
}
private buildStoreBillWhere(query: {
status?: string;
storeId?: string;
dateFrom?: string;
dateTo?: string;
}): Prisma.StoreBillWhereInput {
const where: Prisma.StoreBillWhereInput = {};
if (query.status === 'UNPAID' || query.status === 'PAID') where.status = query.status;
if (query.storeId) where.storeId = BigInt(query.storeId);
if (query.dateFrom || query.dateTo) {
where.billDate = {};
if (query.dateFrom) where.billDate.gte = startOfDay(new Date(query.dateFrom));
if (query.dateTo) where.billDate.lte = startOfDay(new Date(query.dateTo));
}
return where;
}
/** @deprecated 兼容旧 daily 聚合视图 */
async listAdminStoreDailyBills(query: {
page?: number;
pageSize?: number;
status?: string;
storeId?: string;
dateFrom?: string;
dateTo?: string;
}) {
return this.listAdminStoreBills(query);
}
// ─── Partner bills ───────────────────────────────────
async listPartnerBills(partnerAccountId: bigint) {
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const bills = await this.prisma.partnerBill.findMany({
where: {
partnerAccountId: primary.id,
status: { in: ['AWAITING_CONFIRM', 'UNPAID', 'PAID', 'REJECTED'] },
},
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,
};
}
/** 每月 1 日任务:生成上一自然月账单 */
async generatePreviousMonthPartnerBills(anchor = new Date()) {
const prev = new Date(anchor.getFullYear(), anchor.getMonth() - 1, 1);
return this.generateAllPartnerBills({ year: prev.getFullYear(), month: prev.getMonth() + 1 });
}
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.findUnique({
where: {
partnerAccountId_periodStart: { partnerAccountId: primary.id, periodStart },
},
});
if (existing && existing.status !== 'PENDING_REVIEW') {
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 = storeIds.length
? 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 = round2(orderCommission + redeemCommission);
const bill = existing
? await this.prisma.partnerBill.update({
where: { id: existing.id },
data: {
orderCommission: round2(orderCommission),
redeemCommission: round2(redeemCommission),
totalAmount,
periodEnd,
status: 'PENDING_REVIEW',
},
})
: await this.prisma.partnerBill.create({
data: {
billNo: generateBillNo('PB'),
partnerAccountId: primary.id,
periodStart,
periodEnd,
orderCommission: round2(orderCommission),
redeemCommission: round2(redeemCommission),
totalAmount,
status: 'PENDING_REVIEW',
},
});
return serializeBigInt(bill);
}
/** HQ:发送给合伙人(待审核 → 待合伙人确认) */
async sendPartnerBill(id: bigint) {
const bill = await this.prisma.partnerBill.findUnique({ where: { id } });
if (!bill) throw new NotFoundException('账单不存在');
if (bill.status !== 'PENDING_REVIEW' && bill.status !== 'REJECTED') {
throw new BadRequestException('仅待审核或已驳回账单可发送');
}
const updated = await this.prisma.partnerBill.update({
where: { id },
data: {
status: 'AWAITING_CONFIRM',
sentAt: new Date(),
rejectReason: null,
},
});
return serializeBigInt(updated);
}
async batchSendPartnerBills(ids: string[]) {
const results: Array<{ id: string; ok: boolean; message?: string }> = [];
for (const id of ids) {
try {
await this.sendPartnerBill(BigInt(id));
results.push({ id, ok: true });
} catch (e) {
results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' });
}
}
return results;
}
/** @deprecated HQ 代确认:待审核/待确认 → 未打款 */
async confirmPartnerBill(id: bigint) {
const bill = await this.prisma.partnerBill.findUnique({ where: { id } });
if (!bill) throw new NotFoundException('账单不存在');
if (bill.status !== 'PENDING_REVIEW' && bill.status !== 'AWAITING_CONFIRM' && bill.status !== 'REJECTED') {
throw new BadRequestException('当前状态不可确认');
}
const updated = await this.prisma.partnerBill.update({
where: { id },
data: {
status: 'UNPAID',
confirmedAt: new Date(),
sentAt: bill.sentAt ?? new Date(),
rejectReason: null,
},
});
return serializeBigInt(updated);
}
/** 合伙人确认:待合伙人确认 → 未打款 */
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 !== 'AWAITING_CONFIRM') {
throw new BadRequestException('当前账单不可确认');
}
const updated = await this.prisma.partnerBill.update({
where: { id: billId },
data: {
status: 'UNPAID',
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 batchPartnerConfirmBills(partnerAccountId: bigint, ids: string[]) {
const results: Array<{ id: string; ok: boolean; message?: string }> = [];
for (const id of ids) {
try {
await this.partnerConfirmBill(partnerAccountId, BigInt(id));
results.push({ id, ok: true });
} catch (e) {
results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' });
}
}
return results;
}
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 !== 'AWAITING_CONFIRM' && bill.status !== 'UNPAID') {
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 !== 'UNPAID') throw new BadRequestException('仅未打款账单可标记打款');
const updated = await this.prisma.partnerBill.update({
where: { id },
data: { status: 'PAID', paidAt: new Date(), rejectReason: null },
});
return serializeBigInt(updated);
}
async batchMarkPartnerBillsPaid(ids: string[]) {
const results: Array<{ id: string; ok: boolean; message?: string }> = [];
for (const id of ids) {
try {
await this.markPartnerBillPaid(BigInt(id));
results.push({ id, ok: true });
} catch (e) {
results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' });
}
}
return results;
}
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.sentAt ? b.sentAt.toISOString().slice(0, 10) : '',
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 };
}
// ─── Winery bills ────────────────────────────────────
async generateWineryBillForDay(anchor = new Date()) {
const { start, end, billDate } = dayWindow(anchor);
const rate = WINERY_SETTLEMENT_RATE;
const existing = await this.prisma.wineryBill.findUnique({ where: { billDate } });
if (existing?.status === 'PAID') {
return { billDate: billDate.toISOString().slice(0, 10), skipped: true, reason: '已打款' };
}
const orders = await this.prisma.order.findMany({
where: {
payStatus: 'PAID',
deliveryType: { in: ['LOCAL', 'CROSS_CITY'] },
paidAt: { gte: start, lt: end },
},
orderBy: { paidAt: 'asc' },
});
if (orders.length === 0 && !existing) {
return { billDate: billDate.toISOString().slice(0, 10), skipped: true, reason: '无订单' };
}
const orderAmount = round2(orders.reduce((s, o) => s + Number(o.payAmount), 0));
const wineryAmount = round2(orderAmount * rate);
const bill = await this.prisma.$transaction(async (tx) => {
const header = existing
? await tx.wineryBill.update({
where: { id: existing.id },
data: {
orderCount: orders.length,
orderAmount,
wineryRate: rate,
wineryAmount,
},
})
: await tx.wineryBill.create({
data: {
billNo: generateBillNo('WB'),
billDate,
orderCount: orders.length,
orderAmount,
wineryRate: rate,
wineryAmount,
status: 'UNPAID',
},
});
if (existing) {
await tx.wineryBillItem.deleteMany({ where: { wineryBillId: header.id } });
}
if (orders.length > 0) {
await tx.wineryBillItem.createMany({
data: orders.map((o) => ({
wineryBillId: header.id,
orderId: o.id,
orderNo: o.orderNo,
deliveryType: o.deliveryType,
payAmount: o.payAmount,
wineryAmount: round2(Number(o.payAmount) * rate),
paidAt: o.paidAt!,
})),
});
}
return header;
});
return serializeBigInt({
billDate: billDate.toISOString().slice(0, 10),
skipped: false,
bill,
});
}
async listAdminWineryBills(query: {
page?: number;
pageSize?: number;
status?: string;
dateFrom?: string;
dateTo?: string;
year?: number;
month?: number;
}) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where = this.buildWineryBillWhere(query);
const [items, total, aggregates] = await Promise.all([
this.prisma.wineryBill.findMany({
where,
orderBy: { billDate: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.wineryBill.count({ where }),
this.prisma.wineryBill.aggregate({
where,
_sum: { orderAmount: true, wineryAmount: true },
_count: true,
}),
]);
const summary = {
count: aggregates._count,
orderAmount: Number(aggregates._sum.orderAmount ?? 0),
wineryAmount: Number(aggregates._sum.wineryAmount ?? 0),
totalAmount: Number(aggregates._sum.wineryAmount ?? 0),
};
return serializeBigInt({ items, total, page, pageSize, summary });
}
async getAdminWineryBill(id: bigint) {
const bill = await this.prisma.wineryBill.findUnique({
where: { id },
include: { items: { orderBy: { paidAt: 'desc' } } },
});
if (!bill) throw new NotFoundException('酒厂对账单不存在');
return serializeBigInt(bill);
}
async confirmWineryBill(id: bigint) {
const bill = await this.prisma.wineryBill.findUnique({ where: { id } });
if (!bill) throw new NotFoundException('酒厂对账单不存在');
if (bill.status !== 'UNPAID') throw new BadRequestException('仅未打款账单可确认打款');
const updated = await this.prisma.wineryBill.update({
where: { id },
data: { status: 'PAID', paidAt: new Date() },
});
return serializeBigInt(updated);
}
async batchConfirmWineryBills(ids: string[]) {
const results: Array<{ id: string; ok: boolean; message?: string }> = [];
for (const id of ids) {
try {
await this.confirmWineryBill(BigInt(id));
results.push({ id, ok: true });
} catch (e) {
results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' });
}
}
return results;
}
async exportAdminWineryBills(query: {
status?: string;
dateFrom?: string;
dateTo?: string;
year?: number;
month?: number;
}) {
const where = this.buildWineryBillWhere(query);
const bills = await this.prisma.wineryBill.findMany({
where,
include: { items: true },
orderBy: { billDate: 'desc' },
});
const header = [
'账单号',
'账单日',
'订单号',
'配送类型',
'实付金额',
'酒厂比例',
'酒厂应付',
'支付时间',
'账单状态',
].join(',');
const rows: string[] = [];
for (const b of bills) {
if (b.items.length === 0) {
rows.push(
[
csvEscape(b.billNo),
b.billDate.toISOString().slice(0, 10),
'',
'',
Number(b.orderAmount),
Number(b.wineryRate),
Number(b.wineryAmount),
'',
b.status,
].join(','),
);
continue;
}
for (const item of b.items) {
rows.push(
[
csvEscape(b.billNo),
b.billDate.toISOString().slice(0, 10),
csvEscape(item.orderNo),
item.deliveryType === 'LOCAL' ? '同城' : item.deliveryType === 'CROSS_CITY' ? '跨城' : item.deliveryType,
Number(item.payAmount),
Number(b.wineryRate),
Number(item.wineryAmount),
item.paidAt.toISOString().slice(0, 19).replace('T', ' '),
b.status,
].join(','),
);
}
}
return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: rows.length };
}
private buildWineryBillWhere(query: {
status?: string;
dateFrom?: string;
dateTo?: string;
year?: number;
month?: number;
}): Prisma.WineryBillWhereInput {
const where: Prisma.WineryBillWhereInput = {};
if (query.status === 'UNPAID' || query.status === 'PAID') where.status = query.status;
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.billDate = { gte: start, lte: end };
} else if (query.dateFrom || query.dateTo) {
where.billDate = {};
if (query.dateFrom) where.billDate.gte = startOfDay(new Date(query.dateFrom));
if (query.dateTo) where.billDate.lte = startOfDay(new Date(query.dateTo));
}
return where;
}
}