fix(oa-board): allow historical overtime followup editing

This commit is contained in:
jingchuan 2026-08-05 10:44:55 +08:00
parent adaa1ed8ac
commit 8cd5764428
2 changed files with 178 additions and 82 deletions

View File

@ -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({
>
<span className="text-xs font-apple-semibold">{stage.stageLabel}</span>
<span className={`mt-1 text-[10px] ${active ? 'text-white/70' : 'text-apple-label-secondary'}`}>
{isHistorical ? '历史记录' : isDirty ? '待保存' : record ? getTimeoutFollowupStatusLabel(record.status) : '待跟进'}
{isDirty
? '待保存'
: record
? getTimeoutFollowupStatusLabel(record.status)
: isHistorical
? '未填写跟进'
: '待跟进'}
</span>
{deadlineStatus ? (
<span className={`mt-1 text-[10px] font-apple-semibold ${active ? 'text-rose-200' : 'text-rose-700'}`}>
@ -1776,46 +1787,11 @@ function TimeoutFollowupInspector({
</section>
{isHistoricalStage ? (
<section className="mt-6 border border-apple-separator bg-[#fafbfc] p-4">
<p className="text-xs text-apple-label-secondary">
该阶段已离开当前节点,因此只作为历史超时保留,不会再触发钉钉待办。
{selectedFollowup ? ' 以下为已保存的历史跟进内容。' : ' 该阶段尚未填写历史跟进记录。'}
</p>
<dl className="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<dt className="text-[11px] font-apple-medium text-apple-label-secondary">跟进状态</dt>
<dd className="mt-1"><StatusBadge label={getTimeoutFollowupStatusLabel(selectedFollowup?.status ?? 'PENDING')} tone="default" /></dd>
</div>
<div>
<dt className="text-[11px] font-apple-medium text-apple-label-secondary">跟进负责人</dt>
<dd className="mt-1 text-sm text-apple-label-primary">{formatCellText(selectedFollowup?.followUpOwner)}</dd>
</div>
<div>
<dt className="text-[11px] font-apple-medium text-apple-label-secondary">预计完成日期</dt>
<dd className={`mt-1 text-sm ${selectedFollowupDeadlineStatus ? 'font-apple-semibold text-rose-700' : 'text-apple-label-primary'}`}>
{formatCellText(selectedFollowup?.expectedResolveDate)}
{selectedFollowupDeadlineStatus ? (
<span className="ml-2 text-xs">{getFollowupDeadlineLabel(selectedFollowupDeadlineStatus)}</span>
) : null}
</dd>
</div>
</dl>
<div className="mt-5 border-t border-apple-separator pt-4">
<p className="text-[11px] font-apple-medium text-apple-label-secondary">超时原因说明</p>
<p className="mt-1 whitespace-pre-wrap text-sm leading-6 text-apple-label-primary">{formatCellText(selectedFollowup?.reasonDetail)}</p>
</div>
<div className="mt-5 border-t border-apple-separator pt-4">
<p className="text-[11px] font-apple-medium text-apple-label-secondary">解决措施</p>
<p className="mt-1 whitespace-pre-wrap text-sm leading-6 text-apple-label-primary">{formatCellText(selectedFollowup?.actionPlan)}</p>
</div>
{selectedFollowup?.updatedAt ? (
<p className="mt-4 text-[11px] text-apple-label-secondary">
最近更新:{formatCellText(selectedFollowup.updatedBy)} · {selectedFollowup.updatedAt.replace('T', ' ').slice(0, 16)}
</p>
) : null}
</section>
) : (
<>
<div className="mt-5 border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
历史超时跟进仅用于复盘,不会触发钉钉超时待办或计入当前超时。
</div>
) : null}
<>
<div className="mt-6 grid gap-4 sm:grid-cols-2">
<FilterSelect
label="跟进状态"
@ -1876,8 +1852,7 @@ function TimeoutFollowupInspector({
</p>
) : null}
{errorMessage ? <p className="mt-4 text-xs text-rose-700">{errorMessage}</p> : null}
</>
)}
</>
</div>
<footer className="flex shrink-0 items-center justify-end gap-3 border-t border-apple-separator px-6 py-4">
@ -1888,17 +1863,15 @@ function TimeoutFollowupInspector({
>
关闭
</button>
{!isHistoricalStage ? (
<button
type="button"
disabled={isSaving}
onClick={() => void handleSave()}
className={`inline-flex h-9 items-center gap-2 rounded-apple-md border px-4 text-sm font-apple-semibold transition ${WORKBENCH_PRIMARY_BUTTON_CLASS}`}
>
{isSaving ? <LoaderCircle className="h-4 w-4 animate-spin" /> : null}
{dirtyEntries.length > 1 ? `保存 ${dirtyEntries.length} 个节点` : '保存跟进'}
</button>
) : null}
<button
type="button"
disabled={isSaving}
onClick={() => void handleSave()}
className={`inline-flex h-9 items-center gap-2 rounded-apple-md border px-4 text-sm font-apple-semibold transition ${WORKBENCH_PRIMARY_BUTTON_CLASS}`}
>
{isSaving ? <LoaderCircle className="h-4 w-4 animate-spin" /> : null}
{dirtyEntries.length > 1 ? `保存 ${dirtyEntries.length} 个节点` : '保存跟进'}
</button>
</footer>
</section>
</div>

View File

@ -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<string, unknown> | 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<string, unknown> | null, stageKey: string) {
function readTimelineMetrics(
snapshot: Record<string, unknown> | null,
serializedTimelineMetrics?: string | null,
): OaBoardTimelineMetricLike[] {
const metadata = snapshot?.metadata
const rawTimelineMetrics =
metadata && typeof metadata === 'object' && !Array.isArray(metadata)
? (metadata as Record<string, unknown>).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<string, unknown>
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<string, unknown> | 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<string, unknown> | 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<string, unknown> | null,
}
}
const metadata = snapshot?.metadata
const rawTimelineMetrics =
metadata && typeof metadata === 'object' && !Array.isArray(metadata)
? (metadata as Record<string, unknown>).timelineMetrics
: []
const timelineMetrics = Array.isArray(rawTimelineMetrics)
? rawTimelineMetrics.flatMap((metric) => {
if (!metric || typeof metric !== 'object' || Array.isArray(metric)) return []
const record = metric as Record<string, unknown>
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<string, unknown> | null,
}
}
function readHistoricalOverdueAssessment(
snapshot: Record<string, unknown> | 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<SavedTimeoutFollowupAssessment[]>(
`
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<string, string | null> = {
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: '仅管理员、荆川、商品企划或当前节点负责人可保存跟进' }