feat(admin): city delete with impact preview; optional partner fields
CI / verify (pull_request) Has been cancelled
CI / verify (pull_request) Has been cancelled
Allow deleting open cities after listing partners/stores; company name, address, and districts optional on partner create. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -383,10 +383,10 @@ export default function CityPartnersPanel({
|
||||
}
|
||||
}}>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
||||
</Form.Item>
|
||||
@@ -394,8 +394,7 @@ export default function CityPartnersPanel({
|
||||
<Form.Item
|
||||
name="districtCodes"
|
||||
label="区县"
|
||||
rules={[{ required: true, message: '请选择至少一个区县' }]}
|
||||
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={cityCode} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -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>(WarehouseManagerType.HQ);
|
||||
const [editWarehouseManagerType, setEditWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [deleteSubmitting, setDeleteSubmitting] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Row | null>(null);
|
||||
const [deletePreview, setDeletePreview] = useState<CityDeletePreview | null>(null);
|
||||
const [confirmName, setConfirmName] = useState('');
|
||||
|
||||
const loadPartners = useCallback(async (cityId: string) => {
|
||||
const res = await request<Paginated<PartnerOption>>(`/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<CityDeletePreview>(`/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<Row> = [
|
||||
{ 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) => (
|
||||
<Button type="link" size="small" onClick={() => void openDetail(row)}>
|
||||
管理
|
||||
</Button>
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => void openDetail(row)}>
|
||||
管理
|
||||
</Button>
|
||||
<Button type="link" size="small" danger onClick={() => void openDelete(row)}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -264,6 +356,24 @@ export default function CitiesPage() {
|
||||
setDetail(refreshed);
|
||||
void reload();
|
||||
}}>保存</Button>
|
||||
<Button
|
||||
danger
|
||||
style={{ marginLeft: 8 }}
|
||||
onClick={() =>
|
||||
void openDelete({
|
||||
id: String(detail.id),
|
||||
code: String(detail.code),
|
||||
name: String(detail.name),
|
||||
province: String(detail.province),
|
||||
status: String(detail.status),
|
||||
storeCount: Number(detail.storeCount ?? 0),
|
||||
orderCount: Number(detail.orderCount ?? 0),
|
||||
createdAt: String(detail.createdAt ?? ''),
|
||||
})
|
||||
}
|
||||
>
|
||||
删除城市
|
||||
</Button>
|
||||
</Form>
|
||||
</>
|
||||
),
|
||||
@@ -407,6 +517,140 @@ export default function CitiesPage() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={deleteTarget ? `删除城市「${deleteTarget.name}」` : '删除城市'}
|
||||
open={deleteOpen}
|
||||
onCancel={() => {
|
||||
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 ? (
|
||||
<Typography.Text type="secondary">正在加载关联数据…</Typography.Text>
|
||||
) : (
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Descriptions size="small" bordered column={2}>
|
||||
<Descriptions.Item label="编码">{deletePreview.city.code}</Descriptions.Item>
|
||||
<Descriptions.Item label="省份">{deletePreview.city.province}</Descriptions.Item>
|
||||
<Descriptions.Item label="合伙人主账号">{deletePreview.summary.primaryPartnerCount}</Descriptions.Item>
|
||||
<Descriptions.Item label="子账号">{deletePreview.summary.staffCount}</Descriptions.Item>
|
||||
<Descriptions.Item label="门店">{deletePreview.summary.storeCount}</Descriptions.Item>
|
||||
<Descriptions.Item label="仓库">{deletePreview.summary.warehouseCount}</Descriptions.Item>
|
||||
<Descriptions.Item label="订单">{deletePreview.summary.orderCount}</Descriptions.Item>
|
||||
<Descriptions.Item label="核销">{deletePreview.summary.redeemCount}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{deletePreview.blockers.length > 0 && (
|
||||
<Typography.Paragraph type="danger" style={{ marginBottom: 0 }}>
|
||||
{deletePreview.blockers.map((b) => (
|
||||
<div key={b}>• {b}</div>
|
||||
))}
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
{deletePreview.warnings.length > 0 && (
|
||||
<Typography.Paragraph type="warning" style={{ marginBottom: 0 }}>
|
||||
{deletePreview.warnings.map((w) => (
|
||||
<div key={w}>• {w}</div>
|
||||
))}
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Typography.Text strong>合伙人及子账号</Typography.Text>
|
||||
<Table
|
||||
size="small"
|
||||
style={{ marginTop: 8 }}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
locale={{ emptyText: '无合伙人' }}
|
||||
dataSource={deletePreview.partners.flatMap((p) => [
|
||||
{
|
||||
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 },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography.Text strong>门店</Typography.Text>
|
||||
<Table
|
||||
size="small"
|
||||
style={{ marginTop: 8 }}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
locale={{ emptyText: '无门店' }}
|
||||
dataSource={deletePreview.stores}
|
||||
columns={[
|
||||
{ title: '门店名', dataIndex: 'name', ellipsis: true },
|
||||
{ title: '电话', dataIndex: 'phone', width: 120 },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{
|
||||
title: '合伙人',
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
render: (_, r) => r.partnerAccount?.companyName || r.partnerAccount?.phone || '—',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{deletePreview.canDelete ? (
|
||||
<Form.Item
|
||||
label={`请输入城市名称「${deletePreview.city.name}」确认删除`}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input
|
||||
value={confirmName}
|
||||
placeholder={deletePreview.city.name}
|
||||
onChange={(e) => setConfirmName(e.target.value)}
|
||||
disabled={deleteSubmitting}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Typography.Text type="secondary">存在阻断项,无法删除。请先处理订单等关联数据。</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -348,10 +348,10 @@ export default function PartnersPage() {
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
||||
</Form.Item>
|
||||
@@ -359,8 +359,7 @@ export default function PartnersPage() {
|
||||
<Form.Item
|
||||
name="districtCodes"
|
||||
label="区县"
|
||||
rules={[{ required: true, message: '请选择至少一个区县' }]}
|
||||
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||
>
|
||||
<CityDistrictMultiSelect cityCode={createCityCode} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -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'] }],
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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 } } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user