Merge remote-tracking branch 'origin/main' into codex/release-bom-costing-main-20260804

This commit is contained in:
WuYongJie 2026-08-04 16:24:36 +08:00
commit 608da97538
3 changed files with 383 additions and 56 deletions

View File

@ -17,7 +17,12 @@ import {
Sparkles,
X,
} from 'lucide-react'
import { pickCurrentOverdueStageMetric } from '@/lib/oa-board-overtime'
import {
getOaBoardFollowupDeadlineStatus,
listHistoricalOverdueStageMetrics,
OA_BOARD_APPROVAL_STAGE_TIMELINE_METRIC_KEYS,
pickCurrentOverdueStageMetric,
} from '@/lib/oa-board-overtime'
import { OA_BOARD_DEFAULT_TRACKED_STATUSES } from '@/server/services/oa-board.constants'
import type {
OaBoardDashboardDTO,
@ -139,6 +144,16 @@ type OvertimeStage = {
overtimeDays: number
}
type HistoricalOvertimeRecord = {
id: string
boardKey: string
openingCode: string | null
spu: string | null
stage: OvertimeStage
followup: OaBoardTimeoutFollowup | null
item: OaBoardProjectListItemDTO | null
}
type TimeoutFollowupSaveEntry = {
stage: OvertimeStage
draft: TimeoutFollowupDraft
@ -752,6 +767,23 @@ function getOvertimeStages(item: OaBoardProjectListItemDTO): OvertimeStage[] {
}]
}
function getHistoricalOvertimeStages(item: OaBoardProjectListItemDTO): OvertimeStage[] {
const currentStageNames = splitStageText(
item.formSummary.approvalStageDisplay || item.currentApprovalStageName,
)
return listHistoricalOverdueStageMetrics(currentStageNames, item.timelineMetrics).map((stage) => ({
stageKey: stage.metricKey,
stageLabel: stage.metricLabel,
stageContextLabel: '历史超时',
nodeName: '-',
ownerLabel: '-',
targetDays: stage.targetDays,
actualDays: stage.actualDays,
overtimeDays: stage.overtimeDays,
}))
}
function resolveTimeoutFollowupOwner(item: OaBoardProjectListItemDTO, stageKey: string) {
const stage = getOvertimeStages(item).find((entry) => entry.stageKey === stageKey)
if (stage?.ownerLabel?.trim()) return stage.ownerLabel.trim()
@ -763,6 +795,20 @@ function getTimeoutFollowupStatusLabel(status: TimeoutFollowupStatus) {
return TIMEOUT_FOLLOWUP_STATUS_OPTIONS.find((option) => option.value === status)?.label ?? '待填写'
}
function getFollowupDeadlineStatus(followup: OaBoardTimeoutFollowup | null | undefined) {
if (!followup) return null
return getOaBoardFollowupDeadlineStatus(
followup.expectedResolveDate,
followup.status === 'RESOLVED' || followup.status === 'NOT_REQUIRED',
)
}
function getFollowupDeadlineLabel(status: ReturnType<typeof getOaBoardFollowupDeadlineStatus>) {
if (!status) return null
return status.state === 'DUE_TODAY' ? '今日到期' : `已逾期 ${status.overdueDays} 天`
}
function createTimeoutFollowupDraft(
followup: OaBoardTimeoutFollowup | undefined,
defaultOwner: string,
@ -791,22 +837,103 @@ function buildHistoricalFollowupStage(followup: OaBoardTimeoutFollowup): Overtim
function getFollowupDisplayStages(
currentOvertimeStages: OvertimeStage[],
historicalOvertimeStages: OvertimeStage[],
followups: OaBoardTimeoutFollowup[],
) {
const currentStageKeys = new Set(currentOvertimeStages.map((stage) => stage.stageKey))
const historicalStages = followups
.filter((followup) => !currentStageKeys.has(followup.stageKey))
.map(buildHistoricalFollowupStage)
const stagesByKey = new Map(currentOvertimeStages.map((stage) => [stage.stageKey, stage]))
return [...currentOvertimeStages, ...historicalStages]
historicalOvertimeStages.forEach((stage) => {
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())
}
function buildHistoricalOvertimeRecords(
items: OaBoardProjectListItemDTO[],
followups: OaBoardTimeoutFollowup[],
): HistoricalOvertimeRecord[] {
const itemByBoardKey = new Map(items.map((item) => [item.boardKey, item]))
const followupByStageKey = new Map(
followups.map((followup) => [`${followup.boardKey}:${followup.stageKey}`, followup]),
)
const records = new Map<string, HistoricalOvertimeRecord>()
items.forEach((item) => {
getHistoricalOvertimeStages(item).forEach((stage) => {
const id = `${item.boardKey}:${stage.stageKey}`
records.set(id, {
id,
boardKey: item.boardKey,
openingCode: item.formSummary.openingCode || item.openingCode || null,
spu: item.formSummary.spu || item.spu || null,
stage,
followup: followupByStageKey.get(id) ?? null,
item,
})
})
})
followups.forEach((followup) => {
const id = `${followup.boardKey}:${followup.stageKey}`
if (records.has(id)) return
const item = itemByBoardKey.get(followup.boardKey)
const currentMetricKeys = new Set(
splitStageText(item?.formSummary.approvalStageDisplay || item?.currentApprovalStageName)
.map((stageName) => OA_BOARD_APPROVAL_STAGE_TIMELINE_METRIC_KEYS[stageName])
.filter((metricKey): metricKey is string => Boolean(metricKey)),
)
const currentStageKeys = new Set(item ? getOvertimeStages(item).map((stage) => stage.stageKey) : [])
if (currentStageKeys.has(followup.stageKey) || currentMetricKeys.has(followup.stageKey)) return
records.set(id, {
id,
boardKey: followup.boardKey,
openingCode: followup.openingCode,
spu: followup.spu,
stage: buildHistoricalFollowupStage(followup),
followup,
item: item ?? null,
})
})
return Array.from(records.values()).sort((left, right) => {
const leftUpdatedAt = left.followup?.updatedAt ?? ''
const rightUpdatedAt = right.followup?.updatedAt ?? ''
if (leftUpdatedAt !== rightUpdatedAt) return rightUpdatedAt.localeCompare(leftUpdatedAt)
return right.stage.overtimeDays - left.stage.overtimeDays
})
}
function getTimeoutFollowupSummary(
overtimeStages: OvertimeStage[],
followups: OaBoardTimeoutFollowup[],
) {
const relevantFollowups =
overtimeStages.length === 0
? followups
: 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 }
}
@ -818,6 +945,12 @@ function getTimeoutFollowupSummary(
(record) => record.status === 'RESOLVED' || record.status === 'NOT_REQUIRED',
).length
if (allFollowupDeadlineStatuses.length > 0) {
return {
label: `${allFollowupDeadlineStatuses.length} 项跟进到期`,
tone: 'danger' as const,
}
}
if (pending > 0) return { label: `${pending} 项待填写`, tone: 'danger' as const }
if (inProgress > 0) return { label: `${inProgress} 项处理中`, tone: 'warning' as const }
if (resolved === overtimeStages.length) return { label: '已完成跟进', tone: 'success' as const }
@ -1263,57 +1396,71 @@ function HistoricalTimeoutAnalysis({
const [ownerFilter, setOwnerFilter] = useState('')
const [updatedDateStart, setUpdatedDateStart] = useState('')
const [updatedDateEnd, setUpdatedDateEnd] = useState('')
const itemByBoardKey = useMemo(
() => new Map(items.map((item) => [item.boardKey, item])),
[items],
const historicalRecords = useMemo(
() => buildHistoricalOvertimeRecords(items, followups),
[followups, items],
)
const stageOptions = useMemo(
() => buildOptionList(followups.map((followup) => followup.stageLabel), '全部历史阶段'),
[followups],
() => buildOptionList(historicalRecords.map((record) => record.stage.stageLabel), '全部历史阶段'),
[historicalRecords],
)
const ownerOptions = useMemo(
() => buildOptionList(followups.map((followup) => followup.followUpOwner), '全部跟进人'),
[followups],
() => buildOptionList(historicalRecords.map((record) => record.followup?.followUpOwner), '全部跟进人'),
[historicalRecords],
)
const statusOptions = useMemo(
() => [{ value: '', label: '全部跟进状态' }, ...TIMEOUT_FOLLOWUP_STATUS_OPTIONS],
() => [
{ value: '', label: '全部跟进状态' },
{ value: 'UNRECORDED', label: '未填写跟进' },
{ value: 'DEADLINE_DUE', label: '跟进已到期' },
...TIMEOUT_FOLLOWUP_STATUS_OPTIONS,
],
[],
)
const filteredFollowups = useMemo(
const filteredRecords = useMemo(
() =>
followups.filter((followup) => {
const updatedDate = String(followup.updatedAt ?? '').slice(0, 10)
if (statusFilter && followup.status !== statusFilter) return false
if (stageFilter && followup.stageLabel !== stageFilter) return false
if (ownerFilter && followup.followUpOwner !== ownerFilter) return false
historicalRecords.filter((record) => {
const updatedDate = String(record.followup?.updatedAt ?? '').slice(0, 10)
if (statusFilter === 'UNRECORDED' && record.followup) return false
if (statusFilter === 'DEADLINE_DUE' && !getFollowupDeadlineStatus(record.followup)) return false
if (
statusFilter &&
statusFilter !== 'UNRECORDED' &&
statusFilter !== 'DEADLINE_DUE' &&
record.followup?.status !== statusFilter
) return false
if (stageFilter && record.stage.stageLabel !== stageFilter) return false
if (ownerFilter && record.followup?.followUpOwner !== ownerFilter) return false
if (updatedDateStart && updatedDate < updatedDateStart) return false
if (updatedDateEnd && updatedDate > updatedDateEnd) return false
return true
}),
[followups, ownerFilter, stageFilter, statusFilter, updatedDateEnd, updatedDateStart],
[historicalRecords, ownerFilter, stageFilter, statusFilter, updatedDateEnd, updatedDateStart],
)
const historySummary = useMemo(() => {
const totalOvertimeDays = filteredFollowups.reduce(
(total, followup) => total + followup.overtimeDays,
const totalOvertimeDays = filteredRecords.reduce(
(total, record) => total + record.stage.overtimeDays,
0,
)
const pending = filteredFollowups.filter(
(followup) => followup.status === 'PENDING' || followup.status === 'IN_PROGRESS',
const pending = filteredRecords.filter(
(record) => !record.followup || record.followup.status === 'PENDING' || record.followup.status === 'IN_PROGRESS',
).length
const resolved = filteredFollowups.filter(
(followup) => followup.status === 'RESOLVED' || followup.status === 'NOT_REQUIRED',
const resolved = filteredRecords.filter(
(record) => record.followup?.status === 'RESOLVED' || record.followup?.status === 'NOT_REQUIRED',
).length
const due = filteredRecords.filter((record) => Boolean(getFollowupDeadlineStatus(record.followup))).length
return {
total: filteredFollowups.length,
total: filteredRecords.length,
pending,
resolved,
due,
averageOvertimeDays:
filteredFollowups.length > 0
? Number((totalOvertimeDays / filteredFollowups.length).toFixed(1))
filteredRecords.length > 0
? Number((totalOvertimeDays / filteredRecords.length).toFixed(1))
: 0,
}
}, [filteredFollowups])
}, [filteredRecords])
return (
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-auto pb-1">
@ -1327,9 +1474,9 @@ function HistoricalTimeoutAnalysis({
<FilterInput label="更新结束日期" type="date" value={updatedDateEnd} placeholder="结束日期" onChange={setUpdatedDateEnd} />
</div>
</header>
<div className="grid divide-y divide-apple-separator sm:grid-cols-2 sm:divide-x sm:divide-y-0 lg:grid-cols-4">
<div className="grid divide-y divide-apple-separator sm:grid-cols-2 sm:divide-x sm:divide-y-0 lg:grid-cols-5">
<div className="px-5 py-4">
<p className="text-[11px] text-apple-label-secondary">历史跟进项</p>
<p className="text-[11px] text-apple-label-secondary">历史超时项</p>
<p className="mt-1 text-2xl font-apple-semibold text-apple-label-primary">{historySummary.total}</p>
</div>
<div className="px-5 py-4">
@ -1340,6 +1487,10 @@ function HistoricalTimeoutAnalysis({
<p className="text-[11px] text-apple-label-secondary">已完成</p>
<p className="mt-1 text-2xl font-apple-semibold text-emerald-700">{historySummary.resolved}</p>
</div>
<div className="px-5 py-4">
<p className="text-[11px] text-apple-label-secondary">跟进已到期</p>
<p className="mt-1 text-2xl font-apple-semibold text-rose-700">{historySummary.due}</p>
</div>
<div className="px-5 py-4">
<p className="text-[11px] text-apple-label-secondary">平均超时</p>
<p className="mt-1 text-2xl font-apple-semibold text-apple-label-primary">
@ -1353,11 +1504,11 @@ function HistoricalTimeoutAnalysis({
<header className="flex items-center justify-between border-b border-apple-separator px-5 py-3">
<div>
<h2 className="text-lg font-apple-semibold text-apple-label-primary">历史超时跟进</h2>
<p className="mt-1 text-xs text-apple-label-secondary">记录来源于已保存的超时跟进,不参与当前超时统计和钉钉待办发送。</p>
<p className="mt-1 text-xs text-apple-label-secondary">自动保留已发生的超时;仅当前阶段参与当前超时统计和钉钉待办发送。</p>
</div>
<span className="text-xs text-apple-label-secondary">{filteredFollowups.length} 条记录</span>
<span className="text-xs text-apple-label-secondary">{filteredRecords.length} 条记录</span>
</header>
{filteredFollowups.length === 0 ? (
{filteredRecords.length === 0 ? (
<div className="px-5 py-10 text-center text-sm text-apple-label-secondary">当前筛选范围内没有历史跟进记录</div>
) : (
<div className="overflow-x-auto">
@ -1377,23 +1528,38 @@ function HistoricalTimeoutAnalysis({
</tr>
</thead>
<tbody>
{filteredFollowups.map((followup) => {
const item = itemByBoardKey.get(followup.boardKey)
{filteredRecords.map((record) => {
const followup = record.followup
const deadlineStatus = getFollowupDeadlineStatus(followup)
return (
<tr key={followup.id} className="border-b border-apple-separator last:border-b-0">
<td className="px-5 py-3 font-apple-medium text-apple-label-primary">{formatCellText(followup.openingCode || item?.formSummary.openingCode || item?.openingCode)}</td>
<td className="px-5 py-3 text-apple-label-primary">{followup.stageLabel}</td>
<td className="px-5 py-3"><StatusBadge label={getTimeoutFollowupStatusLabel(followup.status)} tone={followup.status === 'PENDING' ? 'danger' : followup.status === 'IN_PROGRESS' ? 'warning' : 'success'} /></td>
<td className="px-5 py-3 text-apple-label-primary">{formatCellText(followup.followUpOwner)}</td>
<td className="px-5 py-3 text-apple-label-primary">{followup.targetDays} 天</td>
<td className="px-5 py-3 text-apple-label-primary">{followup.actualDays} 天</td>
<td className="px-5 py-3 text-rose-700">{followup.overtimeDays} 天</td>
<td className="px-5 py-3 text-apple-label-primary">{formatCellText(followup.expectedResolveDate)}</td>
<td className="px-5 py-3 text-apple-label-secondary">{formatCellText(followup.updatedAt?.replace('T', ' ').slice(0, 16))}</td>
<tr key={record.id} className="border-b border-apple-separator last:border-b-0">
<td className="px-5 py-3 font-apple-medium text-apple-label-primary">{formatCellText(record.openingCode)}</td>
<td className="px-5 py-3 text-apple-label-primary">{record.stage.stageLabel}</td>
<td className="px-5 py-3">
{item ? (
<button type="button" onClick={() => onOpenFollowup(item)} className="rounded-apple-md border border-apple-separator bg-white px-2.5 py-1.5 text-[11px] font-apple-medium text-apple-label-primary transition hover:bg-apple-bg-secondary">
查看记录
<div>
<StatusBadge label={followup ? getTimeoutFollowupStatusLabel(followup.status) : '未填写跟进'} tone={followup?.status === 'PENDING' ? 'danger' : followup?.status === 'IN_PROGRESS' ? 'warning' : followup ? 'success' : 'default'} />
</div>
{deadlineStatus ? (
<span className="mt-1 block text-[10px] font-apple-semibold text-rose-700">
{getFollowupDeadlineLabel(deadlineStatus)}
</span>
) : null}
</td>
<td className="px-5 py-3 text-apple-label-primary">{formatCellText(followup?.followUpOwner)}</td>
<td className="px-5 py-3 text-apple-label-primary">{record.stage.targetDays} 天</td>
<td className="px-5 py-3 text-apple-label-primary">{record.stage.actualDays} 天</td>
<td className="px-5 py-3 text-rose-700">{record.stage.overtimeDays} 天</td>
<td className={`px-5 py-3 ${deadlineStatus ? 'font-apple-semibold text-rose-700' : 'text-apple-label-primary'}`}>
{formatCellText(followup?.expectedResolveDate)}
{deadlineStatus ? (
<span className="mt-1 block text-[10px]">{getFollowupDeadlineLabel(deadlineStatus)}</span>
) : null}
</td>
<td className="px-5 py-3 text-apple-label-secondary">{formatCellText(followup?.updatedAt?.replace('T', ' ').slice(0, 16))}</td>
<td className="px-5 py-3">
{record.item ? (
<button type="button" onClick={() => onOpenFollowup(record.item as OaBoardProjectListItemDTO)} className="rounded-apple-md border border-apple-separator bg-white px-2.5 py-1.5 text-[11px] font-apple-medium text-apple-label-primary transition hover:bg-apple-bg-secondary">
查看详情
</button>
) : '-'}
</td>
@ -1455,7 +1621,12 @@ function TimeoutFollowupInspector({
onSave: (entries: TimeoutFollowupSaveEntry[]) => Promise<boolean>
}) {
const currentOvertimeStages = getOvertimeStages(item)
const overtimeStages = getFollowupDisplayStages(currentOvertimeStages, followups)
const historicalOvertimeStages = getHistoricalOvertimeStages(item)
const overtimeStages = getFollowupDisplayStages(
currentOvertimeStages,
historicalOvertimeStages,
followups,
)
const currentOvertimeStageKeys = new Set(currentOvertimeStages.map((stage) => stage.stageKey))
const [selectedStageKey, setSelectedStageKey] = useState(overtimeStages[0]?.stageKey ?? '')
const selectedStage =
@ -1463,6 +1634,7 @@ function TimeoutFollowupInspector({
const selectedFollowup = selectedStage
? followups.find((followup) => followup.stageKey === selectedStage.stageKey)
: undefined
const selectedFollowupDeadlineStatus = getFollowupDeadlineStatus(selectedFollowup)
const [draftsByStageKey, setDraftsByStageKey] = useState<Record<string, TimeoutFollowupDraft>>(() =>
Object.fromEntries(
overtimeStages.map((stage) => [
@ -1555,6 +1727,7 @@ function TimeoutFollowupInspector({
<div className="flex w-max gap-2">
{overtimeStages.map((stage) => {
const record = followups.find((followup) => followup.stageKey === stage.stageKey)
const deadlineStatus = getFollowupDeadlineStatus(record)
const active = selectedStage.stageKey === stage.stageKey
const isDirty = dirtyStageKeys.has(stage.stageKey)
const isHistorical = !currentOvertimeStageKeys.has(stage.stageKey)
@ -1575,6 +1748,11 @@ function TimeoutFollowupInspector({
<span className={`mt-1 text-[10px] ${active ? 'text-white/70' : 'text-apple-label-secondary'}`}>
{isHistorical ? '历史记录' : isDirty ? '待保存' : record ? getTimeoutFollowupStatusLabel(record.status) : '待跟进'}
</span>
{deadlineStatus ? (
<span className={`mt-1 text-[10px] font-apple-semibold ${active ? 'text-rose-200' : 'text-rose-700'}`}>
{getFollowupDeadlineLabel(deadlineStatus)}
</span>
) : null}
</button>
)
})}
@ -1599,7 +1777,10 @@ function TimeoutFollowupInspector({
{isHistoricalStage ? (
<section className="mt-6 border border-apple-separator bg-[#fafbfc] p-4">
<p className="text-xs text-apple-label-secondary">该节点已不在当前超时阶段,以下为已保存的历史跟进内容。</p>
<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>
@ -1611,7 +1792,12 @@ function TimeoutFollowupInspector({
</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?.expectedResolveDate)}</dd>
<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">
@ -1656,6 +1842,11 @@ function TimeoutFollowupInspector({
onChange={(value) => updateDraft((current) => ({ ...current, expectedResolveDate: value }))}
/>
</div>
{selectedFollowupDeadlineStatus ? (
<div className="mt-3 border border-rose-200 bg-rose-50 px-3 py-2 text-xs font-apple-semibold text-rose-700">
{getFollowupDeadlineLabel(selectedFollowupDeadlineStatus)},请更新跟进状态或调整预计完成日期。
</div>
) : null}
<label className="mt-5 flex flex-col gap-1.5">
<span className="text-[11px] font-apple-medium text-apple-label-secondary">超时原因说明</span>

View File

@ -0,0 +1,51 @@
import {
getOaBoardFollowupDeadlineStatus,
listHistoricalOverdueStageMetrics,
pickCurrentOverdueStageMetric,
} from './oa-board-overtime'
describe('OA 看板时效归类', () => {
const timelineMetrics = [
{ key: 'exterior', label: '外观', targetDays: 3, actualDays: 3.98 },
{ key: 'sample_prepare', label: '打样', targetDays: 8, actualDays: 11.04 },
]
it('将已离开的超时阶段保留为历史记录', () => {
expect(listHistoricalOverdueStageMetrics(['产品打样'], timelineMetrics)).toEqual([
{
stageName: '外观',
metricKey: 'exterior',
metricLabel: '外观',
targetDays: 3,
actualDays: 3.98,
overtimeDays: 0.98,
},
])
})
it('只将当前阶段用于当前超时判断', () => {
expect(pickCurrentOverdueStageMetric(['产品打样'], timelineMetrics)).toEqual({
stageName: '产品打样',
metricKey: 'sample_prepare',
metricLabel: '打样',
targetDays: 8,
actualDays: 11.04,
overtimeDays: 3.04,
})
})
it('仅标记计划日期已到且尚未完成的跟进', () => {
const today = new Date(2026, 7, 4)
expect(getOaBoardFollowupDeadlineStatus('2026-08-03', false, today)).toEqual({
state: 'OVERDUE',
overdueDays: 1,
})
expect(getOaBoardFollowupDeadlineStatus('2026-08-04', false, today)).toEqual({
state: 'DUE_TODAY',
overdueDays: 0,
})
expect(getOaBoardFollowupDeadlineStatus('2026-08-04', true, today)).toBeNull()
expect(getOaBoardFollowupDeadlineStatus('2026-02-30', false, today)).toBeNull()
})
})

View File

@ -14,7 +14,57 @@ export type OaBoardCurrentStageOvertime = {
overtimeDays: number
}
const APPROVAL_STAGE_TIMELINE_METRIC_KEYS: Record<string, string> = {
export type OaBoardFollowupDeadlineStatus = {
state: 'DUE_TODAY' | 'OVERDUE'
overdueDays: number
}
function toDateTimestamp(value: string) {
const matched = value.match(/^(\d{4})-(\d{2})-(\d{2})$/)
if (!matched) return null
const [, year, month, day] = matched
const normalizedYear = Number(year)
const normalizedMonth = Number(month)
const normalizedDay = Number(day)
const timestamp = Date.UTC(normalizedYear, normalizedMonth - 1, normalizedDay)
const parsedDate = new Date(timestamp)
if (
!Number.isFinite(timestamp) ||
parsedDate.getUTCFullYear() !== normalizedYear ||
parsedDate.getUTCMonth() !== normalizedMonth - 1 ||
parsedDate.getUTCDate() !== normalizedDay
) {
return null
}
return timestamp
}
export function getOaBoardFollowupDeadlineStatus(
expectedResolveDate: string | null | undefined,
isCompleted: boolean,
referenceDate = new Date(),
): OaBoardFollowupDeadlineStatus | null {
const expectedTimestamp = toDateTimestamp(String(expectedResolveDate ?? '').trim())
if (isCompleted || expectedTimestamp === null) return null
const todayTimestamp = Date.UTC(
referenceDate.getFullYear(),
referenceDate.getMonth(),
referenceDate.getDate(),
)
const overdueDays = Math.round((todayTimestamp - expectedTimestamp) / 86400000)
if (overdueDays < 0) return null
return {
state: overdueDays === 0 ? 'DUE_TODAY' : 'OVERDUE',
overdueDays,
}
}
export const OA_BOARD_APPROVAL_STAGE_TIMELINE_METRIC_KEYS: Record<string, string> = {
'产品设计-外观': 'exterior',
'产品设计-结构': 'structure',
产品打样: 'sample_prepare',
@ -36,13 +86,48 @@ const APPROVAL_STAGE_TIMELINE_METRIC_KEYS: Record<string, string> = {
运营上架: 'listing',
}
export function listHistoricalOverdueStageMetrics(
currentStageNames: readonly string[],
timelineMetrics: readonly OaBoardTimelineMetricLike[],
): OaBoardCurrentStageOvertime[] {
const currentMetricKeys = new Set(
currentStageNames
.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) ||
currentMetricKeys.has(metric.key) ||
metric.targetDays === null ||
metric.actualDays === null
) {
return []
}
const overtimeDays = Number((metric.actualDays - metric.targetDays).toFixed(2))
if (overtimeDays <= 0) return []
return [{
stageName: metric.label,
metricKey: metric.key,
metricLabel: metric.label,
targetDays: metric.targetDays,
actualDays: metric.actualDays,
overtimeDays,
}]
})
}
export function pickCurrentOverdueStageMetric(
stageNames: readonly string[],
timelineMetrics: readonly OaBoardTimelineMetricLike[],
): OaBoardCurrentStageOvertime | null {
const metricsByKey = new Map(timelineMetrics.map((metric) => [metric.key, metric]))
const candidates = stageNames.flatMap((stageName) => {
const metricKey = APPROVAL_STAGE_TIMELINE_METRIC_KEYS[stageName]
const metricKey = OA_BOARD_APPROVAL_STAGE_TIMELINE_METRIC_KEYS[stageName]
const metric = metricKey ? metricsByKey.get(metricKey) : null
if (!metric || metric.targetDays === null || metric.actualDays === null) return []