import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; 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)}`; } @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; }) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where: Prisma.StorePayoutWhereInput = {}; if (query.status) where.status = query.status as Prisma.EnumStorePayoutStatusFilter['equals']; if (query.storeId) where.storeId = BigInt(query.storeId); const [items, total] = await Promise.all([ this.prisma.storePayout.findMany({ where, orderBy: { 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 } }, }, }), this.prisma.storePayout.count({ where }), ]); return serializeBigInt({ items, total, page, pageSize }); } 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; }) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where: Prisma.PartnerBillWhereInput = {}; if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals']; if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId); const [items, total] = await Promise.all([ this.prisma.partnerBill.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, include: { partnerAccount: { select: { companyName: true } } }, }), this.prisma.partnerBill.count({ where }), ]); return serializeBigInt({ items, total, page, pageSize }); } 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') throw new BadRequestException('仅草稿可确认'); const updated = await this.prisma.partnerBill.update({ where: { id }, data: { status: 'CONFIRMED', confirmedAt: new Date() }, }); 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() }, }); return serializeBigInt(updated); } async exportPartnerBills(query: { partnerId?: string; status?: string }) { const where: Prisma.PartnerBillWhereInput = {}; if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId); if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals']; const bills = await this.prisma.partnerBill.findMany({ where, include: { partnerAccount: { select: { companyName: true } } }, orderBy: { createdAt: 'desc' }, }); const header = 'billNo,partner,periodStart,periodEnd,orderCommission,redeemCommission,totalAmount,status'; const rows = bills.map((b) => [ b.billNo, b.partnerAccount.companyName, b.periodStart.toISOString().slice(0, 10), b.periodEnd.toISOString().slice(0, 10), Number(b.orderCommission), Number(b.redeemCommission), Number(b.totalAmount), b.status, ].join(','), ); return { csv: [header, ...rows].join('\n'), count: bills.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 }); } }