3470 lines
110 KiB
TypeScript
3470 lines
110 KiB
TypeScript
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,
|
||
previousShanghaiWeek,
|
||
resolveSettlementRate,
|
||
shanghaiBillPeriodYmds,
|
||
shanghaiMonthLastInstant,
|
||
shanghaiMonthRange,
|
||
shanghaiPeriodYmds,
|
||
shanghaiPeriodYmdsForWeek,
|
||
shanghaiT1DayWindow,
|
||
shanghaiWeekRange,
|
||
shanghaiWineryPeriodWindow,
|
||
shanghaiYearMonth,
|
||
shanghaiYmd,
|
||
startOfShanghaiDay,
|
||
nextPartnerBillIssueAt,
|
||
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, ...shanghaiBillPeriodYmds(row.periodStart, row.periodEnd) };
|
||
}
|
||
|
||
function resolvePartnerWeekPeriod(weekStartYmd: string): {
|
||
periodStart: Date;
|
||
periodEnd: Date;
|
||
endExclusive: Date;
|
||
} {
|
||
const ymd = String(weekStartYmd || '').trim();
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(ymd)) {
|
||
throw new BadRequestException('请提供周起始日 weekStartYmd(YYYY-MM-DD)');
|
||
}
|
||
const start = parseShanghaiYmd(ymd);
|
||
const week = shanghaiWeekRange(start);
|
||
if (shanghaiYmd(week.start) !== ymd) {
|
||
throw new BadRequestException('账期起始日须为周一');
|
||
}
|
||
return {
|
||
periodStart: week.start,
|
||
periodEnd: new Date(week.endExclusive.getTime() - 1),
|
||
endExclusive: week.endExclusive,
|
||
};
|
||
}
|
||
|
||
function partnerSettlementCycleDto(anchor = new Date()) {
|
||
const { start } = shanghaiWeekRange(anchor);
|
||
const ymds = shanghaiPeriodYmdsForWeek(start);
|
||
return {
|
||
periodStart: ymds.periodStart,
|
||
periodEnd: ymds.periodEnd,
|
||
nextIssueAt: nextPartnerBillIssueAt(anchor).toISOString(),
|
||
cycleLabel: '每周一 08:00 出账',
|
||
};
|
||
}
|
||
|
||
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'] },
|
||
totalAmount: { gt: 0 },
|
||
},
|
||
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));
|
||
}
|
||
|
||
getPartnerSettlementCycle(anchor = new Date()) {
|
||
return partnerSettlementCycleDto(anchor);
|
||
}
|
||
|
||
async getPartnerSettlementPreview(partnerAccountId: bigint) {
|
||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||
const { start } = shanghaiWeekRange();
|
||
const now = new Date();
|
||
const periodEnd = now.getTime() < start.getTime() ? start : now;
|
||
const redeemCommissionRate = Number(primary.redeemCommissionRate ?? 0.03);
|
||
|
||
const orders = await this.prisma.order.findMany({
|
||
where: {
|
||
partnerAccountIdAtPay: primary.id,
|
||
payStatus: 'PAID',
|
||
paidAt: { gte: start, lte: periodEnd },
|
||
},
|
||
select: { payAmount: true, orderCommissionRateAtPay: true },
|
||
});
|
||
const orderCommission = round2(
|
||
orders.reduce((sum, o) => {
|
||
const rate = o.orderCommissionRateAtPay != null ? Number(o.orderCommissionRateAtPay) : 0;
|
||
return sum + Number(o.payAmount) * rate;
|
||
}, 0),
|
||
);
|
||
|
||
const stores = await this.prisma.store.findMany({
|
||
where: { partnerAccountId: primary.id },
|
||
select: { id: true },
|
||
});
|
||
const storeIds = stores.map((s) => s.id);
|
||
const redeems = !storeIds.length
|
||
? []
|
||
: await this.prisma.redeemRecord.findMany({
|
||
where: {
|
||
storeId: { in: storeIds },
|
||
createdAt: { gte: start, lte: periodEnd },
|
||
},
|
||
select: { amount: true },
|
||
});
|
||
const redeemCommission = round2(
|
||
redeems.reduce((sum, r) => sum + Number(r.amount) * redeemCommissionRate, 0),
|
||
);
|
||
|
||
return {
|
||
...partnerSettlementCycleDto(),
|
||
prepaymentAmount: round2(orderCommission + redeemCommission),
|
||
orderCommissionEstimate: orderCommission,
|
||
redeemCommissionEstimate: redeemCommission,
|
||
};
|
||
}
|
||
|
||
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('账单不存在');
|
||
if (Number(bill.totalAmount) <= 0) 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;
|
||
weekStartYmd?: string;
|
||
}) {
|
||
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;
|
||
weekStartYmd?: string;
|
||
}): 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.weekStartYmd) {
|
||
const { periodStart, endExclusive } = resolvePartnerWeekPeriod(query.weekStartYmd);
|
||
where.periodStart = { gte: periodStart, lt: endExclusive };
|
||
} else if (query.year && query.month) {
|
||
const { start, endExclusive } = shanghaiMonthRange(query.year, query.month);
|
||
where.periodStart = { gte: start, lt: endExclusive };
|
||
}
|
||
return where;
|
||
}
|
||
|
||
async generateAllPartnerBills(body: { weekStartYmd: string }) {
|
||
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(),
|
||
weekStartYmd: body.weekStartYmd,
|
||
},
|
||
{ 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.weekStartYmd, created);
|
||
return {
|
||
total: partners.length,
|
||
success: results.filter((r) => r.ok).length,
|
||
failed: results.filter((r) => !r.ok).length,
|
||
results,
|
||
};
|
||
}
|
||
|
||
/** 每周一任务:生成上一自然周账单 */
|
||
async generatePreviousWeekPartnerBills(anchor = new Date()) {
|
||
const prev = previousShanghaiWeek(anchor);
|
||
return this.generateAllPartnerBills({ weekStartYmd: shanghaiYmd(prev.start) });
|
||
}
|
||
|
||
/** @deprecated 月账任务已改为周账 */
|
||
async generatePreviousMonthPartnerBills(anchor = new Date()) {
|
||
return this.generatePreviousWeekPartnerBills(anchor);
|
||
}
|
||
|
||
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; weekStartYmd: string },
|
||
opts?: { notify?: boolean },
|
||
) {
|
||
const partnerAccountId = BigInt(body.partnerId);
|
||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||
const { periodStart, periodEnd, endExclusive } = resolvePartnerWeekPeriod(body.weekStartYmd);
|
||
|
||
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.weekStartYmd, [
|
||
{
|
||
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 (Number(bill.totalAmount) <= 0) {
|
||
throw new BadRequestException('零元账单不同步给合伙人');
|
||
}
|
||
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;
|
||
weekStartYmd?: string;
|
||
}) {
|
||
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 ?? ''),
|
||
shanghaiBillPeriodYmds(b.periodStart, b.periodEnd).periodStart,
|
||
shanghaiBillPeriodYmds(b.periodStart, b.periodEnd).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;
|
||
}
|
||
}
|