feat(supply-chain): add terminated task feedback
This commit is contained in:
parent
0bf579a6f0
commit
32fedbe898
@ -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");
|
||||
@ -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])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 五金件库(标准五金件复用系统)
|
||||
// ============================================
|
||||
|
||||
@ -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 },
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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' },
|
||||
],
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
export const NEW_PRODUCT_TERMINATION_REASON_CATEGORIES = [
|
||||
'企划/需求',
|
||||
'产品设计',
|
||||
'采购/成本',
|
||||
'生产/交付',
|
||||
'视觉交付',
|
||||
'其他',
|
||||
] as const
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
@ -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 })
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -32,6 +32,7 @@ export function filterOaBoardLocalPreviewDashboard(
|
||||
export function createOaBoardLocalPreviewFallback(): OaBoardDashboardDTO {
|
||||
return {
|
||||
items: [],
|
||||
terminatedItems: [],
|
||||
summary: {
|
||||
totalProjects: 0,
|
||||
timeoutProjects: 0,
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user