diff --git a/apps/admin-web/src/components/CityPartnersPanel.tsx b/apps/admin-web/src/components/CityPartnersPanel.tsx
index ceefe94..7deb989 100644
--- a/apps/admin-web/src/components/CityPartnersPanel.tsx
+++ b/apps/admin-web/src/components/CityPartnersPanel.tsx
@@ -383,10 +383,10 @@ export default function CityPartnersPanel({
}
}}>
+
-
+
@@ -394,8 +394,7 @@ export default function CityPartnersPanel({
diff --git a/apps/admin-web/src/pages/CitiesPage.tsx b/apps/admin-web/src/pages/CitiesPage.tsx
index d032a0c..86edf46 100644
--- a/apps/admin-web/src/pages/CitiesPage.tsx
+++ b/apps/admin-web/src/pages/CitiesPage.tsx
@@ -50,6 +50,40 @@ type Row = {
type PartnerOption = { id: string; companyName: string; cityId?: string | null };
+type CityDeletePreview = {
+ city: { id: string; code: string; name: string; province: string; status: string };
+ canDelete: boolean;
+ blockers: string[];
+ warnings: string[];
+ summary: {
+ primaryPartnerCount: number;
+ staffCount: number;
+ storeCount: number;
+ warehouseCount: number;
+ orderCount: number;
+ redeemCount: number;
+ partnerBillCount: number;
+ };
+ partners: Array<{
+ id: string;
+ phone: string;
+ name: string;
+ companyName?: string | null;
+ status: string;
+ staff: Array<{ id: string; phone: string; name: string; staffRole?: string | null }>;
+ }>;
+ orphanStaff: Array<{ id: string; phone: string; name: string }>;
+ stores: Array<{
+ id: string;
+ name: string;
+ phone: string;
+ status: string;
+ address: string;
+ partnerAccount?: { companyName?: string | null; phone?: string } | null;
+ }>;
+ warehouses: Array<{ id: string; name: string; status: string; address: string }>;
+};
+
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
export default function CitiesPage() {
@@ -82,6 +116,12 @@ export default function CitiesPage() {
const createRegionCodes = Form.useWatch('regionCodes', createForm);
const [warehouseManagerType, setWarehouseManagerType] = useState(WarehouseManagerType.HQ);
const [editWarehouseManagerType, setEditWarehouseManagerType] = useState(WarehouseManagerType.HQ);
+ const [deleteOpen, setDeleteOpen] = useState(false);
+ const [deleteLoading, setDeleteLoading] = useState(false);
+ const [deleteSubmitting, setDeleteSubmitting] = useState(false);
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [deletePreview, setDeletePreview] = useState(null);
+ const [confirmName, setConfirmName] = useState('');
const loadPartners = useCallback(async (cityId: string) => {
const res = await request>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`);
@@ -129,6 +169,53 @@ export default function CitiesPage() {
setDrawerOpen(true);
};
+ const openDelete = async (row: Row) => {
+ setDeleteTarget(row);
+ setDeletePreview(null);
+ setConfirmName('');
+ setDeleteOpen(true);
+ setDeleteLoading(true);
+ try {
+ const preview = await request(`/admin/cities/${row.id}/delete-preview`);
+ setDeletePreview(preview);
+ } catch (e) {
+ message.error(e instanceof Error ? e.message : '加载删除预览失败');
+ setDeleteOpen(false);
+ } finally {
+ setDeleteLoading(false);
+ }
+ };
+
+ const confirmDelete = async () => {
+ if (!deleteTarget || !deletePreview) return;
+ if (!deletePreview.canDelete) {
+ message.error(deletePreview.blockers.join(';') || '当前城市不可删除');
+ return;
+ }
+ if (confirmName.trim() !== deletePreview.city.name) {
+ message.warning(`请输入城市名称「${deletePreview.city.name}」确认删除`);
+ return;
+ }
+ setDeleteSubmitting(true);
+ try {
+ await request(`/admin/cities/${deleteTarget.id}`, {
+ method: 'DELETE',
+ body: JSON.stringify({ confirmName: confirmName.trim() }),
+ });
+ message.success(`已删除城市「${deletePreview.city.name}」`);
+ setDeleteOpen(false);
+ setDeleteTarget(null);
+ setDeletePreview(null);
+ setConfirmName('');
+ if (detail?.id === deleteTarget.id) setDrawerOpen(false);
+ void reload();
+ } catch (e) {
+ message.error(e instanceof Error ? e.message : '删除失败');
+ } finally {
+ setDeleteSubmitting(false);
+ }
+ };
+
const columns: ColumnsType = [
{ title: '编码', dataIndex: 'code', width: 90 },
{ title: '城市', dataIndex: 'name', width: 100 },
@@ -140,11 +227,16 @@ export default function CitiesPage() {
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
- width: 80,
+ width: 140,
render: (_, row) => (
-
+
+
+
+
),
},
];
@@ -264,6 +356,24 @@ export default function CitiesPage() {
setDetail(refreshed);
void reload();
}}>保存
+
>
),
@@ -407,6 +517,140 @@ export default function CitiesPage() {
+
+ {
+ if (deleteSubmitting) return;
+ setDeleteOpen(false);
+ }}
+ okText="确认删除"
+ okButtonProps={{
+ danger: true,
+ disabled:
+ !deletePreview?.canDelete ||
+ !deletePreview ||
+ confirmName.trim() !== (deletePreview?.city.name ?? ''),
+ loading: deleteSubmitting,
+ }}
+ confirmLoading={deleteSubmitting}
+ onOk={() => void confirmDelete()}
+ width={720}
+ destroyOnClose
+ >
+ {deleteLoading || !deletePreview ? (
+ 正在加载关联数据…
+ ) : (
+
+
+ {deletePreview.city.code}
+ {deletePreview.city.province}
+ {deletePreview.summary.primaryPartnerCount}
+ {deletePreview.summary.staffCount}
+ {deletePreview.summary.storeCount}
+ {deletePreview.summary.warehouseCount}
+ {deletePreview.summary.orderCount}
+ {deletePreview.summary.redeemCount}
+
+
+ {deletePreview.blockers.length > 0 && (
+
+ {deletePreview.blockers.map((b) => (
+ • {b}
+ ))}
+
+ )}
+ {deletePreview.warnings.length > 0 && (
+
+ {deletePreview.warnings.map((w) => (
+ • {w}
+ ))}
+
+ )}
+
+
+
合伙人及子账号
+
[
+ {
+ id: p.id,
+ kind: '主账号',
+ name: p.companyName || p.name,
+ phone: p.phone,
+ parent: '—',
+ },
+ ...p.staff.map((s) => ({
+ id: s.id,
+ kind: '子账号',
+ name: s.name,
+ phone: s.phone,
+ parent: p.companyName || p.name,
+ })),
+ ]).concat(
+ deletePreview.orphanStaff.map((s) => ({
+ id: s.id,
+ kind: '子账号',
+ name: s.name,
+ phone: s.phone,
+ parent: '(无主账号)',
+ })),
+ )}
+ columns={[
+ { title: '类型', dataIndex: 'kind', width: 80 },
+ { title: '名称', dataIndex: 'name', ellipsis: true },
+ { title: '手机号', dataIndex: 'phone', width: 120 },
+ { title: '归属', dataIndex: 'parent', ellipsis: true },
+ ]}
+ />
+
+
+
+
门店
+
r.partnerAccount?.companyName || r.partnerAccount?.phone || '—',
+ },
+ ]}
+ />
+
+
+ {deletePreview.canDelete ? (
+
+ setConfirmName(e.target.value)}
+ disabled={deleteSubmitting}
+ />
+
+ ) : (
+ 存在阻断项,无法删除。请先处理订单等关联数据。
+ )}
+
+ )}
+
);
}
diff --git a/apps/admin-web/src/pages/PartnersPage.tsx b/apps/admin-web/src/pages/PartnersPage.tsx
index 04aeea7..43cec8c 100644
--- a/apps/admin-web/src/pages/PartnersPage.tsx
+++ b/apps/admin-web/src/pages/PartnersPage.tsx
@@ -348,10 +348,10 @@ export default function PartnersPage() {
}}
/>
-
+
-
+
@@ -359,8 +359,7 @@ export default function PartnersPage() {
diff --git a/packages/domain/src/city-partner.test.ts b/packages/domain/src/city-partner.test.ts
index 1ced97d..7244dbc 100644
--- a/packages/domain/src/city-partner.test.ts
+++ b/packages/domain/src/city-partner.test.ts
@@ -77,6 +77,14 @@ describe('validatePartnerCityBinding', () => {
expect(result.ok).toBe(true);
});
+ it('allows district partner without district codes', () => {
+ const result = validatePartnerCityBinding(
+ [],
+ { partnerAccountId: '11', scopeType: 'DISTRICT', districtCodes: [] },
+ );
+ expect(result.ok).toBe(true);
+ });
+
it('allows valid district binding', () => {
const result = validatePartnerCityBinding(
[{ id: '1', partnerAccountId: '10', scopeType: 'DISTRICT', districtCodes: ['410105'] }],
diff --git a/packages/domain/src/city-partner.ts b/packages/domain/src/city-partner.ts
index ee91785..7d80772 100644
--- a/packages/domain/src/city-partner.ts
+++ b/packages/domain/src/city-partner.ts
@@ -95,12 +95,7 @@ export function validatePartnerCityBinding(
return { ok: true };
}
- const districts = normalizeDistrictCodes(input.districtCodes);
- if (!districts.length) {
- return { ok: false, message: '区域合伙人须至少选择一个区县' };
- }
-
- // 区县仅为标识,允许多个区域合伙人选择相同区县
+ // 区域合伙人所选区县可为空(录入时可稍后补全)
return { ok: true };
}
diff --git a/server/dukang-api/src/modules/ops/admin-cities.controller.ts b/server/dukang-api/src/modules/ops/admin-cities.controller.ts
index f970e8f..f486916 100644
--- a/server/dukang-api/src/modules/ops/admin-cities.controller.ts
+++ b/server/dukang-api/src/modules/ops/admin-cities.controller.ts
@@ -1,4 +1,5 @@
-import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
+import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
+import { IsString, MinLength } from 'class-validator';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
@@ -6,6 +7,12 @@ import { AdminCitiesService } from './admin-cities.service';
import { AdminCitiesQueryDto } from './dto/admin-query.dto';
import { CreateCityDto, UpdateCityDto } from './dto/admin-mutate.dto';
+class DeleteCityDto {
+ @IsString()
+ @MinLength(1)
+ confirmName!: string;
+}
+
@Controller('admin/cities')
@UseGuards(HqAuthGuard)
export class AdminCitiesController {
@@ -16,6 +23,11 @@ export class AdminCitiesController {
return this.service.list(query);
}
+ @Get(':id/delete-preview')
+ deletePreview(@Param('id') id: string) {
+ return this.service.deletePreview(BigInt(id));
+ }
+
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
@@ -37,4 +49,15 @@ export class AdminCitiesController {
update(@Param('id') id: string, @Body() dto: UpdateCityDto) {
return this.service.update(BigInt(id), dto);
}
+
+ @Delete(':id')
+ @HqOperation({
+ action: HqOperationAction.CITY_DELETE,
+ refType: 'CITY',
+ refIdParam: 'id',
+ includeBody: true,
+ })
+ remove(@Param('id') id: string, @Body() dto: DeleteCityDto) {
+ return this.service.deleteCity(BigInt(id), dto.confirmName);
+ }
}
diff --git a/server/dukang-api/src/modules/ops/admin-cities.service.ts b/server/dukang-api/src/modules/ops/admin-cities.service.ts
index 4715acd..8eb0672 100644
--- a/server/dukang-api/src/modules/ops/admin-cities.service.ts
+++ b/server/dukang-api/src/modules/ops/admin-cities.service.ts
@@ -144,4 +144,242 @@ export class AdminCitiesService {
});
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 } } });
+ }
}
diff --git a/server/dukang-api/src/modules/ops/admin-partners.service.ts b/server/dukang-api/src/modules/ops/admin-partners.service.ts
index 9c87a5b..2da4b9d 100644
--- a/server/dukang-api/src/modules/ops/admin-partners.service.ts
+++ b/server/dukang-api/src/modules/ops/admin-partners.service.ts
@@ -191,8 +191,8 @@ export class AdminPartnersService {
orderCommissionRate: orderCommissionRate,
redeemCommissionRate: redeemCommissionRate,
bindingStatus: (dto.bindingStatus ?? 'ACTIVE') as CityPartnerStatus,
- companyName: dto.companyName.trim(),
- address: dto.address.trim(),
+ companyName: dto.companyName?.trim() || null,
+ address: dto.address?.trim() || null,
contactPhone: dto.contactPhone?.trim() ?? phone,
contractNo: dto.contractNo,
bankAccountName: dto.bankAccountName,
diff --git a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts
index b620bf2..8b6c17b 100644
--- a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts
+++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts
@@ -234,13 +234,13 @@ export class CreatePartnerDto {
@IsNotEmpty()
name: string;
+ @IsOptional()
@IsString()
- @IsNotEmpty()
- companyName: string;
+ companyName?: string;
+ @IsOptional()
@IsString()
- @IsNotEmpty()
- address: string;
+ address?: string;
@IsOptional()
@IsString()