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(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 { const locked = await this.prisma.$queryRaw>` 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 { const locked = await this.prisma.$queryRaw>` 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(); 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(); 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(); 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, max = 5): Promise { 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; 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; } }