import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { DEFAULT_STORE_WITHDRAW_DAILY_LIMIT, DEFAULT_XFX_LOGISTICS_PRICING, WINERY_SETTLEMENT_LAG_DAYS, WINERY_SETTLEMENT_RATE, } from '@dukang/shared-types'; import { calcLogisticsFeeByBottles, pickPayoutsForWithdrawAmount, sumUnbilledPayoutAmount, validateStoreWithdraw, type LogisticsPricingRule, } from '@dukang/domain'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { AlertService } from '../../common/alert/alert.service'; import { AnalyticsService } from '../analytics/analytics.service'; import { PartnerCityService } from '../city-scope/partner-city.service'; import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service'; function generateBillNo(prefix: string) { return `${prefix}${Date.now()}${Math.floor(Math.random() * 900 + 100)}`; } function csvEscape(value: string) { if (/[",\n\r]/.test(value)) return `"${value.replace(/"/g, '""')}"`; return value; } /** 上海时区自然日 00:00(用本地 Date 构造;服务器需设 Asia/Shanghai 或等价) */ function startOfDay(d: Date) { return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0); } function dayWindow(anchor = new Date()) { const end = startOfDay(anchor); const start = new Date(end); start.setDate(start.getDate() - 1); return { start, end, billDate: start }; } function wineryDayWindow(anchor = new Date(), lagDays = WINERY_SETTLEMENT_LAG_DAYS) { const billDate = startOfDay(anchor); billDate.setDate(billDate.getDate() - lagDays); const start = billDate; const end = new Date(start); end.setDate(end.getDate() + 1); return { start, end, billDate: start }; } function round2(n: number) { return Math.round(n * 100) / 100; } function getStoreWithdrawDailyLimit(): number { const raw = process.env.STORE_WITHDRAW_DAILY_LIMIT; const n = raw != null && raw !== '' ? Number(raw) : DEFAULT_STORE_WITHDRAW_DAILY_LIMIT; return Number.isFinite(n) && n > 0 ? n : DEFAULT_STORE_WITHDRAW_DAILY_LIMIT; } /** 工作日 18:00 前未审完视为 FIN-003 超时(Asia/Shanghai 自然日) */ function isWithdrawOverdue(appliedAt: Date, now = new Date()): boolean { const day = appliedAt.getDay(); // 0 Sun … 6 Sat if (day === 0 || day === 6) return false; const deadline = new Date( appliedAt.getFullYear(), appliedAt.getMonth(), appliedAt.getDate(), 18, 0, 0, 0, ); return now.getTime() > deadline.getTime(); } @Injectable() export class SettlementService { constructor( private readonly prisma: PrismaService, private readonly analyticsService: AnalyticsService, private readonly partnerCityService: PartnerCityService, private readonly fulfillmentProviderService: FulfillmentProviderService, private readonly alert: AlertService, ) {} // ─── Store payout (line) ───────────────────────────── async createStorePayout( redeemRecordId: bigint, storeId: bigint, redeemAmount: number, payoutAmount: number, settlementRate: number, tx?: Prisma.TransactionClient, ) { const expectedPayAt = new Date(); expectedPayAt.setDate(expectedPayAt.getDate() + 1); const client = tx ?? this.prisma; const payout = await client.storePayout.create({ data: { redeemRecordId, storeId, redeemAmount, payoutAmount, settlementRate, status: 'PENDING', expectedPayAt, }, }); this.analyticsService.trackStoreOneSafe(undefined, 'SHOP_H5', { storeId, eventName: 'store_payout_created', refType: 'STORE_PAYOUT', refId: payout.id, extraJson: { redeemRecordId: redeemRecordId.toString(), payoutAmount, redeemAmount, settlementRate, }, }); return serializeBigInt(payout); } async listShopPayouts(storeAccountId: bigint, storeId: bigint, page = 1, pageSize = 20) { await this.prisma.storeAccountStore.findUniqueOrThrow({ where: { storeAccountId_storeId: { storeAccountId, storeId } }, }); const where = { storeId }; const [items, total] = await Promise.all([ this.prisma.storePayout.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, include: { redeemRecord: { select: { redeemNo: true, amount: true } } }, }), this.prisma.storePayout.count({ where }), ]); return serializeBigInt({ items, total, page, pageSize }); } // ─── Store withdraw (未出账手动提现) ───────────────── private async assertShopStoreAccess(storeAccountId: bigint, storeId: bigint) { await this.prisma.storeAccountStore.findUniqueOrThrow({ where: { storeAccountId_storeId: { storeAccountId, storeId } }, }); } private async listAvailableUnbilledPayouts(storeId: bigint) { return this.prisma.storePayout.findMany({ where: { storeId, status: 'PENDING', storeBillId: null, withdrawItem: null, }, orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], }); } private async todayWithdrawAppliedAmount(storeId: bigint, now = new Date()) { const start = startOfDay(now); const end = new Date(start); end.setDate(end.getDate() + 1); const agg = await this.prisma.storeWithdrawRequest.aggregate({ where: { storeId, status: { in: ['PENDING_REVIEW', 'PAID'] }, appliedAt: { gte: start, lt: end }, }, _sum: { amount: true }, }); return Number(agg._sum.amount ?? 0); } async getShopWithdrawSummary(storeAccountId: bigint, storeId: bigint) { await this.assertShopStoreAccess(storeAccountId, storeId); const [account, available, pending, todayApplied] = await Promise.all([ this.prisma.storeAccount.findUniqueOrThrow({ where: { id: storeAccountId }, select: { isPrimary: true, bankAccountName: true, bankAccountNo: true, bankBranch: true, }, }), this.listAvailableUnbilledPayouts(storeId), this.prisma.storeWithdrawRequest.findFirst({ where: { storeId, status: 'PENDING_REVIEW' }, select: { id: true, amount: true }, }), this.todayWithdrawAppliedAmount(storeId), ]); const availableAmount = sumUnbilledPayoutAmount( available.map((p) => ({ payoutAmount: Number(p.payoutAmount) })), ); const dailyLimit = getStoreWithdrawDailyLimit(); const hasBankAccount = !!( account.bankAccountName?.trim() && account.bankAccountNo?.trim() ); return { availableAmount, pendingReviewAmount: pending ? Number(pending.amount) : 0, todayAppliedAmount: todayApplied, dailyLimit, remainingDailyLimit: Math.max(0, round2(dailyLimit - todayApplied)), isPrimary: account.isPrimary === 1, hasBankAccount, bankAccount: { bankAccountName: account.bankAccountName, bankAccountNo: account.bankAccountNo, bankBranch: account.bankBranch, }, }; } async createStoreWithdrawRequest( storeAccountId: bigint, storeId: bigint, dto?: { amount?: number }, ) { await this.assertShopStoreAccess(storeAccountId, storeId); const [store, account, available, pending, todayApplied] = await Promise.all([ this.prisma.store.findUniqueOrThrow({ where: { id: storeId }, select: { id: true, name: true, phone: true, cityName: true }, }), this.prisma.storeAccount.findUniqueOrThrow({ where: { id: storeAccountId }, select: { isPrimary: true, bankAccountName: true, bankAccountNo: true, }, }), this.listAvailableUnbilledPayouts(storeId), this.prisma.storeWithdrawRequest.findFirst({ where: { storeId, status: 'PENDING_REVIEW' }, select: { id: true }, }), this.todayWithdrawAppliedAmount(storeId), ]); if (account.isPrimary !== 1) { throw new BadRequestException('仅门店主账号可申请提现'); } const availableAmount = sumUnbilledPayoutAmount( available.map((p) => ({ payoutAmount: Number(p.payoutAmount) })), ); const dailyLimit = getStoreWithdrawDailyLimit(); const hasBankAccount = !!( account.bankAccountName?.trim() && account.bankAccountNo?.trim() ); const requestAmount = dto?.amount != null && Number.isFinite(Number(dto.amount)) ? round2(Number(dto.amount)) : availableAmount; const guard = validateStoreWithdraw({ availableAmount, requestAmount, todayApplied, dailyLimit, hasBankAccount, }); if (!guard.ok) throw new BadRequestException(guard.message); const picked = pickPayoutsForWithdrawAmount( available.map((p) => ({ id: p.id, payoutAmount: Number(p.payoutAmount) })), requestAmount, ); if (!picked.ok) throw new BadRequestException(picked.message); const created = await this.prisma.$transaction(async (tx) => { const payoutIds = picked.selected.map((p) => p.id); const locked = await tx.storePayout.findMany({ where: { id: { in: payoutIds }, storeId, status: 'PENDING', storeBillId: null, withdrawItem: null, }, select: { id: true, payoutAmount: true }, }); if (locked.length !== payoutIds.length) { throw new BadRequestException('可提余额已变化,请刷新后重试'); } const amount = round2(locked.reduce((s, p) => s + Number(p.payoutAmount), 0)); const req = await tx.storeWithdrawRequest.create({ data: { withdrawNo: generateBillNo('SW'), storeId, storeAccountId, amount, payoutCount: locked.length, status: 'PENDING_REVIEW', }, }); await tx.storeWithdrawPayoutItem.createMany({ data: locked.map((p) => ({ withdrawRequestId: req.id, storePayoutId: p.id, })), }); return req; }); this.analyticsService.trackStoreOneSafe(storeAccountId, 'SHOP_H5', { storeId, eventName: 'store_withdraw_applied', refType: 'STORE_WITHDRAW', refId: created.id, extraJson: { amount: Number(created.amount), payoutCount: created.payoutCount, }, }); this.alert.notify({ level: 'P1', category: 'finance', title: '门店提现待审', detail: [ `门店:${store.name}(${store.cityName || '-'} / ${store.phone || '-'})`, `单号:${created.withdrawNo}`, `金额:¥${Number(created.amount).toFixed(2)}`, `明细:${created.payoutCount} 笔未出账核销(已锁定,不进入次日 T+1 出账)`, '请尽快在 HQ「财务 → 门店账单(手动提现)」处理。', ].join('\n'), dedupeKey: `store_withdraw_applied|${created.id.toString()}`, dedupeTtlSec: 3600, }); return serializeBigInt(created); } async listShopWithdrawRequests( storeAccountId: bigint, storeId: bigint, query: { page?: number; pageSize?: number; status?: string }, ) { await this.assertShopStoreAccess(storeAccountId, storeId); const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where: Prisma.StoreWithdrawRequestWhereInput = { storeId }; if (query.status) { where.status = query.status as 'PENDING_REVIEW' | 'REJECTED' | 'PAID'; } const [items, total] = await Promise.all([ this.prisma.storeWithdrawRequest.findMany({ where, orderBy: { appliedAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }), this.prisma.storeWithdrawRequest.count({ where }), ]); return serializeBigInt({ items, total, page, pageSize }); } async listAdminStoreWithdrawals(query: { page?: number; pageSize?: number; status?: string; storeId?: string; dateFrom?: string; dateTo?: string; }) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where: Prisma.StoreWithdrawRequestWhereInput = {}; if (query.status) { where.status = query.status as 'PENDING_REVIEW' | 'REJECTED' | 'PAID'; } if (query.storeId) where.storeId = BigInt(query.storeId); if (query.dateFrom || query.dateTo) { where.appliedAt = {}; if (query.dateFrom) where.appliedAt.gte = new Date(query.dateFrom); if (query.dateTo) { const end = new Date(query.dateTo); end.setHours(23, 59, 59, 999); where.appliedAt.lte = end; } } const [items, total, aggregates] = await Promise.all([ this.prisma.storeWithdrawRequest.findMany({ where, orderBy: [{ appliedAt: 'desc' }, { id: 'desc' }], skip: (page - 1) * pageSize, take: pageSize, include: { store: { select: { id: true, name: true, cityName: true, phone: true } }, }, }), this.prisma.storeWithdrawRequest.count({ where }), this.prisma.storeWithdrawRequest.aggregate({ where, _sum: { amount: true }, _count: true, }), ]); const now = new Date(); const mapped = items.map((row) => ({ ...row, overdue: row.status === 'PENDING_REVIEW' ? isWithdrawOverdue(row.appliedAt, now) : false, })); return serializeBigInt({ items: mapped, total, page, pageSize, summary: { count: aggregates._count, totalAmount: Number(aggregates._sum.amount ?? 0), }, }); } async getAdminStoreWithdrawal(id: bigint) { const row = await this.prisma.storeWithdrawRequest.findUnique({ where: { id }, include: { store: { select: { id: true, name: true, cityName: true, phone: true, }, }, storeAccount: { select: { id: true, name: true, phone: true, bankAccountName: true, bankAccountNo: true, bankBranch: true, }, }, items: { include: { storePayout: { include: { redeemRecord: { select: { redeemNo: true, amount: true, createdAt: true } }, }, }, }, }, }, }); if (!row) throw new NotFoundException('提现申请不存在'); return serializeBigInt({ ...row, overdue: row.status === 'PENDING_REVIEW' ? isWithdrawOverdue(row.appliedAt) : false, }); } async approveStoreWithdraw( id: bigint, hqAccountId: bigint, dto?: { paymentRef?: string }, ) { const row = await this.prisma.storeWithdrawRequest.findUnique({ where: { id }, include: { items: { select: { storePayoutId: true } } }, }); if (!row) throw new NotFoundException('提现申请不存在'); if (row.status !== 'PENDING_REVIEW') { throw new BadRequestException('仅待审核提现可审核通过'); } const paidAt = new Date(); const updated = await this.prisma.$transaction(async (tx) => { const req = await tx.storeWithdrawRequest.update({ where: { id }, data: { status: 'PAID', reviewedAt: paidAt, reviewedByHqId: hqAccountId, paidAt, paymentRef: dto?.paymentRef?.trim() || null, }, }); await tx.storePayout.updateMany({ where: { id: { in: row.items.map((i) => i.storePayoutId) }, status: 'PENDING', }, data: { status: 'PAID', paidAt }, }); return req; }); this.analyticsService.trackStoreOneSafe(undefined, 'HQ_WEB', { storeId: row.storeId, eventName: 'store_withdraw_paid', refType: 'STORE_WITHDRAW', refId: id, extraJson: { amount: Number(row.amount), paymentRef: dto?.paymentRef, }, }); return serializeBigInt(updated); } async rejectStoreWithdraw(id: bigint, hqAccountId: bigint, reason: string) { const row = await this.prisma.storeWithdrawRequest.findUnique({ where: { id }, }); if (!row) throw new NotFoundException('提现申请不存在'); if (row.status !== 'PENDING_REVIEW') { throw new BadRequestException('仅待审核提现可驳回'); } const rejectReason = reason.trim(); if (!rejectReason) throw new BadRequestException('请填写驳回理由'); const updated = await this.prisma.$transaction(async (tx) => { const req = await tx.storeWithdrawRequest.update({ where: { id }, data: { status: 'REJECTED', rejectReason, reviewedAt: new Date(), reviewedByHqId: hqAccountId, }, }); // 释放 payout 锁定,允许再次提现 await tx.storeWithdrawPayoutItem.deleteMany({ where: { withdrawRequestId: id } }); return req; }); return serializeBigInt(updated); } async getStoreWithdrawOverdueSummary() { const pending = await this.prisma.storeWithdrawRequest.findMany({ where: { status: 'PENDING_REVIEW' }, select: { id: true, appliedAt: true, amount: true }, }); const now = new Date(); const overdue = pending.filter((r) => isWithdrawOverdue(r.appliedAt, now)); return { pendingCount: pending.length, overdueCount: overdue.length, overdueAmount: round2(overdue.reduce((s, r) => s + Number(r.amount), 0)), pendingAmount: round2(pending.reduce((s, r) => s + Number(r.amount), 0)), }; } /** FIN-003:工作日 18:00 扫描超时未审提现 */ async scanOverdueStoreWithdrawals() { const summary = await this.getStoreWithdrawOverdueSummary(); if (summary.overdueCount > 0) { const overdueRows = await this.prisma.storeWithdrawRequest.findMany({ where: { status: 'PENDING_REVIEW' }, include: { store: { select: { name: true, phone: true } } }, orderBy: { appliedAt: 'asc' }, take: 20, }); const now = new Date(); const lines = overdueRows .filter((r) => isWithdrawOverdue(r.appliedAt, now)) .slice(0, 10) .map( (r) => `- ${r.store?.name || r.storeId} ${r.withdrawNo} ¥${Number(r.amount).toFixed(2)}`, ); this.alert.notify({ level: 'P1', category: 'finance', title: '门店提现超时未审', detail: [ `待审 ${summary.pendingCount} 笔,超时 ${summary.overdueCount} 笔,超时金额 ¥${summary.overdueAmount.toFixed(2)}`, ...lines, '请尽快在 HQ「财务 → 门店账单(手动提现)」处理。', ].join('\n'), dedupeKey: `store_withdraw_overdue|${new Date().toISOString().slice(0, 10)}`, dedupeTtlSec: 6 * 3600, }); } return summary; } async listAdminStorePayouts(query: { page?: number; pageSize?: number; status?: string; storeId?: string; dateFrom?: string; dateTo?: string; }) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where = this.buildStorePayoutWhere(query); const [items, total, aggregates] = await Promise.all([ this.prisma.storePayout.findMany({ where, orderBy: [{ createdAt: 'desc' }, { expectedPayAt: 'asc' }], skip: (page - 1) * pageSize, take: pageSize, include: { store: { select: { id: true, name: true, cityName: true, phone: true } }, redeemRecord: { select: { redeemNo: true, amount: true, userId: true, createdAt: true } }, }, }), this.prisma.storePayout.count({ where }), this.prisma.storePayout.aggregate({ where, _sum: { redeemAmount: true, payoutAmount: true }, _count: true, }), ]); const summary = { count: aggregates._count, redeemAmount: Number(aggregates._sum.redeemAmount ?? 0), payoutAmount: Number(aggregates._sum.payoutAmount ?? 0), totalAmount: Number(aggregates._sum.payoutAmount ?? 0), }; return serializeBigInt({ items, total, page, pageSize, summary }); } async getAdminStorePayout(id: bigint) { const payout = await this.prisma.storePayout.findUnique({ where: { id }, include: { store: true, redeemRecord: true, }, }); if (!payout) throw new NotFoundException('打款记录不存在'); return serializeBigInt(payout); } async confirmStorePayout(id: bigint, dto: { paymentRef?: string; batchNo?: string; remark?: string }) { const payout = await this.prisma.storePayout.findUnique({ where: { id } }); if (!payout) throw new NotFoundException('打款记录不存在'); if (payout.status !== 'PENDING') throw new BadRequestException('仅未打款记录可确认'); const updated = await this.prisma.storePayout.update({ where: { id }, data: { status: 'PAID', paidAt: new Date(), batchNo: dto.batchNo ?? payout.batchNo, }, }); if (payout.storeBillId) { const pending = await this.prisma.storePayout.count({ where: { storeBillId: payout.storeBillId, status: 'PENDING' }, }); if (pending === 0) { await this.prisma.storeBill.update({ where: { id: payout.storeBillId }, data: { status: 'PAID', paidAt: new Date() }, }); } } this.analyticsService.trackStoreOneSafe(undefined, 'HQ_WEB', { storeId: payout.storeId, eventName: 'store_payout_paid', refType: 'STORE_PAYOUT', refId: id, extraJson: { batchNo: dto.batchNo ?? payout.batchNo, paymentRef: dto.paymentRef, payoutAmount: Number(payout.payoutAmount), }, }); return serializeBigInt(updated); } async batchConfirmStorePayouts(ids: string[], dto: { batchNo?: string }) { const results: Array<{ id: string; ok: boolean; message?: string }> = []; for (const id of ids) { try { await this.confirmStorePayout(BigInt(id), { batchNo: dto.batchNo }); results.push({ id, ok: true }); } catch (e) { results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' }); } } return results; } async exportAdminStorePayouts(query: { status?: string; storeId?: string; dateFrom?: string; dateTo?: string; }) { const where = this.buildStorePayoutWhere(query); const items = await this.prisma.storePayout.findMany({ where, include: { store: { select: { name: true, phone: true, cityName: true } }, redeemRecord: { select: { redeemNo: true, createdAt: true } }, }, orderBy: { createdAt: 'desc' }, }); const header = ['核销单号', '门店', '城市', '核销金额', '结算比例', '应付金额', '状态', '核销时间', '打款时间'].join(','); const rows = items.map((p) => [ csvEscape(p.redeemRecord.redeemNo ?? ''), csvEscape(p.store.name), csvEscape(p.store.cityName ?? ''), Number(p.redeemAmount), Number(p.settlementRate), Number(p.payoutAmount), p.status, p.redeemRecord.createdAt.toISOString().slice(0, 19).replace('T', ' '), p.paidAt ? p.paidAt.toISOString().slice(0, 19).replace('T', ' ') : '', ].join(','), ); return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: items.length }; } async scanDueStorePayouts() { const now = new Date(); const due = await this.prisma.storePayout.findMany({ where: { status: 'PENDING', expectedPayAt: { lte: now } }, take: 100, }); return serializeBigInt({ dueCount: due.length, items: due }); } private buildStorePayoutWhere(query: { status?: string; storeId?: string; dateFrom?: string; dateTo?: string; }): Prisma.StorePayoutWhereInput { const where: Prisma.StorePayoutWhereInput = {}; if (query.status === 'PENDING' || query.status === 'PAID') where.status = query.status; if (query.storeId) where.storeId = BigInt(query.storeId); if (query.dateFrom || query.dateTo) { where.createdAt = {}; if (query.dateFrom) where.createdAt.gte = new Date(query.dateFrom); if (query.dateTo) { const end = new Date(query.dateTo); end.setHours(23, 59, 59, 999); where.createdAt.lte = end; } } return where; } // ─── Store bills (daily header) ────────────────────── /** 生成指定自然日窗口内的门店对账单(默认昨日) */ async generateStoreBillsForDay(anchor = new Date()) { const { start, end, billDate } = dayWindow(anchor); const payouts = await this.prisma.storePayout.findMany({ where: { storeBillId: null, createdAt: { gte: start, lt: end }, // 排除已锁定在待审提现单中的明细,避免出账与提现双占 withdrawItem: null, status: 'PENDING', }, include: { store: { select: { id: true, settlementRate: true } } }, }); const byStore = new Map(); for (const p of payouts) { const key = p.storeId.toString(); const list = byStore.get(key) ?? []; list.push(p); byStore.set(key, list); } let created = 0; let skipped = 0; for (const [storeIdStr, list] of byStore) { const storeId = BigInt(storeIdStr); const existing = await this.prisma.storeBill.findUnique({ where: { storeId_billDate: { storeId, billDate } }, }); if (existing) { if (existing.status === 'PAID') { skipped += 1; continue; } const redeemAmount = round2(list.reduce((s, p) => s + Number(p.redeemAmount), 0)); const payoutAmount = round2(list.reduce((s, p) => s + Number(p.payoutAmount), 0)); const rate = Number(list[0].settlementRate); await this.prisma.$transaction(async (tx) => { await tx.storeBill.update({ where: { id: existing.id }, data: { redeemCount: existing.redeemCount + list.length, redeemAmount: round2(Number(existing.redeemAmount) + redeemAmount), payoutAmount: round2(Number(existing.payoutAmount) + payoutAmount), settlementRate: rate, }, }); await tx.storePayout.updateMany({ where: { id: { in: list.map((p) => p.id) } }, data: { storeBillId: existing.id }, }); }); created += 1; continue; } const redeemAmount = round2(list.reduce((s, p) => s + Number(p.redeemAmount), 0)); const payoutAmount = round2(list.reduce((s, p) => s + Number(p.payoutAmount), 0)); const rate = Number(list[0].settlementRate); await this.prisma.$transaction(async (tx) => { const bill = await tx.storeBill.create({ data: { billNo: generateBillNo('SB'), storeId, billDate, redeemCount: list.length, redeemAmount, settlementRate: rate, payoutAmount, status: 'UNPAID', }, }); await tx.storePayout.updateMany({ where: { id: { in: list.map((p) => p.id) } }, data: { storeBillId: bill.id }, }); }); created += 1; } return { billDate: billDate.toISOString().slice(0, 10), created, skipped, payoutLinked: payouts.length }; } /** * 总部「门店账单」统一列表:T+1 StoreBill + 手动提现 StoreWithdrawRequest * 中小数据量内存合并后分页。 */ async listAdminStoreSettlements(query: { page?: number; pageSize?: number; kind?: string; status?: string; storeId?: string; dateFrom?: string; dateTo?: string; }) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const kind = query.kind === 'T1_BILL' || query.kind === 'WITHDRAW' ? query.kind : undefined; const status = query.status?.trim() || undefined; const includeBills = !kind || kind === 'T1_BILL' ? !status || status === 'UNPAID' || status === 'PAID' : false; const includeWithdraws = !kind || kind === 'WITHDRAW' ? !status || status === 'PENDING_REVIEW' || status === 'REJECTED' || status === 'PAID' : false; const billWhere = includeBills ? this.buildStoreBillWhere({ status: status === 'UNPAID' || status === 'PAID' ? status : undefined, storeId: query.storeId, dateFrom: query.dateFrom, dateTo: query.dateTo, }) : null; const withdrawWhere: Prisma.StoreWithdrawRequestWhereInput | null = includeWithdraws ? (() => { const where: Prisma.StoreWithdrawRequestWhereInput = {}; if (status === 'PENDING_REVIEW' || status === 'REJECTED' || status === 'PAID') { where.status = status; } if (query.storeId) where.storeId = BigInt(query.storeId); if (query.dateFrom || query.dateTo) { where.appliedAt = {}; if (query.dateFrom) where.appliedAt.gte = new Date(query.dateFrom); if (query.dateTo) { const end = new Date(query.dateTo); end.setHours(23, 59, 59, 999); where.appliedAt.lte = end; } } return where; })() : null; const bills = billWhere ? await this.prisma.storeBill.findMany({ where: billWhere, include: { store: { select: { id: true, name: true, cityName: true, phone: true } }, }, }) : []; const withdraws = withdrawWhere ? await this.prisma.storeWithdrawRequest.findMany({ where: withdrawWhere, include: { store: { select: { id: true, name: true, cityName: true, phone: true } }, }, }) : []; const now = new Date(); type UnifiedRow = { kind: 'T1_BILL' | 'WITHDRAW'; id: bigint; billNo: string; storeId: bigint; amount: number; status: string; date: Date; overdue?: boolean; redeemCount?: number; redeemAmount?: number; settlementRate?: number; payoutCount?: number; store?: { id: bigint; name: string; cityName: string; phone: string | null }; }; const rows: UnifiedRow[] = [ ...bills.map((b) => ({ kind: 'T1_BILL' as const, id: b.id, billNo: b.billNo, storeId: b.storeId, amount: Number(b.payoutAmount), status: b.status, date: b.billDate, redeemCount: b.redeemCount, redeemAmount: Number(b.redeemAmount), settlementRate: Number(b.settlementRate), store: b.store, })), ...withdraws.map((w) => ({ kind: 'WITHDRAW' as const, id: w.id, billNo: w.withdrawNo, storeId: w.storeId, amount: Number(w.amount), status: w.status, date: w.appliedAt, overdue: w.status === 'PENDING_REVIEW' ? isWithdrawOverdue(w.appliedAt, now) : false, payoutCount: w.payoutCount, store: w.store, })), ]; rows.sort((a, b) => { const dt = b.date.getTime() - a.date.getTime(); if (dt !== 0) return dt; return Number(b.id - a.id); }); const total = rows.length; const slice = rows.slice((page - 1) * pageSize, page * pageSize); const redeemAmount = bills.reduce((s, b) => s + Number(b.redeemAmount), 0); const payoutAmount = rows.reduce((s, r) => s + r.amount, 0); return serializeBigInt({ items: slice, total, page, pageSize, summary: { count: total, redeemAmount, payoutAmount, totalAmount: payoutAmount, }, }); } async listAdminStoreBills(query: { page?: number; pageSize?: number; status?: string; storeId?: string; dateFrom?: string; dateTo?: string; }) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where = this.buildStoreBillWhere(query); const [items, total, aggregates] = await Promise.all([ this.prisma.storeBill.findMany({ where, orderBy: [{ billDate: 'desc' }, { id: 'desc' }], skip: (page - 1) * pageSize, take: pageSize, include: { store: { select: { id: true, name: true, cityName: true, phone: true } }, }, }), this.prisma.storeBill.count({ where }), this.prisma.storeBill.aggregate({ where, _sum: { redeemAmount: true, payoutAmount: true }, _count: true, }), ]); const summary = { count: aggregates._count, redeemAmount: Number(aggregates._sum.redeemAmount ?? 0), payoutAmount: Number(aggregates._sum.payoutAmount ?? 0), totalAmount: Number(aggregates._sum.payoutAmount ?? 0), }; return serializeBigInt({ items, total, page, pageSize, summary }); } async getAdminStoreBill(id: bigint) { const bill = await this.prisma.storeBill.findUnique({ where: { id }, include: { store: { select: { id: true, name: true, cityName: true, phone: true } }, payouts: { include: { redeemRecord: { select: { redeemNo: true, amount: true, createdAt: true } } }, orderBy: { createdAt: 'desc' }, }, }, }); if (!bill) throw new NotFoundException('门店对账单不存在'); return serializeBigInt(bill); } async confirmStoreBill(id: bigint) { const bill = await this.prisma.storeBill.findUnique({ where: { id } }); if (!bill) throw new NotFoundException('门店对账单不存在'); if (bill.status !== 'UNPAID') throw new BadRequestException('仅未打款账单可确认打款'); const paidAt = new Date(); const updated = await this.prisma.$transaction(async (tx) => { const b = await tx.storeBill.update({ where: { id }, data: { status: 'PAID', paidAt }, }); await tx.storePayout.updateMany({ where: { storeBillId: id, status: 'PENDING' }, data: { status: 'PAID', paidAt }, }); return b; }); return serializeBigInt(updated); } async batchConfirmStoreBills(ids: string[]) { const results: Array<{ id: string; ok: boolean; message?: string }> = []; for (const id of ids) { try { await this.confirmStoreBill(BigInt(id)); results.push({ id, ok: true }); } catch (e) { results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' }); } } return results; } async exportAdminStoreBills(query: { status?: string; storeId?: string; dateFrom?: string; dateTo?: string; }) { const where = this.buildStoreBillWhere(query); const bills = await this.prisma.storeBill.findMany({ where, include: { store: { select: { name: true, phone: true, cityName: true } } }, orderBy: { billDate: 'desc' }, }); const header = ['账单号', '账单日', '门店', '城市', '核销笔数', '核销金额', '结算比例', '应付金额', '状态', '打款时间'].join(','); const rows = bills.map((b) => [ csvEscape(b.billNo), b.billDate.toISOString().slice(0, 10), csvEscape(b.store.name), csvEscape(b.store.cityName ?? ''), b.redeemCount, Number(b.redeemAmount), Number(b.settlementRate), Number(b.payoutAmount), b.status, b.paidAt ? b.paidAt.toISOString().slice(0, 19).replace('T', ' ') : '', ].join(','), ); return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: bills.length }; } private buildStoreBillWhere(query: { status?: string; storeId?: string; dateFrom?: string; dateTo?: string; }): Prisma.StoreBillWhereInput { const where: Prisma.StoreBillWhereInput = {}; if (query.status === 'UNPAID' || query.status === 'PAID') where.status = query.status; if (query.storeId) where.storeId = BigInt(query.storeId); if (query.dateFrom || query.dateTo) { where.billDate = {}; if (query.dateFrom) where.billDate.gte = startOfDay(new Date(query.dateFrom)); if (query.dateTo) where.billDate.lte = startOfDay(new Date(query.dateTo)); } return where; } /** @deprecated 兼容旧 daily 聚合视图 */ async listAdminStoreDailyBills(query: { page?: number; pageSize?: number; status?: string; storeId?: string; dateFrom?: string; dateTo?: string; }) { return this.listAdminStoreBills(query); } // ─── Partner bills ─────────────────────────────────── async listPartnerBills(partnerAccountId: bigint) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); const bills = await this.prisma.partnerBill.findMany({ where: { partnerAccountId: primary.id, status: { in: ['AWAITING_CONFIRM', 'UNPAID', 'PAID', 'REJECTED'] }, }, orderBy: { createdAt: 'desc' }, }); this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', { partnerAccountId: primary.id, eventName: 'partner_bill_view', extraJson: { count: bills.length }, }); return serializeBigInt(bills); } async getPartnerBill(partnerAccountId: bigint, billId: bigint) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); const bill = await this.prisma.partnerBill.findFirst({ where: { id: billId, partnerAccountId: primary.id }, }); if (!bill) throw new NotFoundException('账单不存在'); this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', { partnerAccountId: primary.id, eventName: 'partner_bill_detail_view', refType: 'PARTNER_BILL', refId: billId, extraJson: { billId: billId.toString(), status: bill.status }, }); return serializeBigInt(bill); } async listAdminPartnerBills(query: { page?: number; pageSize?: number; status?: string; partnerId?: string; year?: number; month?: number; }) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where = this.buildPartnerBillWhere(query); const [rawItems, total, aggregates] = await Promise.all([ this.prisma.partnerBill.findMany({ where, orderBy: { periodStart: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, include: { partnerAccount: { select: { id: true, companyName: true, name: true, phone: true }, }, }, }), this.prisma.partnerBill.count({ where }), this.prisma.partnerBill.aggregate({ where, _sum: { orderCommission: true, redeemCommission: true, totalAmount: true }, _count: true, }), ]); const items = rawItems.map((b) => ({ ...b, partner: b.partnerAccount, })); const summary = { count: aggregates._count, orderCommission: Number(aggregates._sum.orderCommission ?? 0), redeemCommission: Number(aggregates._sum.redeemCommission ?? 0), totalAmount: Number(aggregates._sum.totalAmount ?? 0), }; return serializeBigInt({ items, total, page, pageSize, summary }); } private buildPartnerBillWhere(query: { status?: string; partnerId?: string; year?: number; month?: number; }): Prisma.PartnerBillWhereInput { const where: Prisma.PartnerBillWhereInput = {}; if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals']; if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId); if (query.year && query.month) { const periodStart = new Date(query.year, query.month - 1, 1); const periodEnd = new Date(query.year, query.month, 0, 23, 59, 59, 999); where.periodStart = { gte: periodStart, lte: periodEnd }; } return where; } async generateAllPartnerBills(body: { year: number; month: number }) { const partners = await this.prisma.partnerAccount.findMany({ where: { isPrimary: 1, status: 'ACTIVE' }, select: { id: true, companyName: true }, }); const results: Array<{ partnerId: string; companyName: string; ok: boolean; message?: string }> = []; for (const p of partners) { try { await this.generatePartnerBill({ partnerId: p.id.toString(), year: body.year, month: body.month, }); results.push({ partnerId: p.id.toString(), companyName: p.companyName ?? '', ok: true }); } catch (e) { results.push({ partnerId: p.id.toString(), companyName: p.companyName ?? '', ok: false, message: e instanceof Error ? e.message : '失败', }); } } return { total: partners.length, success: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results, }; } /** 每月 1 日任务:生成上一自然月账单 */ async generatePreviousMonthPartnerBills(anchor = new Date()) { const prev = new Date(anchor.getFullYear(), anchor.getMonth() - 1, 1); return this.generateAllPartnerBills({ year: prev.getFullYear(), month: prev.getMonth() + 1 }); } async getAdminPartnerBill(id: bigint) { const bill = await this.prisma.partnerBill.findUnique({ where: { id }, include: { partnerAccount: true }, }); if (!bill) throw new NotFoundException('账单不存在'); return serializeBigInt(bill); } async generatePartnerBill(body: { partnerId: string; year: number; month: number }) { const partnerAccountId = BigInt(body.partnerId); const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); const periodStart = new Date(body.year, body.month - 1, 1); const periodEnd = new Date(body.year, body.month, 0, 23, 59, 59, 999); const existing = await this.prisma.partnerBill.findUnique({ where: { partnerAccountId_periodStart: { partnerAccountId: primary.id, periodStart }, }, }); if (existing && existing.status !== 'PENDING_REVIEW') { throw new BadRequestException('该月账单已进入审核流程,不可重复生成'); } if (!primary.cityId) { throw new BadRequestException('合伙人未绑定开城城市'); } const orderCommissionRate = Number(primary.orderCommissionRate ?? 0); const redeemCommissionRate = Number(primary.redeemCommissionRate ?? 0.03); const orders = await this.prisma.order.findMany({ where: { cityId: primary.cityId, payStatus: 'PAID', paidAt: { gte: periodStart, lte: periodEnd }, isTest: false, }, }); const orderCommission = primary.isTest ? 0 : orders.reduce((sum, o) => { if (o.partnerAccountIdAtPay) { if (o.partnerAccountIdAtPay !== primary.id) return sum; const rate = o.orderCommissionRateAtPay != null ? Number(o.orderCommissionRateAtPay) : 0; return sum + Number(o.payAmount) * rate; } return sum + Number(o.payAmount) * orderCommissionRate; }, 0); const stores = await this.prisma.store.findMany({ where: { partnerAccountId: primary.id }, select: { id: true }, }); const storeIds = stores.map((s) => s.id); const redeems = primary.isTest || !storeIds.length ? [] : await this.prisma.redeemRecord.findMany({ where: { storeId: { in: storeIds }, createdAt: { gte: periodStart, lte: periodEnd }, isTest: false, }, }); const redeemCommission = redeems.reduce( (sum, r) => sum + Number(r.amount) * redeemCommissionRate, 0, ); const totalAmount = round2(orderCommission + redeemCommission); const bill = existing ? await this.prisma.partnerBill.update({ where: { id: existing.id }, data: { orderCommission: round2(orderCommission), redeemCommission: round2(redeemCommission), totalAmount, periodEnd, status: 'PENDING_REVIEW', }, }) : await this.prisma.partnerBill.create({ data: { billNo: generateBillNo('PB'), partnerAccountId: primary.id, periodStart, periodEnd, orderCommission: round2(orderCommission), redeemCommission: round2(redeemCommission), totalAmount, status: 'PENDING_REVIEW', }, }); return serializeBigInt(bill); } /** HQ:发送给合伙人(待审核 → 待合伙人确认) */ async sendPartnerBill(id: bigint) { const bill = await this.prisma.partnerBill.findUnique({ where: { id } }); if (!bill) throw new NotFoundException('账单不存在'); if (bill.status !== 'PENDING_REVIEW' && bill.status !== 'REJECTED') { throw new BadRequestException('仅待审核或已驳回账单可发送'); } const updated = await this.prisma.partnerBill.update({ where: { id }, data: { status: 'AWAITING_CONFIRM', sentAt: new Date(), rejectReason: null, }, }); return serializeBigInt(updated); } async batchSendPartnerBills(ids: string[]) { const results: Array<{ id: string; ok: boolean; message?: string }> = []; for (const id of ids) { try { await this.sendPartnerBill(BigInt(id)); results.push({ id, ok: true }); } catch (e) { results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' }); } } return results; } /** @deprecated HQ 代确认:待审核/待确认 → 未打款 */ async confirmPartnerBill(id: bigint) { const bill = await this.prisma.partnerBill.findUnique({ where: { id } }); if (!bill) throw new NotFoundException('账单不存在'); if (bill.status !== 'PENDING_REVIEW' && bill.status !== 'AWAITING_CONFIRM' && bill.status !== 'REJECTED') { throw new BadRequestException('当前状态不可确认'); } const updated = await this.prisma.partnerBill.update({ where: { id }, data: { status: 'UNPAID', confirmedAt: new Date(), sentAt: bill.sentAt ?? new Date(), rejectReason: null, }, }); return serializeBigInt(updated); } /** 合伙人确认:待合伙人确认 → 未打款 */ async partnerConfirmBill(partnerAccountId: bigint, billId: bigint) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); const bill = await this.prisma.partnerBill.findUnique({ where: { id: billId } }); if (!bill || bill.partnerAccountId !== primary.id) { throw new NotFoundException('账单不存在'); } if (bill.status !== 'AWAITING_CONFIRM') { throw new BadRequestException('当前账单不可确认'); } const updated = await this.prisma.partnerBill.update({ where: { id: billId }, data: { status: 'UNPAID', confirmedAt: new Date(), rejectReason: null, }, }); this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', { partnerAccountId: primary.id, eventName: 'partner_bill_confirm', refType: 'PARTNER_BILL', refId: billId, extraJson: { billNo: bill.billNo }, }); return serializeBigInt(updated); } async batchPartnerConfirmBills(partnerAccountId: bigint, ids: string[]) { const results: Array<{ id: string; ok: boolean; message?: string }> = []; for (const id of ids) { try { await this.partnerConfirmBill(partnerAccountId, BigInt(id)); results.push({ id, ok: true }); } catch (e) { results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' }); } } return results; } async rejectPartnerBill(id: bigint, reason: string) { const rejectReason = reason.trim(); if (!rejectReason) throw new BadRequestException('请填写驳回理由'); if (rejectReason.length > 500) throw new BadRequestException('驳回理由不能超过 500 字'); const bill = await this.prisma.partnerBill.findUnique({ where: { id } }); if (!bill) throw new NotFoundException('账单不存在'); if (bill.status !== 'AWAITING_CONFIRM' && bill.status !== 'UNPAID') { throw new BadRequestException('仅待合伙人确认或未打款账单可驳回'); } const updated = await this.prisma.partnerBill.update({ where: { id }, data: { status: 'REJECTED', rejectReason, }, }); return serializeBigInt(updated); } async markPartnerBillPaid(id: bigint, _dto: { paymentRef?: string } = {}) { const bill = await this.prisma.partnerBill.findUnique({ where: { id } }); if (!bill) throw new NotFoundException('账单不存在'); if (bill.status !== 'UNPAID') throw new BadRequestException('仅未打款账单可标记打款'); const updated = await this.prisma.partnerBill.update({ where: { id }, data: { status: 'PAID', paidAt: new Date(), rejectReason: null }, }); return serializeBigInt(updated); } async batchMarkPartnerBillsPaid(ids: string[]) { const results: Array<{ id: string; ok: boolean; message?: string }> = []; for (const id of ids) { try { await this.markPartnerBillPaid(BigInt(id)); results.push({ id, ok: true }); } catch (e) { results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' }); } } return results; } async exportPartnerBills(query: { partnerId?: string; status?: string; year?: number; month?: number; }) { const where = this.buildPartnerBillWhere(query); const bills = await this.prisma.partnerBill.findMany({ where, include: { partnerAccount: { select: { companyName: true, phone: true } } }, orderBy: { periodStart: 'desc' }, }); const header = [ '账单号', '合伙人', '登录手机', '账期起', '账期止', '酒单佣金', '核销佣金', '合计应付', '状态', '发送时间', '确认时间', '打款时间', '驳回理由', ].join(','); const rows = bills.map((b) => [ csvEscape(b.billNo), csvEscape(b.partnerAccount.companyName ?? ''), csvEscape(b.partnerAccount.phone ?? ''), b.periodStart.toISOString().slice(0, 10), b.periodEnd.toISOString().slice(0, 10), Number(b.orderCommission), Number(b.redeemCommission), Number(b.totalAmount), b.status, b.sentAt ? b.sentAt.toISOString().slice(0, 10) : '', b.confirmedAt ? b.confirmedAt.toISOString().slice(0, 10) : '', b.paidAt ? b.paidAt.toISOString().slice(0, 10) : '', csvEscape(b.rejectReason ?? ''), ].join(','), ); return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: bills.length }; } // ─── Winery bills ──────────────────────────────────── async generateWineryBillForDay(anchor = new Date()) { const { start, end, billDate } = wineryDayWindow(anchor); const rate = WINERY_SETTLEMENT_RATE; const existing = await this.prisma.wineryBill.findUnique({ where: { billDate } }); if (existing?.status === 'PAID') { return { billDate: billDate.toISOString().slice(0, 10), skipped: true, reason: '已打款' }; } // T+3:纳入「账单日」当天完成的同城/跨城订单(完成日 = 今天 − lagDays) const orders = await this.prisma.order.findMany({ where: { status: 'COMPLETED', payStatus: 'PAID', deliveryType: { in: ['LOCAL', 'CROSS_CITY'] }, completedAt: { gte: start, lt: end }, isTest: false, }, orderBy: { completedAt: 'asc' }, }); if (orders.length === 0 && !existing) { return { billDate: billDate.toISOString().slice(0, 10), skipped: true, reason: '无订单' }; } const orderAmount = round2(orders.reduce((s, o) => s + Number(o.payAmount), 0)); const wineryAmount = round2(orderAmount * rate); const bill = await this.prisma.$transaction(async (tx) => { const header = existing ? await tx.wineryBill.update({ where: { id: existing.id }, data: { orderCount: orders.length, orderAmount, wineryRate: rate, wineryAmount, }, }) : await tx.wineryBill.create({ data: { billNo: generateBillNo('WB'), billDate, orderCount: orders.length, orderAmount, wineryRate: rate, wineryAmount, status: 'UNPAID', }, }); if (existing) { await tx.wineryBillItem.deleteMany({ where: { wineryBillId: header.id } }); } if (orders.length > 0) { await tx.wineryBillItem.createMany({ data: orders.map((o) => ({ wineryBillId: header.id, orderId: o.id, orderNo: o.orderNo, deliveryType: o.deliveryType, payAmount: o.payAmount, wineryAmount: round2(Number(o.payAmount) * rate), // 明细仍保留支付时间;账期窗口按 completedAt 归属 paidAt: o.paidAt ?? o.completedAt!, })), }); } return header; }); return serializeBigInt({ billDate: billDate.toISOString().slice(0, 10), skipped: false, bill, }); } async listAdminWineryBills(query: { page?: number; pageSize?: number; status?: string; dateFrom?: string; dateTo?: string; year?: number; month?: number; }) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where = this.buildWineryBillWhere(query); const [items, total, aggregates] = await Promise.all([ this.prisma.wineryBill.findMany({ where, orderBy: { billDate: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }), this.prisma.wineryBill.count({ where }), this.prisma.wineryBill.aggregate({ where, _sum: { orderAmount: true, wineryAmount: true }, _count: true, }), ]); const summary = { count: aggregates._count, orderAmount: Number(aggregates._sum.orderAmount ?? 0), wineryAmount: Number(aggregates._sum.wineryAmount ?? 0), totalAmount: Number(aggregates._sum.wineryAmount ?? 0), }; return serializeBigInt({ items, total, page, pageSize, summary }); } async getAdminWineryBill(id: bigint) { const bill = await this.prisma.wineryBill.findUnique({ where: { id }, include: { items: { orderBy: { paidAt: 'desc' } } }, }); if (!bill) throw new NotFoundException('酒厂对账单不存在'); return serializeBigInt(bill); } async confirmWineryBill(id: bigint) { const bill = await this.prisma.wineryBill.findUnique({ where: { id } }); if (!bill) throw new NotFoundException('酒厂对账单不存在'); if (bill.status !== 'UNPAID') throw new BadRequestException('仅未打款账单可确认打款'); if (Number(bill.wineryAmount) === 0) { throw new BadRequestException('应付为 0,无需打款'); } const updated = await this.prisma.wineryBill.update({ where: { id }, data: { status: 'PAID', paidAt: new Date() }, }); return serializeBigInt(updated); } async batchConfirmWineryBills(ids: string[]) { const results: Array<{ id: string; ok: boolean; message?: string }> = []; for (const id of ids) { try { await this.confirmWineryBill(BigInt(id)); results.push({ id, ok: true }); } catch (e) { results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' }); } } return results; } async exportAdminWineryBills(query: { status?: string; dateFrom?: string; dateTo?: string; year?: number; month?: number; }) { const where = this.buildWineryBillWhere(query); const bills = await this.prisma.wineryBill.findMany({ where, include: { items: true }, orderBy: { billDate: 'desc' }, }); const header = [ '账单号', '账单日', '订单号', '配送类型', '实付金额', '酒厂比例', '应付', '支付时间', '账单状态', ].join(','); const rows: string[] = []; const statusLabel = (b: { status: string; wineryAmount: Prisma.Decimal | number }) => { if (Number(b.wineryAmount) === 0) return '无需打款'; if (b.status === 'PAID') return '已打款'; if (b.status === 'UNPAID') return '未打款'; return b.status; }; for (const b of bills) { if (b.items.length === 0) { rows.push( [ csvEscape(b.billNo), b.billDate.toISOString().slice(0, 10), '', '', Number(b.orderAmount), Number(b.wineryRate), Number(b.wineryAmount), '', statusLabel(b), ].join(','), ); continue; } for (const item of b.items) { rows.push( [ csvEscape(b.billNo), b.billDate.toISOString().slice(0, 10), csvEscape(item.orderNo), item.deliveryType === 'LOCAL' ? '同城' : item.deliveryType === 'CROSS_CITY' ? '跨城' : item.deliveryType, Number(item.payAmount), Number(b.wineryRate), Number(item.wineryAmount), item.paidAt.toISOString().slice(0, 19).replace('T', ' '), statusLabel(b), ].join(','), ); } } return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: rows.length }; } private buildWineryBillWhere(query: { status?: string; dateFrom?: string; dateTo?: string; year?: number; month?: number; }): Prisma.WineryBillWhereInput { const where: Prisma.WineryBillWhereInput = {}; if (query.status === 'NO_PAYMENT_NEEDED') { where.wineryAmount = 0; } else if (query.status === 'UNPAID') { where.status = 'UNPAID'; where.wineryAmount = { gt: 0 }; } else if (query.status === 'PAID') { where.status = 'PAID'; where.wineryAmount = { gt: 0 }; } if (query.year && query.month) { const start = new Date(query.year, query.month - 1, 1); const end = new Date(query.year, query.month, 0, 23, 59, 59, 999); where.billDate = { gte: start, lte: end }; } else if (query.dateFrom || query.dateTo) { where.billDate = {}; if (query.dateFrom) where.billDate.gte = startOfDay(new Date(query.dateFrom)); if (query.dateTo) where.billDate.lte = startOfDay(new Date(query.dateTo)); } return where; } // ─── Logistics bills (按承运商月结) ─────────────────── async generatePreviousMonthLogisticsBills(anchor = new Date()) { const prev = new Date(anchor.getFullYear(), anchor.getMonth() - 1, 1); return this.generateAllLogisticsBills({ year: prev.getFullYear(), month: prev.getMonth() + 1, }); } async generateAllLogisticsBills(body: { year: number; month: number }) { const providers = await this.prisma.fulfillmentProvider.findMany({ where: { status: 'ACTIVE' }, orderBy: { code: 'asc' }, }); const results: Array<{ providerId: string; providerCode: string; providerName: string; ok: boolean; skipped?: boolean; message?: string; billId?: string; }> = []; for (const p of providers) { try { const bill = await this.generateLogisticsBill({ providerId: p.id.toString(), year: body.year, month: body.month, }); results.push({ providerId: p.id.toString(), providerCode: p.code, providerName: p.name, ok: true, skipped: Boolean(bill.skipped), message: bill.reason, billId: bill.bill?.id != null ? typeof bill.bill.id === 'string' ? bill.bill.id : String(bill.bill.id) : undefined, }); } catch (e) { results.push({ providerId: p.id.toString(), providerCode: p.code, providerName: p.name, ok: false, message: e instanceof Error ? e.message : '失败', }); } } return { total: providers.length, success: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results, }; } async generateLogisticsBill(body: { providerId: string; year: number; month: number }) { const fulfillmentProviderId = BigInt(body.providerId); const provider = await this.prisma.fulfillmentProvider.findUnique({ where: { id: fulfillmentProviderId }, }); if (!provider) throw new NotFoundException('仓配承运商不存在'); const periodStart = new Date(body.year, body.month - 1, 1); const periodEnd = new Date(body.year, body.month, 0, 23, 59, 59, 999); const existing = await this.prisma.logisticsBill.findUnique({ where: { fulfillmentProviderId_periodStart: { fulfillmentProviderId, periodStart }, }, }); if (existing?.status === 'PAID') { return { skipped: true, reason: '该月账单已结算', bill: serializeBigInt(existing), }; } const pricing = this.fulfillmentProviderService.parsePricingRules(provider.pricingRulesJson) ?? (provider.code.toUpperCase() === 'XFX' || provider.code.toUpperCase() === 'XIAOFEIXIA' ? { ...DEFAULT_XFX_LOGISTICS_PRICING } : null); if (!pricing) { throw new BadRequestException(`承运商 ${provider.name} 未配置计价标准`); } const deliveries = await this.prisma.orderDelivery.findMany({ where: { fulfillmentProviderId, OR: [ { shippingAt: { gte: periodStart, lte: periodEnd } }, { shippingAt: null, outWarehouseAt: { gte: periodStart, lte: periodEnd }, }, ], }, include: { order: { select: { id: true, orderNo: true, quantity: true, deliveryType: true, payStatus: true, isTest: true, }, }, }, orderBy: [{ shippingAt: 'asc' }, { outWarehouseAt: 'asc' }], }); const eligible = deliveries.filter( (d) => !d.order.isTest && d.order.payStatus === 'PAID' && (d.order.deliveryType === 'LOCAL' || d.order.deliveryType === 'CROSS_CITY'), ); if (eligible.length === 0 && !existing) { return { skipped: true, reason: '无发货订单', bill: null }; } const items = eligible.map((d) => { const qty = d.order.quantity; const fee = calcLogisticsFeeByBottles(qty, pricing as LogisticsPricingRule); const shippedAt = d.shippingAt ?? d.outWarehouseAt ?? periodStart; return { orderId: d.order.id, orderNo: d.order.orderNo, quantity: qty, logisticsAmount: fee, shippedAt, }; }); const orderCount = items.length; const bottleCount = items.reduce((s, i) => s + i.quantity, 0); const logisticsAmount = round2(items.reduce((s, i) => s + i.logisticsAmount, 0)); const settlementMethod = provider.settlementMethod; const pricingSnapshotJson = JSON.stringify(pricing); const bill = await this.prisma.$transaction(async (tx) => { const header = existing ? await tx.logisticsBill.update({ where: { id: existing.id }, data: { periodEnd, orderCount, bottleCount, logisticsAmount, settlementMethod, pricingSnapshotJson, status: 'UNPAID', paidAt: null, }, }) : await tx.logisticsBill.create({ data: { billNo: generateBillNo('LB'), fulfillmentProviderId, periodStart, periodEnd, orderCount, bottleCount, logisticsAmount, settlementMethod, pricingSnapshotJson, status: 'UNPAID', }, }); if (existing) { await tx.logisticsBillItem.deleteMany({ where: { logisticsBillId: header.id } }); } if (items.length > 0) { await tx.logisticsBillItem.createMany({ data: items.map((i) => ({ logisticsBillId: header.id, orderId: i.orderId, orderNo: i.orderNo, quantity: i.quantity, logisticsAmount: i.logisticsAmount, shippedAt: i.shippedAt, })), }); } // 充值模式:余额充足则自动扣款并标记已结算 if (settlementMethod === 'PREPAID' && logisticsAmount > 0) { const deducted = await this.fulfillmentProviderService.deductPrepaid( fulfillmentProviderId, logisticsAmount, header.id, tx, ); if (deducted.ok) { return tx.logisticsBill.update({ where: { id: header.id }, data: { status: 'PAID', paidAt: new Date() }, }); } } return header; }); return { skipped: false, bill: serializeBigInt(bill), }; } async listAdminLogisticsBills(query: { page?: number; pageSize?: number; status?: string; providerId?: string; year?: number; month?: number; }) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where = this.buildLogisticsBillWhere(query); const [rawItems, total, aggregates] = await Promise.all([ this.prisma.logisticsBill.findMany({ where, orderBy: { periodStart: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, include: { fulfillmentProvider: { select: { id: true, code: true, name: true, settlementMethod: true, prepaidBalance: true, bankAccountName: true, bankName: true, bankAccountNo: true, }, }, }, }), this.prisma.logisticsBill.count({ where }), this.prisma.logisticsBill.aggregate({ where, _sum: { logisticsAmount: true, bottleCount: true, orderCount: true }, _count: true, }), ]); const items = rawItems.map((b) => ({ ...b, providerCode: b.fulfillmentProvider.code, providerName: b.fulfillmentProvider.name, pricingSnapshot: this.fulfillmentProviderService.parsePricingRules(b.pricingSnapshotJson), })); const summary = { count: aggregates._count, logisticsAmount: Number(aggregates._sum.logisticsAmount ?? 0), bottleCount: Number(aggregates._sum.bottleCount ?? 0), totalAmount: Number(aggregates._sum.logisticsAmount ?? 0), }; return serializeBigInt({ items, total, page, pageSize, summary }); } async listLogisticsProviderSummary(query: { year?: number; month?: number }) { const year = query.year ?? new Date().getFullYear(); const month = query.month ?? new Date().getMonth() + 1; const periodStart = new Date(year, month - 1, 1); const periodEnd = new Date(year, month, 0, 23, 59, 59, 999); const providers = await this.prisma.fulfillmentProvider.findMany({ orderBy: { code: 'asc' }, }); const rows: Array<{ providerId: string; providerCode: string; providerName: string; settlementMethod: string; prepaidBalance: number; bankAccountName: string | null; bankName: string | null; bankAccountNo: string | null; pricingRules: ReturnType; orderCount: number; bottleCount: number; logisticsAmount: number; billId: string | null; billStatus: string | null; billAmount: number | null; }> = []; for (const p of providers) { const pricing = this.fulfillmentProviderService.parsePricingRules(p.pricingRulesJson) ?? (p.code.toUpperCase() === 'XFX' || p.code.toUpperCase() === 'XIAOFEIXIA' ? { ...DEFAULT_XFX_LOGISTICS_PRICING } : null); const deliveries = await this.prisma.orderDelivery.findMany({ where: { fulfillmentProviderId: p.id, OR: [ { shippingAt: { gte: periodStart, lte: periodEnd } }, { shippingAt: null, outWarehouseAt: { gte: periodStart, lte: periodEnd }, }, ], }, include: { order: { select: { quantity: true, payStatus: true, deliveryType: true } }, }, }); const eligible = deliveries.filter( (d) => d.order.payStatus === 'PAID' && (d.order.deliveryType === 'LOCAL' || d.order.deliveryType === 'CROSS_CITY'), ); const orderCount = eligible.length; const bottleCount = eligible.reduce((s, d) => s + d.order.quantity, 0); let logisticsAmount = 0; if (pricing) { logisticsAmount = round2( eligible.reduce( (s, d) => s + calcLogisticsFeeByBottles(d.order.quantity, pricing as LogisticsPricingRule), 0, ), ); } const bill = await this.prisma.logisticsBill.findUnique({ where: { fulfillmentProviderId_periodStart: { fulfillmentProviderId: p.id, periodStart, }, }, }); rows.push({ providerId: p.id.toString(), providerCode: p.code, providerName: p.name, settlementMethod: p.settlementMethod, prepaidBalance: Number(p.prepaidBalance), bankAccountName: p.bankAccountName, bankName: p.bankName, bankAccountNo: p.bankAccountNo, pricingRules: pricing, orderCount, bottleCount, logisticsAmount, billId: bill?.id?.toString() ?? null, billStatus: bill?.status ?? null, billAmount: bill ? Number(bill.logisticsAmount) : null, }); } return { year, month, items: rows }; } async getAdminLogisticsBill(id: bigint) { const bill = await this.prisma.logisticsBill.findUnique({ where: { id }, include: { fulfillmentProvider: true, items: { orderBy: { shippedAt: 'desc' } }, }, }); if (!bill) throw new NotFoundException('物流对账单不存在'); return serializeBigInt({ ...bill, providerCode: bill.fulfillmentProvider.code, providerName: bill.fulfillmentProvider.name, pricingSnapshot: this.fulfillmentProviderService.parsePricingRules(bill.pricingSnapshotJson), }); } async confirmLogisticsBill(id: bigint) { const bill = await this.prisma.logisticsBill.findUnique({ where: { id } }); if (!bill) throw new NotFoundException('物流对账单不存在'); if (bill.status !== 'UNPAID') throw new BadRequestException('仅未结算账单可确认'); if (bill.settlementMethod === 'PREPAID') { const deducted = await this.prisma.$transaction(async (tx) => { const result = await this.fulfillmentProviderService.deductPrepaid( bill.fulfillmentProviderId, Number(bill.logisticsAmount), bill.id, tx, ); if (!result.ok) { throw new BadRequestException( `充值余额不足(当前 ¥${result.balance.toFixed(2)},应付 ¥${Number(bill.logisticsAmount).toFixed(2)})`, ); } return tx.logisticsBill.update({ where: { id }, data: { status: 'PAID', paidAt: new Date() }, }); }); return serializeBigInt(deducted); } const updated = await this.prisma.logisticsBill.update({ where: { id }, data: { status: 'PAID', paidAt: new Date() }, }); return serializeBigInt(updated); } async batchConfirmLogisticsBills(ids: string[]) { const results: Array<{ id: string; ok: boolean; message?: string }> = []; for (const id of ids) { try { await this.confirmLogisticsBill(BigInt(id)); results.push({ id, ok: true }); } catch (e) { results.push({ id, ok: false, message: e instanceof Error ? e.message : '失败' }); } } return results; } async exportAdminLogisticsBills(query: { status?: string; providerId?: string; year?: number; month?: number; }) { const where = this.buildLogisticsBillWhere(query); const bills = await this.prisma.logisticsBill.findMany({ where, include: { fulfillmentProvider: { select: { code: true, name: true } }, items: true, }, orderBy: { periodStart: 'desc' }, }); const header = [ '账单号', '承运商编码', '承运商名称', '账期起', '账期止', '结算方式', '订单号', '瓶数', '物流费', '发货时间', '账单状态', ].join(','); const rows: string[] = []; for (const b of bills) { if (b.items.length === 0) { rows.push( [ csvEscape(b.billNo), csvEscape(b.fulfillmentProvider.code), csvEscape(b.fulfillmentProvider.name), b.periodStart.toISOString().slice(0, 10), b.periodEnd.toISOString().slice(0, 10), b.settlementMethod, '', b.bottleCount, Number(b.logisticsAmount), '', b.status, ].join(','), ); continue; } for (const item of b.items) { rows.push( [ csvEscape(b.billNo), csvEscape(b.fulfillmentProvider.code), csvEscape(b.fulfillmentProvider.name), b.periodStart.toISOString().slice(0, 10), b.periodEnd.toISOString().slice(0, 10), b.settlementMethod, csvEscape(item.orderNo), item.quantity, Number(item.logisticsAmount), item.shippedAt.toISOString().slice(0, 19).replace('T', ' '), b.status, ].join(','), ); } } return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: rows.length }; } private buildLogisticsBillWhere(query: { status?: string; providerId?: string; year?: number; month?: number; }): Prisma.LogisticsBillWhereInput { const where: Prisma.LogisticsBillWhereInput = {}; if (query.status === 'UNPAID' || query.status === 'PAID') where.status = query.status; if (query.providerId) where.fulfillmentProviderId = BigInt(query.providerId); if (query.year && query.month) { const periodStart = new Date(query.year, query.month - 1, 1); const periodEnd = new Date(query.year, query.month, 0, 23, 59, 59, 999); where.periodStart = { gte: periodStart, lte: periodEnd }; } return where; } }