import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { WINERY_SETTLEMENT_RATE } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { AnalyticsService } from '../analytics/analytics.service'; import { PartnerCityService } from '../city-scope/partner-city.service'; function generateBillNo() { return `PB${Date.now()}${Math.floor(Math.random() * 900 + 100)}`; } function csvEscape(value: string) { if (/[",\n\r]/.test(value)) return `"${value.replace(/"/g, '""')}"`; return value; } @Injectable() export class SettlementService { constructor( private readonly prisma: PrismaService, private readonly analyticsService: AnalyticsService, private readonly partnerCityService: PartnerCityService, ) {} 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 }); } 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 }); } /** 门店账单按日汇总(T+1:按核销日归集) */ async listAdminStoreDailyBills(query: { page?: number; pageSize?: number; status?: string; storeId?: string; dateFrom?: string; dateTo?: string; }) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 50; const where = this.buildStorePayoutWhere(query); const payouts = await this.prisma.storePayout.findMany({ where, include: { store: { select: { id: true, name: true, cityName: true, phone: true } }, redeemRecord: { select: { createdAt: true } }, }, orderBy: { createdAt: 'desc' }, }); type DailyAgg = { billDate: string; storeId: string; storeName: string; storePhone?: string; cityName?: string; redeemCount: number; redeemAmount: number; settlementRate: number; payoutAmount: number; pendingCount: number; paidCount: number; }; const map = new Map(); for (const p of payouts) { const billDate = (p.redeemRecord?.createdAt ?? p.createdAt).toISOString().slice(0, 10); const storeId = p.storeId.toString(); const key = `${billDate}:${storeId}`; const existing = map.get(key); if (!existing) { map.set(key, { billDate, storeId, storeName: p.store?.name ?? '—', storePhone: p.store?.phone ?? undefined, cityName: p.store?.cityName ?? undefined, redeemCount: 1, redeemAmount: Number(p.redeemAmount), settlementRate: Number(p.settlementRate), payoutAmount: Number(p.payoutAmount), pendingCount: p.status === 'PENDING' ? 1 : 0, paidCount: p.status === 'PAID' ? 1 : 0, }); } else { existing.redeemCount += 1; existing.redeemAmount += Number(p.redeemAmount); existing.payoutAmount += Number(p.payoutAmount); if (p.status === 'PENDING') existing.pendingCount += 1; if (p.status === 'PAID') existing.paidCount += 1; } } const all = Array.from(map.values()).sort((a, b) => { if (a.billDate === b.billDate) return a.storeName.localeCompare(b.storeName, 'zh-CN'); return b.billDate.localeCompare(a.billDate); }); const summary = { count: all.length, redeemAmount: Math.round(all.reduce((s, r) => s + r.redeemAmount, 0) * 100) / 100, payoutAmount: Math.round(all.reduce((s, r) => s + r.payoutAmount, 0) * 100) / 100, totalAmount: Math.round(all.reduce((s, r) => s + r.payoutAmount, 0) * 100) / 100, }; const start = (page - 1) * pageSize; const items = all.slice(start, start + pageSize).map((row) => ({ ...row, redeemAmount: Math.round(row.redeemAmount * 100) / 100, payoutAmount: Math.round(row.payoutAmount * 100) / 100, })); return { items, total: all.length, page, pageSize, summary }; } async exportAdminStorePayouts(query: { status?: string; storeId?: string; dateFrom?: string; dateTo?: string; }) { const where = this.buildStorePayoutWhere(query); const payouts = await this.prisma.storePayout.findMany({ where, orderBy: { createdAt: 'desc' }, include: { store: { select: { name: true, phone: true, cityName: true } }, redeemRecord: { select: { redeemNo: true, createdAt: true } }, }, }); const header = [ '核销日期', '门店', '登录手机', '城市', '核销单号', '核销面额', '结算比例', '应付金额', '状态', '预计打款', '实际打款', ].join(','); const rows = payouts.map((p) => [ (p.redeemRecord?.createdAt ?? p.createdAt).toISOString().slice(0, 10), csvEscape(p.store?.name ?? ''), csvEscape(p.store?.phone ?? ''), csvEscape(p.store?.cityName ?? ''), csvEscape(p.redeemRecord?.redeemNo ?? ''), Number(p.redeemAmount), `${Math.round(Number(p.settlementRate) * 100)}%`, Number(p.payoutAmount), p.status, p.expectedPayAt.toISOString().slice(0, 10), p.paidAt ? p.paidAt.toISOString().slice(0, 10) : '', ].join(','), ); return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: payouts.length }; } private buildStorePayoutWhere(query: { status?: string; storeId?: string; dateFrom?: string; dateTo?: string; }): Prisma.StorePayoutWhereInput { const where: Prisma.StorePayoutWhereInput = {}; if (query.status) where.status = query.status as Prisma.EnumStorePayoutStatusFilter['equals']; if (query.storeId) where.storeId = BigInt(query.storeId); if (query.dateFrom || query.dateTo) { const createdAt: Prisma.DateTimeFilter = {}; if (query.dateFrom) createdAt.gte = new Date(`${query.dateFrom}T00:00:00.000`); if (query.dateTo) createdAt.lte = new Date(`${query.dateTo}T23:59:59.999`); where.createdAt = createdAt; } return where; } async getAdminStorePayout(id: bigint) { const payout = await this.prisma.storePayout.findUnique({ where: { id }, include: { store: true, redeemRecord: { include: { user: { select: { userNo: true, phone: 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 === 'PAID') throw new BadRequestException('已打款'); const updated = await this.prisma.storePayout.update({ where: { id }, data: { status: 'PAID', paidAt: new Date(), batchNo: dto.batchNo ?? payout.batchNo, }, }); 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 listPartnerBills(partnerAccountId: bigint) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId); const bills = await this.prisma.partnerBill.findMany({ where: { partnerAccountId: primary.id }, orderBy: { createdAt: 'desc' }, }); this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', { partnerAccountId: primary.id, eventName: 'partner_bill_view', extraJson: { count: bills.length }, }); return serializeBigInt(bills); } 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, }; } 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.findFirst({ where: { partnerAccountId: primary.id, periodStart, status: { not: 'DRAFT' }, }, }); if (existing && existing.status !== 'DRAFT') { 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 }, }, }); const orderCommission = 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 = await this.prisma.redeemRecord.findMany({ where: { storeId: { in: storeIds }, createdAt: { gte: periodStart, lte: periodEnd }, }, }); const redeemCommission = redeems.reduce( (sum, r) => sum + Number(r.amount) * redeemCommissionRate, 0, ); const totalAmount = Math.round((orderCommission + redeemCommission) * 100) / 100; const draft = await this.prisma.partnerBill.findFirst({ where: { partnerAccountId: primary.id, periodStart, status: 'DRAFT' }, }); const bill = draft ? await this.prisma.partnerBill.update({ where: { id: draft.id }, data: { orderCommission, redeemCommission, totalAmount, periodEnd }, }) : await this.prisma.partnerBill.create({ data: { billNo: generateBillNo(), partnerAccountId: primary.id, periodStart, periodEnd, orderCommission, redeemCommission, totalAmount, status: 'DRAFT', }, }); return serializeBigInt(bill); } async confirmPartnerBill(id: bigint) { const bill = await this.prisma.partnerBill.findUnique({ where: { id } }); if (!bill) throw new NotFoundException('账单不存在'); if (bill.status !== 'DRAFT' && bill.status !== 'REJECTED') { throw new BadRequestException('仅待确认或已驳回账单可确认'); } const updated = await this.prisma.partnerBill.update({ where: { id }, data: { status: 'CONFIRMED', confirmedAt: new Date(), rejectReason: null, }, }); return serializeBigInt(updated); } /** 合伙人端:确认账单并申请打款(DRAFT/REJECTED → CONFIRMED) */ 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 !== 'DRAFT' && bill.status !== 'REJECTED') { throw new BadRequestException('当前账单不可再次申请打款'); } const updated = await this.prisma.partnerBill.update({ where: { id: billId }, data: { status: 'CONFIRMED', 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 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 !== 'CONFIRMED') { 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 !== 'CONFIRMED') throw new BadRequestException('仅待打款审核的账单可标记打款'); const updated = await this.prisma.partnerBill.update({ where: { id }, data: { status: 'PAID', paidAt: new Date(), rejectReason: null }, }); return serializeBigInt(updated); } 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.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 }; } /** 酒厂账单:T+30,按自然月列出已付酒单,应付 = 实付 × WINERY_SETTLEMENT_RATE */ async listAdminWineryBills(query: { page?: number; pageSize?: number; year?: number; month?: number; deliveryType?: string; }) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where = this.buildWineryOrderWhere(query); const [orders, total, aggregates] = await Promise.all([ this.prisma.order.findMany({ where, orderBy: { paidAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, include: { city: { select: { name: true } } }, }), this.prisma.order.count({ where }), this.prisma.order.aggregate({ where, _sum: { payAmount: true }, _count: true, }), ]); const rate = WINERY_SETTLEMENT_RATE; const items = orders.map((o) => { const payAmount = Number(o.payAmount); const wineryAmount = Math.round(payAmount * rate * 100) / 100; return { orderId: o.id.toString(), orderNo: o.orderNo, deliveryType: o.deliveryType, cityName: o.city?.name, payAmount, wineryRate: rate, wineryAmount, paidAt: o.paidAt?.toISOString() ?? '', receiverCity: o.receiverCity, productName: o.productName, quantity: o.quantity, }; }); const orderAmount = Number(aggregates._sum.payAmount ?? 0); const summary = { count: aggregates._count, orderAmount, wineryAmount: Math.round(orderAmount * rate * 100) / 100, totalAmount: Math.round(orderAmount * rate * 100) / 100, }; return { items, total, page, pageSize, summary }; } async exportAdminWineryBills(query: { year?: number; month?: number; deliveryType?: string; }) { const where = this.buildWineryOrderWhere(query); const orders = await this.prisma.order.findMany({ where, orderBy: { paidAt: 'desc' }, include: { city: { select: { name: true } } }, }); const rate = WINERY_SETTLEMENT_RATE; const header = [ '订单号', '配送类型', '开城城市', '收货城市', '商品', '数量', '实付金额', '酒厂比例', '酒厂应付', '支付时间', ].join(','); const rows = orders.map((o) => { const payAmount = Number(o.payAmount); return [ csvEscape(o.orderNo), o.deliveryType === 'LOCAL' ? '同城' : o.deliveryType === 'CROSS_CITY' ? '跨城' : o.deliveryType, csvEscape(o.city?.name ?? ''), csvEscape(o.receiverCity ?? ''), csvEscape(o.productName ?? ''), o.quantity, payAmount, `${Math.round(rate * 100)}%`, Math.round(payAmount * rate * 100) / 100, o.paidAt ? o.paidAt.toISOString().slice(0, 19).replace('T', ' ') : '', ].join(','); }); return { csv: `\uFEFF${[header, ...rows].join('\n')}`, count: orders.length }; } private buildWineryOrderWhere(query: { year?: number; month?: number; deliveryType?: string; }): Prisma.OrderWhereInput { const where: Prisma.OrderWhereInput = { payStatus: 'PAID', deliveryType: { in: ['LOCAL', 'CROSS_CITY'] }, }; if (query.deliveryType === 'LOCAL' || query.deliveryType === 'CROSS_CITY') { where.deliveryType = query.deliveryType; } 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.paidAt = { gte: start, lte: end }; } else { where.paidAt = { not: null }; } return where; } 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 }); } }