Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b9ab79acb | |||
| d9725b1914 | |||
| ac94f5f5de | |||
| 874ae93a9b |
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
@@ -7,6 +8,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
@@ -42,6 +44,7 @@ type PartnerRow = {
|
|||||||
districtCodes?: string[] | null;
|
districtCodes?: string[] | null;
|
||||||
orderCommissionRate?: number;
|
orderCommissionRate?: number;
|
||||||
redeemCommissionRate?: number;
|
redeemCommissionRate?: number;
|
||||||
|
storeCount?: number;
|
||||||
accountCount: number;
|
accountCount: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
@@ -121,6 +124,7 @@ export default function CityPartnersPanel({
|
|||||||
maxPartnerCommissionRate = 0.05,
|
maxPartnerCommissionRate = 0.05,
|
||||||
onChanged,
|
onChanged,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [editForm] = Form.useForm();
|
const [editForm] = Form.useForm();
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
const [subForm] = Form.useForm();
|
const [subForm] = Form.useForm();
|
||||||
@@ -204,6 +208,31 @@ export default function CityPartnersPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deletePartner(row: { id: string; storeCount?: number }) {
|
||||||
|
if ((row.storeCount ?? 0) > 0) {
|
||||||
|
Modal.warning({
|
||||||
|
title: '请先删除门店',
|
||||||
|
content: `该城市合伙人下还有 ${row.storeCount} 家门店,请先删除门店后再删除合伙人。`,
|
||||||
|
okText: '查看门店',
|
||||||
|
onOk: () => navigate(`/stores?partnerId=${row.id}`),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await request(`/admin/partners/${row.id}`, { method: 'DELETE' });
|
||||||
|
message.success('城市合伙人已删除');
|
||||||
|
if (detail?.id === row.id) {
|
||||||
|
setDrawerOpen(false);
|
||||||
|
setDetail(null);
|
||||||
|
}
|
||||||
|
void loadPartners();
|
||||||
|
onChanged?.();
|
||||||
|
} catch (e) {
|
||||||
|
const msg = formatApiError(e);
|
||||||
|
if (msg) message.error(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteSubAccount(subId: string) {
|
async function deleteSubAccount(subId: string) {
|
||||||
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
|
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
|
||||||
message.success('子账号已删除');
|
message.success('子账号已删除');
|
||||||
@@ -237,11 +266,24 @@ export default function CityPartnersPanel({
|
|||||||
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 80,
|
width: 140,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
<Space size={0}>
|
||||||
管理
|
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
||||||
</Button>
|
管理
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除该城市合伙人?"
|
||||||
|
description="若有门店需先删除门店;账号下有订单或核销单将禁止删除。"
|
||||||
|
okText="删除"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => void deletePartner(row)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -272,7 +314,22 @@ export default function CityPartnersPanel({
|
|||||||
width={640}
|
width={640}
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
extra={<Button type="primary" onClick={() => void savePartner()}>保存</Button>}
|
extra={
|
||||||
|
<Space>
|
||||||
|
{detail ? (
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除该城市合伙人?"
|
||||||
|
description="若有门店需先删除门店;账号下有订单或核销单将禁止删除。"
|
||||||
|
okText="删除"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => void deletePartner(detail)}
|
||||||
|
>
|
||||||
|
<Button danger>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
) : null}
|
||||||
|
<Button type="primary" onClick={() => void savePartner()}>保存</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{detail && (
|
{detail && (
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
@@ -127,6 +127,7 @@ function formatApiError(err: unknown): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CityPartnersPage() {
|
export default function CityPartnersPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [filterForm] = Form.useForm();
|
const [filterForm] = Form.useForm();
|
||||||
const [editForm] = Form.useForm();
|
const [editForm] = Form.useForm();
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
@@ -234,6 +235,30 @@ export default function CityPartnersPage() {
|
|||||||
void reload();
|
void reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deletePartner(row: { id: string; storeCount?: number }) {
|
||||||
|
if ((row.storeCount ?? 0) > 0) {
|
||||||
|
Modal.warning({
|
||||||
|
title: '请先删除门店',
|
||||||
|
content: `该城市合伙人下还有 ${row.storeCount} 家门店,请先删除门店后再删除合伙人。`,
|
||||||
|
okText: '查看门店',
|
||||||
|
onOk: () => navigate(`/stores?partnerId=${row.id}`),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await request(`/admin/partners/${row.id}`, { method: 'DELETE' });
|
||||||
|
message.success('城市合伙人已删除');
|
||||||
|
if (detail?.id === row.id) {
|
||||||
|
setDrawerOpen(false);
|
||||||
|
setDetail(null);
|
||||||
|
}
|
||||||
|
void reload();
|
||||||
|
} catch (e) {
|
||||||
|
const msg = formatApiError(e);
|
||||||
|
if (msg) message.error(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteSubAccount(subId: string, parentId: string) {
|
async function deleteSubAccount(subId: string, parentId: string) {
|
||||||
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
|
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
|
||||||
message.success('子账号已删除');
|
message.success('子账号已删除');
|
||||||
@@ -352,12 +377,25 @@ export default function CityPartnersPage() {
|
|||||||
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 80,
|
width: 140,
|
||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
<Space size={0}>
|
||||||
管理
|
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
||||||
</Button>
|
管理
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除该城市合伙人?"
|
||||||
|
description="若有门店需先删除门店;账号下有订单或核销单将禁止删除。"
|
||||||
|
okText="删除"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => void deletePartner(row)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -491,9 +529,22 @@ export default function CityPartnersPage() {
|
|||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
extra={
|
extra={
|
||||||
<Button type="primary" onClick={() => void savePartner()}>
|
<Space>
|
||||||
保存
|
{detail ? (
|
||||||
</Button>
|
<Popconfirm
|
||||||
|
title="确认删除该城市合伙人?"
|
||||||
|
description="若有门店需先删除门店;账号下有订单或核销单将禁止删除。"
|
||||||
|
okText="删除"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => void deletePartner(detail)}
|
||||||
|
>
|
||||||
|
<Button danger>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
) : null}
|
||||||
|
<Button type="primary" onClick={() => void savePartner()}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{detail && (
|
{detail && (
|
||||||
|
|||||||
@@ -274,7 +274,14 @@ export default function DevPlanTasksPage() {
|
|||||||
<Tag color={STATUS_COLOR[s]}>{DEV_PLAN_TASK_STATUS_LABELS[s]}</Tag>
|
<Tag color={STATUS_COLOR[s]}>{DEV_PLAN_TASK_STATUS_LABELS[s]}</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '内容', dataIndex: 'content' },
|
{
|
||||||
|
title: '内容',
|
||||||
|
dataIndex: 'content',
|
||||||
|
width: 360,
|
||||||
|
render: (v: string) => (
|
||||||
|
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxWidth: 480 }}>{v}</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '附件',
|
title: '附件',
|
||||||
width: 100,
|
width: 100,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ type Row = {
|
|||||||
redeemItems?: BillItemRow[];
|
redeemItems?: BillItemRow[];
|
||||||
partner?: {
|
partner?: {
|
||||||
companyName?: string;
|
companyName?: string;
|
||||||
|
name?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
bankAccountName?: string | null;
|
bankAccountName?: string | null;
|
||||||
bankAccountNo?: string | null;
|
bankAccountNo?: string | null;
|
||||||
@@ -64,6 +65,7 @@ type Row = {
|
|||||||
};
|
};
|
||||||
partnerAccount?: {
|
partnerAccount?: {
|
||||||
companyName?: string;
|
companyName?: string;
|
||||||
|
name?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
bankAccountName?: string | null;
|
bankAccountName?: string | null;
|
||||||
bankAccountNo?: string | null;
|
bankAccountNo?: string | null;
|
||||||
@@ -71,7 +73,7 @@ type Row = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type PartnerOption = { id: string; companyName: string; phone?: string };
|
type PartnerOption = { id: string; companyName?: string | null; name?: string | null; phone?: string };
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
PENDING_REVIEW: '待审核',
|
PENDING_REVIEW: '待审核',
|
||||||
@@ -89,6 +91,18 @@ const STATUS_COLORS: Record<string, string> = {
|
|||||||
REJECTED: 'red',
|
REJECTED: 'red',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function partnerDashLabel(p?: {
|
||||||
|
companyName?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
} | null): string {
|
||||||
|
if (!p) return '—';
|
||||||
|
const company = (p.companyName || '').trim();
|
||||||
|
const person = (p.name || '').trim();
|
||||||
|
if (company && person) return `${company}-${person}`;
|
||||||
|
return company || person || (p.phone || '').trim() || '—';
|
||||||
|
}
|
||||||
|
|
||||||
export default function PartnerBillsPage() {
|
export default function PartnerBillsPage() {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
@@ -257,7 +271,7 @@ export default function PartnerBillsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const summary = data?.summary;
|
const summary = data?.summary;
|
||||||
const partnerName = (r: Row) => r.partner?.companyName || r.partnerAccount?.companyName || '—';
|
const partnerName = (r: Row) => partnerDashLabel(r.partnerAccount ?? r.partner);
|
||||||
const partnerBank = (r: Row) => r.partnerAccount ?? r.partner;
|
const partnerBank = (r: Row) => r.partnerAccount ?? r.partner;
|
||||||
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
||||||
const canSend = selectedRows.filter((r) => r.status === 'PENDING_REVIEW' || r.status === 'REJECTED');
|
const canSend = selectedRows.filter((r) => r.status === 'PENDING_REVIEW' || r.status === 'REJECTED');
|
||||||
@@ -275,7 +289,7 @@ export default function PartnerBillsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '合伙人',
|
title: '合伙人',
|
||||||
width: 160,
|
width: 200,
|
||||||
render: (_, r) => partnerName(r),
|
render: (_, r) => partnerName(r),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -406,7 +420,7 @@ export default function PartnerBillsPage() {
|
|||||||
optionFilterProp="label"
|
optionFilterProp="label"
|
||||||
options={partners.map((p) => ({
|
options={partners.map((p) => ({
|
||||||
value: p.id,
|
value: p.id,
|
||||||
label: p.companyName || p.phone || p.id,
|
label: partnerDashLabel(p),
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -503,7 +517,7 @@ export default function PartnerBillsPage() {
|
|||||||
placeholder="全部主合伙人"
|
placeholder="全部主合伙人"
|
||||||
options={partners.map((p) => ({
|
options={partners.map((p) => ({
|
||||||
value: p.id,
|
value: p.id,
|
||||||
label: p.companyName || p.phone || p.id,
|
label: partnerDashLabel(p),
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Steps,
|
Steps,
|
||||||
@@ -302,6 +303,7 @@ export default function StoresPage() {
|
|||||||
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
|
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
|
||||||
const [optionsLoading, setOptionsLoading] = useState(false);
|
const [optionsLoading, setOptionsLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [phoneMismatch, setPhoneMismatch] = useState<string | null>(null);
|
const [phoneMismatch, setPhoneMismatch] = useState<string | null>(null);
|
||||||
const deepLinkStoreOpenedRef = useRef(false);
|
const deepLinkStoreOpenedRef = useRef(false);
|
||||||
|
|
||||||
@@ -372,6 +374,23 @@ export default function StoresPage() {
|
|||||||
.map((n) => ({ value: n.id, label: n.name }));
|
.map((n) => ({ value: n.id, label: n.name }));
|
||||||
}, [categoryTree, editCategoryParentId]);
|
}, [categoryTree, editCategoryParentId]);
|
||||||
|
|
||||||
|
async function deleteStore(id: string) {
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await request(`/admin/stores/${id}`, { method: 'DELETE' });
|
||||||
|
message.success('门店及门店账号已删除');
|
||||||
|
if (detail && String(detail.id) === id) {
|
||||||
|
setDrawerOpen(false);
|
||||||
|
setDetail(null);
|
||||||
|
}
|
||||||
|
void reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '删除失败');
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function openStoreDetail(row: StoreRow, opts?: { tab?: string }) {
|
async function openStoreDetail(row: StoreRow, opts?: { tab?: string }) {
|
||||||
const [d, cats] = await Promise.all([
|
const [d, cats] = await Promise.all([
|
||||||
request<Record<string, unknown>>(`/admin/stores/${row.id}`),
|
request<Record<string, unknown>>(`/admin/stores/${row.id}`),
|
||||||
@@ -821,7 +840,7 @@ export default function StoresPage() {
|
|||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 280,
|
width: 320,
|
||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space size={0} wrap>
|
<Space size={0} wrap>
|
||||||
@@ -861,6 +880,17 @@ export default function StoresPage() {
|
|||||||
>
|
>
|
||||||
提现
|
提现
|
||||||
</Button>
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除该门店?"
|
||||||
|
description="将同时删除门店账号;若有关联订单或核销单将禁止删除。"
|
||||||
|
okText="删除"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => void deleteStore(row.id)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -1034,6 +1064,17 @@ export default function StoresPage() {
|
|||||||
void reload();
|
void reload();
|
||||||
}} />
|
}} />
|
||||||
<Button type="primary" loading={saving} onClick={() => void saveStoreDetail()}>保存修改</Button>
|
<Button type="primary" loading={saving} onClick={() => void saveStoreDetail()}>保存修改</Button>
|
||||||
|
{detail?.id ? (
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除该门店?"
|
||||||
|
description="将同时删除门店账号;若有关联订单或核销单将禁止删除。"
|
||||||
|
okText="删除"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => void deleteStore(String(detail.id))}
|
||||||
|
>
|
||||||
|
<Button danger loading={deleting}>删除门店</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
)}>
|
)}>
|
||||||
{detail && (
|
{detail && (
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
assertCanDeleteCityPartner,
|
||||||
|
assertCanDeleteStore,
|
||||||
validatePartnerCityBinding,
|
validatePartnerCityBinding,
|
||||||
validatePartnerCommissionRates,
|
validatePartnerCommissionRates,
|
||||||
} from './city-partner';
|
} from './city-partner';
|
||||||
@@ -70,3 +72,33 @@ describe('validatePartnerCommissionRates', () => {
|
|||||||
expect(validatePartnerCommissionRates(0.05, 0.04, 0.08).ok).toBe(false);
|
expect(validatePartnerCommissionRates(0.05, 0.04, 0.08).ok).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('assertCanDeleteCityPartner', () => {
|
||||||
|
it('requires stores to be deleted first', () => {
|
||||||
|
const result = assertCanDeleteCityPartner({ storeCount: 2, orderCount: 0, redeemCount: 0 });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.message).toContain('请先删除门店');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks when orders exist', () => {
|
||||||
|
const result = assertCanDeleteCityPartner({ storeCount: 0, orderCount: 1, redeemCount: 0 });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.message).toContain('关联订单');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows delete when empty', () => {
|
||||||
|
expect(assertCanDeleteCityPartner({ storeCount: 0, orderCount: 0, redeemCount: 0 }).ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('assertCanDeleteStore', () => {
|
||||||
|
it('blocks when redeem records exist', () => {
|
||||||
|
const result = assertCanDeleteStore({ orderCount: 0, redeemCount: 3 });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.message).toContain('关联核销单');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows delete when no orders or redeems', () => {
|
||||||
|
expect(assertCanDeleteStore({ orderCount: 0, redeemCount: 0 }).ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -66,3 +66,33 @@ export function validatePartnerCommissionRates(
|
|||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function assertCanDeleteCityPartner(input: {
|
||||||
|
storeCount: number;
|
||||||
|
orderCount: number;
|
||||||
|
redeemCount: number;
|
||||||
|
}): PartnerCityValidationResult {
|
||||||
|
if (input.storeCount > 0) {
|
||||||
|
return { ok: false, message: '该城市合伙人下还有门店,请先删除门店' };
|
||||||
|
}
|
||||||
|
if (input.orderCount > 0) {
|
||||||
|
return { ok: false, message: '该账号下有关联订单,禁止删除' };
|
||||||
|
}
|
||||||
|
if (input.redeemCount > 0) {
|
||||||
|
return { ok: false, message: '该账号下有关联核销单,禁止删除' };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertCanDeleteStore(input: {
|
||||||
|
orderCount: number;
|
||||||
|
redeemCount: number;
|
||||||
|
}): PartnerCityValidationResult {
|
||||||
|
if (input.orderCount > 0) {
|
||||||
|
return { ok: false, message: '该账号下有关联订单,禁止删除' };
|
||||||
|
}
|
||||||
|
if (input.redeemCount > 0) {
|
||||||
|
return { ok: false, message: '该账号下有关联核销单,禁止删除' };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
resolveTaskStatusFromVersions,
|
||||||
|
taskStatusFromVersionStatus,
|
||||||
|
} from './dev-plan';
|
||||||
|
|
||||||
|
describe('taskStatusFromVersionStatus', () => {
|
||||||
|
it('maps version status to task status', () => {
|
||||||
|
expect(taskStatusFromVersionStatus('PENDING')).toBe('TODO');
|
||||||
|
expect(taskStatusFromVersionStatus('IN_PROGRESS')).toBe('IN_PROGRESS');
|
||||||
|
expect(taskStatusFromVersionStatus('TESTING')).toBe('DEVELOPED');
|
||||||
|
expect(taskStatusFromVersionStatus('RELEASED')).toBe('RELEASED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveTaskStatusFromVersions', () => {
|
||||||
|
it('returns null when no versions', () => {
|
||||||
|
expect(resolveTaskStatusFromVersions([])).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks the highest mapped status across versions', () => {
|
||||||
|
expect(resolveTaskStatusFromVersions(['PENDING', 'TESTING'])).toBe('DEVELOPED');
|
||||||
|
expect(resolveTaskStatusFromVersions(['IN_PROGRESS', 'RELEASED'])).toBe('RELEASED');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -61,3 +61,36 @@ export function taskCompletedAtOnStatus(
|
|||||||
void prev;
|
void prev;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TASK_STATUS_RANK: Record<DevPlanTaskStatus, number> = {
|
||||||
|
TODO: 0,
|
||||||
|
IN_PROGRESS: 1,
|
||||||
|
DEVELOPED: 2,
|
||||||
|
RELEASED: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 版本状态 → 任务状态:待开始/开发中/测试中/已发布 ↔ 待处理/进行中/已开发/已发布 */
|
||||||
|
export function taskStatusFromVersionStatus(status: DevPlanVersionStatus): DevPlanTaskStatus {
|
||||||
|
switch (status) {
|
||||||
|
case 'PENDING':
|
||||||
|
return 'TODO';
|
||||||
|
case 'IN_PROGRESS':
|
||||||
|
return 'IN_PROGRESS';
|
||||||
|
case 'TESTING':
|
||||||
|
return 'DEVELOPED';
|
||||||
|
case 'RELEASED':
|
||||||
|
return 'RELEASED';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 任务挂多个版本时取进度最高的对应状态 */
|
||||||
|
export function resolveTaskStatusFromVersions(
|
||||||
|
versionStatuses: DevPlanVersionStatus[],
|
||||||
|
): DevPlanTaskStatus | null {
|
||||||
|
if (!versionStatuses.length) return null;
|
||||||
|
return versionStatuses
|
||||||
|
.map(taskStatusFromVersionStatus)
|
||||||
|
.reduce((highest, current) =>
|
||||||
|
TASK_STATUS_RANK[current] > TASK_STATUS_RANK[highest] ? current : highest,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const HqOperationAction = {
|
|||||||
WAREHOUSE_DELETE: 'WAREHOUSE_DELETE',
|
WAREHOUSE_DELETE: 'WAREHOUSE_DELETE',
|
||||||
PARTNER_CREATE: 'PARTNER_CREATE',
|
PARTNER_CREATE: 'PARTNER_CREATE',
|
||||||
PARTNER_UPDATE: 'PARTNER_UPDATE',
|
PARTNER_UPDATE: 'PARTNER_UPDATE',
|
||||||
|
PARTNER_DELETE: 'PARTNER_DELETE',
|
||||||
PARTNER_ACCOUNT_CREATE: 'PARTNER_ACCOUNT_CREATE',
|
PARTNER_ACCOUNT_CREATE: 'PARTNER_ACCOUNT_CREATE',
|
||||||
PARTNER_ACCOUNT_UPDATE: 'PARTNER_ACCOUNT_UPDATE',
|
PARTNER_ACCOUNT_UPDATE: 'PARTNER_ACCOUNT_UPDATE',
|
||||||
PARTNER_ACCOUNT_DELETE: 'PARTNER_ACCOUNT_DELETE',
|
PARTNER_ACCOUNT_DELETE: 'PARTNER_ACCOUNT_DELETE',
|
||||||
@@ -29,6 +30,7 @@ export const HqOperationAction = {
|
|||||||
ORDER_EXPORT: 'ORDER_EXPORT',
|
ORDER_EXPORT: 'ORDER_EXPORT',
|
||||||
STORE_CREATE: 'STORE_CREATE',
|
STORE_CREATE: 'STORE_CREATE',
|
||||||
STORE_UPDATE: 'STORE_UPDATE',
|
STORE_UPDATE: 'STORE_UPDATE',
|
||||||
|
STORE_DELETE: 'STORE_DELETE',
|
||||||
STORE_STATUS: 'STORE_STATUS',
|
STORE_STATUS: 'STORE_STATUS',
|
||||||
STORE_AUDIT: 'STORE_AUDIT',
|
STORE_AUDIT: 'STORE_AUDIT',
|
||||||
STORE_ACCOUNT_CREATE: 'STORE_ACCOUNT_CREATE',
|
STORE_ACCOUNT_CREATE: 'STORE_ACCOUNT_CREATE',
|
||||||
@@ -142,6 +144,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
|||||||
[HqOperationAction.WAREHOUSE_DELETE]: '删除城市仓库',
|
[HqOperationAction.WAREHOUSE_DELETE]: '删除城市仓库',
|
||||||
[HqOperationAction.PARTNER_CREATE]: '新增城市合伙人',
|
[HqOperationAction.PARTNER_CREATE]: '新增城市合伙人',
|
||||||
[HqOperationAction.PARTNER_UPDATE]: '编辑城市合伙人',
|
[HqOperationAction.PARTNER_UPDATE]: '编辑城市合伙人',
|
||||||
|
[HqOperationAction.PARTNER_DELETE]: '删除城市合伙人',
|
||||||
[HqOperationAction.PARTNER_ACCOUNT_CREATE]: '新增合伙人账户',
|
[HqOperationAction.PARTNER_ACCOUNT_CREATE]: '新增合伙人账户',
|
||||||
[HqOperationAction.PARTNER_ACCOUNT_UPDATE]: '编辑合伙人账户',
|
[HqOperationAction.PARTNER_ACCOUNT_UPDATE]: '编辑合伙人账户',
|
||||||
[HqOperationAction.PARTNER_ACCOUNT_DELETE]: '删除合伙人子账号',
|
[HqOperationAction.PARTNER_ACCOUNT_DELETE]: '删除合伙人子账号',
|
||||||
@@ -160,6 +163,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
|||||||
[HqOperationAction.ORDER_EXPORT]: '导出订单',
|
[HqOperationAction.ORDER_EXPORT]: '导出订单',
|
||||||
[HqOperationAction.STORE_CREATE]: '新增门店',
|
[HqOperationAction.STORE_CREATE]: '新增门店',
|
||||||
[HqOperationAction.STORE_UPDATE]: '编辑门店',
|
[HqOperationAction.STORE_UPDATE]: '编辑门店',
|
||||||
|
[HqOperationAction.STORE_DELETE]: '删除门店',
|
||||||
[HqOperationAction.STORE_STATUS]: '变更门店状态',
|
[HqOperationAction.STORE_STATUS]: '变更门店状态',
|
||||||
[HqOperationAction.STORE_AUDIT]: '门店审核',
|
[HqOperationAction.STORE_AUDIT]: '门店审核',
|
||||||
[HqOperationAction.STORE_ACCOUNT_CREATE]: '新增门店账户',
|
[HqOperationAction.STORE_ACCOUNT_CREATE]: '新增门店账户',
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ import type {
|
|||||||
|
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
|
|
||||||
import { taskCompletedAtOnStatus, versionStatusTimestamps } from '@dukang/domain';
|
import {
|
||||||
|
resolveTaskStatusFromVersions,
|
||||||
|
taskCompletedAtOnStatus,
|
||||||
|
versionStatusTimestamps,
|
||||||
|
} from '@dukang/domain';
|
||||||
|
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
|
|
||||||
@@ -530,6 +534,8 @@ export class DevPlanService {
|
|||||||
await this.replaceTaskVersions(id, dto.versionIds);
|
await this.replaceTaskVersions(id, dto.versionIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.syncLinkedTaskStatuses([id]);
|
||||||
|
|
||||||
return this.getTask(id);
|
return this.getTask(id);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -768,8 +774,10 @@ export class DevPlanService {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.syncLinkedTasksForVersion(row.id);
|
||||||
|
|
||||||
if (status === 'RELEASED') {
|
if (status === 'RELEASED') {
|
||||||
await this.cascadeVersionReleased(row.id, row.versionNo);
|
await this.cascadeTicketsPublished(row.id, row.versionNo);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.getVersion(row.id);
|
return this.getVersion(row.id);
|
||||||
@@ -840,15 +848,16 @@ export class DevPlanService {
|
|||||||
|
|
||||||
await this.prisma.devPlanVersion.update({ where: { id }, data });
|
await this.prisma.devPlanVersion.update({ where: { id }, data });
|
||||||
|
|
||||||
if (
|
if (dto.taskIds != null) await this.replaceVersionTasks(id, dto.taskIds);
|
||||||
dto.status === 'RELEASED' &&
|
|
||||||
existing.status !== 'RELEASED'
|
if (dto.status != null || dto.taskIds != null) {
|
||||||
) {
|
await this.syncLinkedTasksForVersion(id);
|
||||||
const versionNo = (dto.versionNo ?? existing.versionNo).trim();
|
|
||||||
await this.cascadeVersionReleased(id, versionNo);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dto.taskIds != null) await this.replaceVersionTasks(id, dto.taskIds);
|
if (dto.status === 'RELEASED' && existing.status !== 'RELEASED') {
|
||||||
|
const versionNo = (dto.versionNo ?? existing.versionNo).trim();
|
||||||
|
await this.cascadeTicketsPublished(id, versionNo);
|
||||||
|
}
|
||||||
|
|
||||||
return this.getVersion(id);
|
return this.getVersion(id);
|
||||||
|
|
||||||
@@ -880,11 +889,58 @@ export class DevPlanService {
|
|||||||
|
|
||||||
await this.replaceVersionTasks(versionId, taskIds);
|
await this.replaceVersionTasks(versionId, taskIds);
|
||||||
|
|
||||||
|
await this.syncLinkedTasksForVersion(versionId);
|
||||||
|
|
||||||
return this.getVersion(versionId);
|
return this.getVersion(versionId);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cascadeVersionReleased(versionId: bigint, versionNo: string) {
|
private async syncLinkedTasksForVersion(versionId: bigint) {
|
||||||
|
const links = await this.prisma.devPlanVersionTask.findMany({
|
||||||
|
where: { versionId },
|
||||||
|
select: { taskId: true },
|
||||||
|
});
|
||||||
|
await this.syncLinkedTaskStatuses(links.map((l) => l.taskId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async syncLinkedTaskStatuses(taskIds: bigint[]) {
|
||||||
|
const uniq = [...new Set(taskIds)];
|
||||||
|
if (!uniq.length) return;
|
||||||
|
|
||||||
|
const [tasks, links] = await Promise.all([
|
||||||
|
this.prisma.devPlanTask.findMany({
|
||||||
|
where: { id: { in: uniq } },
|
||||||
|
select: { id: true, status: true, completedAt: true },
|
||||||
|
}),
|
||||||
|
this.prisma.devPlanVersionTask.findMany({
|
||||||
|
where: { taskId: { in: uniq } },
|
||||||
|
include: { version: { select: { status: true } } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const statusesByTask = new Map<string, DevPlanVersionStatus[]>();
|
||||||
|
for (const link of links) {
|
||||||
|
const key = String(link.taskId);
|
||||||
|
const list = statusesByTask.get(key) ?? [];
|
||||||
|
list.push(link.version.status);
|
||||||
|
statusesByTask.set(key, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
const next = resolveTaskStatusFromVersions(statusesByTask.get(String(task.id)) ?? []);
|
||||||
|
if (!next || next === task.status) continue;
|
||||||
|
const completed = taskCompletedAtOnStatus(task.status, next, task.completedAt);
|
||||||
|
await this.prisma.devPlanTask.update({
|
||||||
|
where: { id: task.id },
|
||||||
|
data: {
|
||||||
|
status: next,
|
||||||
|
...(completed ? { completedAt: completed } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cascadeTicketsPublished(versionId: bigint, versionNo: string) {
|
||||||
const links = await this.prisma.devPlanVersionTask.findMany({
|
const links = await this.prisma.devPlanVersionTask.findMany({
|
||||||
where: { versionId },
|
where: { versionId },
|
||||||
select: { taskId: true },
|
select: { taskId: true },
|
||||||
@@ -894,33 +950,26 @@ export class DevPlanService {
|
|||||||
const taskIds = links.map((l) => l.taskId);
|
const taskIds = links.map((l) => l.taskId);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
await this.prisma.$transaction(async (tx) => {
|
const tasks = await this.prisma.devPlanTask.findMany({
|
||||||
await tx.devPlanTask.updateMany({
|
where: { id: { in: taskIds }, supportTicketId: { not: null } },
|
||||||
where: { id: { in: taskIds }, status: { not: 'RELEASED' } },
|
select: { supportTicketId: true },
|
||||||
data: { status: 'RELEASED', completedAt: now },
|
});
|
||||||
});
|
const ticketIds = [
|
||||||
|
...new Set(
|
||||||
|
tasks
|
||||||
|
.map((t) => t.supportTicketId)
|
||||||
|
.filter((id): id is bigint => id != null),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (!ticketIds.length) return;
|
||||||
|
|
||||||
const tasks = await tx.devPlanTask.findMany({
|
await this.prisma.commonSupportTicket.updateMany({
|
||||||
where: { id: { in: taskIds }, supportTicketId: { not: null } },
|
where: { id: { in: ticketIds }, status: { not: 'REJECTED' } },
|
||||||
select: { supportTicketId: true },
|
data: {
|
||||||
});
|
status: 'PUBLISHED',
|
||||||
const ticketIds = [
|
releasedVersionNo: versionNo,
|
||||||
...new Set(
|
publishedAt: now,
|
||||||
tasks
|
},
|
||||||
.map((t) => t.supportTicketId)
|
|
||||||
.filter((id): id is bigint => id != null),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
if (!ticketIds.length) return;
|
|
||||||
|
|
||||||
await tx.commonSupportTicket.updateMany({
|
|
||||||
where: { id: { in: ticketIds }, status: { not: 'REJECTED' } },
|
|
||||||
data: {
|
|
||||||
status: 'PUBLISHED',
|
|
||||||
releasedVersionNo: versionNo,
|
|
||||||
publishedAt: now,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -976,6 +1025,8 @@ export class DevPlanService {
|
|||||||
|
|
||||||
await this.addVersionTasks(versionId, taskIds);
|
await this.addVersionTasks(versionId, taskIds);
|
||||||
|
|
||||||
|
await this.syncLinkedTasksForVersion(versionId);
|
||||||
|
|
||||||
return this.getVersion(versionId);
|
return this.getVersion(versionId);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1248,7 +1299,7 @@ export class DevPlanService {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.syncLinkedTaskStatuses(taskIds);
|
||||||
|
|
||||||
return { updated: taskIds.length };
|
return { updated: taskIds.length };
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,16 @@ export class AdminPartnersController {
|
|||||||
return this.service.updatePartner(BigInt(id), dto);
|
return this.service.updatePartner(BigInt(id), dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.PARTNER_DELETE,
|
||||||
|
refType: 'PARTNER',
|
||||||
|
refIdParam: 'id',
|
||||||
|
})
|
||||||
|
remove(@Param('id') id: string) {
|
||||||
|
return this.service.deletePartner(BigInt(id));
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id/assoc')
|
@Get(':id/assoc')
|
||||||
assocSummary(@Param('id') id: string) {
|
assocSummary(@Param('id') id: string) {
|
||||||
return this.assoc.getSummary(BigInt(id));
|
return this.assoc.getSummary(BigInt(id));
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma, CityPartnerScopeType, CityPartnerStatus } from '@prisma/client';
|
import { Prisma, CityPartnerScopeType, CityPartnerStatus } from '@prisma/client';
|
||||||
import { resolveMaxPartnerCommissionRate, validatePartnerCommissionRates } from '@dukang/domain';
|
import {
|
||||||
|
assertCanDeleteCityPartner,
|
||||||
|
resolveMaxPartnerCommissionRate,
|
||||||
|
validatePartnerCommissionRates,
|
||||||
|
} from '@dukang/domain';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||||
@@ -502,6 +506,62 @@ export class AdminPartnersService {
|
|||||||
return serializeBigInt(account);
|
return serializeBigInt(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deletePartner(id: bigint) {
|
||||||
|
const account = await this.prisma.partnerAccount.findFirst({
|
||||||
|
where: { id, ...PRIMARY_WHERE },
|
||||||
|
include: { children: { select: { id: true } } },
|
||||||
|
});
|
||||||
|
if (!account) throw new NotFoundException('开城合伙人不存在');
|
||||||
|
|
||||||
|
const accountIds = [id, ...account.children.map((c) => c.id)];
|
||||||
|
const [storeCount, orderCount, billCount, redeemCount] = await Promise.all([
|
||||||
|
this.prisma.store.count({ where: { partnerAccountId: { in: accountIds } } }),
|
||||||
|
this.prisma.order.count({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ partnerAccountIdAtPay: { in: accountIds } },
|
||||||
|
{ proxyPartnerAccountId: { in: accountIds } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.partnerBill.count({ where: { partnerAccountId: { in: accountIds } } }),
|
||||||
|
this.prisma.redeemRecord.count({
|
||||||
|
where: { store: { partnerAccountId: { in: accountIds } } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const check = assertCanDeleteCityPartner({
|
||||||
|
storeCount,
|
||||||
|
orderCount: orderCount + billCount,
|
||||||
|
redeemCount,
|
||||||
|
});
|
||||||
|
if (!check.ok) throw new BadRequestException(check.message);
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.user.updateMany({
|
||||||
|
where: { assocPartnerAccountId: { in: accountIds } },
|
||||||
|
data: { assocPartnerAccountId: null, assocBoundAt: null },
|
||||||
|
});
|
||||||
|
await tx.partnerUserNote.deleteMany({ where: { partnerAccountId: { in: accountIds } } });
|
||||||
|
await tx.logPartnerAnalytics.deleteMany({ where: { partnerAccountId: { in: accountIds } } });
|
||||||
|
await tx.cityWarehouse.updateMany({
|
||||||
|
where: { partnerAccountId: { in: accountIds } },
|
||||||
|
data: { partnerAccountId: null },
|
||||||
|
});
|
||||||
|
await tx.partnerAccount.updateMany({
|
||||||
|
where: { id: { in: accountIds } },
|
||||||
|
data: { managedWarehouseId: null, assocQrcodeResourceId: null, activityPosterId: null },
|
||||||
|
});
|
||||||
|
const childIds = account.children.map((c) => c.id);
|
||||||
|
if (childIds.length) {
|
||||||
|
await tx.partnerAccount.deleteMany({ where: { id: { in: childIds } } });
|
||||||
|
}
|
||||||
|
await tx.partnerAccount.delete({ where: { id } });
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, message: '城市合伙人已删除' };
|
||||||
|
}
|
||||||
|
|
||||||
async deletePartnerSubAccount(id: bigint) {
|
async deletePartnerSubAccount(id: bigint) {
|
||||||
const account = await this.prisma.partnerAccount.findUnique({ where: { id } });
|
const account = await this.prisma.partnerAccount.findUnique({ where: { id } });
|
||||||
if (!account) throw new NotFoundException('合伙人账号不存在');
|
if (!account) throw new NotFoundException('合伙人账号不存在');
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ export class AdminStoresController {
|
|||||||
return this.service.updateStore(BigInt(id), dto, user.actorId);
|
return this.service.updateStore(BigInt(id), dto, user.actorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HqOperation({ action: HqOperationAction.STORE_DELETE, refType: 'STORE', refIdParam: 'id' })
|
||||||
|
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||||
|
return this.service.deleteStore(BigInt(id), user.actorId);
|
||||||
|
}
|
||||||
|
|
||||||
@Put(':id/status')
|
@Put(':id/status')
|
||||||
@HqOperation({ action: HqOperationAction.STORE_STATUS, refType: 'STORE', refIdParam: 'id', includeBody: true })
|
@HqOperation({ action: HqOperationAction.STORE_STATUS, refType: 'STORE', refIdParam: 'id', includeBody: true })
|
||||||
updateStatus(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() dto: UpdateStoreStatusDto) {
|
updateStatus(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() dto: UpdateStoreStatusDto) {
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { isMobilePhone, isStoreContactPhone, STORE_CONTACT_PHONE_HINT, validateBusinessHours } from '@dukang/domain';
|
import {
|
||||||
|
assertCanDeleteStore,
|
||||||
|
isMobilePhone,
|
||||||
|
isStoreContactPhone,
|
||||||
|
STORE_CONTACT_PHONE_HINT,
|
||||||
|
validateBusinessHours,
|
||||||
|
} from '@dukang/domain';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
||||||
@@ -241,6 +247,88 @@ export class AdminStoresService {
|
|||||||
return serializeBigInt(store);
|
return serializeBigInt(store);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteStore(id: bigint, actorId: bigint) {
|
||||||
|
await this.hqPermissions.assertStoreIdInScope(actorId, id);
|
||||||
|
const store = await this.prisma.store.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { bindings: { select: { storeAccountId: true } } },
|
||||||
|
});
|
||||||
|
if (!store) throw new NotFoundException('门店不存在');
|
||||||
|
|
||||||
|
const [redeemCount, pendingCount, payoutCount, billCount, ratingCount, withdrawCount] =
|
||||||
|
await Promise.all([
|
||||||
|
this.prisma.redeemRecord.count({ where: { storeId: id } }),
|
||||||
|
this.prisma.redeemPendingRecord.count({ where: { storeId: id } }),
|
||||||
|
this.prisma.storePayout.count({ where: { storeId: id } }),
|
||||||
|
this.prisma.storeBill.count({ where: { storeId: id } }),
|
||||||
|
this.prisma.storeRating.count({ where: { storeId: id } }),
|
||||||
|
this.prisma.storeWithdrawRequest.count({ where: { storeId: id } }),
|
||||||
|
]);
|
||||||
|
const check = assertCanDeleteStore({
|
||||||
|
orderCount: 0,
|
||||||
|
redeemCount:
|
||||||
|
redeemCount + pendingCount + payoutCount + billCount + ratingCount + withdrawCount,
|
||||||
|
});
|
||||||
|
if (!check.ok) throw new BadRequestException(check.message);
|
||||||
|
|
||||||
|
const accountIds = [...new Set(store.bindings.map((b) => b.storeAccountId))];
|
||||||
|
const exclusiveAccountIds: bigint[] = [];
|
||||||
|
for (const accountId of accountIds) {
|
||||||
|
const otherBindings = await this.prisma.storeAccountStore.count({
|
||||||
|
where: { storeAccountId: accountId, NOT: { storeId: id } },
|
||||||
|
});
|
||||||
|
if (otherBindings === 0) exclusiveAccountIds.push(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const staffToDelete: bigint[] = [];
|
||||||
|
if (exclusiveAccountIds.length) {
|
||||||
|
const staff = await this.prisma.storeAccount.findMany({
|
||||||
|
where: { parentAccountId: { in: exclusiveAccountIds }, isPrimary: 0 },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
for (const row of staff) {
|
||||||
|
const other = await this.prisma.storeAccountStore.count({
|
||||||
|
where: { storeAccountId: row.id, NOT: { storeId: id } },
|
||||||
|
});
|
||||||
|
if (other > 0) continue;
|
||||||
|
const pending = await this.prisma.redeemPendingRecord.count({
|
||||||
|
where: { storeAccountId: row.id },
|
||||||
|
});
|
||||||
|
if (pending > 0) {
|
||||||
|
throw new BadRequestException('该账号下有关联核销单,禁止删除');
|
||||||
|
}
|
||||||
|
staffToDelete.push(row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.store.update({ where: { id }, data: { coverResourceId: null } });
|
||||||
|
await tx.commonResource.updateMany({
|
||||||
|
where: { ownerType: 'STORE', ownerId: id },
|
||||||
|
data: { status: 'DELETED' },
|
||||||
|
});
|
||||||
|
await tx.logStoreAnalytics.deleteMany({ where: { storeId: id } });
|
||||||
|
await tx.store.delete({ where: { id } });
|
||||||
|
|
||||||
|
if (staffToDelete.length) {
|
||||||
|
await tx.logStoreAnalytics.deleteMany({ where: { storeAccountId: { in: staffToDelete } } });
|
||||||
|
await tx.storeAccount.deleteMany({ where: { id: { in: staffToDelete } } });
|
||||||
|
}
|
||||||
|
if (exclusiveAccountIds.length) {
|
||||||
|
await tx.logStoreAnalytics.deleteMany({
|
||||||
|
where: { storeAccountId: { in: exclusiveAccountIds } },
|
||||||
|
});
|
||||||
|
await tx.storeAccount.updateMany({
|
||||||
|
where: { parentAccountId: { in: exclusiveAccountIds } },
|
||||||
|
data: { parentAccountId: null },
|
||||||
|
});
|
||||||
|
await tx.storeAccount.deleteMany({ where: { id: { in: exclusiveAccountIds } } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, message: '门店及门店账号已删除' };
|
||||||
|
}
|
||||||
|
|
||||||
async auditStore(id: bigint, dto: { approved: boolean; remark?: string }, actorId: bigint) {
|
async auditStore(id: bigint, dto: { approved: boolean; remark?: string }, actorId: bigint) {
|
||||||
await this.hqPermissions.assertStoreIdInScope(actorId, id);
|
await this.hqPermissions.assertStoreIdInScope(actorId, id);
|
||||||
const store = await this.prisma.store.findUnique({ where: { id } });
|
const store = await this.prisma.store.findUnique({ where: { id } });
|
||||||
|
|||||||
Reference in New Issue
Block a user