From 32fedbe898456a631e31d6d3966887a813fcfd2c Mon Sep 17 00:00:00 2001 From: jingchuan Date: Wed, 19 Aug 2026 16:56:32 +0800 Subject: [PATCH] feat(supply-chain): add terminated task feedback --- .../migration.sql | 18 + furniture-3d-system/prisma/schema.prisma | 14 + .../new-product-termination-feedback/route.ts | 19 + .../NewProductManagementDashboard.tsx | 387 +++++++++++++++++- .../config/supply-chain-menu.test.ts | 4 +- .../supply-chain/config/supply-chain-menu.ts | 3 +- .../new-products/overview/page.tsx | 1 + .../TerminationFeedbackClient.tsx | 258 ++++++++++++ .../termination-feedback/page.tsx | 56 +++ .../SupplyChainPermissionWorkspace.tsx | 11 +- .../lib/new-product-termination-feedback.ts | 8 + .../lib/supply-chain-permission-settings.ts | 7 + ...product-termination-feedback.repository.ts | 72 ++++ .../dingtalk-process-instance.service.test.ts | 31 ++ .../dingtalk-process-instance.service.ts | 145 +++++++ ...ew-product-termination-feedback.service.ts | 53 +++ .../oa-board-local-preview.service.ts | 1 + .../server/services/oa-board-sync.service.ts | 7 + .../src/server/services/oa-board.service.ts | 115 +++++- ...upply-chain-permission-settings.service.ts | 2 + 20 files changed, 1191 insertions(+), 21 deletions(-) create mode 100644 furniture-3d-system/prisma/migrations/20260819173000_add_new_product_termination_feedback/migration.sql create mode 100644 furniture-3d-system/src/app/api/supply-chain/new-product-termination-feedback/route.ts create mode 100644 furniture-3d-system/src/app/supply-chain/new-products/termination-feedback/TerminationFeedbackClient.tsx create mode 100644 furniture-3d-system/src/app/supply-chain/new-products/termination-feedback/page.tsx create mode 100644 furniture-3d-system/src/lib/new-product-termination-feedback.ts create mode 100644 furniture-3d-system/src/server/repositories/new-product-termination-feedback.repository.ts create mode 100644 furniture-3d-system/src/server/services/__tests__/dingtalk-process-instance.service.test.ts create mode 100644 furniture-3d-system/src/server/services/dingtalk-process-instance.service.ts create mode 100644 furniture-3d-system/src/server/services/new-product-termination-feedback.service.ts diff --git a/furniture-3d-system/prisma/migrations/20260819173000_add_new_product_termination_feedback/migration.sql b/furniture-3d-system/prisma/migrations/20260819173000_add_new_product_termination_feedback/migration.sql new file mode 100644 index 00000000..65ab1a67 --- /dev/null +++ b/furniture-3d-system/prisma/migrations/20260819173000_add_new_product_termination_feedback/migration.sql @@ -0,0 +1,18 @@ +CREATE TABLE "NewProductTerminationFeedback" ( + "id" TEXT NOT NULL, + "openingCode" TEXT NOT NULL, + "reasonCategory" TEXT NOT NULL, + "reason" TEXT NOT NULL, + "feedbackById" TEXT, + "feedbackByName" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "NewProductTerminationFeedback_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "NewProductTerminationFeedback_openingCode_key" + ON "NewProductTerminationFeedback"("openingCode"); + +CREATE INDEX "NewProductTerminationFeedback_updatedAt_idx" + ON "NewProductTerminationFeedback"("updatedAt"); diff --git a/furniture-3d-system/prisma/schema.prisma b/furniture-3d-system/prisma/schema.prisma index b7b21995..3af0c20c 100644 --- a/furniture-3d-system/prisma/schema.prisma +++ b/furniture-3d-system/prisma/schema.prisma @@ -3329,6 +3329,20 @@ model SupplyChainPermissionOverride { @@index([subjectKey]) } +model NewProductTerminationFeedback { + id String @id @default(cuid()) + + openingCode String @unique + reasonCategory String + reason String @db.Text + feedbackById String? + feedbackByName String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([updatedAt]) +} + // ============================================ // 五金件库(标准五金件复用系统) // ============================================ diff --git a/furniture-3d-system/src/app/api/supply-chain/new-product-termination-feedback/route.ts b/furniture-3d-system/src/app/api/supply-chain/new-product-termination-feedback/route.ts new file mode 100644 index 00000000..59aba0a0 --- /dev/null +++ b/furniture-3d-system/src/app/api/supply-chain/new-product-termination-feedback/route.ts @@ -0,0 +1,19 @@ +import { NextRequest } from 'next/server' +import { handleRequest, jsonSuccess } from '@/server/core/response' +import { newProductTerminationFeedbackService } from '@/server/services/new-product-termination-feedback.service' + +export async function PUT(request: NextRequest) { + return handleRequest( + request, + async (ctx) => { + const body = (await request.json().catch(() => ({}))) as { + openingCode?: unknown + reasonCategory?: unknown + reason?: unknown + } + const feedback = await newProductTerminationFeedbackService.save(body, ctx.user) + return jsonSuccess(feedback, 200, ctx.requestId) + }, + { requireAuth: true }, + ) +} diff --git a/furniture-3d-system/src/app/intake/new-product-management/NewProductManagementDashboard.tsx b/furniture-3d-system/src/app/intake/new-product-management/NewProductManagementDashboard.tsx index 485ffb56..f8c330be 100644 --- a/furniture-3d-system/src/app/intake/new-product-management/NewProductManagementDashboard.tsx +++ b/furniture-3d-system/src/app/intake/new-product-management/NewProductManagementDashboard.tsx @@ -5,6 +5,7 @@ import { useMemo, useState } from "react"; import { AlertTriangle, ArrowUpRight, + Ban, BarChart3, CalendarDays, CheckCircle2, @@ -129,7 +130,8 @@ type TimingOverview = { completedOnTimeRate: number; completedMaxOvertimeDays: number; completedMaxOvertimeTask: string; - runningCount: number; + runningAverageDays: number; + runningAssessedCount: number; runningOvertimeCount: number; runningNearOvertimeCount: number; runningDepartments: CompletedDepartmentTimingSummary[]; @@ -140,6 +142,17 @@ type TimingOverview = { runningLongestRowId: string | null; }; +type TerminatedTaskRow = { + id: string; + styleImageUrl: string | null; + openingCode: string; + statusLabel: string; + createdDate: string; + terminatedDate: string; + reasonCategory: string; + reason: string; +}; + type CompletedTimingDetailFilter = | { kind: "department"; @@ -1488,6 +1501,9 @@ function buildTimingOverview( const runningOvertimeRows = runningRows.filter( (row) => row.totalOvertimeDays !== null && row.totalOvertimeDays > 0, ); + const runningAssessedRows = runningRows.filter( + (row) => row.totalActualDays !== null, + ); const runningNearOvertimeRows = runningRows.filter((row) => { if (row.totalActualDays === null || row.standardDays === null) return false; const remainingDays = row.standardDays - row.totalActualDays; @@ -1532,7 +1548,12 @@ function buildTimingOverview( completedMaxOvertimeDays: maxCompletedOvertimeRow?.totalOvertimeDays ?? 0, completedMaxOvertimeTask: maxCompletedOvertimeRow?.openingCode ?? "暂无周期超期任务", - runningCount: runningRows.length, + runningAverageDays: average( + runningAssessedRows + .map((row) => row.totalActualDays) + .filter((value): value is number => value !== null), + ), + runningAssessedCount: runningAssessedRows.length, runningOvertimeCount: runningOvertimeRows.length, runningNearOvertimeCount: runningNearOvertimeRows.length, runningDepartments: summarizeCompletedDepartmentTimings( @@ -1994,6 +2015,67 @@ function getPeriodRange(period: Period, anchorDate: string) { return { start, end }; } +function formatTerminatedDate(value: string | null | undefined) { + const text = String(value ?? '').trim(); + if (!text) return "-"; + const match = text.match(/^(\d{4}-\d{2}-\d{2})/); + return match?.[1] ?? text.slice(0, 10); +} + +function buildTerminatedTaskRows( + items: OaBoardProjectListItemDTO[], + start: Date, + end: Date, +): TerminatedTaskRow[] { + const byOpeningCode = new Map(); + items.forEach((item) => { + const terminatedDate = item.finishedDate ? new Date(item.finishedDate) : null; + if (!terminatedDate || Number.isNaN(terminatedDate.getTime())) return; + if (terminatedDate.getTime() < start.getTime() || terminatedDate.getTime() > end.getTime() + 24 * 60 * 60 * 1000 - 1) return; + const key = (item.openingCode || item.formSummary.openingCode || item.boardKey).trim(); + const current = byOpeningCode.get(key); + if (!current || new Date(current.finishedDate ?? 0).getTime() < terminatedDate.getTime()) { + byOpeningCode.set(key, item); + } + }); + + return Array.from(byOpeningCode.values()) + .map((item) => ({ + id: item.id, + styleImageUrl: item.styleImages[0]?.url ?? null, + openingCode: item.openingCode || item.formSummary.openingCode || "未填写", + statusLabel: item.formSummary.totalFlowStatusLabel || "已终止", + createdDate: formatTerminatedDate(item.createdDate), + terminatedDate: formatTerminatedDate(item.finishedDate), + reasonCategory: item.terminationReason ? item.terminationReasonCategory || "其他" : "待反馈", + reason: item.terminationReason || "待在终止任务反馈页面补录", + })) + .sort((left, right) => right.terminatedDate.localeCompare(left.terminatedDate)); +} + +const TERMINATED_SAMPLE_ROWS: TerminatedTaskRow[] = [ + { + id: "terminated-sample-1", + styleImageUrl: null, + openingCode: "KK-202608-001", + statusLabel: "已终止", + createdDate: "2026-08-03", + terminatedDate: "2026-08-14", + reasonCategory: "产品设计", + reason: "设计方案暂不满足当前产品规划,终止本次开发。", + }, + { + id: "terminated-sample-2", + styleImageUrl: null, + openingCode: "KK-202608-007", + statusLabel: "已拒绝", + createdDate: "2026-08-08", + terminatedDate: "2026-08-16", + reasonCategory: "采购/成本", + reason: "供应商成本和目标限价差异较大,暂缓开发。", + }, +]; + function MetricCard({ label, value, @@ -2091,6 +2173,12 @@ function EfficiencyCard({ detail: string; lines?: string[]; previewRows?: TimingHoverPreviewRow[]; + previewColumnLabels?: { + primary: string; + stage: string; + metric: string; + }; + previewGridTemplate?: string; actionLabel?: string; }; active?: boolean; @@ -2261,15 +2349,29 @@ function EfficiencyCard({ ) : null} {hoverDetail.previewRows?.length ? ( - - 开款 / 产品 - 当前环节 - 时效 + + {hoverDetail.previewColumnLabels?.primary ?? "开款 / 产品"} + {hoverDetail.previewColumnLabels?.stage ?? "当前环节"} + + {hoverDetail.previewColumnLabels?.metric ?? "时效"} + {hoverDetail.previewRows.map((row) => ( @@ -3184,10 +3286,56 @@ export default function NewProductManagementDashboard({ useState(null); const [runningTimingFilter, setRunningTimingFilter] = useState(null); + const [terminatedDetailCategory, setTerminatedDetailCategory] = useState< + string | null + >(null); const periodRange = useMemo( () => getPeriodRange(period, anchorDate), [anchorDate, period], ); + const terminatedRows = useMemo( + () => + dashboard + ? buildTerminatedTaskRows( + dashboard.terminatedItems ?? [], + periodRange.start, + periodRange.end, + ) + : TERMINATED_SAMPLE_ROWS.filter((row) => { + const timestamp = new Date(`${row.terminatedDate}T00:00:00`).getTime(); + return ( + timestamp >= periodRange.start.getTime() && + timestamp <= periodRange.end.getTime() + 24 * 60 * 60 * 1000 - 1 + ); + }).sort((left, right) => + right.terminatedDate.localeCompare(left.terminatedDate), + ), + [dashboard, periodRange], + ); + const terminatedReasonDistribution = useMemo(() => { + const counts = new Map(); + terminatedRows.forEach((row) => { + counts.set(row.reasonCategory, (counts.get(row.reasonCategory) ?? 0) + 1); + }); + return Array.from(counts.entries()).sort( + (left, right) => right[1] - left[1] || left[0].localeCompare(right[0], "zh-CN"), + ); + }, [terminatedRows]); + const terminatedDetailRows = useMemo( + () => + terminatedDetailCategory === null + ? [] + : terminatedDetailCategory === "ALL" + ? terminatedRows + : terminatedRows.filter( + (row) => row.reasonCategory === terminatedDetailCategory, + ), + [terminatedDetailCategory, terminatedRows], + ); + const terminatedDetailLabel = + terminatedDetailCategory === null || terminatedDetailCategory === "ALL" + ? "全部终止任务" + : `${terminatedDetailCategory}原因`; const liveItems = useMemo(() => { if (!dashboard) return null; const start = periodRange.start.getTime(); @@ -3758,12 +3906,28 @@ export default function NewProductManagementDashboard({
({ + label: department.label, + value: + department.averageDays === null + ? "-" + : `${department.averageDays}天`, + tooltipTitle: `${department.label}模块平均已用时:${ + department.averageDays === null + ? "暂无数据" + : `${department.averageDays} 天` + }`, + tooltipDetail: `${department.definition};纳入 ${department.assessedCount} 个进行中流程`, + tone: "good" as const, + }), + )} /> ) : null} +
+
+
+

