diff --git a/apps/admin-web/src/components/CityPartnersPanel.tsx b/apps/admin-web/src/components/CityPartnersPanel.tsx
index 0635506..4d1e80a 100644
--- a/apps/admin-web/src/components/CityPartnersPanel.tsx
+++ b/apps/admin-web/src/components/CityPartnersPanel.tsx
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
import {
Button,
Checkbox,
@@ -7,6 +8,7 @@ import {
Input,
InputNumber,
Modal,
+ Popconfirm,
Select,
Space,
Table,
@@ -42,6 +44,7 @@ type PartnerRow = {
districtCodes?: string[] | null;
orderCommissionRate?: number;
redeemCommissionRate?: number;
+ storeCount?: number;
accountCount: number;
createdAt: string;
};
@@ -121,6 +124,7 @@ export default function CityPartnersPanel({
maxPartnerCommissionRate = 0.05,
onChanged,
}: Props) {
+ const navigate = useNavigate();
const [editForm] = Form.useForm();
const [createForm] = 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) {
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
message.success('子账号已删除');
@@ -237,11 +266,24 @@ export default function CityPartnersPanel({
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
{
title: '操作',
- width: 80,
+ width: 140,
render: (_, row) => (
-
+
+
+ void deletePartner(row)}
+ >
+
+
+
),
},
];
@@ -272,7 +314,22 @@ export default function CityPartnersPanel({
width={640}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
- extra={}
+ extra={
+
+ {detail ? (
+ void deletePartner(detail)}
+ >
+
+
+ ) : null}
+
+
+ }
>
{detail && (
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) {
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
message.success('子账号已删除');
@@ -352,12 +377,25 @@ export default function CityPartnersPage() {
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
{
title: '操作',
- width: 80,
+ width: 140,
fixed: 'right',
render: (_, row) => (
-
+
+
+ void deletePartner(row)}
+ >
+
+
+
),
},
];
@@ -491,9 +529,22 @@ export default function CityPartnersPage() {
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
-
+
+ {detail ? (
+ void deletePartner(detail)}
+ >
+
+
+ ) : null}
+
+
}
>
{detail && (
diff --git a/apps/admin-web/src/pages/DevPlanTasksPage.tsx b/apps/admin-web/src/pages/DevPlanTasksPage.tsx
index 40fc1c3..64cce71 100644
--- a/apps/admin-web/src/pages/DevPlanTasksPage.tsx
+++ b/apps/admin-web/src/pages/DevPlanTasksPage.tsx
@@ -274,7 +274,14 @@ export default function DevPlanTasksPage() {
{DEV_PLAN_TASK_STATUS_LABELS[s]}
),
},
- { title: '内容', dataIndex: 'content' },
+ {
+ title: '内容',
+ dataIndex: 'content',
+ width: 360,
+ render: (v: string) => (
+ {v}
+ ),
+ },
{
title: '附件',
width: 100,
diff --git a/apps/admin-web/src/pages/PartnerBillsPage.tsx b/apps/admin-web/src/pages/PartnerBillsPage.tsx
index aca95bc..9d7ef7e 100644
--- a/apps/admin-web/src/pages/PartnerBillsPage.tsx
+++ b/apps/admin-web/src/pages/PartnerBillsPage.tsx
@@ -57,6 +57,7 @@ type Row = {
redeemItems?: BillItemRow[];
partner?: {
companyName?: string;
+ name?: string;
phone?: string;
bankAccountName?: string | null;
bankAccountNo?: string | null;
@@ -64,6 +65,7 @@ type Row = {
};
partnerAccount?: {
companyName?: string;
+ name?: string;
phone?: string;
bankAccountName?: 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 = {
PENDING_REVIEW: '待审核',
@@ -89,6 +91,18 @@ const STATUS_COLORS: Record = {
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() {
const [form] = Form.useForm();
const [filters, setFilters] = useState>({});
@@ -257,7 +271,7 @@ export default function PartnerBillsPage() {
}
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 selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
const canSend = selectedRows.filter((r) => r.status === 'PENDING_REVIEW' || r.status === 'REJECTED');
@@ -275,7 +289,7 @@ export default function PartnerBillsPage() {
},
{
title: '合伙人',
- width: 160,
+ width: 200,
render: (_, r) => partnerName(r),
},
{
@@ -406,7 +420,7 @@ export default function PartnerBillsPage() {
optionFilterProp="label"
options={partners.map((p) => ({
value: p.id,
- label: p.companyName || p.phone || p.id,
+ label: partnerDashLabel(p),
}))}
/>
@@ -503,7 +517,7 @@ export default function PartnerBillsPage() {
placeholder="全部主合伙人"
options={partners.map((p) => ({
value: p.id,
- label: p.companyName || p.phone || p.id,
+ label: partnerDashLabel(p),
}))}
/>
diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx
index f24a4f8..6f74233 100644
--- a/apps/admin-web/src/pages/StoresPage.tsx
+++ b/apps/admin-web/src/pages/StoresPage.tsx
@@ -12,6 +12,7 @@ import {
Input,
InputNumber,
Modal,
+ Popconfirm,
Select,
Space,
Steps,
@@ -302,6 +303,7 @@ export default function StoresPage() {
const [categoryTree, setCategoryTree] = useState([]);
const [optionsLoading, setOptionsLoading] = useState(false);
const [saving, setSaving] = useState(false);
+ const [deleting, setDeleting] = useState(false);
const [phoneMismatch, setPhoneMismatch] = useState(null);
const deepLinkStoreOpenedRef = useRef(false);
@@ -372,6 +374,23 @@ export default function StoresPage() {
.map((n) => ({ value: n.id, label: n.name }));
}, [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 }) {
const [d, cats] = await Promise.all([
request>(`/admin/stores/${row.id}`),
@@ -821,7 +840,7 @@ export default function StoresPage() {
{
key: 'actions',
title: '操作',
- width: 280,
+ width: 320,
fixed: 'right',
render: (_, row) => (
@@ -861,6 +880,17 @@ export default function StoresPage() {
>
提现
+ void deleteStore(row.id)}
+ >
+
+
),
},
@@ -1034,6 +1064,17 @@ export default function StoresPage() {
void reload();
}} />
+ {detail?.id ? (
+ void deleteStore(String(detail.id))}
+ >
+
+
+ ) : null}
)}>
{detail && (
diff --git a/packages/domain/src/city-partner.test.ts b/packages/domain/src/city-partner.test.ts
index 1f0bb0b..a003b46 100644
--- a/packages/domain/src/city-partner.test.ts
+++ b/packages/domain/src/city-partner.test.ts
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
+ assertCanDeleteCityPartner,
+ assertCanDeleteStore,
validatePartnerCityBinding,
validatePartnerCommissionRates,
} from './city-partner';
@@ -70,3 +72,33 @@ describe('validatePartnerCommissionRates', () => {
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);
+ });
+});
diff --git a/packages/domain/src/city-partner.ts b/packages/domain/src/city-partner.ts
index 4082eb1..d6b9893 100644
--- a/packages/domain/src/city-partner.ts
+++ b/packages/domain/src/city-partner.ts
@@ -66,3 +66,33 @@ export function validatePartnerCommissionRates(
}
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 };
+}
diff --git a/packages/domain/src/dev-plan.test.ts b/packages/domain/src/dev-plan.test.ts
new file mode 100644
index 0000000..a14c7b6
--- /dev/null
+++ b/packages/domain/src/dev-plan.test.ts
@@ -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');
+ });
+});
diff --git a/packages/domain/src/dev-plan.ts b/packages/domain/src/dev-plan.ts
index 4ff5c88..bbd270e 100644
--- a/packages/domain/src/dev-plan.ts
+++ b/packages/domain/src/dev-plan.ts
@@ -61,3 +61,36 @@ export function taskCompletedAtOnStatus(
void prev;
return undefined;
}
+
+const TASK_STATUS_RANK: Record = {
+ 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,
+ );
+}
diff --git a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts
index e1e1b78..9ca076f 100644
--- a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts
+++ b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts
@@ -11,6 +11,7 @@ export const HqOperationAction = {
WAREHOUSE_DELETE: 'WAREHOUSE_DELETE',
PARTNER_CREATE: 'PARTNER_CREATE',
PARTNER_UPDATE: 'PARTNER_UPDATE',
+ PARTNER_DELETE: 'PARTNER_DELETE',
PARTNER_ACCOUNT_CREATE: 'PARTNER_ACCOUNT_CREATE',
PARTNER_ACCOUNT_UPDATE: 'PARTNER_ACCOUNT_UPDATE',
PARTNER_ACCOUNT_DELETE: 'PARTNER_ACCOUNT_DELETE',
@@ -29,6 +30,7 @@ export const HqOperationAction = {
ORDER_EXPORT: 'ORDER_EXPORT',
STORE_CREATE: 'STORE_CREATE',
STORE_UPDATE: 'STORE_UPDATE',
+ STORE_DELETE: 'STORE_DELETE',
STORE_STATUS: 'STORE_STATUS',
STORE_AUDIT: 'STORE_AUDIT',
STORE_ACCOUNT_CREATE: 'STORE_ACCOUNT_CREATE',
@@ -142,6 +144,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record = {
[HqOperationAction.WAREHOUSE_DELETE]: '删除城市仓库',
[HqOperationAction.PARTNER_CREATE]: '新增城市合伙人',
[HqOperationAction.PARTNER_UPDATE]: '编辑城市合伙人',
+ [HqOperationAction.PARTNER_DELETE]: '删除城市合伙人',
[HqOperationAction.PARTNER_ACCOUNT_CREATE]: '新增合伙人账户',
[HqOperationAction.PARTNER_ACCOUNT_UPDATE]: '编辑合伙人账户',
[HqOperationAction.PARTNER_ACCOUNT_DELETE]: '删除合伙人子账号',
@@ -160,6 +163,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record = {
[HqOperationAction.ORDER_EXPORT]: '导出订单',
[HqOperationAction.STORE_CREATE]: '新增门店',
[HqOperationAction.STORE_UPDATE]: '编辑门店',
+ [HqOperationAction.STORE_DELETE]: '删除门店',
[HqOperationAction.STORE_STATUS]: '变更门店状态',
[HqOperationAction.STORE_AUDIT]: '门店审核',
[HqOperationAction.STORE_ACCOUNT_CREATE]: '新增门店账户',
diff --git a/server/dukang-api/src/modules/dev-plan/dev-plan.service.ts b/server/dukang-api/src/modules/dev-plan/dev-plan.service.ts
index aa72f16..f56cf8f 100644
--- a/server/dukang-api/src/modules/dev-plan/dev-plan.service.ts
+++ b/server/dukang-api/src/modules/dev-plan/dev-plan.service.ts
@@ -32,7 +32,11 @@ import type {
} from '@dukang/shared-types';
-import { taskCompletedAtOnStatus, versionStatusTimestamps } from '@dukang/domain';
+import {
+ resolveTaskStatusFromVersions,
+ taskCompletedAtOnStatus,
+ versionStatusTimestamps,
+} from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
@@ -530,6 +534,8 @@ export class DevPlanService {
await this.replaceTaskVersions(id, dto.versionIds);
}
+ await this.syncLinkedTaskStatuses([id]);
+
return this.getTask(id);
}
@@ -768,8 +774,10 @@ export class DevPlanService {
}
+ await this.syncLinkedTasksForVersion(row.id);
+
if (status === 'RELEASED') {
- await this.cascadeVersionReleased(row.id, row.versionNo);
+ await this.cascadeTicketsPublished(row.id, row.versionNo);
}
return this.getVersion(row.id);
@@ -840,15 +848,16 @@ export class DevPlanService {
await this.prisma.devPlanVersion.update({ where: { id }, data });
- if (
- dto.status === 'RELEASED' &&
- existing.status !== 'RELEASED'
- ) {
- 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 != null || dto.taskIds != null) {
+ await this.syncLinkedTasksForVersion(id);
}
- 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);
@@ -880,11 +889,58 @@ export class DevPlanService {
await this.replaceVersionTasks(versionId, taskIds);
+ await this.syncLinkedTasksForVersion(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();
+ 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({
where: { versionId },
select: { taskId: true },
@@ -894,33 +950,26 @@ export class DevPlanService {
const taskIds = links.map((l) => l.taskId);
const now = new Date();
- await this.prisma.$transaction(async (tx) => {
- await tx.devPlanTask.updateMany({
- where: { id: { in: taskIds }, status: { not: 'RELEASED' } },
- data: { status: 'RELEASED', completedAt: now },
- });
+ const tasks = await this.prisma.devPlanTask.findMany({
+ where: { id: { in: taskIds }, supportTicketId: { not: null } },
+ select: { supportTicketId: true },
+ });
+ 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({
- where: { id: { in: taskIds }, supportTicketId: { not: null } },
- select: { supportTicketId: true },
- });
- const ticketIds = [
- ...new Set(
- 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,
- },
- });
+ await this.prisma.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.syncLinkedTasksForVersion(versionId);
+
return this.getVersion(versionId);
}
@@ -1248,7 +1299,7 @@ export class DevPlanService {
}
-
+ await this.syncLinkedTaskStatuses(taskIds);
return { updated: taskIds.length };
diff --git a/server/dukang-api/src/modules/ops/admin-partners.controller.ts b/server/dukang-api/src/modules/ops/admin-partners.controller.ts
index 3cc0c59..a49798b 100644
--- a/server/dukang-api/src/modules/ops/admin-partners.controller.ts
+++ b/server/dukang-api/src/modules/ops/admin-partners.controller.ts
@@ -56,6 +56,16 @@ export class AdminPartnersController {
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')
assocSummary(@Param('id') id: string) {
return this.assoc.getSummary(BigInt(id));
diff --git a/server/dukang-api/src/modules/ops/admin-partners.service.ts b/server/dukang-api/src/modules/ops/admin-partners.service.ts
index 84cd041..4a79556 100644
--- a/server/dukang-api/src/modules/ops/admin-partners.service.ts
+++ b/server/dukang-api/src/modules/ops/admin-partners.service.ts
@@ -1,6 +1,10 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
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 { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { PartnerCityService } from '../city-scope/partner-city.service';
@@ -502,6 +506,62 @@ export class AdminPartnersService {
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) {
const account = await this.prisma.partnerAccount.findUnique({ where: { id } });
if (!account) throw new NotFoundException('合伙人账号不存在');
diff --git a/server/dukang-api/src/modules/ops/admin-stores.controller.ts b/server/dukang-api/src/modules/ops/admin-stores.controller.ts
index 4a7d847..0a20847 100644
--- a/server/dukang-api/src/modules/ops/admin-stores.controller.ts
+++ b/server/dukang-api/src/modules/ops/admin-stores.controller.ts
@@ -52,6 +52,12 @@ export class AdminStoresController {
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')
@HqOperation({ action: HqOperationAction.STORE_STATUS, refType: 'STORE', refIdParam: 'id', includeBody: true })
updateStatus(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() dto: UpdateStoreStatusDto) {
diff --git a/server/dukang-api/src/modules/ops/admin-stores.service.ts b/server/dukang-api/src/modules/ops/admin-stores.service.ts
index f336a89..ab2da36 100644
--- a/server/dukang-api/src/modules/ops/admin-stores.service.ts
+++ b/server/dukang-api/src/modules/ops/admin-stores.service.ts
@@ -1,6 +1,12 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
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 { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { mapStoreCompat } from '../../common/compat/v31-compat';
@@ -241,6 +247,88 @@ export class AdminStoresService {
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) {
await this.hqPermissions.assertStoreIdInScope(actorId, id);
const store = await this.prisma.store.findUnique({ where: { id } });