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 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="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item name="phone" 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 }]}>
|
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -394,8 +394,7 @@ export default function CityPartnersPanel({
|
|||||||
<Form.Item
|
<Form.Item
|
||||||
name="districtCodes"
|
name="districtCodes"
|
||||||
label="区县"
|
label="区县"
|
||||||
rules={[{ required: true, message: '请选择至少一个区县' }]}
|
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||||
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
|
||||||
>
|
>
|
||||||
<CityDistrictMultiSelect cityCode={cityCode} />
|
<CityDistrictMultiSelect cityCode={cityCode} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -50,6 +50,40 @@ type Row = {
|
|||||||
|
|
||||||
type PartnerOption = { id: string; companyName: string; cityId?: string | null };
|
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 }));
|
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
|
||||||
|
|
||||||
export default function CitiesPage() {
|
export default function CitiesPage() {
|
||||||
@@ -82,6 +116,12 @@ export default function CitiesPage() {
|
|||||||
const createRegionCodes = Form.useWatch('regionCodes', createForm);
|
const createRegionCodes = Form.useWatch('regionCodes', createForm);
|
||||||
const [warehouseManagerType, setWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
|
const [warehouseManagerType, setWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
|
||||||
const [editWarehouseManagerType, setEditWarehouseManagerType] = 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 loadPartners = useCallback(async (cityId: string) => {
|
||||||
const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`);
|
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);
|
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> = [
|
const columns: ColumnsType<Row> = [
|
||||||
{ title: '编码', dataIndex: 'code', width: 90 },
|
{ title: '编码', dataIndex: 'code', width: 90 },
|
||||||
{ title: '城市', dataIndex: 'name', width: 100 },
|
{ title: '城市', dataIndex: 'name', width: 100 },
|
||||||
@@ -140,11 +227,16 @@ export default function CitiesPage() {
|
|||||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 80,
|
width: 140,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
|
<Space size={0}>
|
||||||
<Button type="link" size="small" onClick={() => void openDetail(row)}>
|
<Button type="link" size="small" onClick={() => void openDetail(row)}>
|
||||||
管理
|
管理
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button type="link" size="small" danger onClick={() => void openDelete(row)}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -264,6 +356,24 @@ export default function CitiesPage() {
|
|||||||
setDetail(refreshed);
|
setDetail(refreshed);
|
||||||
void reload();
|
void reload();
|
||||||
}}>保存</Button>
|
}}>保存</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>
|
</Form>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@@ -407,6 +517,140 @@ export default function CitiesPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -348,10 +348,10 @@ export default function PartnersPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</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="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item name="phone" 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 }]}>
|
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -359,8 +359,7 @@ export default function PartnersPage() {
|
|||||||
<Form.Item
|
<Form.Item
|
||||||
name="districtCodes"
|
name="districtCodes"
|
||||||
label="区县"
|
label="区县"
|
||||||
rules={[{ required: true, message: '请选择至少一个区县' }]}
|
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||||
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
|
||||||
>
|
>
|
||||||
<CityDistrictMultiSelect cityCode={createCityCode} />
|
<CityDistrictMultiSelect cityCode={createCityCode} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -77,6 +77,14 @@ describe('validatePartnerCityBinding', () => {
|
|||||||
expect(result.ok).toBe(true);
|
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', () => {
|
it('allows valid district binding', () => {
|
||||||
const result = validatePartnerCityBinding(
|
const result = validatePartnerCityBinding(
|
||||||
[{ id: '1', partnerAccountId: '10', scopeType: 'DISTRICT', districtCodes: ['410105'] }],
|
[{ id: '1', partnerAccountId: '10', scopeType: 'DISTRICT', districtCodes: ['410105'] }],
|
||||||
|
|||||||
@@ -95,12 +95,7 @@ export function validatePartnerCityBinding(
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const districts = normalizeDistrictCodes(input.districtCodes);
|
// 区域合伙人所选区县可为空(录入时可稍后补全)
|
||||||
if (!districts.length) {
|
|
||||||
return { ok: false, message: '区域合伙人须至少选择一个区县' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 区县仅为标识,允许多个区域合伙人选择相同区县
|
|
||||||
return { ok: true };
|
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 { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
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 { AdminCitiesQueryDto } from './dto/admin-query.dto';
|
||||||
import { CreateCityDto, UpdateCityDto } from './dto/admin-mutate.dto';
|
import { CreateCityDto, UpdateCityDto } from './dto/admin-mutate.dto';
|
||||||
|
|
||||||
|
class DeleteCityDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
confirmName!: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Controller('admin/cities')
|
@Controller('admin/cities')
|
||||||
@UseGuards(HqAuthGuard)
|
@UseGuards(HqAuthGuard)
|
||||||
export class AdminCitiesController {
|
export class AdminCitiesController {
|
||||||
@@ -16,6 +23,11 @@ export class AdminCitiesController {
|
|||||||
return this.service.list(query);
|
return this.service.list(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/delete-preview')
|
||||||
|
deletePreview(@Param('id') id: string) {
|
||||||
|
return this.service.deletePreview(BigInt(id));
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
detail(@Param('id') id: string) {
|
detail(@Param('id') id: string) {
|
||||||
return this.service.detail(BigInt(id));
|
return this.service.detail(BigInt(id));
|
||||||
@@ -37,4 +49,15 @@ export class AdminCitiesController {
|
|||||||
update(@Param('id') id: string, @Body() dto: UpdateCityDto) {
|
update(@Param('id') id: string, @Body() dto: UpdateCityDto) {
|
||||||
return this.service.update(BigInt(id), dto);
|
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);
|
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,
|
orderCommissionRate: orderCommissionRate,
|
||||||
redeemCommissionRate: redeemCommissionRate,
|
redeemCommissionRate: redeemCommissionRate,
|
||||||
bindingStatus: (dto.bindingStatus ?? 'ACTIVE') as CityPartnerStatus,
|
bindingStatus: (dto.bindingStatus ?? 'ACTIVE') as CityPartnerStatus,
|
||||||
companyName: dto.companyName.trim(),
|
companyName: dto.companyName?.trim() || null,
|
||||||
address: dto.address.trim(),
|
address: dto.address?.trim() || null,
|
||||||
contactPhone: dto.contactPhone?.trim() ?? phone,
|
contactPhone: dto.contactPhone?.trim() ?? phone,
|
||||||
contractNo: dto.contractNo,
|
contractNo: dto.contractNo,
|
||||||
bankAccountName: dto.bankAccountName,
|
bankAccountName: dto.bankAccountName,
|
||||||
|
|||||||
@@ -234,13 +234,13 @@ export class CreatePartnerDto {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
companyName?: string;
|
||||||
companyName: string;
|
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
address?: string;
|
||||||
address: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
Reference in New Issue
Block a user