Files
dukang/server/dukang-api/src/modules/settlement/settlement.service.ts
T
jacy f35e1a3ba5 feat(settlement): v4.0.6 酒厂 T+3 每 3 天出一期并与订单完全比对
含现场提货入账、零应付仍出账、核对补生成,以及线上历史账单修复 SQL。
2026-08-31 17:27:38 +08:00

3370 lines
107 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, Logger, NotFoundException, OnModuleInit } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
DEFAULT_STORE_WITHDRAW_DAILY_LIMIT,
DEFAULT_XFX_LOGISTICS_PRICING,
LOGISTICS_SETTLEMENT_METHOD_LABELS,
PAYMENT_PROOF_IMAGE_MAX_COUNT,
WINERY_BILL_EPOCH_YMD,
WINERY_SETTLEMENT_PERIOD_DAYS,
WINERY_SETTLEMENT_RATE,
} from '@dukang/shared-types';
import {
addShanghaiDays,
calcLogisticsFeeByBottles,
calcRedeemSettleAmount,
isWineryIssueDay,
listWineryIssueDays,
parseShanghaiYmd,
pickPayoutsForWithdrawAmount,
previousShanghaiMonth,
resolveSettlementRate,
shanghaiMonthLastInstant,
shanghaiMonthRange,
shanghaiPeriodYmds,
shanghaiT1DayWindow,
shanghaiWineryPeriodWindow,
shanghaiYearMonth,
shanghaiYmd,
startOfShanghaiDay,
sumUnbilledPayoutAmount,
validateStoreWithdraw,
wineryIssueDateFromCompletedAt,
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 { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
import { AnalyticsService } from '../analytics/analytics.service';
import { PartnerCityService } from '../city-scope/partner-city.service';
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
import {
loadStorePrimaryBank,
loadStorePrimaryBanksMap,
loadWineryBankConfig,
formatWecomBankAccount,
} from '../../common/store/store-bank.util';
import {
capBillBlocks,
formatLogisticsBillWecomBlock,
formatPartnerBillWecomBlock,
formatPartnerDashLabel,
formatPercent,
formatStoreBillWecomBlock,
joinLimited,
wecomBankParts,
type WecomBankLike,
} from './wecom-bill-digest';
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;
}
function parsePaymentProofUrls(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
return raw
.map((u) => String(u ?? '').trim())
.filter((u) => /^https?:\/\//i.test(u))
.slice(0, PAYMENT_PROOF_IMAGE_MAX_COUNT);
}
function paymentProofUrlsInput(urls?: string[]): Prisma.InputJsonValue | typeof Prisma.JsonNull {
const parsed = parsePaymentProofUrls(urls);
return parsed.length ? (parsed as Prisma.InputJsonValue) : Prisma.JsonNull;
}
function withShanghaiPeriod<T extends { periodStart: Date; periodEnd: Date }>(row: T) {
return { ...row, ...shanghaiPeriodYmds(row.periodStart) };
}
function toPartnerBillItemDto(row: {
id: bigint;
kind: string;
refId: bigint;
refNo: string;
title: string | null;
extra: string | null;
baseAmount: Prisma.Decimal | number;
rate: Prisma.Decimal | number;
commission: Prisma.Decimal | number;
occurredAt: Date;
}) {
return {
id: row.id.toString(),
kind: row.kind,
refId: row.refId.toString(),
refNo: row.refNo,
title: row.title,
extra: row.extra,
baseAmount: Number(row.baseAmount),
rate: Number(row.rate),
commission: Number(row.commission),
occurredAt: row.occurredAt.toISOString(),
};
}
function splitPartnerBillItems(
items: Array<{
id: bigint;
kind: string;
refId: bigint;
refNo: string;
title: string | null;
extra: string | null;
baseAmount: Prisma.Decimal | number;
rate: Prisma.Decimal | number;
commission: Prisma.Decimal | number;
occurredAt: Date;
}>,
) {
const orderItems = items.filter((i) => i.kind === 'ORDER').map(toPartnerBillItemDto);
const redeemItems = items.filter((i) => i.kind === 'REDEEM').map(toPartnerBillItemDto);
return { orderItems, redeemItems };
}
function round2(n: number) {
return Math.round(n * 100) / 100;
}
function sumAmounts(rows: Array<{ amount: number }>): string {
return rows.reduce((s, r) => s + Number(r.amount || 0), 0).toFixed(2);
}
function storeWecomMeta(
store:
| {
name?: string | null;
cityName?: string | null;
cityRef?: { name: string } | null;
partnerAccount?: {
name: string | null;
companyName: string | null;
parent?: { companyName: string | null } | null;
} | null;
}
| null
| undefined,
fallbackName: string,
) {
const cityName = store?.cityRef?.name?.trim() || store?.cityName?.trim() || '—';
const acc = store?.partnerAccount;
const partnerLabel = formatPartnerDashLabel(
acc?.companyName || acc?.parent?.companyName,
acc?.name,
);
return {
storeName: store?.name?.trim() || fallbackName,
cityName,
partnerLabel,
};
}
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 ymd = shanghaiYmd(appliedAt);
const noon = new Date(`${ymd}T12:00:00+08:00`);
const day = noon.getUTCDay(); // 与北京日历日相同
if (day === 0 || day === 6) return false;
const deadline = new Date(`${ymd}T18:00:00+08:00`);
return now.getTime() > deadline.getTime();
}
@Injectable()
export class SettlementService implements OnModuleInit {
private readonly logger = new Logger(SettlementService.name);
constructor(
private readonly prisma: PrismaService,
private readonly analyticsService: AnalyticsService,
private readonly partnerCityService: PartnerCityService,
private readonly fulfillmentProviderService: FulfillmentProviderService,
private readonly alert: AlertService,
private readonly wecomPush: WecomMessagePushService,
) {}
async onModuleInit() {
try {
const shifted = await this.realignStoreBillIssueDates();
if (shifted > 0) this.logger.log(`Store bill dates aligned to Beijing issue day: ${shifted}`);
} catch (e) {
this.logger.warn(`Store bill date align skipped: ${e instanceof Error ? e.message : e}`);
}
try {
const shifted = await this.realignWineryBillIssueDates();
if (shifted > 0) this.logger.log(`Winery bill dates aligned to Beijing issue day: ${shifted}`);
} catch (e) {
this.logger.warn(`Winery bill date align skipped: ${e instanceof Error ? e.message : e}`);
}
}
/**
* 账单日 = 出账当天(created_at 的北京日历日 00:00+08)。
* 纠正:旧窗口左端、UTC DATE() +1 误伤、以及 toISOString 跨日。
*/
private async realignStoreBillIssueDates(): Promise<number> {
const locked = await this.prisma.$queryRaw<Array<{ acquired: number | bigint | null }>>`
SELECT GET_LOCK('store_bill_issue_date_align', 5) AS acquired
`;
if (!Number(locked[0]?.acquired)) return 0;
try {
const bills = await this.prisma.storeBill.findMany({
select: { id: true, storeId: true, billDate: true, createdAt: true },
orderBy: { createdAt: 'desc' },
});
let shifted = 0;
for (const b of bills) {
const issue = startOfShanghaiDay(b.createdAt);
if (shanghaiYmd(b.billDate) === shanghaiYmd(issue)) continue;
const clash = await this.prisma.storeBill.findUnique({
where: { storeId_billDate: { storeId: b.storeId, billDate: issue } },
});
if (clash && clash.id !== b.id) continue;
await this.prisma.storeBill.update({ where: { id: b.id }, data: { billDate: issue } });
shifted += 1;
}
return shifted;
} finally {
await this.prisma.$queryRaw`SELECT RELEASE_LOCK('store_bill_issue_date_align')`;
}
}
/**
* 一次性:旧「billDate = 完成日」改为出账当天。
* 已是 T+3 周期出账日的账单禁止再按 createdAt 挪日,否则补生成的历史期账单会被改成今天。
*/
private async realignWineryBillIssueDates(): Promise<number> {
const locked = await this.prisma.$queryRaw<Array<{ acquired: number | bigint | null }>>`
SELECT GET_LOCK('winery_bill_issue_date_align', 5) AS acquired
`;
if (!Number(locked[0]?.acquired)) return 0;
try {
const periodDays = WINERY_SETTLEMENT_PERIOD_DAYS;
const epochYmd = WINERY_BILL_EPOCH_YMD;
const bills = await this.prisma.wineryBill.findMany({
select: { id: true, billDate: true, createdAt: true },
orderBy: { createdAt: 'desc' },
});
let shifted = 0;
for (const b of bills) {
if (isWineryIssueDay(b.billDate, periodDays, epochYmd)) continue;
const issue = startOfShanghaiDay(b.createdAt);
if (shanghaiYmd(b.billDate) === shanghaiYmd(issue)) continue;
const clash = await this.prisma.wineryBill.findUnique({ where: { billDate: issue } });
if (clash && clash.id !== b.id) continue;
await this.prisma.wineryBill.update({ where: { id: b.id }, data: { billDate: issue } });
shifted += 1;
}
return shifted;
} finally {
await this.prisma.$queryRaw`SELECT RELEASE_LOCK('winery_bill_issue_date_align')`;
}
}
private notifyPartnerBillDigest(
period: string,
rows: Array<{
cityName: string;
partnerLabel: string;
orderCount?: number;
redeemCount?: number;
orderCommission: number;
redeemCommission: number;
amount: number;
bank?: WecomBankLike | null;
}>,
) {
if (!rows.length) return;
const blocks = rows.map((r) => formatPartnerBillWecomBlock(r));
void this.wecomPush.dispatchEvent(
'finance.partner_bill',
{
period,
billCount: String(rows.length),
totalAmount: sumAmounts(rows),
billSummary: capBillBlocks(blocks),
},
{ handlePath: '/finance/partner-bills' },
);
}
private notifyLogisticsBillDigest(
period: string,
rows: Array<{
cityNames: string;
partnerNames: string;
providerName: string;
settlementMethod: string;
orderCount: number;
bottleCount: number;
amount: number;
bank?: WecomBankLike | null;
}>,
) {
if (!rows.length) return;
const blocks = rows.map((r) => formatLogisticsBillWecomBlock(r));
void this.wecomPush.dispatchEvent(
'finance.logistics_bill',
{
period,
billCount: String(rows.length),
totalAmount: sumAmounts(rows),
billSummary: capBillBlocks(blocks),
},
{ handlePath: '/finance/logistics-bills' },
);
}
// ─── Store payout (line) ─────────────────────────────
async createStorePayout(
redeemRecordId: bigint,
storeId: bigint,
redeemAmount: number,
payoutAmount: number,
settlementRate: number,
tx?: Prisma.TransactionClient,
) {
const redeemAmountNum = Number(redeemAmount);
const payoutAmountNum = Number(payoutAmount);
const settlementRateNum = resolveSettlementRate(settlementRate);
if (!(redeemAmountNum > 0) || !(payoutAmountNum > 0)) {
throw new BadRequestException('结算金额无效,无法入账');
}
const expectedPayAt = new Date();
expectedPayAt.setDate(expectedPayAt.getDate() + 1);
const client = tx ?? this.prisma;
const payout = await client.storePayout.create({
data: {
redeemRecordId,
storeId,
redeemAmount: redeemAmountNum,
payoutAmount: payoutAmountNum,
settlementRate: settlementRateNum,
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) {
await this.backfillMissingStorePayouts(storeId);
return this.prisma.storePayout.findMany({
where: {
storeId,
status: 'PENDING',
storeBillId: null,
withdrawItem: { is: null },
},
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
});
}
/**
* 补写历史核销缺失的 store_payout(含测试流水)。
* 线上曾出现核销成功但未入账、可提余额为 0 的情况。
*/
async backfillMissingStorePayouts(storeId: bigint) {
const store = await this.prisma.store.findUnique({
where: { id: storeId },
select: { settlementRate: true },
});
if (!store) return 0;
const orphans = await this.prisma.redeemRecord.findMany({
where: {
storeId,
payout: { is: null },
},
select: { id: true, amount: true, settleAmount: true },
take: 200,
});
if (!orphans.length) return 0;
const settlementRate = resolveSettlementRate(store.settlementRate);
let created = 0;
for (const row of orphans) {
const redeemAmount = Number(row.amount);
let payoutAmount = Number(row.settleAmount);
if (!(redeemAmount > 0)) continue;
if (!(payoutAmount > 0)) {
payoutAmount = calcRedeemSettleAmount(redeemAmount, settlementRate);
}
if (!(payoutAmount > 0)) continue;
try {
await this.createStorePayout(
row.id,
storeId,
redeemAmount,
payoutAmount,
settlementRate,
);
created += 1;
} catch {
// 并发补写时可能已存在 payout
}
}
return created;
}
private async todayWithdrawAppliedAmount(storeId: bigint, now = new Date()) {
const start = startOfShanghaiDay(now);
const end = addShanghaiDays(start, 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: { is: 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,
},
});
void this.wecomPush.dispatchEvent(
'store.withdraw_pending',
{
storeName: store.name,
withdrawNo: created.withdrawNo,
amount: Number(created.amount).toFixed(2),
payoutCount: String(created.payoutCount),
storeId: storeId.toString(),
},
{
handlePath: `/finance/store-bills?kind=WITHDRAW&status=PENDING_REVIEW&storeId=${storeId.toString()}`,
},
);
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,
paymentProofUrls: parsePaymentProofUrls(row.paymentProofUrls),
overdue:
row.status === 'PENDING_REVIEW' ? isWithdrawOverdue(row.appliedAt) : false,
});
}
async approveStoreWithdraw(
id: bigint,
hqAccountId: bigint,
dto?: { paymentRef?: string; paymentProofUrls?: 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,
paymentProofUrls: paymentProofUrlsInput(dto?.paymentProofUrls),
},
});
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,
paymentProofCount: parsePaymentProofUrls(dto?.paymentProofUrls).length,
},
});
const [store, bank] = await Promise.all([
this.prisma.store.findUnique({
where: { id: row.storeId },
select: {
name: true,
cityName: true,
cityRef: { select: { name: true } },
partnerAccount: {
select: {
name: true,
companyName: true,
parent: { select: { companyName: true } },
},
},
},
}),
loadStorePrimaryBank(this.prisma, row.storeId),
]);
const meta = storeWecomMeta(store, String(row.storeId));
const bankParts = wecomBankParts(bank);
void this.wecomPush.dispatchEvent(
'store.withdraw_approved',
{
cityName: meta.cityName,
storeName: meta.storeName,
partnerLabel: meta.partnerLabel,
withdrawNo: row.withdrawNo,
amount: Number(row.amount).toFixed(2),
payee: bankParts.payee,
accountNo: bankParts.accountNo,
bankBranch: bankParts.bankBranch,
bankAccount: formatWecomBankAccount(bank),
storeId: row.storeId.toString(),
},
{
handlePath: `/finance/store-bills?kind=WITHDRAW&status=PAID&storeId=${row.storeId.toString()}`,
},
);
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: 'settlement',
title: '门店提现超时未审',
detail: [
`待审 ${summary.pendingCount} 笔,超时 ${summary.overdueCount} 笔,超时金额 ¥${summary.overdueAmount.toFixed(2)}`,
...lines,
'请尽快在 HQ「财务 → 门店账单(手动提现)」处理。',
].join('\n'),
dedupeKey: `store_withdraw_overdue|${shanghaiYmd(new Date())}`,
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) ──────────────────────
/** 生成昨日核销窗口的门店对账单;billDate = 出账日(北京时间今天) */
async generateStoreBillsForDay(anchor = new Date()) {
const { start, end, billDate } = shanghaiT1DayWindow(anchor);
const payouts = await this.prisma.storePayout.findMany({
where: {
storeBillId: null,
createdAt: { gte: start, lt: end },
// 排除已锁定在待审提现单中的明细,避免出账与提现双占
withdrawItem: { is: null },
status: 'PENDING',
},
include: {
store: {
select: {
id: true,
name: true,
cityName: true,
settlementRate: true,
cityRef: { select: { name: true } },
partnerAccount: {
select: {
name: true,
companyName: true,
parent: { select: { companyName: 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;
const createdBills: Array<{
storeId: bigint;
storeName: string;
cityName: string;
partnerLabel: string;
redeemCount: number;
redeemAmount: number;
settlementRate: number;
amount: number;
}> = [];
for (const [storeIdStr, list] of byStore) {
const storeId = BigInt(storeIdStr);
const meta = storeWecomMeta(list[0]?.store, 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);
const updatedTotal = round2(Number(existing.payoutAmount) + payoutAmount);
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: updatedTotal,
settlementRate: rate,
},
});
await tx.storePayout.updateMany({
where: { id: { in: list.map((p) => p.id) } },
data: { storeBillId: existing.id },
});
});
created += 1;
createdBills.push({
storeId,
...meta,
redeemCount: existing.redeemCount + list.length,
redeemAmount: round2(Number(existing.redeemAmount) + redeemAmount),
settlementRate: rate,
amount: updatedTotal,
});
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;
createdBills.push({
storeId,
...meta,
redeemCount: list.length,
redeemAmount,
settlementRate: rate,
amount: payoutAmount,
});
}
const period = shanghaiYmd(billDate);
if (createdBills.length) {
const banks = await loadStorePrimaryBanksMap(
this.prisma,
createdBills.map((b) => b.storeId),
);
const blocks = createdBills.map((b) =>
formatStoreBillWecomBlock({
...b,
bank: banks.get(b.storeId.toString()) ?? null,
}),
);
void this.wecomPush.dispatchEvent(
'finance.store_bill',
{
period,
billCount: String(createdBills.length),
totalAmount: sumAmounts(createdBills),
billSummary: capBillBlocks(blocks),
},
{ handlePath: '/finance/store-bills?kind=T1_BILL' },
);
}
return { billDate: period, 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 = parseShanghaiYmd(query.dateFrom);
if (query.dateTo) where.appliedAt.lt = addShanghaiDays(parseShanghaiYmd(query.dateTo), 1);
}
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 storeIds = [...new Set(rows.filter((r) => r.kind === 'T1_BILL').map((r) => r.storeId))];
const bankMap = await loadStorePrimaryBanksMap(this.prisma, storeIds);
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.map((r) => ({
...r,
date: r.kind === 'T1_BILL' ? shanghaiYmd(r.date) : r.date,
bankAccount:
r.kind === 'T1_BILL'
? bankMap.get(String(r.storeId)) ?? null
: null,
})),
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: items.map((b) => ({ ...b, billDate: shanghaiYmd(b.billDate) })),
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('门店对账单不存在');
const storeAccount = await loadStorePrimaryBank(this.prisma, bill.storeId);
return serializeBigInt({
...bill,
billDate: shanghaiYmd(bill.billDate),
storeAccount,
paymentProofUrls: parsePaymentProofUrls(bill.paymentProofUrls),
});
}
async confirmStoreBill(
id: bigint,
dto: { paymentRef?: string; paymentProofUrls?: string[] } = {},
) {
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,
paymentRef: dto.paymentRef?.trim() || null,
paymentProofUrls: paymentProofUrlsInput(dto.paymentProofUrls),
},
});
await tx.storePayout.updateMany({
where: { storeBillId: id, status: 'PENDING' },
data: { status: 'PAID', paidAt },
});
return b;
});
return serializeBigInt({
...updated,
paymentProofUrls: parsePaymentProofUrls(updated.paymentProofUrls),
});
}
async batchConfirmStoreBills(
ids: string[],
dto: { paymentRef?: string; paymentProofUrls?: string[] } = {},
) {
const results: Array<{ id: string; ok: boolean; message?: string }> = [];
for (const id of ids) {
try {
await this.confirmStoreBill(BigInt(id), dto);
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: { id: true, name: true, phone: true, cityName: true } } },
orderBy: { billDate: 'desc' },
});
const bankMap = await loadStorePrimaryBanksMap(
this.prisma,
bills.map((b) => b.storeId),
);
const header = [
'账单号',
'出账日',
'门店',
'城市',
'核销笔数',
'核销金额',
'结算比例',
'应付金额',
'状态',
'打款时间',
'打款凭证',
'打款凭证照片',
'收款户名',
'收款账号',
'开户行',
].join(',');
const rows = bills.map((b) => {
const bank = bankMap.get(String(b.storeId));
return [
csvEscape(b.billNo),
shanghaiYmd(b.billDate),
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', ' ') : '',
csvEscape(b.paymentRef ?? ''),
csvEscape(parsePaymentProofUrls(b.paymentProofUrls).join(' ')),
csvEscape(bank?.bankAccountName ?? ''),
csvEscape(bank?.bankAccountNo ?? ''),
csvEscape(bank?.bankBranch ?? ''),
].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 = parseShanghaiYmd(query.dateFrom);
if (query.dateTo) where.billDate.lte = parseShanghaiYmd(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.map(withShanghaiPeriod));
}
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 },
include: { items: { orderBy: { occurredAt: 'asc' } } },
});
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 },
});
const { items, ...header } = bill;
return serializeBigInt({
...withShanghaiPeriod(header),
partnerId: header.partnerAccountId.toString(),
...splitPartnerBillItems(items),
});
}
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,
bankAccountName: true,
bankAccountNo: true,
bankBranch: 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) => ({
...withShanghaiPeriod(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 { start, endExclusive } = shanghaiMonthRange(query.year, query.month);
where.periodStart = { gte: start, lt: endExclusive };
}
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,
name: true,
bankAccountName: true,
bankBranch: true,
bankAccountNo: true,
city: { select: { name: true } },
},
});
const results: Array<{ partnerId: string; companyName: string; ok: boolean; message?: string }> = [];
const created: Array<{
cityName: string;
partnerLabel: string;
orderCount?: number;
redeemCount?: number;
orderCommission: number;
redeemCommission: number;
amount: number;
bank?: WecomBankLike | null;
}> = [];
for (const p of partners) {
try {
const bill = await this.generatePartnerBill(
{
partnerId: p.id.toString(),
year: body.year,
month: body.month,
},
{ notify: false },
);
results.push({ partnerId: p.id.toString(), companyName: p.companyName ?? '', ok: true });
created.push({
cityName: p.city?.name?.trim() || '—',
partnerLabel: formatPartnerDashLabel(p.companyName, p.name),
orderCount: Number(bill.orderCount ?? 0),
redeemCount: Number(bill.redeemCount ?? 0),
orderCommission: Number(bill.orderCommission),
redeemCommission: Number(bill.redeemCommission),
amount: Number(bill.totalAmount),
bank: {
bankAccountName: p.bankAccountName,
bankBranch: p.bankBranch,
bankAccountNo: p.bankAccountNo,
},
});
} catch (e) {
results.push({
partnerId: p.id.toString(),
companyName: p.companyName ?? '',
ok: false,
message: e instanceof Error ? e.message : '失败',
});
}
}
this.notifyPartnerBillDigest(
`${body.year}-${String(body.month).padStart(2, '0')}`,
created,
);
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 = previousShanghaiMonth(anchor);
return this.generateAllPartnerBills({ year: prev.year, month: prev.month });
}
async getAdminPartnerBill(id: bigint) {
const bill = await this.prisma.partnerBill.findUnique({
where: { id },
include: { partnerAccount: true, items: { orderBy: { occurredAt: 'asc' } } },
});
if (!bill) throw new NotFoundException('账单不存在');
const { items, ...header } = bill;
return serializeBigInt({
...withShanghaiPeriod(header),
partnerId: header.partnerAccountId.toString(),
...splitPartnerBillItems(items),
});
}
async generatePartnerBill(
body: { partnerId: string; year: number; month: number },
opts?: { notify?: boolean },
) {
const partnerAccountId = BigInt(body.partnerId);
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const { start: periodStart, endExclusive } = shanghaiMonthRange(body.year, body.month);
const periodEnd = shanghaiMonthLastInstant(body.year, body.month);
const existing = await this.prisma.partnerBill.findFirst({
where: {
partnerAccountId: primary.id,
periodStart: { gte: periodStart, lt: endExclusive },
},
});
if (existing && existing.status !== 'PENDING_REVIEW') {
throw new BadRequestException('该月账单已进入审核流程,不可重复生成');
}
if (!primary.cityId) {
throw new BadRequestException('合伙人未绑定开城城市');
}
const redeemCommissionRate = Number(primary.redeemCommissionRate ?? 0.03);
const orders = await this.prisma.order.findMany({
where: {
partnerAccountIdAtPay: primary.id,
payStatus: 'PAID',
paidAt: { gte: periodStart, lte: periodEnd },
},
orderBy: { paidAt: 'asc' },
});
const orderRows = orders.map((o) => {
const rate = o.orderCommissionRateAtPay != null ? Number(o.orderCommissionRateAtPay) : 0;
const baseAmount = Number(o.payAmount);
return {
kind: 'ORDER' as const,
refId: o.id,
refNo: o.orderNo,
title: o.productName,
extra: `×${o.quantity}`,
baseAmount,
rate,
commission: round2(baseAmount * rate),
occurredAt: o.paidAt ?? o.createdAt,
};
});
const orderCommission = orderRows.reduce((sum, r) => sum + r.commission, 0);
const stores = await this.prisma.store.findMany({
where: { partnerAccountId: primary.id },
select: { id: true, name: true },
});
const storeIds = stores.map((s) => s.id);
const storeNameById = new Map(stores.map((s) => [s.id.toString(), s.name]));
const redeems = !storeIds.length
? []
: await this.prisma.redeemRecord.findMany({
where: {
storeId: { in: storeIds },
createdAt: { gte: periodStart, lte: periodEnd },
},
orderBy: { createdAt: 'asc' },
});
const redeemRows = redeems.map((r) => {
const baseAmount = Number(r.amount);
return {
kind: 'REDEEM' as const,
refId: r.id,
refNo: r.redeemNo,
title: storeNameById.get(r.storeId.toString()) ?? '门店',
extra: null as string | null,
baseAmount,
rate: redeemCommissionRate,
commission: round2(baseAmount * redeemCommissionRate),
occurredAt: r.createdAt,
};
});
const redeemCommission = redeemRows.reduce((sum, r) => sum + r.commission, 0);
const totalAmount = round2(orderCommission + redeemCommission);
const itemRows = [...orderRows, ...redeemRows];
const bill = await this.prisma.$transaction(async (tx) => {
const header = existing
? await tx.partnerBill.update({
where: { id: existing.id },
data: {
orderCommission: round2(orderCommission),
redeemCommission: round2(redeemCommission),
totalAmount,
periodStart,
periodEnd,
status: 'PENDING_REVIEW',
},
})
: await tx.partnerBill.create({
data: {
billNo: generateBillNo('PB'),
partnerAccountId: primary.id,
periodStart,
periodEnd,
orderCommission: round2(orderCommission),
redeemCommission: round2(redeemCommission),
totalAmount,
status: 'PENDING_REVIEW',
},
});
await tx.partnerBillItem.deleteMany({ where: { partnerBillId: header.id } });
if (itemRows.length > 0) {
await tx.partnerBillItem.createMany({
data: itemRows.map((row) => ({
partnerBillId: header.id,
kind: row.kind,
refId: row.refId,
refNo: row.refNo,
title: row.title,
extra: row.extra,
baseAmount: row.baseAmount,
rate: new Prisma.Decimal(row.rate.toFixed(4)),
commission: row.commission,
occurredAt: row.occurredAt,
})),
});
}
return header;
});
if (opts?.notify !== false) {
let cityName = '—';
if (primary.cityId) {
const city = await this.prisma.commonCity.findUnique({
where: { id: primary.cityId },
select: { name: true },
});
cityName = city?.name?.trim() || '—';
}
this.notifyPartnerBillDigest(`${body.year}-${String(body.month).padStart(2, '0')}`, [
{
cityName,
partnerLabel: formatPartnerDashLabel(primary.companyName, primary.name),
orderCount: orders.length,
redeemCount: redeems.length,
orderCommission: round2(orderCommission),
redeemCommission: round2(redeemCommission),
amount: totalAmount,
bank: {
bankAccountName: primary.bankAccountName,
bankBranch: primary.bankBranch,
bankAccountNo: primary.bankAccountNo,
},
},
]);
}
return serializeBigInt({
...bill,
orderCount: orders.length,
redeemCount: redeems.length,
});
}
/** 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,
paymentRef: dto.paymentRef?.trim() || 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,
bankAccountName: true,
bankAccountNo: true,
bankBranch: true,
},
},
items: { orderBy: { occurredAt: 'asc' } },
},
orderBy: { periodStart: 'desc' },
});
const header = [
'账单号',
'合伙人',
'登录手机',
'账期起',
'账期止',
'酒单佣金',
'核销佣金',
'合计应付',
'状态',
'发送时间',
'确认时间',
'打款时间',
'打款凭证',
'收款户名',
'收款账号',
'开户行',
'驳回理由',
].join(',');
const rows = bills.map((b) =>
[
csvEscape(b.billNo),
csvEscape(b.partnerAccount.companyName ?? ''),
csvEscape(b.partnerAccount.phone ?? ''),
shanghaiPeriodYmds(b.periodStart).periodStart,
shanghaiPeriodYmds(b.periodStart).periodEnd,
Number(b.orderCommission),
Number(b.redeemCommission),
Number(b.totalAmount),
b.status,
b.sentAt ? shanghaiYmd(b.sentAt) : '',
b.confirmedAt ? shanghaiYmd(b.confirmedAt) : '',
b.paidAt ? shanghaiYmd(b.paidAt) : '',
csvEscape(b.paymentRef ?? ''),
csvEscape(b.partnerAccount.bankAccountName ?? ''),
csvEscape(b.partnerAccount.bankAccountNo ?? ''),
csvEscape(b.partnerAccount.bankBranch ?? ''),
csvEscape(b.rejectReason ?? ''),
].join(','),
);
const itemHeader = ['账单号', '类型', '单号', '标题', '备注', '基数', '费率', '佣金', '发生时间'].join(',');
const itemRows = bills.flatMap((b) =>
b.items.map((it) =>
[
csvEscape(b.billNo),
it.kind === 'ORDER' ? '酒订单' : '核销',
csvEscape(it.refNo),
csvEscape(it.title ?? ''),
csvEscape(it.extra ?? ''),
Number(it.baseAmount),
Number(it.rate),
Number(it.commission),
it.occurredAt.toISOString().slice(0, 19).replace('T', ' '),
].join(','),
),
);
const csv = ['账单汇总', header, ...rows, '', '酒订单/核销明细', itemHeader, ...itemRows].join('\n');
return { csv: `\uFEFF${csv}`, count: bills.length };
}
// ─── Winery bills ────────────────────────────────────
/** 酒厂账单纳入的配送类型:同城 / 跨城 / 现场提货 */
private static readonly WINERY_DELIVERY_TYPES = ['LOCAL', 'CROSS_CITY', 'ON_SITE_PICKUP'] as const;
/**
* 历史现场提货单支付即 COMPLETED 但未写 completedAt,补齐为 paidAt,否则核对补生成捞不到。
*/
private async backfillMissingCompletedAt() {
await this.prisma.$executeRaw`
UPDATE user_order
SET completed_at = paid_at
WHERE status = 'COMPLETED'
AND pay_status = 'PAID'
AND completed_at IS NULL
AND paid_at IS NOT NULL
`;
}
/** 期窗口内已付已完成订单(含同城/跨城/现场提货;completedAt 优先,缺省用 paidAt */
private wineryEligibleOrderWhere(start: Date, end: Date): Prisma.OrderWhereInput {
return {
status: 'COMPLETED',
payStatus: 'PAID',
deliveryType: { in: [...SettlementService.WINERY_DELIVERY_TYPES] },
OR: [
{ completedAt: { gte: start, lt: end } },
{ completedAt: null, paidAt: { gte: start, lt: end } },
],
};
}
/**
* 生成/重算某一出账日的酒厂对账单(T+3 = 每 3 天出一期)。
* - 仅在周期出账日生成:相对 epoch 每 3 天一张
* - 纳入上一期 3 个自然日内完成的同城/跨城/现场提货已付订单(含实付/应付为 0)
* - 出账日无订单也出账(无需打款),保证每隔三天有账单
* - 已打款账单不回刷;未打款账单可重算;冲突明细从其他未打款账单迁入
*/
async generateWineryBillForDay(anchor = new Date(), opts?: { notify?: boolean }) {
const periodDays = WINERY_SETTLEMENT_PERIOD_DAYS;
const epochYmd = WINERY_BILL_EPOCH_YMD;
const billDate = startOfShanghaiDay(anchor);
const issueYmd = shanghaiYmd(billDate);
const today = startOfShanghaiDay(new Date());
if (billDate.getTime() > today.getTime()) {
return { billDate: issueYmd, skipped: true, reason: '出账日未到' };
}
if (!isWineryIssueDay(billDate, periodDays, epochYmd)) {
return { billDate: issueYmd, skipped: true, reason: '非出账周期日(T+3 每 3 天出账)' };
}
const { start, end } = shanghaiWineryPeriodWindow(billDate, periodDays);
const rate = WINERY_SETTLEMENT_RATE;
const existing =
(await this.prisma.wineryBill.findUnique({ where: { billDate } })) ??
(await this.prisma.wineryBill.findFirst({
where: { createdAt: { gte: billDate, lt: addShanghaiDays(billDate, 1) } },
}));
if (existing?.status === 'PAID') {
return { billDate: issueYmd, skipped: true, reason: '已打款' };
}
// 手工单日出账时也补齐缺 completedAt 的现场单
await this.backfillMissingCompletedAt();
// 期窗口 [出账日−3, 出账日) 内:同城 / 跨城 / 现场提货
const candidates = await this.prisma.order.findMany({
where: this.wineryEligibleOrderWhere(start, end),
include: { city: { select: { name: true } } },
orderBy: [{ completedAt: 'asc' }, { paidAt: 'asc' }],
});
// 已打款账单上的明细不可再迁;其余订单一律纳入本账
const candidateIds = candidates.map((o) => o.id);
const paidLockedIds = new Set<string>();
if (candidateIds.length) {
const locked = await this.prisma.wineryBillItem.findMany({
where: {
orderId: { in: candidateIds },
wineryBill: { status: 'PAID' },
...(existing ? { wineryBillId: { not: existing.id } } : {}),
},
select: { orderId: true },
});
for (const row of locked) paidLockedIds.add(row.orderId.toString());
}
const orders = candidates.filter((o) => !paidLockedIds.has(o.id.toString()));
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: {
billDate,
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 } });
}
// 从未打款的其他账单迁出冲突明细,避免 orderId 唯一约束失败导致整日对不上
const affectedBillIds = new Set<bigint>();
if (orders.length > 0) {
const conflicts = await tx.wineryBillItem.findMany({
where: {
orderId: { in: orders.map((o) => o.id) },
wineryBillId: { not: header.id },
wineryBill: { status: 'UNPAID' },
},
select: { id: true, wineryBillId: true },
});
for (const c of conflicts) affectedBillIds.add(c.wineryBillId);
if (conflicts.length) {
await tx.wineryBillItem.deleteMany({ where: { id: { in: conflicts.map((c) => c.id) } } });
}
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!,
})),
});
}
for (const otherId of affectedBillIds) {
await this.recalcWineryBillHeader(tx, otherId, rate);
}
return header;
});
const period = issueYmd;
if (opts?.notify !== false && wineryAmount > 0) {
const wineryBank = await loadWineryBankConfig(this.prisma);
const bankParts = wecomBankParts(wineryBank);
const cityNames = joinLimited(
orders.map((o) => o.city?.name || ''),
'城',
);
const partnerNames = await this.partnerDashLabels(orders.map((o) => o.partnerAccountIdAtPay));
void this.wecomPush.dispatchEvent(
'finance.winery_bill',
{
period,
billCount: '1',
totalAmount: wineryAmount.toFixed(2),
cityNames,
partnerNames,
orderCount: String(orders.length),
orderAmount: orderAmount.toFixed(2),
rate: formatPercent(rate),
amount: wineryAmount.toFixed(2),
payee: bankParts.payee,
accountNo: bankParts.accountNo,
bankBranch: bankParts.bankBranch,
bankAccount: formatWecomBankAccount(wineryBank),
},
{ handlePath: '/finance/winery-bills' },
);
}
return serializeBigInt({
billDate: period,
skipped: false,
bill,
orderCount: orders.length,
orderAmount,
wineryAmount,
lockedOnPaid: paidLockedIds.size,
});
}
/** 按明细重算酒厂账单表头(应付为 0 保留账单,展示为无需打款) */
private async recalcWineryBillHeader(
tx: Prisma.TransactionClient,
wineryBillId: bigint,
rate = WINERY_SETTLEMENT_RATE,
) {
const items = await tx.wineryBillItem.findMany({ where: { wineryBillId } });
const orderAmount = round2(items.reduce((s, i) => s + Number(i.payAmount), 0));
const wineryAmount = round2(orderAmount * rate);
await tx.wineryBill.update({
where: { id: wineryBillId },
data: {
orderCount: items.length,
orderAmount,
wineryRate: rate,
wineryAmount,
},
});
}
/**
* 按出账日区间核对补生成酒厂账单:只处理 T+3 周期出账日,捞回未入账的同城/跨城/现场提货订单,并清理误生成的非周期日空账单。
*/
async reconcileWineryBills(body?: { dateFrom?: string; dateTo?: string }) {
const periodDays = WINERY_SETTLEMENT_PERIOD_DAYS;
const epochYmd = WINERY_BILL_EPOCH_YMD;
const today = startOfShanghaiDay(new Date());
const to = body?.dateTo ? parseShanghaiYmd(body.dateTo) : today;
const from = body?.dateFrom
? parseShanghaiYmd(body.dateFrom)
: addShanghaiDays(today, -60);
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
throw new BadRequestException('日期格式无效,请使用 YYYY-MM-DD');
}
if (from.getTime() > to.getTime()) {
throw new BadRequestException('开始日期不能晚于结束日期');
}
// 先补历史现场提货等缺 completedAt 的已完成单,再按期入账
await this.backfillMissingCompletedAt();
const endIssue = to.getTime() > today.getTime() ? today : to;
const startIssue = from.getTime() > endIssue.getTime() ? endIssue : from;
const issueYmids = new Set(
listWineryIssueDays(startIssue, endIssue, periodDays, epochYmd).map(shanghaiYmd),
);
// 捞仍未入账的孤儿订单(同城/跨城/现场提货),按其完成日归属到对应出账日
const orphanLookbackStart = addShanghaiDays(startIssue, -periodDays - 90);
const orphanLookbackEnd = addShanghaiDays(today, 1);
const windowOrders = await this.prisma.order.findMany({
where: this.wineryEligibleOrderWhere(orphanLookbackStart, orphanLookbackEnd),
select: { id: true, completedAt: true, paidAt: true },
});
const billedSet = new Set(
windowOrders.length === 0
? []
: (
await this.prisma.wineryBillItem.findMany({
where: { orderId: { in: windowOrders.map((o) => o.id) } },
select: { orderId: true },
})
).map((r) => r.orderId.toString()),
);
let orphanCount = 0;
for (const o of windowOrders) {
const attributedAt = o.completedAt ?? o.paidAt;
if (!attributedAt || billedSet.has(o.id.toString())) continue;
const issue = wineryIssueDateFromCompletedAt(attributedAt, periodDays, epochYmd);
if (issue.getTime() > today.getTime()) continue;
issueYmids.add(shanghaiYmd(issue));
orphanCount += 1;
}
const sorted = [...issueYmids].sort();
const results: Array<{
billDate: string;
skipped: boolean;
reason?: string;
orderCount?: number;
wineryAmount?: number;
}> = [];
for (const ymd of sorted) {
const r = await this.generateWineryBillForDay(parseShanghaiYmd(ymd), { notify: false });
results.push({
billDate: String(r.billDate),
skipped: Boolean(r.skipped),
reason: 'reason' in r ? String(r.reason ?? '') : undefined,
orderCount: 'orderCount' in r ? Number(r.orderCount ?? 0) : undefined,
wineryAmount: 'wineryAmount' in r ? Number(r.wineryAmount ?? 0) : undefined,
});
}
// 清理旧「每日出账」留下的非周期日出账单(明细已迁走后为空)
let cleaned = 0;
const strayBills = await this.prisma.wineryBill.findMany({
where: {
status: 'UNPAID',
billDate: { gte: startIssue, lte: endIssue },
},
include: { _count: { select: { items: true } } },
});
for (const b of strayBills) {
if (isWineryIssueDay(b.billDate, periodDays, epochYmd)) continue;
if (b._count.items > 0) {
await this.prisma.$transaction(async (tx) => {
await this.recalcWineryBillHeader(tx, b.id);
});
continue;
}
await this.prisma.wineryBill.delete({ where: { id: b.id } });
cleaned += 1;
}
return {
dateFrom: shanghaiYmd(startIssue),
dateTo: shanghaiYmd(endIssue),
days: results.length,
generated: results.filter((r) => !r.skipped).length,
skipped: results.filter((r) => r.skipped).length,
orphanOrders: orphanCount,
cleanedNonIssueBills: cleaned,
results,
};
}
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: items.map((b) => ({ ...b, billDate: shanghaiYmd(b.billDate) })),
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('酒厂对账单不存在');
const wineryBank = await loadWineryBankConfig(this.prisma);
return serializeBigInt({ ...bill, billDate: shanghaiYmd(bill.billDate), wineryBank });
}
async confirmWineryBill(id: bigint, dto: { paymentRef?: string } = {}) {
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(),
paymentRef: dto.paymentRef?.trim() || null,
},
});
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 wineryBank = await loadWineryBankConfig(this.prisma);
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;
};
const bankCols = (b: { paymentRef?: string | null }) => [
csvEscape(b.paymentRef ?? ''),
csvEscape(wineryBank.bankAccountName ?? ''),
csvEscape(wineryBank.bankName ?? ''),
csvEscape(wineryBank.bankBranch ?? ''),
csvEscape(wineryBank.bankAccountNo ?? ''),
];
for (const b of bills) {
if (b.items.length === 0) {
rows.push(
[
csvEscape(b.billNo),
shanghaiYmd(b.billDate),
'',
'',
Number(b.orderAmount),
Number(b.wineryRate),
Number(b.wineryAmount),
'',
statusLabel(b),
...bankCols(b),
].join(','),
);
continue;
}
for (const item of b.items) {
rows.push(
[
csvEscape(b.billNo),
shanghaiYmd(b.billDate),
csvEscape(item.orderNo),
item.deliveryType === 'LOCAL'
? '同城'
: item.deliveryType === 'CROSS_CITY'
? '跨城'
: item.deliveryType === 'ON_SITE_PICKUP'
? '现场提货'
: item.deliveryType,
Number(item.payAmount),
Number(b.wineryRate),
Number(item.wineryAmount),
item.paidAt.toISOString().slice(0, 19).replace('T', ' '),
statusLabel(b),
...bankCols(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, endExclusive } = shanghaiMonthRange(query.year, query.month);
where.billDate = { gte: start, lt: endExclusive };
} else if (query.dateFrom || query.dateTo) {
where.billDate = {};
if (query.dateFrom) where.billDate.gte = parseShanghaiYmd(query.dateFrom);
if (query.dateTo) where.billDate.lte = parseShanghaiYmd(query.dateTo);
}
return where;
}
// ─── Logistics bills (按承运商月结) ───────────────────
async generatePreviousMonthLogisticsBills(anchor = new Date()) {
const prev = previousShanghaiMonth(anchor);
return this.generateAllLogisticsBills({
year: prev.year,
month: prev.month,
});
}
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;
}> = [];
const created: Array<{
cityNames: string;
partnerNames: string;
providerName: string;
settlementMethod: string;
orderCount: number;
bottleCount: number;
amount: number;
bank?: WecomBankLike | null;
}> = [];
for (const p of providers) {
try {
const bill = await this.generateLogisticsBill(
{
providerId: p.id.toString(),
year: body.year,
month: body.month,
},
{ notify: false },
);
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,
});
if (!bill.skipped && bill.wecom) {
created.push(bill.wecom);
}
} catch (e) {
results.push({
providerId: p.id.toString(),
providerCode: p.code,
providerName: p.name,
ok: false,
message: e instanceof Error ? e.message : '失败',
});
}
}
this.notifyLogisticsBillDigest(
`${body.year}-${String(body.month).padStart(2, '0')}`,
created,
);
return {
total: providers.length,
success: results.filter((r) => r.ok).length,
failed: results.filter((r) => !r.ok).length,
results,
};
}
private async partnerDashLabels(ids: Array<bigint | null | undefined>, max = 5): Promise<string> {
const uniq = [...new Set(ids.filter((id): id is bigint => id != null))];
if (!uniq.length) return '—';
const partners = await this.prisma.partnerAccount.findMany({
where: { id: { in: uniq } },
select: { name: true, companyName: true },
});
return joinLimited(
partners.map((p) => formatPartnerDashLabel(p.companyName, p.name)),
'家',
max,
);
}
async generateLogisticsBill(
body: { providerId: string; year: number; month: number },
opts?: { notify?: boolean },
) {
const fulfillmentProviderId = BigInt(body.providerId);
const provider = await this.prisma.fulfillmentProvider.findUnique({
where: { id: fulfillmentProviderId },
});
if (!provider) throw new NotFoundException('仓配承运商不存在');
const { start: periodStart, endExclusive } = shanghaiMonthRange(body.year, body.month);
const periodEnd = shanghaiMonthLastInstant(body.year, body.month);
const existing = await this.prisma.logisticsBill.findFirst({
where: {
fulfillmentProviderId,
periodStart: { gte: periodStart, lt: endExclusive },
},
});
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,
partnerAccountIdAtPay: true,
city: { select: { name: true } },
},
},
},
orderBy: [{ shippingAt: 'asc' }, { outWarehouseAt: 'asc' }],
});
const eligible = deliveries.filter(
(d) =>
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: {
periodStart,
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;
});
const wecom = {
cityNames: joinLimited(
eligible.map((d) => d.order.city?.name || ''),
'城',
),
partnerNames: await this.partnerDashLabels(eligible.map((d) => d.order.partnerAccountIdAtPay)),
providerName: provider.name,
settlementMethod:
LOGISTICS_SETTLEMENT_METHOD_LABELS[
provider.settlementMethod as keyof typeof LOGISTICS_SETTLEMENT_METHOD_LABELS
] ?? String(provider.settlementMethod),
orderCount,
bottleCount,
amount: Number(bill.logisticsAmount),
bank: {
bankAccountName: provider.bankAccountName,
bankName: provider.bankName,
bankBranch: provider.bankBranch,
bankAccountNo: provider.bankAccountNo,
},
};
if (opts?.notify !== false) {
this.notifyLogisticsBillDigest(`${body.year}-${String(body.month).padStart(2, '0')}`, [
wecom,
]);
}
return {
skipped: false,
bill: serializeBigInt(bill),
wecom,
};
}
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) => ({
...withShanghaiPeriod(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 fallback = shanghaiYearMonth();
const year = query.year ?? fallback.year;
const month = query.month ?? fallback.month;
const { start: periodStart, endExclusive } = shanghaiMonthRange(year, month);
const periodEnd = new Date(endExclusive.getTime() - 1);
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.findFirst({
where: {
fulfillmentProviderId: p.id,
periodStart: { gte: periodStart, lt: endExclusive },
},
});
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({
...withShanghaiPeriod(bill),
providerCode: bill.fulfillmentProvider.code,
providerName: bill.fulfillmentProvider.name,
pricingSnapshot: this.fulfillmentProviderService.parsePricingRules(bill.pricingSnapshotJson),
});
}
async confirmLogisticsBill(id: bigint, dto: { paymentRef?: string } = {}) {
const bill = await this.prisma.logisticsBill.findUnique({ where: { id } });
if (!bill) throw new NotFoundException('物流对账单不存在');
if (bill.status !== 'UNPAID') throw new BadRequestException('仅未结算账单可确认');
const paidData = {
status: 'PAID' as const,
paidAt: new Date(),
paymentRef: dto.paymentRef?.trim() || null,
};
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: paidData,
});
});
return serializeBigInt(deducted);
}
const updated = await this.prisma.logisticsBill.update({
where: { id },
data: paidData,
});
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,
bankAccountName: true,
bankName: true,
bankBranch: true,
bankAccountNo: true,
},
},
items: true,
},
orderBy: { periodStart: 'desc' },
});
const header = [
'账单号',
'承运商编码',
'承运商名称',
'账期起',
'账期止',
'结算方式',
'订单号',
'瓶数',
'物流费',
'发货时间',
'账单状态',
'打款凭证',
'收款户名',
'开户银行',
'开户支行',
'收款账号',
].join(',');
const rows: string[] = [];
for (const b of bills) {
const p = b.fulfillmentProvider;
const bankCols = [
csvEscape(b.paymentRef ?? ''),
csvEscape(p.bankAccountName ?? ''),
csvEscape(p.bankName ?? ''),
csvEscape(p.bankBranch ?? ''),
csvEscape(p.bankAccountNo ?? ''),
];
if (b.items.length === 0) {
rows.push(
[
csvEscape(b.billNo),
csvEscape(p.code),
csvEscape(p.name),
shanghaiPeriodYmds(b.periodStart).periodStart,
shanghaiPeriodYmds(b.periodStart).periodEnd,
b.settlementMethod,
'',
b.bottleCount,
Number(b.logisticsAmount),
'',
b.status,
...bankCols,
].join(','),
);
continue;
}
for (const item of b.items) {
rows.push(
[
csvEscape(b.billNo),
csvEscape(p.code),
csvEscape(p.name),
shanghaiPeriodYmds(b.periodStart).periodStart,
shanghaiPeriodYmds(b.periodStart).periodEnd,
b.settlementMethod,
csvEscape(item.orderNo),
item.quantity,
Number(item.logisticsAmount),
item.shippedAt.toISOString().slice(0, 19).replace('T', ' '),
b.status,
...bankCols,
].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 { start, endExclusive } = shanghaiMonthRange(query.year, query.month);
where.periodStart = { gte: start, lt: endExclusive };
}
return where;
}
}