fix(oa-board): unify stage overtime followups

This commit is contained in:
jingchuan 2026-08-06 15:33:24 +08:00
parent 100d2d05fa
commit 5fffc3d184
5 changed files with 59 additions and 81 deletions

View File

@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import ExcelJS from 'exceljs'
import { handleRequest, jsonError } from '@/server/core/response'
import { ErrorCode } from '@/server/core/errors'
import { isOaBoardTimelineMetricOverdue } from '@/lib/oa-board-overtime'
import {
hasOaBoardProjectOvertime,
oaBoardService,
@ -226,7 +227,7 @@ function readMetricValue(
if (!metric) return ''
if (valueType === 'timeout') {
if (metric.targetDays === null || metric.actualDays === null) return ''
return metric.actualDays - metric.targetDays > 0 ? '是' : '否'
return isOaBoardTimelineMetricOverdue(metric) ? '是' : '否'
}
return valueType === 'target'
? formatDayValue(metric.targetDays)

View File

@ -19,6 +19,7 @@ import {
} from 'lucide-react'
import {
getOaBoardFollowupDeadlineStatus,
isOaBoardTimelineMetricOverdue,
listHistoricalOverdueStageMetrics,
OA_BOARD_APPROVAL_STAGE_TIMELINE_METRIC_KEYS,
pickCurrentOverdueStageMetric,
@ -551,7 +552,7 @@ function readMetricValue(
if (!metric) return '-'
if (valueType === 'timeout') {
if (metric.targetDays === null || metric.actualDays === null) return '-'
return metric.actualDays - metric.targetDays > 0 ? '是' : '否'
return isOaBoardTimelineMetricOverdue(metric) ? '是' : '否'
}
return valueType === 'target'
? formatDayValue(metric.targetDays)
@ -689,9 +690,7 @@ function buildTimingDetailFields(item: OaBoardProjectListItemDTO): TimingDetailF
return TIMING_DETAIL_DEFINITIONS.map((field) => {
const metric = metrics[field.metricKey]
const timeoutState =
field.valueType === 'timeout' && metric && metric.targetDays !== null && metric.actualDays !== null
? metric.actualDays - metric.targetDays > 0
: null
field.valueType === 'timeout' ? isOaBoardTimelineMetricOverdue(metric) : null
return {
id: field.id,
@ -803,7 +802,6 @@ function buildHistoricalFollowupStage(followup: OaBoardTimeoutFollowup): Overtim
function getFollowupDisplayStages(
currentOvertimeStages: OvertimeStage[],
historicalOvertimeStages: OvertimeStage[],
followups: OaBoardTimeoutFollowup[],
) {
const stagesByKey = new Map(currentOvertimeStages.map((stage) => [stage.stageKey, stage]))
@ -811,12 +809,6 @@ function getFollowupDisplayStages(
if (!stagesByKey.has(stage.stageKey)) stagesByKey.set(stage.stageKey, stage)
})
followups.forEach((followup) => {
if (!stagesByKey.has(followup.stageKey)) {
stagesByKey.set(followup.stageKey, buildHistoricalFollowupStage(followup))
}
})
return Array.from(stagesByKey.values())
}
@ -881,27 +873,15 @@ function getTimeoutFollowupSummary(
overtimeStages: OvertimeStage[],
followups: OaBoardTimeoutFollowup[],
) {
const relevantFollowups =
overtimeStages.length === 0
? followups
: followups.filter((followup) =>
if (overtimeStages.length === 0) return null
const relevantFollowups = followups.filter((followup) =>
overtimeStages.some((stage) => stage.stageKey === followup.stageKey),
)
const allFollowupDeadlineStatuses = relevantFollowups
.map((followup) => getFollowupDeadlineStatus(followup))
.filter((status): status is NonNullable<typeof status> => Boolean(status))
if (overtimeStages.length === 0) {
if (followups.length === 0) return null
if (allFollowupDeadlineStatuses.length > 0) {
return {
label: `${allFollowupDeadlineStatuses.length} 项跟进到期`,
tone: 'danger' as const,
}
}
return { label: `${followups.length} 项历史跟进`, tone: 'default' as const }
}
const overtimeStageKeys = new Set(overtimeStages.map((stage) => stage.stageKey))
const records = followups.filter((followup) => overtimeStageKeys.has(followup.stageKey))
const pending = records.filter((record) => record.status === 'PENDING').length
@ -1590,7 +1570,6 @@ function TimeoutFollowupInspector({
const overtimeStages = getFollowupDisplayStages(
currentOvertimeStages,
historicalOvertimeStages,
followups,
)
const currentOvertimeStageKeys = new Set(currentOvertimeStages.map((stage) => stage.stageKey))
const [selectedStageKey, setSelectedStageKey] = useState(overtimeStages[0]?.stageKey ?? '')
@ -2206,7 +2185,6 @@ function DetailTableRow({
const followupDisplayStages = getFollowupDisplayStages(
currentOvertimeStages,
historicalOvertimeStages,
timeoutFollowups,
)
const isOvertime = item.overallTimeoutFlag
const timeoutFollowupSummary = getTimeoutFollowupSummary(followupDisplayStages, timeoutFollowups)

View File

@ -1,5 +1,7 @@
import {
getOaBoardFollowupDeadlineStatus,
getOaBoardTimelineMetricOvertimeDays,
isOaBoardTimelineMetricOverdue,
listHistoricalOverdueStageMetrics,
pickCurrentOverdueStageMetric,
} from './oa-board-overtime'
@ -42,6 +44,20 @@ describe('OA 看板时效归类', () => {
).toBeNull()
})
it('节点旧判定超时不能覆盖阶段时效详情', () => {
const sampleMetric = {
key: 'sample_prepare',
label: '打样',
targetDays: 3,
actualDays: 1.14,
}
expect(getOaBoardTimelineMetricOvertimeDays(sampleMetric)).toBeNull()
expect(isOaBoardTimelineMetricOverdue(sampleMetric)).toBe(false)
expect(pickCurrentOverdueStageMetric(['产品打样'], [sampleMetric])).toBeNull()
expect(listHistoricalOverdueStageMetrics([], [sampleMetric])).toEqual([])
})
it('仅标记计划日期已到且尚未完成的跟进', () => {
const today = new Date(2026, 7, 4)

View File

@ -86,6 +86,33 @@ export const OA_BOARD_APPROVAL_STAGE_TIMELINE_METRIC_KEYS: Record<string, string
运营上架: 'listing',
}
const OA_BOARD_TRACKED_TIMELINE_METRIC_KEYS = new Set(
Object.values(OA_BOARD_APPROVAL_STAGE_TIMELINE_METRIC_KEYS),
)
export function getOaBoardTimelineMetricOvertimeDays(
metric: OaBoardTimelineMetricLike | null | undefined,
) {
if (
!metric ||
metric.targetDays === null ||
metric.actualDays === null ||
!Number.isFinite(metric.targetDays) ||
!Number.isFinite(metric.actualDays)
) {
return null
}
const overtimeDays = Number((metric.actualDays - metric.targetDays).toFixed(2))
return overtimeDays > 0 ? overtimeDays : null
}
export function isOaBoardTimelineMetricOverdue(
metric: OaBoardTimelineMetricLike | null | undefined,
) {
return getOaBoardTimelineMetricOvertimeDays(metric) !== null
}
export function listHistoricalOverdueStageMetrics(
currentStageNames: readonly string[],
timelineMetrics: readonly OaBoardTimelineMetricLike[],
@ -95,11 +122,9 @@ export function listHistoricalOverdueStageMetrics(
.map((stageName) => OA_BOARD_APPROVAL_STAGE_TIMELINE_METRIC_KEYS[stageName])
.filter((metricKey): metricKey is string => Boolean(metricKey)),
)
const trackedMetricKeys = new Set(Object.values(OA_BOARD_APPROVAL_STAGE_TIMELINE_METRIC_KEYS))
return timelineMetrics.flatMap((metric) => {
if (
!trackedMetricKeys.has(metric.key) ||
!OA_BOARD_TRACKED_TIMELINE_METRIC_KEYS.has(metric.key) ||
currentMetricKeys.has(metric.key) ||
metric.targetDays === null ||
metric.actualDays === null
@ -107,8 +132,8 @@ export function listHistoricalOverdueStageMetrics(
return []
}
const overtimeDays = Number((metric.actualDays - metric.targetDays).toFixed(2))
if (overtimeDays <= 0) return []
const overtimeDays = getOaBoardTimelineMetricOvertimeDays(metric)
if (overtimeDays === null) return []
return [{
stageName: metric.label,
@ -131,8 +156,8 @@ export function pickCurrentOverdueStageMetric(
const metric = metricKey ? metricsByKey.get(metricKey) : null
if (!metric || metric.targetDays === null || metric.actualDays === null) return []
const overtimeDays = Number((metric.actualDays - metric.targetDays).toFixed(2))
if (overtimeDays <= 0) return []
const overtimeDays = getOaBoardTimelineMetricOvertimeDays(metric)
if (overtimeDays === null) return []
return [{
stageName,

View File

@ -159,13 +159,6 @@ type OaBoardTimeoutFollowupAssessment = {
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
@ -303,26 +296,6 @@ function readHistoricalOverdueAssessment(
}
}
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
@ -463,22 +436,7 @@ export class OaBoardTimeoutFollowupService {
? 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
const overdueStage = currentOverdueStage ?? historicalOverdueStage
if (!overdueStage) {
return { allowed: false, message: '该节点未超时,或流程数据已变化,无法保存超时跟进' }
}