b392c28787
CI / verify (pull_request) Has been cancelled
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>
1133 lines
37 KiB
TypeScript
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;
|
|
}
|
|
}
|