webadmin增加财务模块

This commit is contained in:
2026-07-14 15:16:41 +08:00
parent 450dc44d0e
commit d61a43cc8c
15 changed files with 1352 additions and 132 deletions
@@ -1,5 +1,6 @@
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';
@@ -9,6 +10,11 @@ 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(
@@ -77,27 +83,192 @@ export class SettlementService {
pageSize?: number;
status?: string;
storeId?: string;
dateFrom?: string;
dateTo?: 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 where = this.buildStorePayoutWhere(query);
const [items, total] = await Promise.all([
const [items, total, aggregates] = await Promise.all([
this.prisma.storePayout.findMany({
where,
orderBy: { expectedPayAt: 'asc' },
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 } },
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,
}),
]);
return serializeBigInt({ items, total, page, pageSize });
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) {
@@ -172,24 +343,95 @@ export class SettlementService {
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;
}
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 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) {
@@ -310,31 +552,170 @@ export class SettlementService {
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'];
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 } } },
orderBy: { createdAt: 'desc' },
include: { partnerAccount: { select: { companyName: true, phone: true } } },
orderBy: { periodStart: 'desc' },
});
const header = 'billNo,partner,periodStart,periodEnd,orderCommission,redeemCommission,totalAmount,status';
const header = [
'账单号',
'合伙人',
'登录手机',
'账期起',
'账期止',
'酒单佣金',
'核销佣金',
'合计应付',
'状态',
'确认时间',
'打款时间',
].join(',');
const rows = bills.map((b) =>
[
b.billNo,
b.partnerAccount.companyName,
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) : '',
].join(','),
);
return { csv: [header, ...rows].join('\n'), count: bills.length };
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() {