import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { resolveMaxPartnerCommissionRate, validatePartnerCommissionRates } from '@dukang/domain'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { PartnerCityService } from '../city-scope/partner-city.service'; import { HqPermissionsResolver } from '../../common/guards/hq-permission.guard'; import type { AdminCitiesQueryDto } from './dto/admin-query.dto'; import type { CreateCityDto, UpdateCityDto } from './dto/admin-mutate.dto'; @Injectable() export class AdminCitiesService { constructor( private readonly prisma: PrismaService, private readonly partnerCityService: PartnerCityService, private readonly hqPermissions: HqPermissionsResolver, ) {} async list(query: AdminCitiesQueryDto, actorId?: bigint) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where: Prisma.CommonCityWhereInput = {}; if (query.name) where.name = { contains: query.name }; if (query.code) where.code = { contains: query.code }; if (query.status) where.status = query.status as Prisma.EnumCityStatusFilter['equals']; if (query.partnerId) { where.partnerAccounts = { some: { id: BigInt(query.partnerId), isPrimary: 1 } }; } if (actorId) { const scope = await this.hqPermissions.resolveCityScope(actorId); if (scope !== null) { where.id = { in: scope.length ? scope : [BigInt(0)] }; } } const [items, total] = await Promise.all([ this.prisma.commonCity.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, include: { partnerAccounts: { where: { isPrimary: 1 }, select: { id: true, companyName: true, scopeType: true, bindingStatus: true }, orderBy: { createdAt: 'asc' }, }, _count: { select: { stores: true, orders: true, partnerAccounts: true, warehouses: true } }, }, }), this.prisma.commonCity.count({ where }), ]); return serializeBigInt({ items: items.map((c) => ({ ...c, partnerBindings: c.partnerAccounts.map((bp) => ({ id: bp.id.toString(), partnerAccountId: bp.id.toString(), partnerCompanyName: bp.companyName, scopeType: bp.scopeType, status: bp.bindingStatus, })), partnerAccounts: undefined, storeCount: c._count.stores, orderCount: c._count.orders, partnerBindingCount: c.partnerAccounts.length, warehouseCount: c._count.warehouses, _count: undefined, })), total, page, pageSize, }); } async detail(id: bigint) { const city = await this.prisma.commonCity.findUnique({ where: { id }, include: { warehouses: { include: { partnerAccount: { select: { id: true, companyName: true } } }, orderBy: { createdAt: 'desc' }, }, _count: { select: { stores: true, orders: true } }, }, }); if (!city) throw new NotFoundException('开城城市不存在'); const cityPartners = await this.partnerCityService.listByCity(id); return serializeBigInt({ ...city, // Decimal 经 JSON 会变成字符串,显式 Number 避免前端 `"0.05" + 1e-9` 字符串拼接误判 maxPartnerCommissionRate: city.maxPartnerCommissionRate != null ? Number(city.maxPartnerCommissionRate) : null, cityPartners, storeCount: city._count.stores, orderCount: city._count.orders, _count: undefined, }); } async create(dto: CreateCityDto) { const exists = await this.prisma.commonCity.findUnique({ where: { code: dto.code } }); if (exists) throw new BadRequestException('城市编码已存在'); const city = await this.prisma.commonCity.create({ data: { code: dto.code, name: dto.name, province: dto.province, status: (dto.status ?? 'PENDING') as 'PENDING' | 'ACTIVE' | 'PAUSED', }, }); return serializeBigInt(city); } async update(id: bigint, dto: UpdateCityDto) { if (dto.maxPartnerCommissionRate !== undefined) { const maxRate = resolveMaxPartnerCommissionRate(dto.maxPartnerCommissionRate); const partners = await this.prisma.partnerAccount.findMany({ where: { cityId: id, isPrimary: 1 }, select: { companyName: true, orderCommissionRate: true, redeemCommissionRate: true, }, }); for (const partner of partners) { const check = validatePartnerCommissionRates( Number(partner.orderCommissionRate ?? 0), Number(partner.redeemCommissionRate ?? 0.03), maxRate, ); if (!check.ok) { throw new BadRequestException( `无法保存:合伙人「${partner.companyName ?? '—'}」${check.message}`, ); } } } const city = await this.prisma.commonCity.update({ where: { id }, data: { ...(dto.name !== undefined ? { name: dto.name } : {}), ...(dto.province !== undefined ? { province: dto.province } : {}), ...(dto.status !== undefined ? { status: dto.status as 'PENDING' | 'ACTIVE' | 'PAUSED' } : {}), ...(dto.localMinQty !== undefined ? { localMinQty: dto.localMinQty } : {}), ...(dto.crossMinQty !== undefined ? { crossMinQty: dto.crossMinQty } : {}), ...(dto.maxPartnerCommissionRate !== undefined ? { maxPartnerCommissionRate: dto.maxPartnerCommissionRate } : {}), }, }); return serializeBigInt(city); } /** 删除前预览:列出城市下合伙人(含子账号)与门店,以及不可删阻断项 */ async deletePreview(id: bigint) { const city = await this.prisma.commonCity.findUnique({ where: { id }, select: { id: true, code: true, name: true, province: true, status: true }, }); if (!city) throw new NotFoundException('开城城市不存在'); const [primaries, staff, stores, warehouses, orderCount] = await Promise.all([ this.prisma.partnerAccount.findMany({ where: { cityId: id, isPrimary: 1 }, select: { id: true, phone: true, name: true, companyName: true, status: true, bindingStatus: true, scopeType: true, }, orderBy: { createdAt: 'asc' }, }), this.prisma.partnerAccount.findMany({ where: { cityId: id, isPrimary: 0 }, select: { id: true, phone: true, name: true, companyName: true, status: true, parentAccountId: true, staffRole: true, }, orderBy: { createdAt: 'asc' }, }), this.prisma.store.findMany({ where: { cityId: id }, select: { id: true, name: true, phone: true, status: true, auditStatus: true, address: true, partnerAccountId: true, partnerAccount: { select: { companyName: true, phone: true } }, }, orderBy: { createdAt: 'desc' }, }), this.prisma.cityWarehouse.findMany({ where: { cityId: id }, select: { id: true, name: true, status: true, address: true }, orderBy: { createdAt: 'desc' }, }), this.prisma.order.count({ where: { cityId: id } }), ]); const storeIds = stores.map((s) => s.id); const partnerIds = [...primaries, ...staff].map((p) => p.id); const [redeemCount, partnerBillCount] = await Promise.all([ storeIds.length ? this.prisma.redeemRecord.count({ where: { storeId: { in: storeIds } } }) : Promise.resolve(0), partnerIds.length ? this.prisma.partnerBill.count({ where: { partnerAccountId: { in: partnerIds } } }) : Promise.resolve(0), ]); const warnings: string[] = []; const blockers: string[] = []; if (orderCount > 0) blockers.push(`该城市下已有 ${orderCount} 笔订单,无法删除`); if (redeemCount > 0) warnings.push(`门店核销记录 ${redeemCount} 笔将删除,并回滚对应权益券余额`); if (partnerBillCount > 0) warnings.push(`合伙人账单 ${partnerBillCount} 条将一并删除`); if (stores.length) warnings.push(`将删除 ${stores.length} 家门店及其门店账号绑定`); if (primaries.length || staff.length) { warnings.push(`将删除 ${primaries.length} 个合伙人主账号、${staff.length} 个子账号`); } if (warehouses.length) warnings.push(`将删除 ${warehouses.length} 个城市仓库`); return serializeBigInt({ city, canDelete: blockers.length === 0, blockers, warnings, summary: { primaryPartnerCount: primaries.length, staffCount: staff.length, storeCount: stores.length, warehouseCount: warehouses.length, orderCount, redeemCount, partnerBillCount, }, partners: primaries.map((p) => ({ ...p, staff: staff.filter((s) => s.parentAccountId === p.id), })), orphanStaff: staff.filter( (s) => !s.parentAccountId || !primaries.some((p) => p.id === s.parentAccountId), ), stores, warehouses, }); } async deleteCity(id: bigint, confirmName: string) { const preview = await this.deletePreview(id); if (!preview.canDelete) { throw new BadRequestException(preview.blockers.join(';') || '当前城市不可删除'); } const expected = String(preview.city.name || '').trim(); if (!confirmName?.trim() || confirmName.trim() !== expected) { throw new BadRequestException(`请输入城市名称「${expected}」以确认删除`); } await this.prisma.$transaction(async (tx) => { const storeIds = (preview.stores as Array<{ id: string | number | bigint }>).map((s) => BigInt(s.id), ); const partnerIdSet = new Set(); for (const p of preview.partners as Array<{ id: string | number | bigint; staff?: Array<{ id: string | number | bigint }>; }>) { partnerIdSet.add(String(p.id)); for (const s of p.staff ?? []) partnerIdSet.add(String(s.id)); } for (const s of preview.orphanStaff as Array<{ id: string | number | bigint }>) { partnerIdSet.add(String(s.id)); } const uniquePartnerIds = [...partnerIdSet].map(BigInt); if (storeIds.length) { await this.purgeStoresInTx(tx, storeIds); } if (uniquePartnerIds.length) { await tx.partnerBill.deleteMany({ where: { partnerAccountId: { in: uniquePartnerIds } } }); await tx.$executeRaw` DELETE FROM log_partner_analytics WHERE partner_account_id IN (${Prisma.join(uniquePartnerIds)}) `; await tx.partnerAccount.updateMany({ where: { id: { in: uniquePartnerIds } }, data: { managedWarehouseId: null }, }); await tx.cityWarehouse.updateMany({ where: { cityId: id }, data: { partnerAccountId: null }, }); await tx.partnerAccount.deleteMany({ where: { id: { in: uniquePartnerIds }, isPrimary: 0 }, }); await tx.partnerAccount.deleteMany({ where: { id: { in: uniquePartnerIds }, isPrimary: 1 }, }); } await tx.cityWarehouse.deleteMany({ where: { cityId: id } }); await tx.commonCity.delete({ where: { id } }); }); return { ok: true, id: id.toString(), name: preview.city.name }; } /** 事务内清除门店及核销/结算/绑定(回滚权益券核销额) */ private async purgeStoresInTx(tx: Prisma.TransactionClient, storeIds: bigint[]) { const redeems = await tx.redeemRecord.findMany({ where: { storeId: { in: storeIds } }, include: { allocations: true }, }); const restoreMap = new Map(); for (const r of redeems) { if (r.allocations.length) { for (const a of r.allocations) { const key = a.couponId.toString(); const prev = restoreMap.get(key) ?? new Prisma.Decimal(0); restoreMap.set(key, prev.add(a.amount)); } } else { const key = r.couponId.toString(); const prev = restoreMap.get(key) ?? new Prisma.Decimal(0); restoreMap.set(key, prev.add(r.amount)); } } for (const [couponId, amount] of restoreMap) { const coupon = await tx.benefitCoupon.findUnique({ where: { id: BigInt(couponId) } }); if (!coupon) continue; const used = new Prisma.Decimal(coupon.usedAmount).sub(amount); const balance = new Prisma.Decimal(coupon.balance).add(amount); const nextUsed = used.lt(0) ? new Prisma.Decimal(0) : used; const nextBalance = Prisma.Decimal.min(balance, coupon.totalAmount); await tx.benefitCoupon.update({ where: { id: BigInt(couponId) }, data: { usedAmount: nextUsed, balance: nextBalance, status: nextBalance.gt(0) ? 'ACTIVE' : coupon.status, version: { increment: 1 }, }, }); } const redeemIds = redeems.map((r) => r.id); await tx.storePayout.deleteMany({ where: { storeId: { in: storeIds } } }); await tx.storeRating.deleteMany({ where: { storeId: { in: storeIds } } }); await tx.redeemPendingRecord.deleteMany({ where: { storeId: { in: storeIds } } }); if (redeemIds.length) { await tx.redeemRecordAllocation.deleteMany({ where: { redeemRecordId: { in: redeemIds } } }); await tx.redeemRecord.deleteMany({ where: { id: { in: redeemIds } } }); } await tx.storeBill.deleteMany({ where: { storeId: { in: storeIds } } }); await tx.$executeRaw`DELETE FROM log_store_analytics WHERE store_id IN (${Prisma.join(storeIds)})`; const bindings = await tx.storeAccountStore.findMany({ where: { storeId: { in: storeIds } }, select: { storeAccountId: true }, }); const accountIds = [...new Set(bindings.map((b) => b.storeAccountId.toString()))].map(BigInt); await tx.storeAccountStore.deleteMany({ where: { storeId: { in: storeIds } } }); const orphanAccountIds: bigint[] = []; for (const aid of accountIds) { const other = await tx.storeAccountStore.count({ where: { storeAccountId: aid, storeId: { notIn: storeIds } }, }); if (other === 0) orphanAccountIds.push(aid); } if (orphanAccountIds.length) { await tx.storeAccount.deleteMany({ where: { parentAccountId: { in: orphanAccountIds } } }); await tx.storeAccount.deleteMany({ where: { id: { in: orphanAccountIds } } }); } await tx.store.updateMany({ where: { id: { in: storeIds } }, data: { coverResourceId: null } }); await tx.store.deleteMany({ where: { id: { in: storeIds } } }); } }