城市合伙人端的修改(后台)

This commit is contained in:
2026-07-12 10:19:39 +08:00
parent d0f0fa09af
commit 7f031cc4c2
74 changed files with 5430 additions and 975 deletions
@@ -1,7 +1,9 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
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,
@@ -10,77 +12,36 @@ import type {
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) {}
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.PartnerWhereInput = {};
const where: Prisma.PartnerAccountWhereInput = { ...PRIMARY_WHERE };
if (query.companyName) where.companyName = { contains: query.companyName };
if (query.contactPhone) where.contactPhone = { contains: query.contactPhone };
const [items, total] = await Promise.all([
this.prisma.partner.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
_count: { select: { stores: true, accounts: true, cities: true } },
},
}),
this.prisma.partner.count({ where }),
]);
return serializeBigInt({
items: items.map((p) => ({
...p,
storeCount: p._count.stores,
accountCount: p._count.accounts,
cityCount: p._count.cities,
_count: undefined,
})),
total,
page,
pageSize,
});
}
async detailPartner(id: bigint) {
const partner = await this.prisma.partner.findUnique({
where: { id },
include: {
cities: { select: { id: true, code: true, name: true, status: true } },
accounts: { select: { id: true, phone: true, name: true, isPrimary: true, status: true } },
stores: { select: { id: true, name: true, status: true }, take: 10, orderBy: { createdAt: 'desc' } },
_count: { select: { stores: true, accounts: true } },
},
});
if (!partner) throw new NotFoundException('开城合伙人不存在');
return serializeBigInt(partner);
}
async createPartner(dto: CreatePartnerDto) {
const partner = await this.prisma.partner.create({ data: dto });
return serializeBigInt(partner);
}
async updatePartner(id: bigint, dto: UpdatePartnerDto) {
const partner = await this.prisma.partner.update({ where: { id }, data: dto });
return serializeBigInt(partner);
}
async listPartnerAccounts(query: AdminPartnerAccountsQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.PartnerAccountWhereInput = {};
if (query.phone) where.phone = { contains: query.phone };
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
if (query.isPrimary === '0' || query.isPrimary === '1') {
where.isPrimary = Number(query.isPrimary);
}
if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.partnerId) where.id = BigInt(query.partnerId);
const [items, total] = await Promise.all([
this.prisma.partnerAccount.findMany({
@@ -89,8 +50,257 @@ export class AdminPartnersService {
skip: (page - 1) * pageSize,
take: pageSize,
include: {
partner: { select: { id: true, companyName: true } },
parent: { select: { id: true, name: true, phone: true } },
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,
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(),
address: dto.address.trim(),
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 account = await this.prisma.partnerAccount.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(dto.phone !== undefined ? { phone: dto.phone.trim() } : {}),
...(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 }),
@@ -98,14 +308,14 @@ export class AdminPartnersService {
return serializeBigInt({ items, total, page, pageSize });
}
async listPartnerAccountTree(partnerId?: bigint) {
const where: Prisma.PartnerAccountWhereInput = {};
if (partnerId) where.partnerId = partnerId;
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' }],
include: { partner: { select: { id: true, companyName: true } } },
});
type TreeNode = (typeof accounts)[number] & { children: TreeNode[] };
@@ -122,7 +332,7 @@ export class AdminPartnersService {
const parent = nodeMap.get(account.parentAccountId.toString());
if (parent) parent.children.push(node);
else roots.push(node);
} else {
} else if (account.isPrimary === 1) {
roots.push(node);
}
}
@@ -134,8 +344,9 @@ export class AdminPartnersService {
status: node.status,
isPrimary: node.isPrimary,
staffRole: node.staffRole,
permissions: node.permissions,
parentAccountId: node.parentAccountId,
partner: node.partner,
companyName: node.companyName,
createdAt: node.createdAt,
lastLoginAt: node.lastLoginAt,
children: node.children.length ? node.children.map(mapNode) : undefined,
@@ -148,27 +359,21 @@ export class AdminPartnersService {
const account = await this.prisma.partnerAccount.findUnique({
where: { id },
include: {
partner: {
select: {
id: true,
companyName: true,
contactPhone: true,
address: true,
},
},
parent: { select: { id: true, name: true, phone: true } },
parent: { select: { id: true, name: true, phone: true, companyName: true } },
},
});
if (!account) throw new NotFoundException('开城合伙人账号不存在');
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: { partnerId: account.partnerId },
where: { partnerAccountId: primary.id },
orderBy: { createdAt: 'desc' },
take: 50,
}),
this.prisma.order.findMany({
where: { city: { partnerId: account.partnerId } },
where: orderWhere,
orderBy: { createdAt: 'desc' },
take: 50,
select: {
@@ -182,10 +387,14 @@ export class AdminPartnersService {
}),
]);
return serializeBigInt({ ...account, bills, orders });
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('请输入正确的手机号码');
@@ -193,53 +402,40 @@ export class AdminPartnersService {
const phoneTaken = await this.prisma.partnerAccount.findUnique({ where: { phone } });
if (phoneTaken) throw new BadRequestException('该手机号已被使用');
if (dto.parentAccountId) {
const parent = await this.prisma.partnerAccount.findUnique({
where: { id: BigInt(dto.parentAccountId) },
});
if (!parent) throw new BadRequestException('主账号不存在');
if (parent.isPrimary !== 1) throw new BadRequestException('仅可向主账号添加子账号');
if (dto.partnerId && dto.partnerId !== parent.partnerId.toString()) {
throw new BadRequestException('开城合伙人与主账号不匹配');
}
const account = await this.prisma.partnerAccount.create({
data: {
partnerId: parent.partnerId,
phone,
name: dto.name.trim(),
staffRole: (dto.staffRole ?? 'INTERNAL') as 'PARTNER' | 'INTERNAL' | 'PROMOTER',
isPrimary: 0,
parentAccountId: parent.id,
status: 'ACTIVE',
},
include: { partner: { select: { id: true, companyName: true } } },
});
return serializeBigInt(account);
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 partner = await this.prisma.partner.findUnique({ where: { id: BigInt(dto.partnerId!) } });
if (!partner) throw new BadRequestException('开城合伙人不存在');
const account = await this.prisma.partnerAccount.create({
data: {
partnerId: partner.id,
phone,
name: dto.name.trim(),
staffRole: dto.staffRole ? (dto.staffRole as 'PARTNER' | 'INTERNAL' | 'PROMOTER') : undefined,
staffRole: (dto.staffRole ?? 'INTERNAL') as 'PARTNER' | 'INTERNAL' | 'PROMOTER',
permissions: dto.permissions ?? undefined,
isPrimary: 0,
parentAccountId: parent.id,
status: 'ACTIVE',
},
include: { partner: { select: { id: true, companyName: true } } },
});
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) 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)) {
@@ -258,7 +454,7 @@ export class AdminPartnersService {
async deletePartnerSubAccount(id: bigint) {
const account = await this.prisma.partnerAccount.findUnique({ where: { id } });
if (!account) throw new NotFoundException('开城合伙人账号不存在');
if (!account) throw new NotFoundException('合伙人账号不存在');
if (!account.parentAccountId) {
throw new BadRequestException('仅可删除子账号');
}