import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma, CityPartnerScopeType, CityPartnerStatus } 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 type { AdminPartnerAccountsQueryDto, AdminPartnersQueryDto } from './dto/admin-query.dto'; import type { CreatePartnerAccountDto, CreatePartnerDto, UpdatePartnerAccountDto, UpdatePartnerDto, } from './dto/admin-mutate.dto'; const PRIMARY_WHERE = { isPrimary: 1 } as const; function assertPartnerCommissionRates( city: { maxPartnerCommissionRate: Prisma.Decimal | number | null }, orderCommissionRate: number, redeemCommissionRate: number, ) { const maxRate = resolveMaxPartnerCommissionRate( city.maxPartnerCommissionRate != null ? Number(city.maxPartnerCommissionRate) : null, ); const check = validatePartnerCommissionRates(orderCommissionRate, redeemCommissionRate, maxRate); if (!check.ok) throw new BadRequestException(check.message); } @Injectable() export class AdminPartnersService { constructor( private readonly prisma: PrismaService, private readonly partnerCityService: PartnerCityService, ) {} async listPartners(query: AdminPartnersQueryDto) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where: Prisma.PartnerAccountWhereInput = { ...PRIMARY_WHERE }; if (query.companyName) where.companyName = { contains: query.companyName }; if (query.contactPhone) where.contactPhone = { contains: query.contactPhone }; if (query.phone) where.phone = { contains: query.phone }; if (query.cityId) where.cityId = BigInt(query.cityId); if (query.partnerId) where.id = BigInt(query.partnerId); if (query.excludeTest) where.isTest = false; const [items, total] = await Promise.all([ this.prisma.partnerAccount.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, include: { city: { select: { id: true, code: true, name: true, status: true, maxPartnerCommissionRate: true } }, managedWarehouse: { select: { id: true, name: true } }, children: { select: { id: true, phone: true, name: true, staffRole: true, permissions: true, status: true, }, orderBy: { createdAt: 'asc' }, }, _count: { select: { stores: true, children: true } }, }, }), this.prisma.partnerAccount.count({ where }), ]); return serializeBigInt({ items: items.map((p) => ({ id: p.id.toString(), companyName: p.companyName, contactPhone: p.contactPhone, phone: p.phone, name: p.name, cityId: p.cityId?.toString() ?? null, cityName: p.city?.name ?? null, maxPartnerCommissionRate: p.city?.maxPartnerCommissionRate != null ? Number(p.city.maxPartnerCommissionRate) : null, scopeType: p.scopeType, districtCodes: this.partnerCityService.parseDistrictCodes(p.districtCodes), orderCommissionRate: Number(p.orderCommissionRate ?? 0), redeemCommissionRate: Number(p.redeemCommissionRate ?? 0.03), bindingStatus: p.bindingStatus, managedWarehouseId: p.managedWarehouseId?.toString() ?? null, managedWarehouseName: p.managedWarehouse?.name ?? null, storeCount: p._count.stores, accountCount: p._count.children + 1, children: p.children.map((c) => ({ id: c.id.toString(), phone: c.phone, name: c.name, staffRole: c.staffRole, permissions: c.permissions, status: c.status, })), createdAt: p.createdAt, })), total, page, pageSize, }); } async detailPartner(id: bigint) { const account = await this.prisma.partnerAccount.findFirst({ where: { id, ...PRIMARY_WHERE }, include: { city: { select: { id: true, code: true, name: true, status: true, maxPartnerCommissionRate: true } }, managedWarehouse: { select: { id: true, name: true } }, children: { where: { isPrimary: 0 }, select: { id: true, phone: true, name: true, staffRole: true, permissions: true, status: true, createdAt: true, }, orderBy: { createdAt: 'asc' }, }, stores: { select: { id: true, name: true, status: true }, take: 10, orderBy: { createdAt: 'desc' } }, _count: { select: { stores: true, children: true } }, }, }); if (!account) throw new NotFoundException('开城合伙人不存在'); return serializeBigInt({ ...this.partnerCityService.toDto({ ...account, city: account.city, }), contactPhone: account.contactPhone, address: account.address, bankAccountName: account.bankAccountName, bankAccountNo: account.bankAccountNo, bankBranch: account.bankBranch, managedWarehouseName: account.managedWarehouse?.name ?? null, accountCount: account._count.children + 1, maxPartnerCommissionRate: account.city?.maxPartnerCommissionRate != null ? Number(account.city.maxPartnerCommissionRate) : null, children: account.children.map((c) => ({ id: c.id.toString(), phone: c.phone, name: c.name, staffRole: c.staffRole, permissions: c.permissions, status: c.status, createdAt: c.createdAt, })), }); } async createPartner(dto: CreatePartnerDto) { const phone = dto.phone.trim(); if (!/^1[3-9]\d{9}$/.test(phone)) { throw new BadRequestException('请输入正确的登录手机号'); } const phoneTaken = await this.prisma.partnerAccount.findUnique({ where: { phone } }); if (phoneTaken) throw new BadRequestException('该手机号已被使用'); const cityId = BigInt(dto.cityId); const city = await this.prisma.commonCity.findUnique({ where: { id: cityId } }); if (!city) throw new NotFoundException('开城城市不存在'); await this.partnerCityService.validatePrimaryBinding(cityId, { scopeType: dto.scopeType as CityPartnerScopeType, districtCodes: dto.districtCodes, }); const orderCommissionRate = dto.orderCommissionRate ?? 0; const redeemCommissionRate = dto.redeemCommissionRate ?? 0.03; assertPartnerCommissionRates(city, orderCommissionRate, redeemCommissionRate); const account = await this.prisma.partnerAccount.create({ data: { phone, name: dto.name.trim(), isPrimary: 1, staffRole: 'PARTNER', status: 'ACTIVE', cityId, scopeType: dto.scopeType as CityPartnerScopeType, districtCodes: dto.scopeType === 'DISTRICT' ? (dto.districtCodes ?? []) : Prisma.JsonNull, orderCommissionRate: orderCommissionRate, redeemCommissionRate: redeemCommissionRate, bindingStatus: (dto.bindingStatus ?? 'ACTIVE') as CityPartnerStatus, companyName: dto.companyName?.trim() || null, address: dto.address?.trim() || null, contactPhone: dto.contactPhone?.trim() ?? phone, contractNo: dto.contractNo, bankAccountName: dto.bankAccountName, bankAccountNo: dto.bankAccountNo, bankBranch: dto.bankBranch, weeklyStoreTarget: dto.weeklyStoreTarget ?? 20, }, include: { city: { select: { id: true, code: true, name: true } } }, }); return serializeBigInt(this.partnerCityService.toDto(account)); } async updatePartner(id: bigint, dto: UpdatePartnerDto) { const existing = await this.prisma.partnerAccount.findFirst({ where: { id, ...PRIMARY_WHERE }, }); if (!existing) throw new NotFoundException('开城合伙人不存在'); const city = await this.prisma.commonCity.findUniqueOrThrow({ where: { id: existing.cityId! } }); const cityId = existing.cityId!; const scopeType = (dto.scopeType ?? existing.scopeType) as CityPartnerScopeType; const districtCodes = scopeType === 'DISTRICT' ? dto.districtCodes ?? this.partnerCityService.parseDistrictCodes(existing.districtCodes) : null; await this.partnerCityService.validatePrimaryBinding( cityId, { partnerAccountId: id.toString(), scopeType, districtCodes: districtCodes ?? undefined, }, id, ); if (dto.phone !== undefined) { const phone = dto.phone.trim(); if (!/^1[3-9]\d{9}$/.test(phone)) { throw new BadRequestException('请输入正确的登录手机号'); } const phoneTaken = await this.prisma.partnerAccount.findUnique({ where: { phone } }); if (phoneTaken && phoneTaken.id !== id) { throw new BadRequestException('该手机号已被使用'); } } const orderCommissionRate = dto.orderCommissionRate !== undefined ? dto.orderCommissionRate : Number(existing.orderCommissionRate ?? 0); const redeemCommissionRate = dto.redeemCommissionRate !== undefined ? dto.redeemCommissionRate : Number(existing.redeemCommissionRate ?? 0.03); if (dto.orderCommissionRate !== undefined || dto.redeemCommissionRate !== undefined) { assertPartnerCommissionRates(city, orderCommissionRate, redeemCommissionRate); } const phoneChanged = dto.phone !== undefined && dto.phone.trim() !== existing.phone; const account = await this.prisma.partnerAccount.update({ where: { id }, data: { ...(dto.name !== undefined ? { name: dto.name.trim() } : {}), ...(dto.phone !== undefined ? { phone: dto.phone.trim() } : {}), ...(phoneChanged ? { wxOpenId: null, wxUnionId: null } : {}), ...(dto.companyName !== undefined ? { companyName: dto.companyName.trim() } : {}), ...(dto.address !== undefined ? { address: dto.address.trim() } : {}), ...(dto.contactPhone !== undefined ? { contactPhone: dto.contactPhone.trim() } : {}), ...(dto.scopeType !== undefined ? { scopeType: dto.scopeType as CityPartnerScopeType } : {}), ...(dto.scopeType !== undefined || dto.districtCodes !== undefined ? { districtCodes: scopeType === 'DISTRICT' ? ((districtCodes ?? []) as Prisma.InputJsonValue) : Prisma.JsonNull, } : {}), ...(dto.orderCommissionRate !== undefined ? { orderCommissionRate: dto.orderCommissionRate } : {}), ...(dto.redeemCommissionRate !== undefined ? { redeemCommissionRate: dto.redeemCommissionRate } : {}), ...(dto.bindingStatus !== undefined ? { bindingStatus: dto.bindingStatus as CityPartnerStatus } : {}), ...(dto.contractNo !== undefined ? { contractNo: dto.contractNo } : {}), ...(dto.bankAccountName !== undefined ? { bankAccountName: dto.bankAccountName } : {}), ...(dto.bankAccountNo !== undefined ? { bankAccountNo: dto.bankAccountNo } : {}), ...(dto.bankBranch !== undefined ? { bankBranch: dto.bankBranch } : {}), ...(dto.weeklyStoreTarget !== undefined ? { weeklyStoreTarget: dto.weeklyStoreTarget } : {}), ...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DISABLED' } : {}), }, include: { city: { select: { id: true, code: true, name: true } } }, }); return serializeBigInt(this.partnerCityService.toDto(account)); } async listPartnerAccounts(query: AdminPartnerAccountsQueryDto) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; const where: Prisma.PartnerAccountWhereInput = { isPrimary: 0 }; if (query.phone) where.phone = { contains: query.phone }; if (query.partnerId) where.parentAccountId = BigInt(query.partnerId); if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals']; const [items, total] = await Promise.all([ this.prisma.partnerAccount.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, include: { parent: { select: { id: true, name: true, phone: true, companyName: true } }, }, }), this.prisma.partnerAccount.count({ where }), ]); return serializeBigInt({ items, total, page, pageSize }); } async listPartnerAccountTree(primaryAccountId?: bigint) { const where: Prisma.PartnerAccountWhereInput = primaryAccountId ? { OR: [{ id: primaryAccountId }, { parentAccountId: primaryAccountId }] } : { isPrimary: 1 }; const accounts = await this.prisma.partnerAccount.findMany({ where, orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }], }); type TreeNode = (typeof accounts)[number] & { children: TreeNode[] }; const nodeMap = new Map(); const roots: TreeNode[] = []; for (const account of accounts) { nodeMap.set(account.id.toString(), { ...account, children: [] }); } for (const account of accounts) { const node = nodeMap.get(account.id.toString())!; if (account.parentAccountId) { const parent = nodeMap.get(account.parentAccountId.toString()); if (parent) parent.children.push(node); else roots.push(node); } else if (account.isPrimary === 1) { roots.push(node); } } const mapNode = (node: TreeNode) => ({ id: node.id, phone: node.phone, name: node.name, status: node.status, isPrimary: node.isPrimary, staffRole: node.staffRole, permissions: node.permissions, parentAccountId: node.parentAccountId, companyName: node.companyName, createdAt: node.createdAt, lastLoginAt: node.lastLoginAt, children: node.children.length ? node.children.map(mapNode) : undefined, }); return serializeBigInt(roots.map(mapNode)); } async detailPartnerAccount(id: bigint) { const account = await this.prisma.partnerAccount.findUnique({ where: { id }, include: { parent: { select: { id: true, name: true, phone: true, companyName: true } }, }, }); if (!account) throw new NotFoundException('合伙人账号不存在'); const primary = await this.partnerCityService.resolvePrimaryAccount(id); const orderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id); const [bills, orders] = await Promise.all([ this.prisma.partnerBill.findMany({ where: { partnerAccountId: primary.id }, orderBy: { createdAt: 'desc' }, take: 50, }), this.prisma.order.findMany({ where: orderWhere, orderBy: { createdAt: 'desc' }, take: 50, select: { id: true, orderNo: true, status: true, payAmount: true, createdAt: true, user: { select: { userNo: true, phone: true } }, }, }), ]); return serializeBigInt({ ...account, primaryAccountId: primary.id, bills, orders }); } async createPartnerAccount(dto: CreatePartnerAccountDto) { if (!dto.parentAccountId) { throw new BadRequestException('请指定主账号 parentAccountId 创建子账号'); } const phone = dto.phone.trim(); if (!/^1[3-9]\d{9}$/.test(phone)) { throw new BadRequestException('请输入正确的手机号码'); } const phoneTaken = await this.prisma.partnerAccount.findUnique({ where: { phone } }); if (phoneTaken) throw new BadRequestException('该手机号已被使用'); const parent = await this.prisma.partnerAccount.findUnique({ where: { id: BigInt(dto.parentAccountId) }, }); if (!parent) throw new BadRequestException('主账号不存在'); if (parent.isPrimary !== 1) { throw new BadRequestException('仅可向主账号添加子账号,不支持多级子账号'); } const account = await this.prisma.partnerAccount.create({ data: { phone, name: dto.name.trim(), staffRole: (dto.staffRole ?? 'INTERNAL') as 'PARTNER' | 'INTERNAL' | 'PROMOTER', permissions: dto.permissions ?? undefined, isPrimary: 0, parentAccountId: parent.id, status: 'ACTIVE', }, }); return serializeBigInt(account); } async updatePartnerAccount(id: bigint, dto: UpdatePartnerAccountDto) { const existing = await this.prisma.partnerAccount.findUnique({ where: { id } }); if (!existing) throw new NotFoundException('合伙人账号不存在'); if (existing.isPrimary === 1) { throw new BadRequestException('请通过开城合伙人接口编辑主账号'); } const data: Prisma.PartnerAccountUpdateInput = {}; if (dto.name !== undefined) data.name = dto.name; if (dto.status !== undefined) data.status = dto.status as 'ACTIVE' | 'DISABLED'; if (dto.staffRole !== undefined) data.staffRole = dto.staffRole as 'PARTNER' | 'INTERNAL' | 'PROMOTER'; if (dto.permissions !== undefined) data.permissions = dto.permissions; if (dto.phone !== undefined) { const phone = dto.phone.trim(); if (!/^1[3-9]\d{9}$/.test(phone)) { throw new BadRequestException('请输入正确的手机号码'); } const phoneTaken = await this.prisma.partnerAccount.findUnique({ where: { phone } }); if (phoneTaken && phoneTaken.id !== id) { throw new BadRequestException('该手机号已被使用'); } data.phone = phone; if (phone !== existing.phone) { data.wxOpenId = null; data.wxUnionId = null; } } const account = await this.prisma.partnerAccount.update({ where: { id }, data }); return serializeBigInt(account); } async deletePartnerSubAccount(id: bigint) { const account = await this.prisma.partnerAccount.findUnique({ where: { id } }); if (!account) throw new NotFoundException('合伙人账号不存在'); if (!account.parentAccountId) { throw new BadRequestException('仅可删除子账号'); } await this.prisma.partnerAccount.delete({ where: { id } }); return { ok: true }; } }