feat(supply-chain): add terminated task feedback

This commit is contained in:
jingchuan 2026-08-19 16:56:32 +08:00
parent 0bf579a6f0
commit 32fedbe898
20 changed files with 1191 additions and 21 deletions

View File

@ -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");

View File

@ -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])
}
// ============================================
// 五金件库(标准五金件复用系统)
// ============================================

View File

@ -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 },
)
}

View File

@ -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<string, OaBoardProjectListItemDTO>();
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 ? (
<span className="mt-2 block border-t border-white/15 pt-1.5">
<span className="grid grid-cols-[minmax(0,1fr)_58px_48px] gap-2 pb-1 text-[9px] text-white/40">
<span>开款 / 产品</span>
<span>当前环节</span>
<span className="text-right">时效</span>
<span
className="grid gap-2 pb-1 text-[9px] text-white/40"
style={{
gridTemplateColumns:
hoverDetail.previewGridTemplate ??
"minmax(0, 1fr) 58px 48px",
}}
>
<span>{hoverDetail.previewColumnLabels?.primary ?? "开款 / 产品"}</span>
<span>{hoverDetail.previewColumnLabels?.stage ?? "当前环节"}</span>
<span className="text-right">
{hoverDetail.previewColumnLabels?.metric ?? "时效"}
</span>
</span>
{hoverDetail.previewRows.map((row) => (
<span
key={`${row.openingCode}-${row.stage ?? ""}`}
className="grid grid-cols-[minmax(0,1fr)_58px_48px] items-center gap-2 border-t border-white/[0.07] py-1.5 text-[10px] leading-4"
className="grid items-center gap-2 border-t border-white/[0.07] py-1.5 text-[10px] leading-4"
style={{
gridTemplateColumns:
hoverDetail.previewGridTemplate ??
"minmax(0, 1fr) 58px 48px",
}}
>
<span className="min-w-0">
<span className="block truncate font-semibold text-white">
@ -3184,10 +3286,56 @@ export default function NewProductManagementDashboard({
useState<CompletedTimingDetailFilter>(null);
const [runningTimingFilter, setRunningTimingFilter] =
useState<RunningTimingDetailFilter>(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<string, number>();
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({
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<EfficiencyCard
label="进行中任务"
value={timingOverview.runningCount}
suffix="个"
benchmark="按开款编号去重"
note="当前未完成流程"
label="平均已用时"
value={timingOverview.runningAverageDays}
suffix="天"
benchmark={`已评估 ${timingOverview.runningAssessedCount} 个流程`}
note="进行中流程平均已用时"
tone="neutral"
departmentItems={timingOverview.runningDepartments.map(
(department) => ({
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,
}),
)}
/>
<EfficiencyCard
label="周期超期任务"
@ -3958,6 +4122,205 @@ export default function NewProductManagementDashboard({
/>
) : null}
</section>
<section className="space-y-2.5">
<div className="flex items-end justify-between gap-3 px-0.5">
<div>
<h2 className="flex items-center gap-2 text-sm font-semibold text-[#273438]">
<Ban className="h-4 w-4 text-[#8d5960]" />
已终止任务
</h2>
<p className="mt-1 text-[11px] text-[#879297]">
按终止日期统计 · 按开款编号去重 · 原因说明优先取钉钉终止节点审批意见
</p>
</div>
<button
type="button"
aria-expanded={terminatedDetailCategory !== null}
onClick={() =>
setTerminatedDetailCategory((current) =>
current === null ? "ALL" : null,
)
}
disabled={terminatedRows.length === 0}
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-black/[0.09] bg-white px-3 text-[11px] font-medium text-[#334044] hover:bg-[#f3f6f6] disabled:cursor-not-allowed disabled:opacity-45"
>
{terminatedDetailCategory === null
? "查看已终止任务明细"
: "收起已终止任务明细"}
<ChevronRight
className={`h-3.5 w-3.5 transition ${terminatedDetailCategory !== null ? "rotate-90" : ""}`}
/>
</button>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<EfficiencyCard
label="终止任务量"
value={terminatedRows.length}
suffix="款"
benchmark="当前统计区间按终止日期计算"
note={terminatedRows.length ? "悬浮预览 · 点击查看" : "暂无终止任务"}
tone={terminatedRows.length ? "danger" : "good"}
active={terminatedDetailCategory === "ALL"}
hoverDetail={
terminatedRows.length
? {
title: `已终止任务 · ${terminatedRows.length} 款`,
detail: "包含已撤销、已拒绝和已终止的总流程;按最近终止日期排列",
previewRows: terminatedRows.slice(0, 3).map((row) => ({
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 (
<EfficiencyCard
key={label}
label={label}
value={count}
suffix="款"
benchmark="终止原因分类"
note="悬浮预览 · 点击查看"
tone={label === "待反馈" ? "warning" : "danger"}
active={isActive}
hoverDetail={{
title: `${label} · ${count} 款`,
detail:
label === "待反馈"
? "钉钉流程未填写有效终止意见,待在反馈页面补录"
: "展示该原因分类下最近终止的任务",
previewRows: reasonRows.slice(0, 3).map((row) => ({
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,
)
}
/>
);
})}
</div>
{terminatedDetailCategory !== null ? (
<section className="overflow-hidden rounded-lg border border-[#d9c6c8] bg-white shadow-[0_8px_24px_rgba(78,40,45,0.07)]">
<SectionHeader
title={`已终止任务明细 · ${terminatedDetailLabel}`}
eyebrow={`当前 ${terminatedDetailRows.length} 款 · 点击上方原因卡可切换筛选`}
icon={<Ban className="h-4 w-4 text-[#8d5960]" />}
detail={
<button
type="button"
onClick={() => setTerminatedDetailCategory(null)}
className="inline-flex h-7 items-center gap-1 rounded-md border border-black/[0.08] bg-white px-2 text-[10px] font-medium text-[#647176] hover:bg-[#f3f6f6] hover:text-[#182225]"
>
<X className="h-3.5 w-3.5" />
收起明细
</button>
}
/>
<div className="overflow-x-auto">
<table className="w-full min-w-[880px] text-left text-[11px]">
<thead className="bg-[#f7f9f9] text-[#647176]">
<tr>
<th className="w-[76px] px-3 py-2.5 font-medium">款式图片</th>
<th className="w-[190px] px-3 py-2.5 font-medium">开款编号</th>
<th className="w-[100px] px-3 py-2.5 font-medium">流程状态</th>
<th className="w-[120px] px-3 py-2.5 font-medium">创建日期</th>
<th className="w-[120px] px-3 py-2.5 font-medium">终止日期</th>
<th className="w-[140px] px-3 py-2.5 font-medium">终止原因分类</th>
<th className="px-3 py-2.5 font-medium">终止原因说明</th>
</tr>
</thead>
<tbody>
{terminatedDetailRows.length ? (
terminatedDetailRows.map((row) => (
<tr key={row.id} className="border-t border-black/[0.05]">
<td className="px-3 py-2.5">
{row.styleImageUrl ? (
<Image
src={row.styleImageUrl}
alt={`${row.openingCode} 款式图片`}
width={44}
height={44}
unoptimized
className="h-11 w-11 rounded-md border border-black/[0.07] object-cover"
/>
) : (
<span className="flex h-11 w-11 items-center justify-center rounded-md border border-dashed border-black/[0.1] bg-[#f7f9f9] text-[10px] text-[#9aa5a8]">
暂无
</span>
)}
</td>
<td className="px-3 py-3 font-semibold text-[#245f5a]">{row.openingCode}</td>
<td className="px-3 py-3">
<span className="rounded-full bg-[#f3eeee] px-2 py-1 text-[10px] font-medium text-[#8d5960]">
{row.statusLabel}
</span>
</td>
<td className="px-3 py-3 text-[#4b585c]">{row.createdDate}</td>
<td className="px-3 py-3 font-medium text-[#8d5960]">{row.terminatedDate}</td>
<td className="px-3 py-3">
<span className="rounded-full bg-[#fff1f1] px-2 py-1 text-[10px] font-medium text-[#8d5960]">
{row.reasonCategory}
</span>
</td>
<td className="max-w-[430px] px-3 py-3 leading-5 text-[#4b585c]">{row.reason}</td>
</tr>
))
) : (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-xs text-[#879297]">
当前筛选条件下暂无已终止任务
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
) : null}
</section>
<section className="space-y-2.5">
<div className="flex items-end justify-between gap-3 px-0.5">
<div>

View File

@ -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({

View File

@ -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' },
],
},

View File

@ -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,

View File

@ -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<TerminationFeedbackTask | null>(null)
const [category, setCategory] = useState('')
const [reason, setReason] = useState('')
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<string | null>(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 (
<main className="min-h-full bg-[#f4f7f7] px-4 py-5 text-[#182225] sm:px-6 xl:px-8">
<div className="mx-auto max-w-[1500px] space-y-4">
<header className="flex flex-col gap-3 border-b border-black/[0.08] pb-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-[11px] font-medium text-[#6f7d81]">供应链中心 / 新品管理</p>
<h1 className="mt-1 text-[26px] font-semibold text-[#182225]">终止任务反馈</h1>
</div>
<div className="flex gap-2 text-[11px]">
<span className="rounded-md border border-black/[0.08] bg-white px-3 py-2 text-[#647176]">全部 {tasks.length}</span>
<span className="rounded-md border border-[#ead7d8] bg-[#fff8f8] px-3 py-2 text-[#8d5960]">待反馈 {pendingCount}</span>
<span className="rounded-md border border-[#cfe2dd] bg-[#f4faf8] px-3 py-2 text-[#28705a]">已反馈 {manualCount}</span>
</div>
</header>
<section className="flex flex-col gap-2 rounded-lg border border-black/[0.08] bg-white p-3 sm:flex-row sm:items-center sm:justify-between">
<label className="flex h-9 min-w-[280px] items-center gap-2 rounded-md border border-black/[0.09] bg-[#f9fbfb] px-3 text-[#647176]">
<Search className="h-3.5 w-3.5" />
<input
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索开款编号或原因"
className="w-full bg-transparent text-xs outline-none placeholder:text-[#a3adaf]"
/>
</label>
<div className="flex items-center gap-1 rounded-md border border-black/[0.08] bg-[#f9fbfb] p-1">
<Filter className="mx-1 h-3.5 w-3.5 text-[#879297]" />
{([['PENDING', '待反馈'], ['DONE', '已反馈'], ['ALL', '全部']] as const).map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => setFeedbackFilter(value)}
className={`rounded px-3 py-1.5 text-[11px] ${feedbackFilter === value ? 'bg-[#245f5a] font-semibold text-white' : 'text-[#647176] hover:bg-white'}`}
>
{label}
</button>
))}
</div>
</section>
<section className="overflow-hidden rounded-lg border border-black/[0.08] bg-white shadow-[0_5px_18px_rgba(26,39,43,0.04)]">
<div className="overflow-x-auto">
<table className="w-full min-w-[1120px] text-left text-[11px]">
<thead className="bg-[#f7f9f9] text-[#647176]">
<tr>
<th className="w-[72px] px-3 py-2.5 font-medium">款式图片</th>
<th className="w-[175px] px-3 py-2.5 font-medium">开款编号</th>
<th className="w-[95px] px-3 py-2.5 font-medium">状态</th>
<th className="w-[115px] px-3 py-2.5 font-medium">创建日期</th>
<th className="w-[115px] px-3 py-2.5 font-medium">终止日期</th>
<th className="w-[130px] px-3 py-2.5 font-medium">原因分类</th>
<th className="px-3 py-2.5 font-medium">原因说明</th>
<th className="w-[150px] px-3 py-2.5 font-medium">反馈记录</th>
<th className="w-[90px] px-3 py-2.5 text-right font-medium">操作</th>
</tr>
</thead>
<tbody>
{filteredTasks.map((task) => (
<tr key={task.id} className="border-t border-black/[0.05] hover:bg-[#fbfcfc]">
<td className="px-3 py-2.5">
{task.styleImageUrl ? (
<Image src={task.styleImageUrl} alt={`${task.openingCode} 款式图片`} width={44} height={44} unoptimized className="h-11 w-11 rounded-md border border-black/[0.07] object-cover" />
) : (
<span className="flex h-11 w-11 items-center justify-center rounded-md border border-dashed border-black/[0.1] bg-[#f7f9f9] text-[9px] text-[#9aa5a8]">暂无</span>
)}
</td>
<td className="px-3 py-3 font-semibold text-[#245f5a]">{task.openingCode}</td>
<td className="px-3 py-3 text-[#8d5960]">{task.statusLabel}</td>
<td className="px-3 py-3 text-[#4b585c]">{formatDate(task.createdDate)}</td>
<td className="px-3 py-3 text-[#4b585c]">{formatDate(task.terminatedDate)}</td>
<td className="px-3 py-3">
<span className={`rounded-full px-2 py-1 text-[10px] font-medium ${task.reason ? 'bg-[#f3eeee] text-[#8d5960]' : 'bg-[#fff6e9] text-[#a75b29]'}`}>
{task.reason ? task.reasonCategory || '其他' : '待反馈'}
</span>
</td>
<td className="max-w-[380px] px-3 py-3 leading-5 text-[#4b585c]">
{task.reason || '钉钉流程未填写原因,请补充反馈'}
{task.source === 'DINGTALK' ? <span className="ml-2 text-[9px] text-[#879297]">钉钉意见</span> : null}
</td>
<td className="px-3 py-3 text-[10px] text-[#879297]">
{task.source === 'MANUAL' ? `${task.feedbackByName || '-'} · ${formatDate(task.feedbackAt)}` : '尚未人工反馈'}
</td>
<td className="px-3 py-3 text-right">
<button
type="button"
disabled={!canEdit}
onClick={() => openEditor(task)}
className="inline-flex h-8 items-center gap-1 rounded-md border border-black/[0.09] bg-white px-2.5 text-[10px] font-medium text-[#334044] hover:bg-[#f3f6f6] disabled:cursor-not-allowed disabled:opacity-45"
>
<ClipboardPenLine className="h-3.5 w-3.5" />
{task.source === 'MANUAL' ? '修改' : '反馈'}
</button>
</td>
</tr>
))}
{filteredTasks.length === 0 ? (
<tr><td colSpan={9} className="px-4 py-12 text-center text-xs text-[#879297]">当前筛选条件下暂无任务</td></tr>
) : null}
</tbody>
</table>
</div>
</section>
</div>
{editing ? (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/35 p-4">
<section role="dialog" aria-modal="true" aria-label="填写终止原因" className="w-full max-w-[560px] rounded-lg border border-black/[0.08] bg-white shadow-[0_24px_80px_rgba(16,28,31,0.2)]">
<header className="flex items-start justify-between border-b border-black/[0.07] px-5 py-4">
<div>
<h2 className="text-sm font-semibold text-[#273438]">终止原因反馈</h2>
<p className="mt-1 text-[11px] text-[#879297]">{editing.openingCode} · {editing.statusLabel}</p>
</div>
<button type="button" aria-label="关闭" onClick={() => setEditing(null)} className="flex h-8 w-8 items-center justify-center rounded-md text-[#647176] hover:bg-[#f3f6f6]"><X className="h-4 w-4" /></button>
</header>
<div className="space-y-4 px-5 py-4">
{editing.source === 'DINGTALK' && editing.reason ? (
<div className="rounded-md border border-[#d7e3e6] bg-[#f5f9fa] px-3 py-2 text-[11px] leading-5 text-[#526166]">
钉钉审批意见:{editing.reason}
</div>
) : null}
<label className="block text-[11px] font-medium text-[#4f5d61]">
终止原因分类
<select value={category} onChange={(event) => setCategory(event.target.value)} className="mt-1.5 h-10 w-full rounded-md border border-black/[0.1] bg-white px-3 text-xs outline-none focus:border-[#245f5a]">
<option value="">请选择</option>
{NEW_PRODUCT_TERMINATION_REASON_CATEGORIES.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</label>
<label className="block text-[11px] font-medium text-[#4f5d61]">
终止原因说明
<textarea value={reason} onChange={(event) => setReason(event.target.value)} maxLength={1000} rows={5} placeholder="说明终止背景和具体原因" className="mt-1.5 w-full resize-none rounded-md border border-black/[0.1] bg-white px-3 py-2.5 text-xs leading-5 outline-none focus:border-[#245f5a]" />
<span className="mt-1 block text-right text-[9px] text-[#a3adaf]">{reason.length} / 1000</span>
</label>
{message ? <p className="rounded-md bg-[#fff1f1] px-3 py-2 text-[11px] text-[#a84741]">{message}</p> : null}
</div>
<footer className="flex justify-end gap-2 border-t border-black/[0.07] px-5 py-3">
<button type="button" onClick={() => setEditing(null)} className="h-9 rounded-md border border-black/[0.09] bg-white px-4 text-xs text-[#647176] hover:bg-[#f3f6f6]">取消</button>
<button type="button" disabled={saving} onClick={saveFeedback} className="inline-flex h-9 items-center gap-1.5 rounded-md bg-[#245f5a] px-4 text-xs font-semibold text-white hover:bg-[#1f534f] disabled:opacity-60">
<CheckCircle2 className="h-3.5 w-3.5" />{saving ? '保存中' : '保存反馈'}
</button>
</footer>
</section>
</div>
) : null}
</main>
)
}

View File

@ -0,0 +1,56 @@
import { redirect } from 'next/navigation'
import { requireSession } from '@/lib/server-session'
import { oaBoardService } from '@/server/services/oa-board.service'
import { supplyChainPermissionSettingsService } from '@/server/services/supply-chain-permission-settings.service'
import TerminationFeedbackClient, { type TerminationFeedbackTask } from './TerminationFeedbackClient'
export const dynamic = 'force-dynamic'
export const metadata = { title: '终止任务反馈 · 供应链中心' }
function toTask(item: Awaited<ReturnType<typeof oaBoardService.getDashboard>>['terminatedItems'][number]): TerminationFeedbackTask {
return {
id: item.id,
styleImageUrl: item.styleImages[0]?.url ?? null,
openingCode: item.openingCode || item.formSummary.openingCode || '未填写',
statusLabel: item.formSummary.totalFlowStatusLabel || '已终止',
createdDate: item.createdDate || '',
terminatedDate: item.finishedDate || '',
reasonCategory: item.terminationReasonCategory,
reason: item.terminationReason,
source: item.terminationReasonSource,
feedbackByName: item.terminationFeedbackByName,
feedbackAt: item.terminationFeedbackAt,
}
}
function deduplicateByOpeningCode(tasks: TerminationFeedbackTask[]) {
const latestByOpeningCode = new Map<string, TerminationFeedbackTask>()
tasks.forEach((task) => {
const current = latestByOpeningCode.get(task.openingCode)
if (!current || task.terminatedDate.localeCompare(current.terminatedDate) > 0) {
latestByOpeningCode.set(task.openingCode, task)
}
})
return Array.from(latestByOpeningCode.values()).sort((left, right) =>
right.terminatedDate.localeCompare(left.terminatedDate),
)
}
export default async function NewProductTerminationFeedbackPage() {
const { authUser } = await requireSession('/login')
const access = await supplyChainPermissionSettingsService.getUserAccess(
authUser,
'newProductTerminationFeedback',
)
if (!access.view) redirect('/supply-chain')
const dashboard = await oaBoardService.getDashboard({ includeTerminated: true })
return (
<div className="h-full overflow-y-auto">
<TerminationFeedbackClient
initialTasks={deduplicateByOpeningCode(dashboard.terminatedItems.map(toTask))}
canEdit={access.edit}
/>
</div>
)
}

View File

@ -1,7 +1,7 @@
'use client'
import { Fragment, useEffect, useMemo, useState } from 'react'
import { Check, ChevronDown, ClipboardList, Database, LayoutDashboard, PackagePlus, ShoppingCart, Users, Warehouse, X } from 'lucide-react'
import { Check, ChevronDown, ClipboardList, Database, LayoutDashboard, PackagePlus, PackageX, ShoppingCart, Users, Warehouse, X } from 'lucide-react'
import {
SUPPLY_CHAIN_PERMISSION_ACTION_LABELS,
SUPPLY_CHAIN_PERMISSION_CORE_SUBJECTS,
@ -27,6 +27,7 @@ const TARGET_ICONS: Record<SupplyChainPermissionTargetKey, typeof LayoutDashboar
categoryConfig: Database,
warehouseConfig: Warehouse,
newProductOverview: LayoutDashboard,
newProductTerminationFeedback: PackageX,
newProductUnifiedTable: PackagePlus,
newProductSampleProgress: PackagePlus,
purchaseDailyAffairs: ShoppingCart,
@ -45,16 +46,16 @@ const SUPPLY_PERMISSION_GROUPS = [
{
key: 'database',
label: '数据库',
description: '产品档案、类目和仓库配置',
description: '产品档案、新品宽表、类目和仓库配置',
icon: Database,
targets: SUPPLY_CHAIN_PERMISSION_TARGETS.filter((target) => ['productArchive', 'categoryConfig', 'warehouseConfig'].includes(target.key)),
targets: SUPPLY_CHAIN_PERMISSION_TARGETS.filter((target) => ['productArchive', 'newProductUnifiedTable', 'categoryConfig', 'warehouseConfig'].includes(target.key)),
},
{
key: 'new-products',
label: '新品开发',
description: '新品开发总表与打样进度明细',
description: '新品经营、终止反馈与打样进度明细',
icon: PackagePlus,
targets: SUPPLY_CHAIN_PERMISSION_TARGETS.filter((target) => ['newProductOverview', 'newProductUnifiedTable', 'newProductSampleProgress'].includes(target.key)),
targets: SUPPLY_CHAIN_PERMISSION_TARGETS.filter((target) => ['newProductOverview', 'newProductTerminationFeedback', 'newProductSampleProgress'].includes(target.key)),
},
{
key: 'purchase',

View File

@ -0,0 +1,8 @@
export const NEW_PRODUCT_TERMINATION_REASON_CATEGORIES = [
'企划/需求',
'产品设计',
'采购/成本',
'生产/交付',
'视觉交付',
'其他',
] as const

View File

@ -20,6 +20,7 @@ export type SupplyChainPermissionTargetKey =
| 'categoryConfig'
| 'warehouseConfig'
| 'newProductOverview'
| 'newProductTerminationFeedback'
| 'newProductUnifiedTable'
| 'newProductSampleProgress'
| 'purchaseDailyAffairs'
@ -73,6 +74,7 @@ export const SUPPLY_CHAIN_PERMISSION_TARGETS: SupplyChainPermissionTarget[] = [
{ key: 'categoryConfig', label: '类目配置', description: '产品全量档案类目维护' },
{ key: 'warehouseConfig', label: '仓库配置', description: '出入库仓库维护' },
{ key: 'newProductOverview', label: '新品经营总览', description: '新品经营指标和阶段时效分析' },
{ key: 'newProductTerminationFeedback', label: '终止任务反馈', description: '维护已撤销、已拒绝和已终止流程的原因' },
{ key: 'newProductUnifiedTable', label: '新品开发进度总表', description: '新品流程公共数据源' },
{ key: 'newProductSampleProgress', label: '打样进度明细', description: '新品打样进度公共数据源' },
{ key: 'purchaseDailyAffairs', label: '日常事项', description: '采购日常协作' },
@ -92,6 +94,7 @@ export const SUPPLY_CHAIN_PERMISSION_TARGET_ROUTES: Partial<
categoryConfig: '/supply-chain/database/category-config',
warehouseConfig: '/supply-chain/database/warehouse-config',
newProductOverview: '/supply-chain/new-products/overview',
newProductTerminationFeedback: '/supply-chain/new-products/termination-feedback',
newProductUnifiedTable: '/supply-chain/new-products/unified-table',
newProductSampleProgress: '/supply-chain/new-products/sample-progress',
purchaseDailyAffairs: '/supply-chain/purchase/daily-affairs',
@ -280,6 +283,7 @@ export function getSupplyChainPermissionTargetForPath(pathname: string) {
if (pathname === '/supply-chain/database/category-config') return 'categoryConfig' as const
if (pathname === '/supply-chain/database/warehouse-config') return 'warehouseConfig' as const
if (pathname === '/supply-chain/new-products/overview') return 'newProductOverview' as const
if (pathname === '/supply-chain/new-products/termination-feedback') return 'newProductTerminationFeedback' as const
if (pathname === '/supply-chain/new-products/unified-table') return 'newProductUnifiedTable' as const
if (pathname === '/supply-chain/new-products/sample-progress') return 'newProductSampleProgress' as const
if (pathname === '/supply-chain/purchase/daily-affairs') return 'purchaseDailyAffairs' as const
@ -307,5 +311,8 @@ export function getSupplyChainApiPermissionRequirement(pathname: string, method:
if (pathname.startsWith('/api/supply-chain/warehouse-config')) {
return { targetKey: 'warehouseConfig' as const, action }
}
if (pathname.startsWith('/api/supply-chain/new-product-termination-feedback')) {
return { targetKey: 'newProductTerminationFeedback' as const, action }
}
return undefined
}

View File

@ -0,0 +1,72 @@
import { randomUUID } from 'crypto'
import { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
export type NewProductTerminationFeedbackRecord = {
openingCode: string
reasonCategory: string
reason: string
feedbackById: string | null
feedbackByName: string
updatedAt: Date
}
type FeedbackRow = {
openingCode: string
reasonCategory: string
reason: string
feedbackById: string | null
feedbackByName: string
updatedAt: Date
}
class NewProductTerminationFeedbackRepository {
async findByOpeningCodes(openingCodes: string[]): Promise<NewProductTerminationFeedbackRecord[]> {
const values = Array.from(new Set(openingCodes.map((value) => value.trim()).filter(Boolean)))
if (values.length === 0) return []
return prisma.$queryRaw<FeedbackRow[]>(Prisma.sql`
SELECT
"openingCode",
"reasonCategory",
"reason",
"feedbackById",
"feedbackByName",
"updatedAt"
FROM "NewProductTerminationFeedback"
WHERE "openingCode" IN (${Prisma.join(values)})
`)
}
async upsert(input: {
openingCode: string
reasonCategory: string
reason: string
feedbackById: string | null
feedbackByName: string
}): Promise<NewProductTerminationFeedbackRecord> {
const id = randomUUID()
const rows = await prisma.$queryRaw<FeedbackRow[]>(Prisma.sql`
INSERT INTO "NewProductTerminationFeedback" (
"id", "openingCode", "reasonCategory", "reason",
"feedbackById", "feedbackByName", "createdAt", "updatedAt"
) VALUES (
${id}, ${input.openingCode}, ${input.reasonCategory}, ${input.reason},
${input.feedbackById}, ${input.feedbackByName}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
)
ON CONFLICT ("openingCode") DO UPDATE SET
"reasonCategory" = EXCLUDED."reasonCategory",
"reason" = EXCLUDED."reason",
"feedbackById" = EXCLUDED."feedbackById",
"feedbackByName" = EXCLUDED."feedbackByName",
"updatedAt" = CURRENT_TIMESTAMP
RETURNING
"openingCode", "reasonCategory", "reason", "feedbackById",
"feedbackByName", "updatedAt"
`)
if (!rows[0]) throw new Error('终止任务反馈保存后未返回记录')
return rows[0]
}
}
export const newProductTerminationFeedbackRepository =
new NewProductTerminationFeedbackRepository()

View File

@ -0,0 +1,31 @@
import { extractDingtalkTerminationReason } from '../dingtalk-process-instance.service'
describe('extractDingtalkTerminationReason', () => {
it('prefers the termination operation opinion', () => {
expect(
extractDingtalkTerminationReason([
{ operationType: 'REDIRECT_PROCESS', operationResult: 'NONE', remark: '旧意见', date: '2026-08-01' },
{ operationType: 'TERMINATE_PROCESS_INSTANCE', operationResult: 'NONE', remark: '供应商成本超出限价', date: '2026-08-02' },
]),
).toEqual({ category: '采购/成本', detail: '供应商成本超出限价' })
})
it('uses the latest meaningful approval opinion when termination is blank', () => {
expect(
extractDingtalkTerminationReason([
{ operationType: 'EXECUTE_TASK_NORMAL', operationResult: 'AGREE', remark: '确认', date: '2026-08-01' },
{ operationType: 'REDIRECT_PROCESS', operationResult: 'NONE', remark: '设计方案需重新规划', date: '2026-08-02' },
{ operationType: 'TERMINATE_PROCESS_INSTANCE', operationResult: 'NONE', remark: '', date: '2026-08-03' },
]),
).toEqual({ category: '产品设计', detail: '设计方案需重新规划' })
})
it('does not treat generic approval text as a termination reason', () => {
expect(
extractDingtalkTerminationReason([
{ operationType: 'EXECUTE_TASK_NORMAL', operationResult: 'AGREE', remark: '同意', date: '2026-08-01' },
{ operationType: 'TERMINATE_PROCESS_INSTANCE', operationResult: 'NONE', remark: '', date: '2026-08-02' },
]),
).toEqual({ category: '未填写', detail: null })
})
})

View File

@ -0,0 +1,145 @@
import 'server-only'
export type DingtalkOperationRecord = {
operationType: string
operationResult: string
remark: string
date: string
}
type DingtalkProcessInstanceResponse = {
errcode?: number
errmsg?: string
process_instance?: {
status?: string
operation_records?: Array<{
operation_type?: string
operation_result?: string
remark?: string
date?: string
}>
}
}
export type DingtalkTerminationReason = {
category: string
detail: string | null
}
const APP_TOKEN_URL = 'https://oapi.dingtalk.com/gettoken'
const PROCESS_INSTANCE_URL = 'https://oapi.dingtalk.com/topapi/processinstance/get'
const CACHE_TTL_MS = 10 * 60 * 1000
const GENERIC_APPROVAL_REMARKS = new Set(['同意', '确认', '通过', '已阅', '无', 'none', 'agree'])
function trim(value: unknown) {
return String(value ?? '').trim()
}
function classifyReason(detail: string | null) {
if (!detail) return '未填写'
if (/设计|外观|结构|方案|图纸/.test(detail)) return '产品设计'
if (/企划|需求|市场|评审|立项/.test(detail)) return '企划/需求'
if (/采购|供应商|成本|价格|限价|核价/.test(detail)) return '采购/成本'
if (/打样|生产|交期|交付|出货/.test(detail)) return '生产/交付'
if (/视觉|出图|拍摄/.test(detail)) return '视觉交付'
return '其他'
}
export function extractDingtalkTerminationReason(
records: DingtalkOperationRecord[],
): DingtalkTerminationReason {
const terminationRecord = [...records]
.reverse()
.find((record) => record.operationType === 'TERMINATE_PROCESS_INSTANCE')
const terminationRemark = terminationRecord?.remark || null
if (terminationRemark) {
return { category: classifyReason(terminationRemark), detail: terminationRemark }
}
// DingTalk often leaves the system termination operation blank. In that
// case use the latest non-generic approval/redirect opinion as the reason.
const meaningful = [...records]
.reverse()
.find((record) => {
const remark = record.remark.trim()
return Boolean(remark) && !GENERIC_APPROVAL_REMARKS.has(remark.toLowerCase())
})
const detail = meaningful?.remark || null
return { category: classifyReason(detail), detail }
}
class DingtalkProcessInstanceService {
private token: { value: string; expiresAt: number } | null = null
private tokenPromise: Promise<string> | null = null
private reasonCache = new Map<string, { value: DingtalkTerminationReason; expiresAt: number }>()
private async getAppAccessToken() {
if (this.token && Date.now() < this.token.expiresAt) return this.token.value
if (this.tokenPromise) return this.tokenPromise
this.tokenPromise = (async () => {
const appKey = trim(process.env.DINGTALK_CLIENT_ID)
const appSecret = trim(process.env.DINGTALK_CLIENT_SECRET)
if (!appKey || !appSecret) throw new Error('钉钉应用凭据未配置')
const response = await fetch(
`${APP_TOKEN_URL}?appkey=${encodeURIComponent(appKey)}&appsecret=${encodeURIComponent(appSecret)}`,
{ cache: 'no-store' },
)
const payload = (await response.json().catch(() => null)) as
| { access_token?: string; expires_in?: number; errcode?: number; errmsg?: string }
| null
if (!response.ok || payload?.errcode !== 0 || !payload.access_token) {
throw new Error(payload?.errmsg || `获取钉钉应用令牌失败(HTTP ${response.status})`)
}
const expiresIn = typeof payload.expires_in === 'number' ? payload.expires_in : 7200
this.token = { value: payload.access_token, expiresAt: Date.now() + Math.max(expiresIn - 120, 60) * 1000 }
return payload.access_token
})()
try {
return await this.tokenPromise
} finally {
this.tokenPromise = null
}
}
async getTerminationReason(processInstanceId: string): Promise<DingtalkTerminationReason> {
const id = trim(processInstanceId)
if (!id) return { category: '未填写', detail: null }
const cached = this.reasonCache.get(id)
if (cached && Date.now() < cached.expiresAt) return cached.value
try {
const accessToken = await this.getAppAccessToken()
const response = await fetch(
`${PROCESS_INSTANCE_URL}?access_token=${encodeURIComponent(accessToken)}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ process_instance_id: id }),
cache: 'no-store',
},
)
const payload = (await response.json().catch(() => null)) as DingtalkProcessInstanceResponse | null
if (!response.ok || payload?.errcode !== 0 || !payload.process_instance) {
throw new Error(payload?.errmsg || `读取审批实例失败(HTTP ${response.status})`)
}
const records = (payload.process_instance.operation_records ?? []).map((record) => ({
operationType: trim(record.operation_type),
operationResult: trim(record.operation_result),
remark: trim(record.remark),
date: trim(record.date),
}))
const value = extractDingtalkTerminationReason(records)
this.reasonCache.set(id, { value, expiresAt: Date.now() + CACHE_TTL_MS })
return value
} catch (error) {
console.error('Failed to read DingTalk termination reason', { processInstanceId: id, error })
const fallback = { category: '未获取', detail: null }
this.reasonCache.set(id, { value: fallback, expiresAt: Date.now() + 60_000 })
return fallback
}
}
}
export const dingtalkProcessInstanceService = new DingtalkProcessInstanceService()

View File

@ -0,0 +1,53 @@
import { AppError, ErrorCode } from '@/server/core/errors'
import { NEW_PRODUCT_TERMINATION_REASON_CATEGORIES } from '@/lib/new-product-termination-feedback'
import {
newProductTerminationFeedbackRepository,
type NewProductTerminationFeedbackRecord,
} from '@/server/repositories/new-product-termination-feedback.repository'
function normalizeText(value: unknown) {
return String(value ?? '').trim()
}
class NewProductTerminationFeedbackService {
async listByOpeningCodes(openingCodes: string[]): Promise<NewProductTerminationFeedbackRecord[]> {
return newProductTerminationFeedbackRepository.findByOpeningCodes(openingCodes)
}
async save(
input: { openingCode?: unknown; reasonCategory?: unknown; reason?: unknown },
user: { id?: string | null; name?: string | null },
): Promise<NewProductTerminationFeedbackRecord> {
const openingCode = normalizeText(input.openingCode)
const reasonCategory = normalizeText(input.reasonCategory)
const reason = normalizeText(input.reason)
const feedbackByName = normalizeText(user.name)
if (!openingCode) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '开款编号不能为空')
}
if (!NEW_PRODUCT_TERMINATION_REASON_CATEGORIES.includes(
reasonCategory as (typeof NEW_PRODUCT_TERMINATION_REASON_CATEGORIES)[number],
)) {
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '请选择有效的终止原因分类')
}
if (!reason) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '终止原因说明不能为空')
}
if (reason.length > 1000) {
throw new AppError(ErrorCode.VALIDATION_INVALID_FORMAT, '终止原因说明不能超过 1000 字')
}
if (!feedbackByName) {
throw new AppError(ErrorCode.VALIDATION_REQUIRED_FIELD, '当前反馈人信息不完整')
}
return newProductTerminationFeedbackRepository.upsert({
openingCode,
reasonCategory,
reason,
feedbackById: normalizeText(user.id) || null,
feedbackByName,
})
}
}
export const newProductTerminationFeedbackService =
new NewProductTerminationFeedbackService()

View File

@ -32,6 +32,7 @@ export function filterOaBoardLocalPreviewDashboard(
export function createOaBoardLocalPreviewFallback(): OaBoardDashboardDTO {
return {
items: [],
terminatedItems: [],
summary: {
totalProjects: 0,
timeoutProjects: 0,

View File

@ -1808,6 +1808,9 @@ function getFlowLifecycleStatus(record: NormalizedFlowRecord | null) {
if (normalizedStatus === 'TERMINATED') {
return 'TERMINATED'
}
if (normalizedStatus === 'CANCELED' || normalizedStatus === 'CANCELLED') {
return 'CANCELED'
}
if (normalizedStatus === 'COMPLETED') {
return 'COMPLETED'
}
@ -2063,6 +2066,7 @@ function computeOverallStatus(records: NormalizedFlowRecord[]) {
)
if (fallbackStatuses.includes('REFUSED')) return 'REFUSED'
if (fallbackStatuses.includes('TERMINATED')) return 'TERMINATED'
if (fallbackStatuses.includes('CANCELED')) return 'CANCELED'
if (fallbackStatuses.includes('RUNNING')) return 'RUNNING'
if (fallbackStatuses.length > 0 && fallbackStatuses.every((status) => status === 'COMPLETED')) {
return 'COMPLETED'
@ -2088,6 +2092,9 @@ function computeOverallStatus(records: NormalizedFlowRecord[]) {
if (orderStatus === 'TERMINATED' || artStatus === 'TERMINATED') {
return 'TERMINATED'
}
if (orderStatus === 'CANCELED' || artStatus === 'CANCELED') {
return 'CANCELED'
}
if (orderStatus === 'RUNNING' || artStatus === 'RUNNING') {
return 'RUNNING'
}

View File

@ -23,6 +23,21 @@ import {
getOaBoardTimelineMetricOvertimeDays,
OA_BOARD_APPROVAL_STAGE_TIMELINE_METRIC_KEYS,
} from '@/lib/oa-board-overtime'
import { dingtalkProcessInstanceService } from './dingtalk-process-instance.service'
import { newProductTerminationFeedbackService } from './new-product-termination-feedback.service'
const OA_BOARD_TERMINAL_STATUSES = ['TERMINATED', 'REFUSED', 'CANCELED', 'CANCELLED'] as const
function isTerminalFlowStatus(status: string | null | undefined, result?: string | null) {
const normalizedStatus = String(status ?? '').trim().toUpperCase()
const normalizedResult = String(result ?? '').trim().toUpperCase()
return (
OA_BOARD_TERMINAL_STATUSES.includes(
normalizedStatus as (typeof OA_BOARD_TERMINAL_STATUSES)[number],
) ||
(normalizedStatus === 'COMPLETED' && normalizedResult === 'REFUSE')
)
}
export interface OaBoardProjectListFilter {
keyword?: string
@ -42,6 +57,7 @@ export interface OaBoardProjectListFilter {
nodeName?: string
timeoutOnly?: boolean
timeoutStatus?: 'YES' | 'NO'
includeTerminated?: boolean
}
export interface OaBoardFlowChipDTO {
@ -114,6 +130,11 @@ export interface OaBoardProjectListItemDTO {
overallTimeoutFlag: boolean
createdDate: string | null
finishedDate: string | null
terminationReasonCategory: string | null
terminationReason: string | null
terminationReasonSource: 'MANUAL' | 'DINGTALK' | 'NONE' | 'UNAVAILABLE'
terminationFeedbackByName: string | null
terminationFeedbackAt: string | null
originatorUserName: string | null
originatorDeptName: string | null
lastSourceUpdatedAt: string | null
@ -1095,6 +1116,7 @@ export interface OaBoardStageMatrixRowDTO {
export interface OaBoardDashboardDTO {
items: OaBoardProjectListItemDTO[]
terminatedItems: OaBoardProjectListItemDTO[]
summary: OaBoardSummaryDTO
dailyReport?: OaBoardDailyReportDTO
monthlyReport?: OaBoardMonthlyReportDTO
@ -1199,6 +1221,7 @@ export function createEmptyOaBoardDashboard(productTypes: string[] = []): OaBoar
return {
items,
terminatedItems: [],
summary: {
totalProjects: 0,
timeoutProjects: 0,
@ -1300,6 +1323,11 @@ function buildMockOaBoardItem(input: {
overallTimeoutFlag: timeoutHours > 0,
createdDate: input.createdDate,
finishedDate: input.finishedDate,
terminationReasonCategory: null,
terminationReason: null,
terminationReasonSource: 'NONE',
terminationFeedbackByName: null,
terminationFeedbackAt: null,
originatorUserName: '系统管理员',
originatorDeptName: '产品开发中心',
lastSourceUpdatedAt: new Date().toISOString(),
@ -1504,6 +1532,7 @@ export function createMockOaBoardDashboard(): OaBoardDashboardDTO {
return {
items,
terminatedItems: [],
summary: {
totalProjects: items.length,
timeoutProjects: items.filter(hasOaBoardProjectOvertime).length,
@ -1684,6 +1713,12 @@ type PreviewProjectRecord = Awaited<ReturnType<typeof queryOaBoardPreview>>['pro
function toProjectListItem(record: PreviewProjectRecord): OaBoardProjectListItemDTO {
const metadata = isRecord(record.metadata) ? record.metadata : null
const reviewFlow = record.flowInstances.find((flowInstance) => flowInstance.flowType === 'review')
const terminatedFlow = record.flowInstances
.filter((flowInstance) => isTerminalFlowStatus(flowInstance.status, flowInstance.result))
.sort(
(left, right) =>
(right.finishedAtSource?.getTime() ?? 0) - (left.finishedAtSource?.getTime() ?? 0),
)[0]
const formSummary = readFormSummary(metadata?.formSummary)
const styleImages = normalizeImageList(metadata?.styleImages)
const timelineMetrics = Array.isArray(metadata?.timelineMetrics)
@ -1777,7 +1812,16 @@ function toProjectListItem(record: PreviewProjectRecord): OaBoardProjectListItem
overallTimeoutHours: toDays(record.overallTimeoutHours),
overallTimeoutFlag: record.overallTimeoutFlag,
createdDate: toIsoString(reviewFlow?.createdAtSource ?? record.flowInstances[0]?.createdAtSource),
finishedDate: toIsoString(reviewFlow?.finishedAtSource ?? record.flowInstances[0]?.finishedAtSource),
finishedDate: toIsoString(
isTerminalFlowStatus(record.overallStatus, record.overallResult)
? terminatedFlow?.finishedAtSource ?? reviewFlow?.finishedAtSource
: reviewFlow?.finishedAtSource ?? record.flowInstances[0]?.finishedAtSource,
),
terminationReasonCategory: null,
terminationReason: null,
terminationReasonSource: 'NONE',
terminationFeedbackByName: null,
terminationFeedbackAt: null,
originatorUserName: record.originatorUserName,
originatorDeptName: record.originatorDeptName,
lastSourceUpdatedAt: toIsoString(record.lastSourceUpdatedAt),
@ -2077,6 +2121,74 @@ export class OaBoardService {
})
const items = filterOaBoardProjectItems(allItems, filter)
let terminatedItems: OaBoardProjectListItemDTO[] = []
if (filter.includeTerminated) {
terminatedItems = filterOaBoardProjectItems(
allItems,
{ ...filter, overallStatus: [...OA_BOARD_TERMINAL_STATUSES], includeTerminated: false },
[...OA_BOARD_TERMINAL_STATUSES],
)
// Keep the dashboard request bounded. The termination count/table comes
// from the snapshot; only the latest 100 rows need a DingTalk opinion
// lookup, and the rest remain visibly marked as not yet fetched.
const reasonLookupItems = terminatedItems.slice(0, 100)
const enrichedItems: OaBoardProjectListItemDTO[] = []
for (let index = 0; index < reasonLookupItems.length; index += 8) {
const batch = await Promise.all(
reasonLookupItems.slice(index, index + 8).map(async (item) => {
const terminatedFlow = item.flowChips
.filter((flow) => isTerminalFlowStatus(flow.status, flow.result))
.sort(
(left, right) =>
parseDateSortValue(right.finishedAtSource) -
parseDateSortValue(left.finishedAtSource),
)[0]
const fallbackFlow = terminatedFlow ?? item.flowChips[0]
const reason = await dingtalkProcessInstanceService.getTerminationReason(
fallbackFlow?.processInstanceId ?? '',
)
return {
...item,
terminationReasonCategory: reason.category,
terminationReason: reason.detail,
terminationReasonSource:
reason.category === '未获取'
? 'UNAVAILABLE' as const
: reason.detail
? 'DINGTALK' as const
: 'NONE' as const,
}
}),
)
enrichedItems.push(...batch)
}
const enrichedById = new Map(enrichedItems.map((item) => [item.id, item]))
terminatedItems = terminatedItems.map((item) => enrichedById.get(item.id) ?? item)
const feedbacks = await newProductTerminationFeedbackService
.listByOpeningCodes(
terminatedItems.map((item) => item.openingCode || item.formSummary.openingCode),
)
.catch((error) => {
console.error('Failed to read new product termination feedbacks', error)
return []
})
const feedbackByOpeningCode = new Map(
feedbacks.map((feedback) => [feedback.openingCode, feedback] as const),
)
terminatedItems = terminatedItems.map((item) => {
const openingCode = item.openingCode || item.formSummary.openingCode
const feedback = feedbackByOpeningCode.get(openingCode)
if (!feedback) return item
return {
...item,
terminationReasonCategory: feedback.reasonCategory,
terminationReason: feedback.reason,
terminationReasonSource: 'MANUAL' as const,
terminationFeedbackByName: feedback.feedbackByName,
terminationFeedbackAt: feedback.updatedAt.toISOString(),
}
})
}
const productTypes = Array.from(
new Set(
@ -2112,6 +2224,7 @@ export class OaBoardService {
return {
items,
terminatedItems,
summary: {
totalProjects: items.length,
timeoutProjects: items.filter(hasOaBoardProjectOvertime).length,

View File

@ -104,6 +104,7 @@ class SupplyChainPermissionSettingsService {
'categoryConfig',
'warehouseConfig',
'newProductOverview',
'newProductTerminationFeedback',
'newProductUnifiedTable',
'newProductSampleProgress',
'purchaseDailyAffairs',
@ -122,6 +123,7 @@ class SupplyChainPermissionSettingsService {
'categoryConfig',
'warehouseConfig',
'newProductOverview',
'newProductTerminationFeedback',
'newProductUnifiedTable',
'newProductSampleProgress',
'purchaseDailyAffairs',