From 8cd5764428bef99a0b0de6b276e27343e721f8f3 Mon Sep 17 00:00:00 2001 From: jingchuan Date: Wed, 5 Aug 2026 10:44:55 +0800 Subject: [PATCH] fix(oa-board): allow historical overtime followup editing --- .../src/app/intake/oa-board/OaBoardClient.tsx | 85 +++------ .../oa-board-timeout-followup.service.ts | 175 +++++++++++++++--- 2 files changed, 178 insertions(+), 82 deletions(-) diff --git a/furniture-3d-system/src/app/intake/oa-board/OaBoardClient.tsx b/furniture-3d-system/src/app/intake/oa-board/OaBoardClient.tsx index f80cd5c0..d63d442b 100644 --- a/furniture-3d-system/src/app/intake/oa-board/OaBoardClient.tsx +++ b/furniture-3d-system/src/app/intake/oa-board/OaBoardClient.tsx @@ -787,6 +787,12 @@ function getHistoricalOvertimeStages(item: OaBoardProjectListItemDTO): OvertimeS function resolveTimeoutFollowupOwner(item: OaBoardProjectListItemDTO, stageKey: string) { const stage = getOvertimeStages(item).find((entry) => entry.stageKey === stageKey) if (stage?.ownerLabel?.trim()) return stage.ownerLabel.trim() + + const isHistoricalStage = getHistoricalOvertimeStages(item).some( + (entry) => entry.stageKey === stageKey, + ) + if (isHistoricalStage) return '' + const resolvedCurrentOwner = resolveCurrentOwnerDisplay(item) return resolvedCurrentOwner && resolvedCurrentOwner !== '-' ? resolvedCurrentOwner : '待分配' } @@ -1671,7 +1677,7 @@ function TimeoutFollowupInspector({ })) setDirtyStageKeys((current) => new Set(current).add(selectedStage.stageKey)) } - const dirtyEntries = currentOvertimeStages + const dirtyEntries = overtimeStages .filter((stage) => dirtyStageKeys.has(stage.stageKey)) .map((stage) => ({ stage, @@ -1683,7 +1689,6 @@ function TimeoutFollowupInspector({ ), })) const handleSave = async () => { - if (isHistoricalStage) return const entries = dirtyEntries.length > 0 ? dirtyEntries : [{ stage: selectedStage, draft }] const saved = await onSave(entries) @@ -1746,7 +1751,13 @@ function TimeoutFollowupInspector({ > {stage.stageLabel} - {isHistorical ? '历史记录' : isDirty ? '待保存' : record ? getTimeoutFollowupStatusLabel(record.status) : '待跟进'} + {isDirty + ? '待保存' + : record + ? getTimeoutFollowupStatusLabel(record.status) + : isHistorical + ? '未填写跟进' + : '待跟进'} {deadlineStatus ? ( @@ -1776,46 +1787,11 @@ function TimeoutFollowupInspector({ {isHistoricalStage ? ( -
-

- 该阶段已离开当前节点,因此只作为历史超时保留,不会再触发钉钉待办。 - {selectedFollowup ? ' 以下为已保存的历史跟进内容。' : ' 该阶段尚未填写历史跟进记录。'} -

-
-
-
跟进状态
-
-
-
-
跟进负责人
-
{formatCellText(selectedFollowup?.followUpOwner)}
-
-
-
预计完成日期
-
- {formatCellText(selectedFollowup?.expectedResolveDate)} - {selectedFollowupDeadlineStatus ? ( - {getFollowupDeadlineLabel(selectedFollowupDeadlineStatus)} - ) : null} -
-
-
-
-

超时原因说明

-

{formatCellText(selectedFollowup?.reasonDetail)}

-
-
-

解决措施

-

{formatCellText(selectedFollowup?.actionPlan)}

-
- {selectedFollowup?.updatedAt ? ( -

- 最近更新:{formatCellText(selectedFollowup.updatedBy)} · {selectedFollowup.updatedAt.replace('T', ' ').slice(0, 16)} -

- ) : null} -
- ) : ( - <> +
+ 历史超时跟进仅用于复盘,不会触发钉钉超时待办或计入当前超时。 +
+ ) : null} + <>
) : null} {errorMessage ?

{errorMessage}

: null} - - )} +
@@ -1888,17 +1863,15 @@ function TimeoutFollowupInspector({ > 关闭 - {!isHistoricalStage ? ( - - ) : null} +
diff --git a/furniture-3d-system/src/server/services/oa-board-timeout-followup.service.ts b/furniture-3d-system/src/server/services/oa-board-timeout-followup.service.ts index 5e974868..2cf09fa5 100644 --- a/furniture-3d-system/src/server/services/oa-board-timeout-followup.service.ts +++ b/furniture-3d-system/src/server/services/oa-board-timeout-followup.service.ts @@ -1,7 +1,11 @@ import * as mysql from 'mysql2/promise' import type { ResultSetHeader, RowDataPacket } from 'mysql2/promise' import type { AuthUser } from '@/server/core/types' -import { pickCurrentOverdueStageMetric } from '@/lib/oa-board-overtime' +import { + listHistoricalOverdueStageMetrics, + pickCurrentOverdueStageMetric, + type OaBoardTimelineMetricLike, +} from '@/lib/oa-board-overtime' import { createPlanningConnectionConfig } from './oa-board-sync.service' const OA_BOARD_TIMEOUT_FOLLOWUP_TABLE = 'oa_board_timeout_followup' @@ -139,12 +143,29 @@ type TimeoutFollowupPermissionSnapshotRow = RowDataPacket & { export type OaBoardTimeoutFollowupEditPermission = { allowed: boolean message?: string + mode?: 'CURRENT' | 'HISTORICAL' stageLabel?: string targetDays?: number actualDays?: number overtimeDays?: number } +type OaBoardTimeoutFollowupAssessment = { + mode: 'CURRENT' | 'HISTORICAL' + stageLabel: string + targetDays: number + actualDays: number + overtimeDays: number + ownerLabel: string | null +} + +type SavedTimeoutFollowupAssessment = RowDataPacket & { + stageLabel: string + targetDays: string | number + actualDays: string | number + overtimeDays: string | number +} + function asNullableText(value: string | null | undefined) { const text = String(value ?? '').trim() return text || null @@ -187,6 +208,17 @@ function readJsonRecord(value: string | null) { } } +function readJsonList(value: string | null) { + if (!value) return [] + + try { + const parsed: unknown = JSON.parse(value) + return Array.isArray(parsed) ? parsed : [] + } catch { + return [] + } +} + function readFormSummary(snapshot: Record | null) { const metadata = snapshot?.metadata if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return {} @@ -209,7 +241,37 @@ function buildCurrentNodeOvertimeStageKey(input: { return `${String(input.flowType ?? '').trim()}:${String(input.nodeName ?? '').trim()}` } -function readCurrentOverdueAssessment(snapshot: Record | null, stageKey: string) { +function readTimelineMetrics( + snapshot: Record | null, + serializedTimelineMetrics?: string | null, +): OaBoardTimelineMetricLike[] { + const metadata = snapshot?.metadata + const rawTimelineMetrics = + metadata && typeof metadata === 'object' && !Array.isArray(metadata) + ? (metadata as Record).timelineMetrics + : null + const source = Array.isArray(rawTimelineMetrics) + ? rawTimelineMetrics + : readJsonList(serializedTimelineMetrics ?? null) + + return source.flatMap((metric) => { + if (!metric || typeof metric !== 'object' || Array.isArray(metric)) return [] + const record = metric as Record + const key = readText(record, 'key') + const label = readText(record, 'label') + const targetDays = Number(record.targetDays) + const actualDays = Number(record.actualDays) + if (!key || !label || !Number.isFinite(targetDays) || !Number.isFinite(actualDays)) return [] + + return [{ key, label, targetDays, actualDays }] + }) +} + +function readCurrentOverdueAssessment( + snapshot: Record | null, + timelineMetrics: readonly OaBoardTimelineMetricLike[], + stageKey: string, +): OaBoardTimeoutFollowupAssessment | null { const formSummary = readFormSummary(snapshot) const stageNames = splitStageValues(readText(formSummary, 'approvalStageDisplay')) const currentNodeNames = splitStageValues(readText(formSummary, 'currentNodeDisplay')) @@ -264,6 +326,7 @@ function readCurrentOverdueAssessment(snapshot: Record | null, const taskRecord = taskRecords[0] if (taskRecord) { return { + mode: 'CURRENT', stageLabel: `${taskRecord.stageName || taskRecord.processName || '-'} · ${taskRecord.nodeName || stageKey}`, targetDays: Number(taskRecord.targetHours.toFixed(2)), actualDays: Number(taskRecord.actualHours.toFixed(2)), @@ -272,27 +335,11 @@ function readCurrentOverdueAssessment(snapshot: Record | null, } } - const metadata = snapshot?.metadata - const rawTimelineMetrics = - metadata && typeof metadata === 'object' && !Array.isArray(metadata) - ? (metadata as Record).timelineMetrics - : [] - const timelineMetrics = Array.isArray(rawTimelineMetrics) - ? rawTimelineMetrics.flatMap((metric) => { - if (!metric || typeof metric !== 'object' || Array.isArray(metric)) return [] - const record = metric as Record - const key = readText(record, 'key') - const label = readText(record, 'label') - const targetDays = Number(record.targetDays) - const actualDays = Number(record.actualDays) - if (!key || !label || !Number.isFinite(targetDays) || !Number.isFinite(actualDays)) return [] - return [{ key, label, targetDays, actualDays }] - }) - : [] const overdueStage = pickCurrentOverdueStageMetric(stageNames, timelineMetrics) if (!overdueStage || overdueStage.metricKey !== stageKey) return null return { + mode: 'CURRENT', stageLabel: `${overdueStage.stageName} · ${currentNodeNames[0] || '-'}`, targetDays: overdueStage.targetDays, actualDays: overdueStage.actualDays, @@ -301,6 +348,48 @@ function readCurrentOverdueAssessment(snapshot: Record | null, } } +function readHistoricalOverdueAssessment( + snapshot: Record | null, + timelineMetrics: readonly OaBoardTimelineMetricLike[], + stageKey: string, +): OaBoardTimeoutFollowupAssessment | null { + const formSummary = readFormSummary(snapshot) + const stageNames = splitStageValues(readText(formSummary, 'approvalStageDisplay')) + const historicalStage = listHistoricalOverdueStageMetrics(stageNames, timelineMetrics).find( + (stage) => stage.metricKey === stageKey, + ) + if (!historicalStage) return null + + return { + mode: 'HISTORICAL', + stageLabel: historicalStage.metricLabel, + targetDays: historicalStage.targetDays, + actualDays: historicalStage.actualDays, + overtimeDays: historicalStage.overtimeDays, + ownerLabel: null, + } +} + +function readSavedHistoricalAssessment( + row: SavedTimeoutFollowupAssessment | undefined, +): OaBoardTimeoutFollowupAssessment | null { + if (!row) return null + + const targetDays = toNumber(row.targetDays) + const actualDays = toNumber(row.actualDays) + const overtimeDays = toNumber(row.overtimeDays) + if (!row.stageLabel.trim() || overtimeDays <= 0) return null + + return { + mode: 'HISTORICAL', + stageLabel: row.stageLabel.trim(), + targetDays, + actualDays, + overtimeDays, + ownerLabel: null, + } +} + function isFullAccessEditor(user: AuthUser | undefined) { if (!user) return false if (user.role === 'SUPER_ADMIN' || user.role === 'ADMIN') return true @@ -424,12 +513,37 @@ export class OaBoardTimeoutFollowupService { const snapshot = readJsonRecord(row.raw_snapshot) const formSummary = readFormSummary(snapshot) - const currentOverdueStage = readCurrentOverdueAssessment(snapshot, input.stageKey) - if (!currentOverdueStage) { - return { allowed: false, message: '当前审批节点未超时或已变化,无法保存超时跟进' } + const timelineMetrics = readTimelineMetrics(snapshot, row.timeline_metrics) + const currentOverdueStage = readCurrentOverdueAssessment( + snapshot, + timelineMetrics, + input.stageKey, + ) + const historicalOverdueStage = currentOverdueStage + ? null + : readHistoricalOverdueAssessment(snapshot, timelineMetrics, input.stageKey) + + await ensureTable(connection) + const [savedRows] = await connection.query( + ` + SELECT stage_label AS stageLabel, target_days AS targetDays, actual_days AS actualDays, + overtime_days AS overtimeDays + FROM ${OA_BOARD_TIMEOUT_FOLLOWUP_TABLE} + WHERE board_key = ? AND stage_key = ? + LIMIT 1 + `, + [input.boardKey.trim(), input.stageKey.trim()], + ) + const savedHistoricalStage = + currentOverdueStage || historicalOverdueStage + ? null + : readSavedHistoricalAssessment(savedRows[0]) + const overdueStage = currentOverdueStage ?? historicalOverdueStage ?? savedHistoricalStage + if (!overdueStage) { + return { allowed: false, message: '该节点未超时,或流程数据已变化,无法保存超时跟进' } } - if (isFullAccessEditor(user)) return { allowed: true, ...currentOverdueStage } + if (isFullAccessEditor(user)) return { allowed: true, ...overdueStage } const fieldValues: Record = { productPlanner: readText(formSummary, 'productPlanner') || row.product_planner, @@ -442,12 +556,21 @@ export class OaBoardTimeoutFollowupService { } const actorName = user.name.trim() const productPlanners = splitUserNames(fieldValues.productPlanner) + if (productPlanners.includes(actorName)) return { allowed: true, ...overdueStage } + + if (overdueStage.mode === 'HISTORICAL') { + return { + allowed: false, + message: '历史超时仅管理员、荆川或商品企划可保存跟进', + } + } + const currentOwners = splitUserNames(readText(formSummary, 'currentOwnerDisplay')) - const stageOwners = currentOverdueStage.ownerLabel ? splitUserNames(currentOverdueStage.ownerLabel) : [] + const stageOwners = overdueStage.ownerLabel ? splitUserNames(overdueStage.ownerLabel) : [] const mergedOwners = Array.from(new Set([...currentOwners, ...stageOwners])) - if (productPlanners.includes(actorName) || mergedOwners.includes(actorName)) { - return { allowed: true, ...currentOverdueStage } + if (mergedOwners.includes(actorName)) { + return { allowed: true, ...overdueStage } } return { allowed: false, message: '仅管理员、荆川、商品企划或当前节点负责人可保存跟进' }