From ac94f5f5de30e757bc9584e746e688308bb82115 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Mon, 31 Aug 2026 16:08:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(ops):=20v4.0.4=20=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=A4=9A=E8=A1=8C=E4=B8=8E=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=90=8C=E6=AD=A5=E3=80=81=E5=90=88=E4=BC=99?= =?UTF-8?q?=E4=BA=BA/=E9=97=A8=E5=BA=97=E5=88=A0=E9=99=A4=E3=80=81?= =?UTF-8?q?=E8=B4=A6=E5=8D=95=E5=90=88=E4=BC=99=E4=BA=BA=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../src/components/CityPartnersPanel.tsx | 67 +++++++++- apps/admin-web/src/pages/CityPartnersPage.tsx | 67 ++++++++-- apps/admin-web/src/pages/DevPlanTasksPage.tsx | 9 +- apps/admin-web/src/pages/PartnerBillsPage.tsx | 24 +++- apps/admin-web/src/pages/StoresPage.tsx | 43 +++++- packages/domain/src/city-partner.test.ts | 32 +++++ packages/domain/src/city-partner.ts | 30 +++++ packages/domain/src/dev-plan.test.ts | 25 ++++ packages/domain/src/dev-plan.ts | 33 +++++ .../hq-operation/hq-operation.constants.ts | 4 + .../src/modules/dev-plan/dev-plan.service.ts | 125 ++++++++++++------ .../modules/ops/admin-partners.controller.ts | 10 ++ .../src/modules/ops/admin-partners.service.ts | 62 ++++++++- .../modules/ops/admin-stores.controller.ts | 6 + .../src/modules/ops/admin-stores.service.ts | 90 ++++++++++++- 15 files changed, 568 insertions(+), 59 deletions(-) create mode 100644 packages/domain/src/dev-plan.test.ts 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 } });