394 lines
15 KiB
TypeScript
394 lines
15 KiB
TypeScript
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<string>();
|
||
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<string, Prisma.Decimal>();
|
||
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 } } });
|
||
}
|
||
}
|