Files
dukang/server/dukang-api/src/modules/settlement/settlement.service.ts
T
jacy 478291e2b3 fix(admin): contract preview, package audit detail, empty packages, winery status
Allow HQ to save stores with zero packages, preview contract images/PDFs, inspect package audit images/content, and show gray 无需打款 when winery payable is 0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 20:30:55 +08:00

2362 lines
75 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 {
DEFAULT_STORE_WITHDRAW_DAILY_LIMIT,
DEFAULT_XFX_LOGISTICS_PRICING,
WINERY_SETTLEMENT_LAG_DAYS,
WINERY_SETTLEMENT_RATE,
} from '@dukang/shared-types';
import {
calcLogisticsFeeByBottles,
pickPayoutsForWithdrawAmount,
sumUnbilledPayoutAmount,
validateStoreWithdraw,
type LogisticsPricingRule,
} from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AlertService } from '../../common/alert/alert.service';
import { AnalyticsService } from '../analytics/analytics.service';
import { PartnerCityService } from '../city-scope/partner-city.service';
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.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 wineryDayWindow(anchor = new Date(), lagDays = WINERY_SETTLEMENT_LAG_DAYS) {
const billDate = startOfDay(anchor);
billDate.setDate(billDate.getDate() - lagDays);
const start = billDate;
const end = new Date(start);
end.setDate(end.getDate() + 1);
return { start, end, billDate: start };
}
function round2(n: number) {
return Math.round(n * 100) / 100;
}
function getStoreWithdrawDailyLimit(): number {
const raw = process.env.STORE_WITHDRAW_DAILY_LIMIT;
const n = raw != null && raw !== '' ? Number(raw) : DEFAULT_STORE_WITHDRAW_DAILY_LIMIT;
return Number.isFinite(n) && n > 0 ? n : DEFAULT_STORE_WITHDRAW_DAILY_LIMIT;
}
/** 工作日 18:00 前未审完视为 FIN-003 超时(Asia/Shanghai 自然日) */
function isWithdrawOverdue(appliedAt: Date, now = new Date()): boolean {
const day = appliedAt.getDay(); // 0 Sun … 6 Sat
if (day === 0 || day === 6) return false;
const deadline = new Date(
appliedAt.getFullYear(),
appliedAt.getMonth(),
appliedAt.getDate(),
18,
0,
0,
0,
);
return now.getTime() > deadline.getTime();
}
@Injectable()
export class SettlementService {
constructor(
private readonly prisma: PrismaService,
private readonly analyticsService: AnalyticsService,
private readonly partnerCityService: PartnerCityService,
private readonly fulfillmentProviderService: FulfillmentProviderService,
private readonly alert: AlertService,
) {}
// ─── 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 });
}
// ─── Store withdraw (未出账手动提现) ─────────────────
private async assertShopStoreAccess(storeAccountId: bigint, storeId: bigint) {
await this.prisma.storeAccountStore.findUniqueOrThrow({
where: { storeAccountId_storeId: { storeAccountId, storeId } },
});
}
private async listAvailableUnbilledPayouts(storeId: bigint) {
return this.prisma.storePayout.findMany({
where: {
storeId,
status: 'PENDING',
storeBillId: null,
withdrawItem: null,
},
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
});
}
private async todayWithdrawAppliedAmount(storeId: bigint, now = new Date()) {
const start = startOfDay(now);
const end = new Date(start);
end.setDate(end.getDate() + 1);
const agg = await this.prisma.storeWithdrawRequest.aggregate({
where: {
storeId,
status: { in: ['PENDING_REVIEW', 'PAID'] },
appliedAt: { gte: start, lt: end },
},
_sum: { amount: true },
});
return Number(agg._sum.amount ?? 0);
}
async getShopWithdrawSummary(storeAccountId: bigint, storeId: bigint) {
await this.assertShopStoreAccess(storeAccountId, storeId);
const [account, available, pending, todayApplied] = await Promise.all([
this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId },
select: {
isPrimary: true,
bankAccountName: true,
bankAccountNo: true,
bankBranch: true,
},
}),
this.listAvailableUnbilledPayouts(storeId),
this.prisma.storeWithdrawRequest.findFirst({
where: { storeId, status: 'PENDING_REVIEW' },
select: { id: true, amount: true },
}),
this.todayWithdrawAppliedAmount(storeId),
]);
const availableAmount = sumUnbilledPayoutAmount(
available.map((p) => ({ payoutAmount: Number(p.payoutAmount) })),
);
const dailyLimit = getStoreWithdrawDailyLimit();
const hasBankAccount = !!(
account.bankAccountName?.trim() &&
account.bankAccountNo?.trim()
);
return {
availableAmount,
pendingReviewAmount: pending ? Number(pending.amount) : 0,
todayAppliedAmount: todayApplied,
dailyLimit,
remainingDailyLimit: Math.max(0, round2(dailyLimit - todayApplied)),
isPrimary: account.isPrimary === 1,
hasBankAccount,
bankAccount: {
bankAccountName: account.bankAccountName,
bankAccountNo: account.bankAccountNo,
bankBranch: account.bankBranch,
},
};
}
async createStoreWithdrawRequest(
storeAccountId: bigint,
storeId: bigint,
dto?: { amount?: number },
) {
await this.assertShopStoreAccess(storeAccountId, storeId);
const [store, account, available, pending, todayApplied] = await Promise.all([
this.prisma.store.findUniqueOrThrow({
where: { id: storeId },
select: { id: true, name: true, phone: true, cityName: true },
}),
this.prisma.storeAccount.findUniqueOrThrow({
where: { id: storeAccountId },
select: {
isPrimary: true,
bankAccountName: true,
bankAccountNo: true,
},
}),
this.listAvailableUnbilledPayouts(storeId),
this.prisma.storeWithdrawRequest.findFirst({
where: { storeId, status: 'PENDING_REVIEW' },
select: { id: true },
}),
this.todayWithdrawAppliedAmount(storeId),
]);
if (account.isPrimary !== 1) {
throw new BadRequestException('仅门店主账号可申请提现');
}
const availableAmount = sumUnbilledPayoutAmount(
available.map((p) => ({ payoutAmount: Number(p.payoutAmount) })),
);
const dailyLimit = getStoreWithdrawDailyLimit();
const hasBankAccount = !!(
account.bankAccountName?.trim() &&
account.bankAccountNo?.trim()
);
const requestAmount =
dto?.amount != null && Number.isFinite(Number(dto.amount))
? round2(Number(dto.amount))
: availableAmount;
const guard = validateStoreWithdraw({
availableAmount,
requestAmount,
todayApplied,
dailyLimit,
hasBankAccount,
});
if (!guard.ok) throw new BadRequestException(guard.message);
const picked = pickPayoutsForWithdrawAmount(
available.map((p) => ({ id: p.id, payoutAmount: Number(p.payoutAmount) })),
requestAmount,
);
if (!picked.ok) throw new BadRequestException(picked.message);
const created = await this.prisma.$transaction(async (tx) => {
const payoutIds = picked.selected.map((p) => p.id);
const locked = await tx.storePayout.findMany({
where: {
id: { in: payoutIds },
storeId,
status: 'PENDING',
storeBillId: null,
withdrawItem: null,
},
select: { id: true, payoutAmount: true },
});
if (locked.length !== payoutIds.length) {
throw new BadRequestException('可提余额已变化,请刷新后重试');
}
const amount = round2(locked.reduce((s, p) => s + Number(p.payoutAmount), 0));
const req = await tx.storeWithdrawRequest.create({
data: {
withdrawNo: generateBillNo('SW'),
storeId,
storeAccountId,
amount,
payoutCount: locked.length,
status: 'PENDING_REVIEW',
},
});
await tx.storeWithdrawPayoutItem.createMany({
data: locked.map((p) => ({
withdrawRequestId: req.id,
storePayoutId: p.id,
})),
});
return req;
});
this.analyticsService.trackStoreOneSafe(storeAccountId, 'SHOP_H5', {
storeId,
eventName: 'store_withdraw_applied',
refType: 'STORE_WITHDRAW',
refId: created.id,
extraJson: {
amount: Number(created.amount),
payoutCount: created.payoutCount,
},
});
this.alert.notify({
level: 'P1',
category: 'finance',
title: '门店提现待审',
detail: [
`门店:${store.name}${store.cityName || '-'} / ${store.phone || '-'}`,
`单号:${created.withdrawNo}`,
`金额:¥${Number(created.amount).toFixed(2)}`,
`明细:${created.payoutCount} 笔未出账核销(已锁定,不进入次日 T+1 出账)`,
'请尽快在 HQ「财务 → 门店账单(手动提现)」处理。',
].join('\n'),
dedupeKey: `store_withdraw_applied|${created.id.toString()}`,
dedupeTtlSec: 3600,
});
return serializeBigInt(created);
}
async listShopWithdrawRequests(
storeAccountId: bigint,
storeId: bigint,
query: { page?: number; pageSize?: number; status?: string },
) {
await this.assertShopStoreAccess(storeAccountId, storeId);
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.StoreWithdrawRequestWhereInput = { storeId };
if (query.status) {
where.status = query.status as 'PENDING_REVIEW' | 'REJECTED' | 'PAID';
}
const [items, total] = await Promise.all([
this.prisma.storeWithdrawRequest.findMany({
where,
orderBy: { appliedAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.storeWithdrawRequest.count({ where }),
]);
return serializeBigInt({ items, total, page, pageSize });
}
async listAdminStoreWithdrawals(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: Prisma.StoreWithdrawRequestWhereInput = {};
if (query.status) {
where.status = query.status as 'PENDING_REVIEW' | 'REJECTED' | 'PAID';
}
if (query.storeId) where.storeId = BigInt(query.storeId);
if (query.dateFrom || query.dateTo) {
where.appliedAt = {};
if (query.dateFrom) where.appliedAt.gte = new Date(query.dateFrom);
if (query.dateTo) {
const end = new Date(query.dateTo);
end.setHours(23, 59, 59, 999);
where.appliedAt.lte = end;
}
}
const [items, total, aggregates] = await Promise.all([
this.prisma.storeWithdrawRequest.findMany({
where,
orderBy: [{ appliedAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
include: {
store: { select: { id: true, name: true, cityName: true, phone: true } },
},
}),
this.prisma.storeWithdrawRequest.count({ where }),
this.prisma.storeWithdrawRequest.aggregate({
where,
_sum: { amount: true },
_count: true,
}),
]);
const now = new Date();
const mapped = items.map((row) => ({
...row,
overdue:
row.status === 'PENDING_REVIEW' ? isWithdrawOverdue(row.appliedAt, now) : false,
}));
return serializeBigInt({
items: mapped,
total,
page,
pageSize,
summary: {
count: aggregates._count,
totalAmount: Number(aggregates._sum.amount ?? 0),
},
});
}
async getAdminStoreWithdrawal(id: bigint) {
const row = await this.prisma.storeWithdrawRequest.findUnique({
where: { id },
include: {
store: {
select: {
id: true,
name: true,
cityName: true,
phone: true,
},
},
storeAccount: {
select: {
id: true,
name: true,
phone: true,
bankAccountName: true,
bankAccountNo: true,
bankBranch: true,
},
},
items: {
include: {
storePayout: {
include: {
redeemRecord: { select: { redeemNo: true, amount: true, createdAt: true } },
},
},
},
},
},
});
if (!row) throw new NotFoundException('提现申请不存在');
return serializeBigInt({
...row,
overdue:
row.status === 'PENDING_REVIEW' ? isWithdrawOverdue(row.appliedAt) : false,
});
}
async approveStoreWithdraw(
id: bigint,
hqAccountId: bigint,
dto?: { paymentRef?: string },
) {
const row = await this.prisma.storeWithdrawRequest.findUnique({
where: { id },
include: { items: { select: { storePayoutId: true } } },
});
if (!row) throw new NotFoundException('提现申请不存在');
if (row.status !== 'PENDING_REVIEW') {
throw new BadRequestException('仅待审核提现可审核通过');
}
const paidAt = new Date();
const updated = await this.prisma.$transaction(async (tx) => {
const req = await tx.storeWithdrawRequest.update({
where: { id },
data: {
status: 'PAID',
reviewedAt: paidAt,
reviewedByHqId: hqAccountId,
paidAt,
paymentRef: dto?.paymentRef?.trim() || null,
},
});
await tx.storePayout.updateMany({
where: {
id: { in: row.items.map((i) => i.storePayoutId) },
status: 'PENDING',
},
data: { status: 'PAID', paidAt },
});
return req;
});
this.analyticsService.trackStoreOneSafe(undefined, 'HQ_WEB', {
storeId: row.storeId,
eventName: 'store_withdraw_paid',
refType: 'STORE_WITHDRAW',
refId: id,
extraJson: {
amount: Number(row.amount),
paymentRef: dto?.paymentRef,
},
});
return serializeBigInt(updated);
}
async rejectStoreWithdraw(id: bigint, hqAccountId: bigint, reason: string) {
const row = await this.prisma.storeWithdrawRequest.findUnique({
where: { id },
});
if (!row) throw new NotFoundException('提现申请不存在');
if (row.status !== 'PENDING_REVIEW') {
throw new BadRequestException('仅待审核提现可驳回');
}
const rejectReason = reason.trim();
if (!rejectReason) throw new BadRequestException('请填写驳回理由');
const updated = await this.prisma.$transaction(async (tx) => {
const req = await tx.storeWithdrawRequest.update({
where: { id },
data: {
status: 'REJECTED',
rejectReason,
reviewedAt: new Date(),
reviewedByHqId: hqAccountId,
},
});
// 释放 payout 锁定,允许再次提现
await tx.storeWithdrawPayoutItem.deleteMany({ where: { withdrawRequestId: id } });
return req;
});
return serializeBigInt(updated);
}
async getStoreWithdrawOverdueSummary() {
const pending = await this.prisma.storeWithdrawRequest.findMany({
where: { status: 'PENDING_REVIEW' },
select: { id: true, appliedAt: true, amount: true },
});
const now = new Date();
const overdue = pending.filter((r) => isWithdrawOverdue(r.appliedAt, now));
return {
pendingCount: pending.length,
overdueCount: overdue.length,
overdueAmount: round2(overdue.reduce((s, r) => s + Number(r.amount), 0)),
pendingAmount: round2(pending.reduce((s, r) => s + Number(r.amount), 0)),
};
}
/** FIN-003:工作日 18:00 扫描超时未审提现 */
async scanOverdueStoreWithdrawals() {
const summary = await this.getStoreWithdrawOverdueSummary();
if (summary.overdueCount > 0) {
const overdueRows = await this.prisma.storeWithdrawRequest.findMany({
where: { status: 'PENDING_REVIEW' },
include: { store: { select: { name: true, phone: true } } },
orderBy: { appliedAt: 'asc' },
take: 20,
});
const now = new Date();
const lines = overdueRows
.filter((r) => isWithdrawOverdue(r.appliedAt, now))
.slice(0, 10)
.map(
(r) =>
`- ${r.store?.name || r.storeId} ${r.withdrawNo} ¥${Number(r.amount).toFixed(2)}`,
);
this.alert.notify({
level: 'P1',
category: 'finance',
title: '门店提现超时未审',
detail: [
`待审 ${summary.pendingCount} 笔,超时 ${summary.overdueCount} 笔,超时金额 ¥${summary.overdueAmount.toFixed(2)}`,
...lines,
'请尽快在 HQ「财务 → 门店账单(手动提现)」处理。',
].join('\n'),
dedupeKey: `store_withdraw_overdue|${new Date().toISOString().slice(0, 10)}`,
dedupeTtlSec: 6 * 3600,
});
}
return summary;
}
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 },
// 排除已锁定在待审提现单中的明细,避免出账与提现双占
withdrawItem: null,
status: 'PENDING',
},
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 };
}
/**
* 总部「门店账单」统一列表:T+1 StoreBill + 手动提现 StoreWithdrawRequest
* 中小数据量内存合并后分页。
*/
async listAdminStoreSettlements(query: {
page?: number;
pageSize?: number;
kind?: string;
status?: string;
storeId?: string;
dateFrom?: string;
dateTo?: string;
}) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const kind = query.kind === 'T1_BILL' || query.kind === 'WITHDRAW' ? query.kind : undefined;
const status = query.status?.trim() || undefined;
const includeBills =
!kind || kind === 'T1_BILL'
? !status || status === 'UNPAID' || status === 'PAID'
: false;
const includeWithdraws =
!kind || kind === 'WITHDRAW'
? !status ||
status === 'PENDING_REVIEW' ||
status === 'REJECTED' ||
status === 'PAID'
: false;
const billWhere = includeBills
? this.buildStoreBillWhere({
status: status === 'UNPAID' || status === 'PAID' ? status : undefined,
storeId: query.storeId,
dateFrom: query.dateFrom,
dateTo: query.dateTo,
})
: null;
const withdrawWhere: Prisma.StoreWithdrawRequestWhereInput | null = includeWithdraws
? (() => {
const where: Prisma.StoreWithdrawRequestWhereInput = {};
if (status === 'PENDING_REVIEW' || status === 'REJECTED' || status === 'PAID') {
where.status = status;
}
if (query.storeId) where.storeId = BigInt(query.storeId);
if (query.dateFrom || query.dateTo) {
where.appliedAt = {};
if (query.dateFrom) where.appliedAt.gte = new Date(query.dateFrom);
if (query.dateTo) {
const end = new Date(query.dateTo);
end.setHours(23, 59, 59, 999);
where.appliedAt.lte = end;
}
}
return where;
})()
: null;
const bills = billWhere
? await this.prisma.storeBill.findMany({
where: billWhere,
include: {
store: { select: { id: true, name: true, cityName: true, phone: true } },
},
})
: [];
const withdraws = withdrawWhere
? await this.prisma.storeWithdrawRequest.findMany({
where: withdrawWhere,
include: {
store: { select: { id: true, name: true, cityName: true, phone: true } },
},
})
: [];
const now = new Date();
type UnifiedRow = {
kind: 'T1_BILL' | 'WITHDRAW';
id: bigint;
billNo: string;
storeId: bigint;
amount: number;
status: string;
date: Date;
overdue?: boolean;
redeemCount?: number;
redeemAmount?: number;
settlementRate?: number;
payoutCount?: number;
store?: { id: bigint; name: string; cityName: string; phone: string | null };
};
const rows: UnifiedRow[] = [
...bills.map((b) => ({
kind: 'T1_BILL' as const,
id: b.id,
billNo: b.billNo,
storeId: b.storeId,
amount: Number(b.payoutAmount),
status: b.status,
date: b.billDate,
redeemCount: b.redeemCount,
redeemAmount: Number(b.redeemAmount),
settlementRate: Number(b.settlementRate),
store: b.store,
})),
...withdraws.map((w) => ({
kind: 'WITHDRAW' as const,
id: w.id,
billNo: w.withdrawNo,
storeId: w.storeId,
amount: Number(w.amount),
status: w.status,
date: w.appliedAt,
overdue: w.status === 'PENDING_REVIEW' ? isWithdrawOverdue(w.appliedAt, now) : false,
payoutCount: w.payoutCount,
store: w.store,
})),
];
rows.sort((a, b) => {
const dt = b.date.getTime() - a.date.getTime();
if (dt !== 0) return dt;
return Number(b.id - a.id);
});
const total = rows.length;
const slice = rows.slice((page - 1) * pageSize, page * pageSize);
const redeemAmount = bills.reduce((s, b) => s + Number(b.redeemAmount), 0);
const payoutAmount = rows.reduce((s, r) => s + r.amount, 0);
return serializeBigInt({
items: slice,
total,
page,
pageSize,
summary: {
count: total,
redeemAmount,
payoutAmount,
totalAmount: payoutAmount,
},
});
}
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 getPartnerBill(partnerAccountId: bigint, billId: bigint) {
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const bill = await this.prisma.partnerBill.findFirst({
where: { id: billId, partnerAccountId: primary.id },
});
if (!bill) throw new NotFoundException('账单不存在');
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerAccountId: primary.id,
eventName: 'partner_bill_detail_view',
refType: 'PARTNER_BILL',
refId: billId,
extraJson: { billId: billId.toString(), status: bill.status },
});
return serializeBigInt(bill);
}
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 },
isTest: false,
},
});
const orderCommission = primary.isTest
? 0
: 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 =
primary.isTest || !storeIds.length
? []
: await this.prisma.redeemRecord.findMany({
where: {
storeId: { in: storeIds },
createdAt: { gte: periodStart, lte: periodEnd },
isTest: false,
},
});
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 } = wineryDayWindow(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: '已打款' };
}
// T+3:纳入「账单日」当天完成的同城/跨城订单(完成日 = 今天 − lagDays)
const orders = await this.prisma.order.findMany({
where: {
status: 'COMPLETED',
payStatus: 'PAID',
deliveryType: { in: ['LOCAL', 'CROSS_CITY'] },
completedAt: { gte: start, lt: end },
isTest: false,
},
orderBy: { completedAt: '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),
// 明细仍保留支付时间;账期窗口按 completedAt 归属
paidAt: o.paidAt ?? o.completedAt!,
})),
});
}
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('仅未打款账单可确认打款');
if (Number(bill.wineryAmount) === 0) {
throw new BadRequestException('应付为 0,无需打款');
}
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[] = [];
const statusLabel = (b: { status: string; wineryAmount: Prisma.Decimal | number }) => {
if (Number(b.wineryAmount) === 0) return '无需打款';
if (b.status === 'PAID') return '已打款';
if (b.status === 'UNPAID') return '未打款';
return b.status;
};
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),
'',
statusLabel(b),
].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', ' '),
statusLabel(b),
].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 === 'NO_PAYMENT_NEEDED') {
where.wineryAmount = 0;
} else if (query.status === 'UNPAID') {
where.status = 'UNPAID';
where.wineryAmount = { gt: 0 };
} else if (query.status === 'PAID') {
where.status = 'PAID';
where.wineryAmount = { gt: 0 };
}
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;
}
// ─── Logistics bills (按承运商月结) ───────────────────
async generatePreviousMonthLogisticsBills(anchor = new Date()) {
const prev = new Date(anchor.getFullYear(), anchor.getMonth() - 1, 1);
return this.generateAllLogisticsBills({
year: prev.getFullYear(),
month: prev.getMonth() + 1,
});
}
async generateAllLogisticsBills(body: { year: number; month: number }) {
const providers = await this.prisma.fulfillmentProvider.findMany({
where: { status: 'ACTIVE' },
orderBy: { code: 'asc' },
});
const results: Array<{
providerId: string;
providerCode: string;
providerName: string;
ok: boolean;
skipped?: boolean;
message?: string;
billId?: string;
}> = [];
for (const p of providers) {
try {
const bill = await this.generateLogisticsBill({
providerId: p.id.toString(),
year: body.year,
month: body.month,
});
results.push({
providerId: p.id.toString(),
providerCode: p.code,
providerName: p.name,
ok: true,
skipped: Boolean(bill.skipped),
message: bill.reason,
billId:
bill.bill?.id != null
? typeof bill.bill.id === 'string'
? bill.bill.id
: String(bill.bill.id)
: undefined,
});
} catch (e) {
results.push({
providerId: p.id.toString(),
providerCode: p.code,
providerName: p.name,
ok: false,
message: e instanceof Error ? e.message : '失败',
});
}
}
return {
total: providers.length,
success: results.filter((r) => r.ok).length,
failed: results.filter((r) => !r.ok).length,
results,
};
}
async generateLogisticsBill(body: { providerId: string; year: number; month: number }) {
const fulfillmentProviderId = BigInt(body.providerId);
const provider = await this.prisma.fulfillmentProvider.findUnique({
where: { id: fulfillmentProviderId },
});
if (!provider) throw new NotFoundException('仓配承运商不存在');
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.logisticsBill.findUnique({
where: {
fulfillmentProviderId_periodStart: { fulfillmentProviderId, periodStart },
},
});
if (existing?.status === 'PAID') {
return {
skipped: true,
reason: '该月账单已结算',
bill: serializeBigInt(existing),
};
}
const pricing =
this.fulfillmentProviderService.parsePricingRules(provider.pricingRulesJson) ??
(provider.code.toUpperCase() === 'XFX' || provider.code.toUpperCase() === 'XIAOFEIXIA'
? { ...DEFAULT_XFX_LOGISTICS_PRICING }
: null);
if (!pricing) {
throw new BadRequestException(`承运商 ${provider.name} 未配置计价标准`);
}
const deliveries = await this.prisma.orderDelivery.findMany({
where: {
fulfillmentProviderId,
OR: [
{ shippingAt: { gte: periodStart, lte: periodEnd } },
{
shippingAt: null,
outWarehouseAt: { gte: periodStart, lte: periodEnd },
},
],
},
include: {
order: {
select: {
id: true,
orderNo: true,
quantity: true,
deliveryType: true,
payStatus: true,
isTest: true,
},
},
},
orderBy: [{ shippingAt: 'asc' }, { outWarehouseAt: 'asc' }],
});
const eligible = deliveries.filter(
(d) =>
!d.order.isTest &&
d.order.payStatus === 'PAID' &&
(d.order.deliveryType === 'LOCAL' || d.order.deliveryType === 'CROSS_CITY'),
);
if (eligible.length === 0 && !existing) {
return { skipped: true, reason: '无发货订单', bill: null };
}
const items = eligible.map((d) => {
const qty = d.order.quantity;
const fee = calcLogisticsFeeByBottles(qty, pricing as LogisticsPricingRule);
const shippedAt = d.shippingAt ?? d.outWarehouseAt ?? periodStart;
return {
orderId: d.order.id,
orderNo: d.order.orderNo,
quantity: qty,
logisticsAmount: fee,
shippedAt,
};
});
const orderCount = items.length;
const bottleCount = items.reduce((s, i) => s + i.quantity, 0);
const logisticsAmount = round2(items.reduce((s, i) => s + i.logisticsAmount, 0));
const settlementMethod = provider.settlementMethod;
const pricingSnapshotJson = JSON.stringify(pricing);
const bill = await this.prisma.$transaction(async (tx) => {
const header = existing
? await tx.logisticsBill.update({
where: { id: existing.id },
data: {
periodEnd,
orderCount,
bottleCount,
logisticsAmount,
settlementMethod,
pricingSnapshotJson,
status: 'UNPAID',
paidAt: null,
},
})
: await tx.logisticsBill.create({
data: {
billNo: generateBillNo('LB'),
fulfillmentProviderId,
periodStart,
periodEnd,
orderCount,
bottleCount,
logisticsAmount,
settlementMethod,
pricingSnapshotJson,
status: 'UNPAID',
},
});
if (existing) {
await tx.logisticsBillItem.deleteMany({ where: { logisticsBillId: header.id } });
}
if (items.length > 0) {
await tx.logisticsBillItem.createMany({
data: items.map((i) => ({
logisticsBillId: header.id,
orderId: i.orderId,
orderNo: i.orderNo,
quantity: i.quantity,
logisticsAmount: i.logisticsAmount,
shippedAt: i.shippedAt,
})),
});
}
// 充值模式:余额充足则自动扣款并标记已结算
if (settlementMethod === 'PREPAID' && logisticsAmount > 0) {
const deducted = await this.fulfillmentProviderService.deductPrepaid(
fulfillmentProviderId,
logisticsAmount,
header.id,
tx,
);
if (deducted.ok) {
return tx.logisticsBill.update({
where: { id: header.id },
data: { status: 'PAID', paidAt: new Date() },
});
}
}
return header;
});
return {
skipped: false,
bill: serializeBigInt(bill),
};
}
async listAdminLogisticsBills(query: {
page?: number;
pageSize?: number;
status?: string;
providerId?: string;
year?: number;
month?: number;
}) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where = this.buildLogisticsBillWhere(query);
const [rawItems, total, aggregates] = await Promise.all([
this.prisma.logisticsBill.findMany({
where,
orderBy: { periodStart: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
fulfillmentProvider: {
select: {
id: true,
code: true,
name: true,
settlementMethod: true,
prepaidBalance: true,
bankAccountName: true,
bankName: true,
bankAccountNo: true,
},
},
},
}),
this.prisma.logisticsBill.count({ where }),
this.prisma.logisticsBill.aggregate({
where,
_sum: { logisticsAmount: true, bottleCount: true, orderCount: true },
_count: true,
}),
]);
const items = rawItems.map((b) => ({
...b,
providerCode: b.fulfillmentProvider.code,
providerName: b.fulfillmentProvider.name,
pricingSnapshot: this.fulfillmentProviderService.parsePricingRules(b.pricingSnapshotJson),
}));
const summary = {
count: aggregates._count,
logisticsAmount: Number(aggregates._sum.logisticsAmount ?? 0),
bottleCount: Number(aggregates._sum.bottleCount ?? 0),
totalAmount: Number(aggregates._sum.logisticsAmount ?? 0),
};
return serializeBigInt({ items, total, page, pageSize, summary });
}
async listLogisticsProviderSummary(query: { year?: number; month?: number }) {
const year = query.year ?? new Date().getFullYear();
const month = query.month ?? new Date().getMonth() + 1;
const periodStart = new Date(year, month - 1, 1);
const periodEnd = new Date(year, month, 0, 23, 59, 59, 999);
const providers = await this.prisma.fulfillmentProvider.findMany({
orderBy: { code: 'asc' },
});
const rows: Array<{
providerId: string;
providerCode: string;
providerName: string;
settlementMethod: string;
prepaidBalance: number;
bankAccountName: string | null;
bankName: string | null;
bankAccountNo: string | null;
pricingRules: ReturnType<FulfillmentProviderService['parsePricingRules']>;
orderCount: number;
bottleCount: number;
logisticsAmount: number;
billId: string | null;
billStatus: string | null;
billAmount: number | null;
}> = [];
for (const p of providers) {
const pricing =
this.fulfillmentProviderService.parsePricingRules(p.pricingRulesJson) ??
(p.code.toUpperCase() === 'XFX' || p.code.toUpperCase() === 'XIAOFEIXIA'
? { ...DEFAULT_XFX_LOGISTICS_PRICING }
: null);
const deliveries = await this.prisma.orderDelivery.findMany({
where: {
fulfillmentProviderId: p.id,
OR: [
{ shippingAt: { gte: periodStart, lte: periodEnd } },
{
shippingAt: null,
outWarehouseAt: { gte: periodStart, lte: periodEnd },
},
],
},
include: {
order: { select: { quantity: true, payStatus: true, deliveryType: true } },
},
});
const eligible = deliveries.filter(
(d) =>
d.order.payStatus === 'PAID' &&
(d.order.deliveryType === 'LOCAL' || d.order.deliveryType === 'CROSS_CITY'),
);
const orderCount = eligible.length;
const bottleCount = eligible.reduce((s, d) => s + d.order.quantity, 0);
let logisticsAmount = 0;
if (pricing) {
logisticsAmount = round2(
eligible.reduce(
(s, d) => s + calcLogisticsFeeByBottles(d.order.quantity, pricing as LogisticsPricingRule),
0,
),
);
}
const bill = await this.prisma.logisticsBill.findUnique({
where: {
fulfillmentProviderId_periodStart: {
fulfillmentProviderId: p.id,
periodStart,
},
},
});
rows.push({
providerId: p.id.toString(),
providerCode: p.code,
providerName: p.name,
settlementMethod: p.settlementMethod,
prepaidBalance: Number(p.prepaidBalance),
bankAccountName: p.bankAccountName,
bankName: p.bankName,
bankAccountNo: p.bankAccountNo,
pricingRules: pricing,
orderCount,
bottleCount,
logisticsAmount,
billId: bill?.id?.toString() ?? null,
billStatus: bill?.status ?? null,
billAmount: bill ? Number(bill.logisticsAmount) : null,
});
}
return { year, month, items: rows };
}
async getAdminLogisticsBill(id: bigint) {
const bill = await this.prisma.logisticsBill.findUnique({
where: { id },
include: {
fulfillmentProvider: true,
items: { orderBy: { shippedAt: 'desc' } },
},
});
if (!bill) throw new NotFoundException('物流对账单不存在');
return serializeBigInt({
...bill,
providerCode: bill.fulfillmentProvider.code,
providerName: bill.fulfillmentProvider.name,
pricingSnapshot: this.fulfillmentProviderService.parsePricingRules(bill.pricingSnapshotJson),
});
}
async confirmLogisticsBill(id: bigint) {
const bill = await this.prisma.logisticsBill.findUnique({ where: { id } });
if (!bill) throw new NotFoundException('物流对账单不存在');
if (bill.status !== 'UNPAID') throw new BadRequestException('仅未结算账单可确认');
if (bill.settlementMethod === 'PREPAID') {
const deducted = await this.prisma.$transaction(async (tx) => {
const result = await this.fulfillmentProviderService.deductPrepaid(
bill.fulfillmentProviderId,
Number(bill.logisticsAmount),
bill.id,
tx,
);
if (!result.ok) {
throw new BadRequestException(
`充值余额不足(当前 ¥${result.balance.toFixed(2)},应付 ¥${Number(bill.logisticsAmount).toFixed(2)}`,
);
}
return tx.logisticsBill.update({
where: { id },
data: { status: 'PAID', paidAt: new Date() },
});
});
return serializeBigInt(deducted);
}
const updated = await this.prisma.logisticsBill.update({
where: { id },
data: { status: 'PAID', paidAt: new Date() },
});
return serializeBigInt(updated);
}
async batchConfirmLogisticsBills(ids: string[]) {
const results: Array<{ id: string; ok: boolean; message?: string }> = [];
for (const id of ids) {
try {
await this.confirmLogisticsBill(BigInt(id));
results.push({ id, ok: true });
} catch (e) {
results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' });
}
}
return results;
}
async exportAdminLogisticsBills(query: {
status?: string;
providerId?: string;
year?: number;
month?: number;
}) {
const where = this.buildLogisticsBillWhere(query);
const bills = await this.prisma.logisticsBill.findMany({
where,
include: {
fulfillmentProvider: { select: { code: true, name: true } },
items: true,
},
orderBy: { periodStart: 'desc' },
});
const header = [
'账单号',
'承运商编码',
'承运商名称',
'账期起',
'账期止',
'结算方式',
'订单号',
'瓶数',
'物流费',
'发货时间',
'账单状态',
].join(',');
const rows: string[] = [];
for (const b of bills) {
if (b.items.length === 0) {
rows.push(
[
csvEscape(b.billNo),
csvEscape(b.fulfillmentProvider.code),
csvEscape(b.fulfillmentProvider.name),
b.periodStart.toISOString().slice(0, 10),
b.periodEnd.toISOString().slice(0, 10),
b.settlementMethod,
'',
b.bottleCount,
Number(b.logisticsAmount),
'',
b.status,
].join(','),
);
continue;
}
for (const item of b.items) {
rows.push(
[
csvEscape(b.billNo),
csvEscape(b.fulfillmentProvider.code),
csvEscape(b.fulfillmentProvider.name),
b.periodStart.toISOString().slice(0, 10),
b.periodEnd.toISOString().slice(0, 10),
b.settlementMethod,
csvEscape(item.orderNo),
item.quantity,
Number(item.logisticsAmount),
item.shippedAt.toISOString().slice(0, 19).replace('T', ' '),
b.status,
].join(','),
);
}
}
return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: rows.length };
}
private buildLogisticsBillWhere(query: {
status?: string;
providerId?: string;
year?: number;
month?: number;
}): Prisma.LogisticsBillWhereInput {
const where: Prisma.LogisticsBillWhereInput = {};
if (query.status === 'UNPAID' || query.status === 'PAID') where.status = query.status;
if (query.providerId) where.fulfillmentProviderId = BigInt(query.providerId);
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;
}
}