+ + 已终止任务 +

+

+ 按终止日期统计 · 按开款编号去重 · 原因说明优先取钉钉终止节点审批意见 +

+
+ +
+
+ ({ + openingCode: row.openingCode, + productName: row.reason, + stage: row.statusLabel, + metric: row.terminatedDate, + })), + previewColumnLabels: { + primary: "开款 / 原因", + stage: "状态", + metric: "终止日期", + }, + previewGridTemplate: + "minmax(0, 1fr) 48px 72px", + actionLabel: + terminatedDetailCategory === "ALL" + ? "已展开,再次点击收起" + : `查看全部 ${terminatedRows.length} 款`, + } + : undefined + } + onClick={ + terminatedRows.length + ? () => + setTerminatedDetailCategory((current) => + current === "ALL" ? null : "ALL", + ) + : undefined + } + /> + {terminatedReasonDistribution.map(([label, count]) => { + const reasonRows = terminatedRows.filter( + (row) => row.reasonCategory === label, + ); + const isActive = terminatedDetailCategory === label; + return ( + ({ + openingCode: row.openingCode, + productName: row.reason, + stage: row.statusLabel, + metric: row.terminatedDate, + })), + previewColumnLabels: { + primary: "开款 / 原因", + stage: "状态", + metric: "终止日期", + }, + previewGridTemplate: + "minmax(0, 1fr) 48px 72px", + actionLabel: isActive + ? "已展开,再次点击收起" + : `查看全部 ${count} 款`, + }} + onClick={() => + setTerminatedDetailCategory((current) => + current === label ? null : label, + ) + } + /> + ); + })} +
+ {terminatedDetailCategory !== null ? ( +
+ } + detail={ + + } + /> +
+ + + + + + + + + + + + + + {terminatedDetailRows.length ? ( + terminatedDetailRows.map((row) => ( + + + + + + + + + + )) + ) : ( + + + + )} + +
款式图片开款编号流程状态创建日期终止日期终止原因分类终止原因说明
+ {row.styleImageUrl ? ( + {`${row.openingCode} + ) : ( + + 暂无 + + )} + {row.openingCode} + + {row.statusLabel} + + {row.createdDate}{row.terminatedDate} + + {row.reasonCategory} + + {row.reason}
+ 当前筛选条件下暂无已终止任务 +
+
+
+ ) : null} +
diff --git a/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.test.ts b/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.test.ts index c5c4266f..8ce48029 100644 --- a/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.test.ts +++ b/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.test.ts @@ -1,14 +1,14 @@ import { filterSupplyChainMenu, flattenSupplyChainMenu } from './supply-chain-menu' describe('supply chain menu', () => { - it('exposes the new product shared datasets', () => { + it('places the unified table under database and keeps sample progress under new product management', () => { const items = flattenSupplyChainMenu() expect(items).toEqual( expect.arrayContaining([ expect.objectContaining({ label: '新品开发进度总表', - module: '新品管理', + module: '数据库', route: '/supply-chain/new-products/unified-table', }), expect.objectContaining({ diff --git a/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.ts b/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.ts index c2b4157a..5f2b4e9d 100644 --- a/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.ts +++ b/furniture-3d-system/src/app/supply-chain/config/supply-chain-menu.ts @@ -39,6 +39,7 @@ export const SUPPLY_CHAIN_MENU: SupplyChainMenuModule[] = [ { items: [ { label: '产品全量档案', route: '/supply-chain/database/product-archive', targetKey: 'productArchive' }, + { label: '新品开发进度总表', route: '/supply-chain/new-products/unified-table', targetKey: 'newProductUnifiedTable' }, { label: '类目配置', route: '/supply-chain/database/category-config', targetKey: 'categoryConfig' }, ], }, @@ -57,12 +58,12 @@ export const SUPPLY_CHAIN_MENU: SupplyChainMenuModule[] = [ { items: [ { label: '新品经营总览', route: '/supply-chain/new-products/overview', targetKey: 'newProductOverview' }, + { label: '终止任务反馈', route: '/supply-chain/new-products/termination-feedback', targetKey: 'newProductTerminationFeedback' }, ], }, { title: '公共数据源', items: [ - { label: '新品开发进度总表', route: '/supply-chain/new-products/unified-table', targetKey: 'newProductUnifiedTable' }, { label: '打样进度明细', route: '/supply-chain/new-products/sample-progress', targetKey: 'newProductSampleProgress' }, ], }, diff --git a/furniture-3d-system/src/app/supply-chain/new-products/overview/page.tsx b/furniture-3d-system/src/app/supply-chain/new-products/overview/page.tsx index 39845d5c..d0bd5b1c 100644 --- a/furniture-3d-system/src/app/supply-chain/new-products/overview/page.tsx +++ b/furniture-3d-system/src/app/supply-chain/new-products/overview/page.tsx @@ -22,6 +22,7 @@ export default async function NewProductOverviewPage() { const result = await oaBoardService .getDashboard({ overallStatus: [...OA_BOARD_DEFAULT_TRACKED_STATUSES], + includeTerminated: true, }) .then((dashboard) => ({ dashboard, diff --git a/furniture-3d-system/src/app/supply-chain/new-products/termination-feedback/TerminationFeedbackClient.tsx b/furniture-3d-system/src/app/supply-chain/new-products/termination-feedback/TerminationFeedbackClient.tsx new file mode 100644 index 00000000..8cb59333 --- /dev/null +++ b/furniture-3d-system/src/app/supply-chain/new-products/termination-feedback/TerminationFeedbackClient.tsx @@ -0,0 +1,258 @@ +'use client' + +import Image from 'next/image' +import { useMemo, useState } from 'react' +import { CheckCircle2, ClipboardPenLine, Filter, Search, X } from 'lucide-react' +import { NEW_PRODUCT_TERMINATION_REASON_CATEGORIES } from '@/lib/new-product-termination-feedback' + +export type TerminationFeedbackTask = { + id: string + styleImageUrl: string | null + openingCode: string + statusLabel: string + createdDate: string + terminatedDate: string + reasonCategory: string | null + reason: string | null + source: 'MANUAL' | 'DINGTALK' | 'NONE' | 'UNAVAILABLE' + feedbackByName: string | null + feedbackAt: string | null +} + +function formatDate(value: string | null) { + if (!value) return '-' + return value.slice(0, 10) +} + +export default function TerminationFeedbackClient({ + initialTasks, + canEdit, +}: { + initialTasks: TerminationFeedbackTask[] + canEdit: boolean +}) { + const [tasks, setTasks] = useState(initialTasks) + const [keyword, setKeyword] = useState('') + const [feedbackFilter, setFeedbackFilter] = useState<'ALL' | 'PENDING' | 'DONE'>('PENDING') + const [editing, setEditing] = useState(null) + const [category, setCategory] = useState('') + const [reason, setReason] = useState('') + const [saving, setSaving] = useState(false) + const [message, setMessage] = useState(null) + + const filteredTasks = useMemo(() => { + const normalizedKeyword = keyword.trim().toLowerCase() + return tasks.filter((task) => { + const hasManualFeedback = task.source === 'MANUAL' + if (feedbackFilter === 'PENDING' && hasManualFeedback) return false + if (feedbackFilter === 'DONE' && !hasManualFeedback) return false + if (!normalizedKeyword) return true + return [task.openingCode, task.statusLabel, task.reasonCategory, task.reason] + .join('\n') + .toLowerCase() + .includes(normalizedKeyword) + }) + }, [feedbackFilter, keyword, tasks]) + + const manualCount = tasks.filter((task) => task.source === 'MANUAL').length + const pendingCount = tasks.length - manualCount + + function openEditor(task: TerminationFeedbackTask) { + setEditing(task) + setCategory(task.source === 'MANUAL' ? task.reasonCategory ?? '' : '') + setReason(task.reason ?? '') + setMessage(null) + } + + async function saveFeedback() { + if (!editing || !category || !reason.trim()) { + setMessage('请选择原因分类并填写终止原因说明。') + return + } + setSaving(true) + setMessage(null) + try { + const response = await fetch('/api/supply-chain/new-product-termination-feedback', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ openingCode: editing.openingCode, reasonCategory: category, reason }), + }) + const payload = (await response.json().catch(() => null)) as + | { + success?: boolean + data?: { reasonCategory: string; reason: string; feedbackByName: string; updatedAt: string } + error?: { message?: string } + } + | null + if (!response.ok || !payload?.success || !payload.data) { + throw new Error(payload?.error?.message || '保存失败,请稍后重试。') + } + const saved = payload.data + setTasks((current) => + current.map((task) => + task.openingCode === editing.openingCode + ? { + ...task, + reasonCategory: saved.reasonCategory, + reason: saved.reason, + source: 'MANUAL', + feedbackByName: saved.feedbackByName, + feedbackAt: saved.updatedAt, + } + : task, + ), + ) + setEditing(null) + } catch (error) { + setMessage(error instanceof Error ? error.message : '保存失败,请稍后重试。') + } finally { + setSaving(false) + } + } + + return ( +
+
+
+
+

供应链中心 / 新品管理

+

终止任务反馈

+
+
+ 全部 {tasks.length} + 待反馈 {pendingCount} + 已反馈 {manualCount} +
+
+ +
+ +
+ + {([['PENDING', '待反馈'], ['DONE', '已反馈'], ['ALL', '全部']] as const).map(([value, label]) => ( + + ))} +
+
+ +
+
+ + + + + + + + + + + + + + + + {filteredTasks.map((task) => ( + + + + + + + + + + + + ))} + {filteredTasks.length === 0 ? ( + + ) : null} + +
款式图片开款编号状态创建日期终止日期原因分类原因说明反馈记录操作
+ {task.styleImageUrl ? ( + {`${task.openingCode} + ) : ( + 暂无 + )} + {task.openingCode}{task.statusLabel}{formatDate(task.createdDate)}{formatDate(task.terminatedDate)} + + {task.reason ? task.reasonCategory || '其他' : '待反馈'} + + + {task.reason || '钉钉流程未填写原因,请补充反馈'} + {task.source === 'DINGTALK' ? 钉钉意见 : null} + + {task.source === 'MANUAL' ? `${task.feedbackByName || '-'} · ${formatDate(task.feedbackAt)}` : '尚未人工反馈'} + + +
当前筛选条件下暂无任务
+
+
+
+ + {editing ? ( +
+
+
+
+

终止原因反馈

+

{editing.openingCode} · {editing.statusLabel}

+
+ +
+
+ {editing.source === 'DINGTALK' && editing.reason ? ( +
+ 钉钉审批意见:{editing.reason} +
+ ) : null} + +