fix(oa-board): allow historical overtime followup editing
This commit is contained in:
parent
adaa1ed8ac
commit
8cd5764428
@ -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>
|
||||
|
||||
@ -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: '仅管理员、荆川、商品企划或当前节点负责人可保存跟进' }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user