465 lines
18 KiB
TypeScript
465 lines
18 KiB
TypeScript
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);
|
|
|
|
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,
|
|
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 }),
|
|
]);
|
|
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<string, TreeNode>();
|
|
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;
|
|
}
|
|
|
|
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 };
|
|
}
|
|
}
|