import { randomUUID } from 'node:crypto' import { hostname } from 'node:os' import type { Session } from 'next-auth' import mssql, { type ConnectionPool } from 'mssql' import { resolveBrandCenterAccessForUser } from '@/lib/brand-center-access' import { isBrandCenterOrganizationPath } from '@/lib/brand-center-access-policy' import { assessDingWorkflowDeliveryBatch, type DingWorkflowDeliveryAssessment, } from '@/lib/ding-workflow-delivery' import { prisma } from '@/lib/prisma' const SYNC_STATE_ID = 'ding-workflow-hourly' const TERMINAL_WORKFLOW_STATUSES = new Set(['COMPLETED', 'TERMINATED', 'CANCELED']) const REJECTED_WORKFLOW_DECISIONS = new Set([ 'REFUSE', 'REFUSED', 'REJECT', 'REJECTED', 'DISAGREE', 'DENY', 'DENIED', '拒绝', '已拒绝', '不同意', ]) const LEASE_DURATION_MS = 20 * 60 * 1000 const DEFAULT_SYNC_INTERVAL_MS = 60 * 60 * 1000 const INITIAL_SYNC_DELAY_MS = 5_000 const UPSERT_BATCH_SIZE = 750 export const DING_WORKFLOW_STARTED_AT_CUTOFF_LOCAL = '2026-04-01 00:00:00' export const DING_WORKFLOW_STARTED_AT_CUTOFF_ISO = '2026-03-31T16:00:00.000Z' export const DING_WORKFLOW_ALLOWED_PROCESS_CODES = [ 'PROC-3571C915-0506-4490-A91A-8AA7EA33F838', 'PROC-AE9FF3E1-A442-4E6A-85CF-0F2B86409673', ] as const export const DING_WORKFLOW_BRANDS = ['亿家达', '卓禾', '欧意朗', '顾家', '索菲亚', '朴美'] as const export const DING_WORKFLOW_NEW_PRODUCT_DEMAND_TYPE = '新品' const ALLOWED_PROCESS_CODE_VALUES: string[] = [...DING_WORKFLOW_ALLOWED_PROCESS_CODES] const ALLOWED_PROCESS_CODE_SET = new Set(DING_WORKFLOW_ALLOWED_PROCESS_CODES) const BRAND_CENTER_ACTIVE_TASK_JSON = [{ isBrandCenter: true, isActive: true }] // Review milestones come only from the confirmed activityId mapping. A // completed leadership task with AGREE is not sufficient to infer a review: // content flows contain several supervisor assignment and production tasks. const CONFIRMED_REVIEW_NODE_NAMES = new Set([ '品牌策划主管审核', '编导审核', '内容主管审核', ]) const PROCESS_TYPE_LABELS: Record = { [DING_WORKFLOW_ALLOWED_PROCESS_CODES[0]]: '美术设计流程', [DING_WORKFLOW_ALLOWED_PROCESS_CODES[1]]: '内容视频和图片拍摄', } const ACTIVITY_NODE_NAMES_BY_PROCESS_CODE: Readonly>>> = { [DING_WORKFLOW_ALLOWED_PROCESS_CODES[0]]: { 'sid-startevent': '发起人', '1de3_de55': '部门负责人审核', '94c1_1f0d': '部门总监审核', '3cb8_8096': '品牌策划总监审批', '38da_544a': '品牌策划主管分配任务', '31cf_5b23': '出策划案', 'a9e1_5be0': '出图', 'de46_708d': '平面', '2f9e_36f0': 'AI视频', 'b306_c849': '出图', 'f073_738b': '平面', '6864_5429': '出图', '162b_23ea': '平面', 'c9fe_a6b8': '出图/平面/AI视频', 'c255_84b3': '品牌策划主管审核', 'ebb9_213a': '运营接收图片并评分', 'b426_1125': '运营接收图片并评分', '3e66_c105': '运营接收图片并评分', '64bb_b63a': '运营接收图片并评分', '2748_c5f9': '运营接收图片并评分', '45f4_e095': '运营接收图片并评分', 'a8aa_ce41': '部门负责确认', '00d9_b57f': '抄送部门总监', }, [DING_WORKFLOW_ALLOWED_PROCESS_CODES[1]]: { 'sid-startevent': '发起人', 'cc59_3e5a': '内容负责人分配任务', '9fb8_32e7': '内容主管分配任务', 'd742_3645': '内容主管分配任务', 'b9a4_c03a': '内容主管分配任务', '9f14_7ed4': '内容主管分配任务', '436a_c54c': '内容主管分配任务', 'fdcc_6e23': '内容主管分配任务', 'b596_d441': '内容主管分配任务', '660b_13c2': '内容主管分配任务', '0392_2965': '编导', '2d94_f5f4': '编导', '61b4_a6df': '编导', 'f4fb_5337': '编导', 'b7e6_434c': '编导', 'b454_f597': '编导', 'a25b_3158': '编导', '3f4a_3e4a': '编导', 'ed38_970c': '编导', 'd3e1_85ec': '编导', '27d5_92a3': '编导', '375c_e8b0': '摄像', '1d43_056a': '摄像', '3078_2143': '摄像', 'f9fb_94bb': '摄像', '003f_37a0': '摄像', 'be4c_5d3d': '摄像', '6d09_69b9': '摄像', '562b_2040': '摄像', '9804_36c4': '摄像', '663f_b63b': '摄像', '7b9b_d818': '剪辑', '3bde_6d5d': '剪辑', 'c372_4bc2': '剪辑', '70e5_5548': '剪辑', '538d_60ee': '剪辑', '0768_9fc0': '剪辑', 'a8b6_f915': '剪辑', '72f0_2fae': '剪辑', '2fdb_dd98': '二次剪辑', '238a_ba93': '二次剪辑', '4151_7ec0': '二次剪辑', 'fe6d_4f56': '二次剪辑', '7358_3df5': '封面', 'd027_fe1f': '封面', 'fe3d_054a': '封面', '8a65_ac0b': '封面', '7060_f10d': '封面', '7190_0bdb': '封面', 'ca0a_c52d': '封面', '3dfd_6eed': '修图', '6b40_cb6e': '修图', '45bd_56ad': '修图', '7e09_6fad': '修图', 'e56b_4e05': 'AI视频制作', 'a021_1e2e': '编导审核', '39ba_efb1': '编导审核', 'ce0a_7681': '编导审核', 'a3bb_f3fc': '编导审核', '2e58_b875': '编导审核', '6c43_575f': '内容主管审核', '0abf_690f': '内容主管审核', '36a0_2698': '内容主管审核', '9a79_9db8': '运营验收', 'cdec_df3c': '运营验收', 'f7ff_0cd4': '运营验收', '4073_b7af': '运营验收', 'cddb_3c2d': '运营验收', 'a03e_07f7': '策划验收', '7f14_5f28': '策划验收', 'fa9d_aab6': '客服验收', '5e28_391d': '预约客服安装', }, } const WORKFLOW_TABLE = { schema: process.env.REPORT_DB_WORKFLOW_SCHEMA || 'rpt', table: process.env.REPORT_DB_WORKFLOW_TABLE || 'ding_Workflow', } const WORKFLOW_SCHEME_TABLE = { schema: process.env.REPORT_DB_WORKFLOW_SCHEMA || 'rpt', table: process.env.REPORT_DB_WORKFLOW_SCHEME_TABLE || 'ding_WorkflowScheme', } const WORKFLOW_TASK_TABLE = { schema: process.env.REPORT_DB_WORKFLOW_SCHEMA || 'rpt', table: process.env.REPORT_DB_WORKFLOW_TASK_TABLE || 'ding_WorkflowTask', } type SourceWorkflowRow = { processInstanceId: unknown businessId: unknown title: unknown result: unknown originatorUserId: unknown originatorDeptId: unknown originatorDeptName: unknown status: unknown bizAction: unknown processCode: unknown flowType: unknown joinedAtRaw: unknown sourceCreatedAtRaw: unknown sourceUpdatedAtRaw: unknown sourceFinishedAtRaw: unknown } type SourceTaskAggregate = { processInstanceId: unknown taskCount: unknown activeTaskCount: unknown latestTaskStatus: unknown latestTaskAtRaw: unknown } type SourceTaskRow = { processInstanceId: unknown taskId: unknown activityId: unknown userId: unknown status: unknown result: unknown createTime: unknown finishTime: unknown } type SourceSchemeAggregate = { processInstanceId: unknown schemeFieldCount: unknown } type SourceSchemeField = { processInstanceId: unknown componentType: unknown name: unknown value: unknown extValue: unknown } type DingWorkflowSourceCursor = { version: 3 workflowUpdatedAt: string | null taskId: number | null schemeId: number | null } type SourceDeltaState = { changedWorkflowIds: string[] maxTaskId: number | null maxSchemeId: number | null requiresFullSync: boolean } type ContactAssignment = { userId: string name: string | null nodeName: string } export type OngoingDingWorkflowActiveTask = { taskId: string activityId: string | null nodeName: string assigneeUserId: string | null assigneeName: string assigneeDepartment: string | null isBrandCenter: boolean isLeadership: boolean isActive: boolean reviewPassed: boolean result: string | null createdAt: string | null finishedAt: string | null } export type DingWorkflowDetailFormFields = { platform: string | null brand: string | null category: string | null spu: string | null demandType: string | null standardOutputDeadline: string | null sourceType: string | null taskType: string | null installVideo: string | null quantity: string | null cameraQuantity: string | null planningTaskStats: string | null graphicTaskStats: string | null imageTaskStats: string | null } export type DingWorkflowDetailMilestones = { assignmentNodeLabel: string assignmentStartedAt: string | null reviewNodeLabel: string reviewFinishedAt: string | null } type WorkflowSnapshotPayload = { processInstanceId: string businessId: string | null title: string | null result: string | null originatorUserId: string | null originatorDeptId: string | null originatorDeptName: string | null status: string bizAction: string | null processCode: string | null flowType: string | null joinedAtRaw: string | null sourceCreatedAtRaw: string | null sourceUpdatedAtRaw: string | null sourceFinishedAtRaw: string | null isOngoing: boolean schemeFieldCount: number taskCount: number activeTaskCount: number latestTaskStatus: string | null latestTaskAtRaw: string | null brand: string demandType: string | null requesterName: string requesterDepartment: string projectType: string formFields: DingWorkflowDetailFormFields activeTasks: OngoingDingWorkflowActiveTask[] } export type DingWorkflowSyncStatus = 'pending' | 'syncing' | 'ready' | 'error' export type OngoingDingWorkflowSummary = { total: number lastSyncedAt: string | null syncStatus: DingWorkflowSyncStatus } export type OngoingDingWorkflowListItem = { id: string businessId: string | null title: string status: string originatorUserId: string | null originatorDeptName: string | null sourceCreatedAt: string | null sourceUpdatedAt: string | null activeTaskCount: number taskCount: number schemeFieldCount: number latestTaskStatus: string | null processCode: string projectType: string brand: string requesterName: string requesterDepartment: string initiatorName: string initiatorDepartment: string formFields: DingWorkflowDetailFormFields assignmentNodeLabel: string assignmentStartedAt: string | null reviewNodeLabel: string reviewFinishedAt: string | null activeTasks: OngoingDingWorkflowActiveTask[] } export type OngoingDingWorkflowPage = { items: OngoingDingWorkflowListItem[] pagination: { page: number pageSize: number total: number totalPages: number } sync: OngoingDingWorkflowSummary } export type OngoingDingWorkflowInsights = { items: OngoingDingWorkflowListItem[] total: number sync: OngoingDingWorkflowSummary } export type MonthlyLaunchWorkflowStatus = 'completed' | 'ongoing' export type MonthlyLaunchWorkflowItem = OngoingDingWorkflowListItem & { launchStatus: MonthlyLaunchWorkflowStatus result: string | null sourceFinishedAt: string | null } export type MonthlyLaunchBrandSummary = { brand: typeof DING_WORKFLOW_BRANDS[number] completed: number ongoing: number total: number } export type MonthlyLaunchSummary = { completed: number ongoing: number total: number monthStart: string monthEnd: string lastSyncedAt: string | null syncStatus: DingWorkflowSyncStatus } export type MonthlyLaunchInsights = { items: MonthlyLaunchWorkflowItem[] brands: MonthlyLaunchBrandSummary[] summary: MonthlyLaunchSummary } export type OnTimeDeliveryWorkflowItem = OngoingDingWorkflowListItem & { result: string | null sourceFinishedAt: string | null assessment: DingWorkflowDeliveryAssessment } export type OnTimeDeliverySummary = { onTime: number overtime: number pending: number total: number rate: number | null monthStart: string monthEnd: string lastSyncedAt: string | null syncStatus: DingWorkflowSyncStatus } export type OnTimeDeliveryInsights = { items: OnTimeDeliveryWorkflowItem[] summary: OnTimeDeliverySummary } export class DingWorkflowMetricApiError extends Error { status: number constructor(message: string, status = 400) { super(message) this.status = status } } function clean(value: unknown): string | null { if (value === null || value === undefined) return null const normalized = String(value).trim() return normalized || null } export function normalizeDingWorkflowProcessCode(value: unknown): string | null { return clean(value)?.toUpperCase() || null } export function isAllowedDingWorkflowProcessCode(value: unknown): boolean { const processCode = normalizeDingWorkflowProcessCode(value) return Boolean(processCode && ALLOWED_PROCESS_CODE_SET.has(processCode)) } export function getDingWorkflowProjectType(value: unknown): string { const processCode = normalizeDingWorkflowProcessCode(value) return processCode ? PROCESS_TYPE_LABELS[processCode] || '其他流程' : '其他流程' } export function getDingWorkflowActivityNodeName( processCodeValue: unknown, activityIdValue: unknown, ): string | null { const processCode = normalizeDingWorkflowProcessCode(processCodeValue) const activityId = clean(activityIdValue) if (!processCode || !activityId) return null return ACTIVITY_NODE_NAMES_BY_PROCESS_CODE[processCode]?.[activityId] || null } export function normalizeDingWorkflowBrand(value: unknown): string { const normalized = clean(value) if (!normalized) return '其他品牌' return DING_WORKFLOW_BRANDS.find((brand) => normalized.includes(brand)) || '其他品牌' } function collectDingWorkflowDepartmentNames(value: unknown): string[] { if (value === null || value === undefined) return [] if (Array.isArray(value)) { return value.flatMap(collectDingWorkflowDepartmentNames) } if (typeof value === 'object') { const record = value as Record return ['name', 'label', 'value', 'text', 'title'] .flatMap((key) => collectDingWorkflowDepartmentNames(record[key])) } const normalized = clean(value) if (!normalized) return [] if (normalized.startsWith('[') || normalized.startsWith('{')) { try { return collectDingWorkflowDepartmentNames(JSON.parse(normalized)) } catch { // Keep non-JSON department names unchanged. } } return [normalized] } export function normalizeDingWorkflowDepartment(value: unknown): string | null { const names = Array.from(new Set(collectDingWorkflowDepartmentNames(value))) return names.length > 0 ? names.join(' / ') : null } function parseJson(value: unknown): unknown { const normalized = clean(value) if (!normalized) return null try { return JSON.parse(normalized) } catch { return null } } function collectContactAssignments(value: unknown, fallbackNodeName: string): ContactAssignment[] { if (!value) return [] if (Array.isArray(value)) { return value.flatMap((entry) => collectContactAssignments(entry, fallbackNodeName)) } if (typeof value !== 'object') return [] const record = value as Record const userId = clean(record.emplId) || clean(record.userId) const contactName = clean(record.name) || clean(record.value) const currentNodeName = clean(record.label) || fallbackNodeName const direct = userId ? [{ userId, name: contactName, nodeName: currentNodeName }] : [] const rowValues = Array.isArray(record.rowValue) ? record.rowValue.flatMap((entry) => { if (!entry || typeof entry !== 'object') return [] const cell = entry as Record const nodeName = clean(cell.label) || fallbackNodeName return [cell.extendValue, cell.value] .flatMap((candidate) => collectContactAssignments(candidate, nodeName)) }) : [] return [...direct, ...rowValues] } function extractContactAssignments(fields: SourceSchemeField[]): ContactAssignment[] { const assignments = fields.flatMap((field) => { const componentType = clean(field.componentType) const nodeName = clean(field.name) || '当前处理节点' if (componentType === 'InnerContactField') { return [parseJson(field.extValue), parseJson(field.value)] .flatMap((value) => collectContactAssignments(value, nodeName)) } if (componentType === 'TableField') { return collectContactAssignments(parseJson(field.value), nodeName) } return [] }) return Array.from(new Map(assignments.map((assignment) => [ `${assignment.userId}:${assignment.nodeName}`, assignment, ])).values()) } function firstSchemeValue(fields: SourceSchemeField[], names: string[]): string | null { for (const name of names) { const value = fields.find((field) => clean(field.name) === name && clean(field.value))?.value const normalized = clean(value) if (normalized) return normalized } return null } function collectSchemeDisplayValues(value: unknown): string[] { if (value === null || value === undefined) return [] if (Array.isArray(value)) return value.flatMap(collectSchemeDisplayValues) if (typeof value === 'object') { const record = value as Record return ['name', 'label', 'value', 'text', 'title'] .flatMap((key) => collectSchemeDisplayValues(record[key])) } const normalized = clean(value) if (!normalized) return [] if (normalized.startsWith('[') || normalized.startsWith('{')) { try { return collectSchemeDisplayValues(JSON.parse(normalized)) } catch { // Keep non-JSON form values unchanged. } } return [normalized] } export function normalizeDingWorkflowDetailValue(value: unknown): string | null { const values = Array.from(new Set(collectSchemeDisplayValues(value))) return values.length > 0 ? values.join(' / ') : null } function buildDingWorkflowDetailFormFields(fields: SourceSchemeField[]): DingWorkflowDetailFormFields { return { platform: normalizeDingWorkflowDetailValue(firstSchemeValue(fields, ['平台'])), brand: normalizeDingWorkflowDetailValue(firstSchemeValue(fields, ['品牌'])), category: normalizeDingWorkflowDetailValue(firstSchemeValue(fields, ['类目'])), spu: normalizeDingWorkflowDetailValue(firstSchemeValue(fields, ['SPU', 'Spu', 'spu'])), demandType: normalizeDingWorkflowDetailValue(firstSchemeValue(fields, ['需求类型'])), standardOutputDeadline: normalizeDingWorkflowDetailValue(firstSchemeValue(fields, ['标准出图最长时效限制'])), sourceType: normalizeDingWorkflowDetailValue(firstSchemeValue(fields, ['原图需求', '原图类型'])), taskType: normalizeDingWorkflowDetailValue(firstSchemeValue(fields, ['任务类型'])), installVideo: normalizeDingWorkflowDetailValue(firstSchemeValue(fields, [ '安装视频', '安装视频分类', '安装图纸/安装视频', '【安装图纸/视频】标准', ])), quantity: normalizeDingWorkflowDetailValue(firstSchemeValue(fields, ['数量', '需求数量', '拍摄数量'])), cameraQuantity: normalizeDingWorkflowDetailValue(firstSchemeValue(fields, ['摄像数量', '摄影数量'])), planningTaskStats: firstSchemeValue(fields, ['策划任务统计']), graphicTaskStats: firstSchemeValue(fields, ['平面任务统计']), imageTaskStats: firstSchemeValue(fields, ['出图任务统计']), } } function titleOriginatorName(value: unknown): string | null { return clean(value)?.match(/^(.+?)提交的/)?.[1]?.trim() || null } function normalizeWorkflowTasks(value: unknown): OngoingDingWorkflowActiveTask[] { if (!Array.isArray(value)) return [] return value.flatMap((task) => { if (!task || typeof task !== 'object') return [] const row = task as Record const taskId = clean(row.taskId) if (!taskId) return [] return [{ taskId, activityId: clean(row.activityId), nodeName: clean(row.nodeName) || '当前处理节点', assigneeUserId: clean(row.assigneeUserId), assigneeName: clean(row.assigneeName) || '待同步人员', assigneeDepartment: clean(row.assigneeDepartment), isBrandCenter: row.isBrandCenter === true, isLeadership: row.isLeadership === true, isActive: row.isActive !== false, reviewPassed: row.reviewPassed === true, result: clean(row.result)?.toUpperCase() || null, createdAt: clean(row.createdAt), finishedAt: clean(row.finishedAt), }] }) } function normalizeDetailFormFields(value: unknown): DingWorkflowDetailFormFields { const record = value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {} return { platform: normalizeDingWorkflowDetailValue(record.platform), brand: normalizeDingWorkflowDetailValue(record.brand), category: normalizeDingWorkflowDetailValue(record.category), spu: normalizeDingWorkflowDetailValue(record.spu), demandType: normalizeDingWorkflowDetailValue(record.demandType), standardOutputDeadline: normalizeDingWorkflowDetailValue(record.standardOutputDeadline), sourceType: normalizeDingWorkflowDetailValue(record.sourceType), taskType: normalizeDingWorkflowDetailValue(record.taskType), installVideo: normalizeDingWorkflowDetailValue(record.installVideo), quantity: normalizeDingWorkflowDetailValue(record.quantity), cameraQuantity: normalizeDingWorkflowDetailValue(record.cameraQuantity), planningTaskStats: clean(record.planningTaskStats), graphicTaskStats: clean(record.graphicTaskStats), imageTaskStats: clean(record.imageTaskStats), } } const DETAIL_MILESTONE_NODES: Readonly> = { [DING_WORKFLOW_ALLOWED_PROCESS_CODES[0]]: { assignmentNodeLabel: '品牌策划主管分配任务', assignmentNodeNames: ['品牌策划主管分配任务'], reviewNodes: [{ label: '品牌策划主管审核', names: ['品牌策划主管审核'], }], }, [DING_WORKFLOW_ALLOWED_PROCESS_CODES[1]]: { assignmentNodeLabel: '内容主管分配任务', assignmentNodeNames: ['内容主管分配任务', '内容负责人分配任务'], // 进行中项目只展示内容主管审核已完成的时间;编导审核是制作分支 // 的过程节点,不能在主管尚未审核时被误显示为“审核结束时间”。 reviewNodes: [{ label: '内容主管审核', names: ['内容主管审核'], }], }, } function compareTaskTimestamps(valueA: string | null, valueB: string | null) { return String(valueA || '').localeCompare(String(valueB || '')) } export function resolveDingWorkflowDetailMilestones( processCodeValue: unknown, tasks: OngoingDingWorkflowActiveTask[], ): DingWorkflowDetailMilestones { const processCode = normalizeDingWorkflowProcessCode(processCodeValue) || '' const config = DETAIL_MILESTONE_NODES[processCode] || { assignmentNodeLabel: '分配任务', assignmentNodeNames: ['分配任务'], reviewNodes: [{ label: '审核', names: ['审核'] }], } const assignmentStartedAt = tasks .filter((task) => config.assignmentNodeNames.includes(task.nodeName) && task.createdAt) .map((task) => task.createdAt) .sort(compareTaskTimestamps)[0] || null const completedReviews = config.reviewNodes.flatMap((reviewNode) => tasks // A source task may carry a finish timestamp while the approval node is // still active (for example, during an upstream mirror update). An active // or unconfirmed review node is not an end milestone, even when // finishTime is present. .filter((task) => { if ( !reviewNode.names.includes(task.nodeName) || !task.finishedAt || task.isActive || task.reviewPassed !== true ) return false // When an activity id is present, require it to resolve to the same // canonical review label. This protects old snapshots from arbitrary // labels attached by the previous leadership-task fallback. const mappedName = task.activityId ? getDingWorkflowActivityNodeName(processCode, task.activityId) : task.nodeName return mappedName === task.nodeName }) .map((task) => ({ label: reviewNode.label, finishedAt: task.finishedAt as string, }))) .sort((reviewA, reviewB) => compareTaskTimestamps(reviewA.finishedAt, reviewB.finishedAt)) const completedReview = completedReviews.at(-1) return { assignmentNodeLabel: config.assignmentNodeLabel, assignmentStartedAt, reviewNodeLabel: completedReview?.label || config.reviewNodes[0].label, reviewFinishedAt: completedReview?.finishedAt || null, } } function nonNegativeInteger(value: unknown): number { const number = Number(value) return Number.isFinite(number) && number > 0 ? Math.floor(number) : 0 } function quoteIdentifier(value: string) { return '[' + String(value).replace(/]/g, ']]') + ']' } function tableRef(table: { schema: string; table: string }) { return `${quoteIdentifier(table.schema)}.${quoteIdentifier(table.table)}` } function sourceTimestampSql(value: string) { return `TRY_CONVERT(datetime2, REPLACE(REPLACE(REPLACE(NULLIF(CONVERT(nvarchar(64), ${value}), ''), 'T', ' '), 'Z', ''), 'z', ''))` } export function isDingWorkflowOngoing(status: unknown, finishedAt: unknown): boolean { const normalizedStatus = String(status || '').trim().toUpperCase() return Boolean(normalizedStatus) && !TERMINAL_WORKFLOW_STATUSES.has(normalizedStatus) && !clean(finishedAt) } export function sourceWorkflowTimestampToIso(value: unknown): string | null { const normalized = clean(value) if (!normalized) return null // 报表库三张钉钉表中的时间是上海本地墙上时间;SQL Server 驱动序列化 // datetime2 时会附加一个并不代表 UTC 的 `Z`。先移除这个伪时区标记, // 再按 Asia/Shanghai 解释,避免页面展示和 SLA 计算整体多出 8 小时。 const workflowClockValue = normalized.replace(/Z$/i, '') const hasNumericTimeZone = /[+-]\d{2}:?\d{2}$/.test(workflowClockValue) const candidate = hasNumericTimeZone ? workflowClockValue : `${workflowClockValue.replace(' ', 'T')}+08:00` const parsed = new Date(candidate) return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString() } export function storedWorkflowTimestampToIso(value: unknown): string | null { const normalized = clean(value) if (!normalized) return null const hasTimeZone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized) if (!hasTimeZone) return sourceWorkflowTimestampToIso(normalized) const parsed = new Date(normalized) return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString() } export function getShanghaiMonthRange(now = new Date()) { const parts = new Intl.DateTimeFormat('en-US', { timeZone: 'Asia/Shanghai', year: 'numeric', month: 'numeric', }).formatToParts(now) const year = Number(parts.find((part) => part.type === 'year')?.value) const month = Number(parts.find((part) => part.type === 'month')?.value) const shanghaiOffsetMs = 8 * 60 * 60 * 1000 return { start: new Date(Date.UTC(year, month - 1, 1) - shanghaiOffsetMs), end: new Date(Date.UTC(year, month, 1) - shanghaiOffsetMs), } } export function isDingWorkflowCompletedInRange( status: unknown, finishedAt: unknown, range: { start: Date; end: Date }, ) { if (String(status || '').trim().toUpperCase() !== 'COMPLETED') return false const finishedAtIso = storedWorkflowTimestampToIso(finishedAt) if (!finishedAtIso) return false const finishedAtMs = new Date(finishedAtIso).getTime() return finishedAtMs >= range.start.getTime() && finishedAtMs < range.end.getTime() } /** * 按时交付只统计已经完成时效判定的流程:待计算(缺少标准时效或关键节点) * 不进入分母。月份边界以时效实际结束节点(主管审核或该内容规则指定的审核 * 节点)为准,而不是审批实例的最终结束时间。 */ export function isDingWorkflowDeliveryAssessmentInRange( assessment: Pick, range: { start: Date; end: Date }, ) { if (assessment.status !== 'on_time' && assessment.status !== 'overtime') return false return isDingWorkflowCompletedInRange('COMPLETED', assessment.finishedAt, range) } /** * 钉钉会把审批结束状态和审批结论分开保存:被拒绝的流程也可能处于 COMPLETED。 * 按时交付只统计已完结且没有明确拒绝结论的流程,避免拒绝单进入分母。 */ export function isDingWorkflowApprovedCompletion( status: unknown, result: unknown, bizAction: unknown, ) { if (String(status || '').trim().toUpperCase() !== 'COMPLETED') return false return ![result, bizAction].some((value) => { const decision = String(value || '').trim().toUpperCase() return decision ? REJECTED_WORKFLOW_DECISIONS.has(decision) : false }) } export function isDingWorkflowStartedOnOrAfterCutoff(value: unknown): boolean { const startedAt = sourceWorkflowTimestampToIso(value) return Boolean(startedAt && startedAt >= DING_WORKFLOW_STARTED_AT_CUTOFF_ISO) } /** * 品牌中心“进行中项目”的统一查询口径。 * * 一个流程可能同时存在多个当前任务;只要至少一个当前节点处理人属于品牌中心, * 该流程就计入。快照 JSON 同时保存当前任务和审核历史,所以查询必须显式要求 * isActive=true,避免把历史品牌中心处理人误当作当前负责人。 */ export function buildBrandCenterOngoingWorkflowWhere() { return { isOngoing: true, processCode: { in: ALLOWED_PROCESS_CODE_VALUES }, sourceCreatedAtRaw: { gte: DING_WORKFLOW_STARTED_AT_CUTOFF_ISO }, activeTasks: { array_contains: BRAND_CENTER_ACTIVE_TASK_JSON }, } } /** * “进行中项目”中美术设计流程的统一子集。 * * 本月上新的进行中数量必须直接复用这个口径,避免同一条在办美术流程 * 在两个指标中因状态、时间或当前处理人条件不同而出现不一致。 */ export function buildBrandCenterOngoingArtWorkflowWhere() { return { ...buildBrandCenterOngoingWorkflowWhere(), processCode: DING_WORKFLOW_ALLOWED_PROCESS_CODES[0], } } function maxSourceCursor(rows: WorkflowSnapshotPayload[], current: string | null) { return rows.reduce((latest, row) => { const candidate = row.sourceUpdatedAtRaw if (!candidate) return latest return !latest || candidate > latest ? candidate : latest }, current) } function normalizeSourceCursorId(value: unknown): number | null { if (value === null || value === undefined || value === '') return null const numeric = Number(value) return Number.isSafeInteger(numeric) && numeric >= 0 ? numeric : null } /** * 兼容旧版只保存 ding_Workflow.updateAt 的游标。 * * v3 新增交付时效所需表单字段;读取旧版 v2 时保留主表游标,但清空 * Task / Scheme 游标触发一次全量对齐,之后继续按三表复合游标增量同步。 * 后续每小时只读取主表变化和 Task / Scheme 新增版本所关联的流程。 */ export function parseDingWorkflowSourceCursor(raw: string | null): DingWorkflowSourceCursor { const normalized = clean(raw) if (!normalized) { return { version: 3, workflowUpdatedAt: null, taskId: null, schemeId: null } } try { const parsed = JSON.parse(normalized) as Record if (parsed.version === 3) { return { version: 3, workflowUpdatedAt: clean(parsed.workflowUpdatedAt), taskId: normalizeSourceCursorId(parsed.taskId), schemeId: normalizeSourceCursorId(parsed.schemeId), } } if (parsed.version === 2) { return { version: 3, workflowUpdatedAt: clean(parsed.workflowUpdatedAt), taskId: null, schemeId: null, } } } catch { // Legacy cursor is a plain ding_Workflow.updateAt string. } return { version: 3, workflowUpdatedAt: normalized, taskId: null, schemeId: null } } export function serializeDingWorkflowSourceCursor(cursor: DingWorkflowSourceCursor): string { return JSON.stringify(cursor) } export function isReportWorkflowDbConfigured() { return Boolean( process.env.REPORT_DB_SERVER && process.env.REPORT_DB_DATABASE && process.env.REPORT_DB_USER && process.env.REPORT_DB_PASSWORD, ) } function buildSourceConfig() { return { server: process.env.REPORT_DB_SERVER as string, database: process.env.REPORT_DB_DATABASE as string, user: process.env.REPORT_DB_USER as string, password: process.env.REPORT_DB_PASSWORD as string, port: Number(process.env.REPORT_DB_PORT || 1433), options: { encrypt: process.env.REPORT_DB_ENCRYPT === 'true', trustServerCertificate: process.env.REPORT_DB_TRUST_SERVER_CERTIFICATE !== 'false', }, connectionTimeout: 20_000, requestTimeout: 300_000, pool: { max: 3, min: 0, idleTimeoutMillis: 30_000 }, } } async function assertDashboardAccess(session: Session | null) { const userId = session?.user?.id if (!userId) throw new DingWorkflowMetricApiError('请先登录', 401) const user = await prisma.user.findUnique({ where: { id: userId }, select: { role: true, isSuperAdmin: true, isActive: true, status: true }, }) if (!user || !user.isActive || user.status === 'archived' || user.status === 'suspended') { throw new DingWorkflowMetricApiError('账号不可用,请重新登录', 401) } const allowed = await resolveBrandCenterAccessForUser(userId, { role: user.role, isSuperAdmin: user.isSuperAdmin, }) if (!allowed) throw new DingWorkflowMetricApiError('无权查看品牌中心流程指标', 403) } async function fetchSourceDeltaState( pool: ConnectionPool, cursor: DingWorkflowSourceCursor, ): Promise { const request = pool.request() request.multiple = true request.input('taskCursorId', mssql.Int, cursor.taskId) request.input('schemeCursorId', mssql.Int, cursor.schemeId) request.input('startedAtCutoff', mssql.NVarChar(64), DING_WORKFLOW_STARTED_AT_CUTOFF_LOCAL) DING_WORKFLOW_ALLOWED_PROCESS_CODES.forEach((processCode, index) => { request.input(`allowedProcessCode${index}`, mssql.NVarChar(255), processCode) }) const result = await request.query(` SELECT MAX(id) AS maxTaskId FROM ${tableRef(WORKFLOW_TASK_TABLE)}; SELECT MAX(id) AS maxSchemeId FROM ${tableRef(WORKFLOW_SCHEME_TABLE)}; ;WITH changed_workflow_ids AS ( SELECT CONVERT(nvarchar(255), processInstanceId) AS processInstanceId FROM ${tableRef(WORKFLOW_TASK_TABLE)} WHERE @taskCursorId IS NOT NULL AND id > @taskCursorId UNION SELECT CONVERT(nvarchar(255), processInstanceId) AS processInstanceId FROM ${tableRef(WORKFLOW_SCHEME_TABLE)} WHERE @schemeCursorId IS NOT NULL AND id > @schemeCursorId ) SELECT DISTINCT changed.processInstanceId FROM changed_workflow_ids changed INNER JOIN ${tableRef(WORKFLOW_TABLE)} workflow ON CONVERT(nvarchar(255), workflow.processInstanceId) = changed.processInstanceId WHERE UPPER(LTRIM(RTRIM(COALESCE(CONVERT(nvarchar(255), workflow.processCode), '')))) IN ( @allowedProcessCode0, @allowedProcessCode1 ) AND ${sourceTimestampSql('workflow.createAt')} >= TRY_CONVERT(datetime2, @startedAtCutoff); `) const taskMaxRows = (result.recordsets?.[0] || []) as Array<{ maxTaskId: unknown }> const schemeMaxRows = (result.recordsets?.[1] || []) as Array<{ maxSchemeId: unknown }> const changedRows = (result.recordsets?.[2] || []) as Array<{ processInstanceId: unknown }> return { changedWorkflowIds: changedRows.flatMap((row) => { const processInstanceId = clean(row.processInstanceId) return processInstanceId ? [processInstanceId] : [] }), maxTaskId: normalizeSourceCursorId(taskMaxRows[0]?.maxTaskId), maxSchemeId: normalizeSourceCursorId(schemeMaxRows[0]?.maxSchemeId), requiresFullSync: cursor.taskId === null || cursor.schemeId === null, } } async function fetchSourceWorkflows( pool: ConnectionPool, cursor: string | null, changedWorkflowIds: string[] = [], ) { const request = pool.request() const currentMonth = getShanghaiMonthRange() const currentMonthStart = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }).format(currentMonth.start) request.input('cursor', mssql.NVarChar(64), cursor) request.input('changedWorkflowIds', mssql.NVarChar(mssql.MAX), JSON.stringify(changedWorkflowIds)) request.input('startedAtCutoff', mssql.NVarChar(64), DING_WORKFLOW_STARTED_AT_CUTOFF_LOCAL) request.input('currentMonthStart', mssql.NVarChar(64), currentMonthStart) DING_WORKFLOW_ALLOWED_PROCESS_CODES.forEach((processCode, index) => { request.input(`allowedProcessCode${index}`, mssql.NVarChar(255), processCode) }) const result = await request.query(` SELECT CONVERT(nvarchar(255), processInstanceId) AS processInstanceId, CONVERT(nvarchar(255), businessId) AS businessId, CONVERT(nvarchar(max), title) AS title, CONVERT(nvarchar(100), result) AS result, CONVERT(nvarchar(255), originatorUserId) AS originatorUserId, CONVERT(nvarchar(255), originatorDeptId) AS originatorDeptId, CONVERT(nvarchar(500), originatorDeptName) AS originatorDeptName, CONVERT(nvarchar(100), status) AS status, CONVERT(nvarchar(100), bizAction) AS bizAction, CONVERT(nvarchar(255), processCode) AS processCode, CONVERT(nvarchar(100), flowType) AS flowType, CONVERT(nvarchar(64), joinAt) AS joinedAtRaw, CONVERT(nvarchar(64), createAt) AS sourceCreatedAtRaw, CONVERT(nvarchar(64), updateAt) AS sourceUpdatedAtRaw, CONVERT(nvarchar(64), finishTime) AS sourceFinishedAtRaw FROM ${tableRef(WORKFLOW_TABLE)} WHERE UPPER(LTRIM(RTRIM(COALESCE(CONVERT(nvarchar(255), processCode), '')))) IN ( @allowedProcessCode0, @allowedProcessCode1 ) AND ${sourceTimestampSql('createAt')} >= TRY_CONVERT(datetime2, @startedAtCutoff) AND ( @cursor IS NULL OR CONVERT(nvarchar(255), processInstanceId) IN ( SELECT CONVERT(nvarchar(255), [value]) FROM OPENJSON(@changedWorkflowIds) ) OR UPPER(LTRIM(RTRIM(COALESCE(CONVERT(nvarchar(100), status), '')))) NOT IN ('COMPLETED', 'TERMINATED', 'CANCELED') OR ( UPPER(LTRIM(RTRIM(COALESCE(CONVERT(nvarchar(100), status), '')))) IN ('COMPLETED', 'TERMINATED', 'CANCELED') AND ${sourceTimestampSql('finishTime')} >= TRY_CONVERT(datetime2, @currentMonthStart) ) OR ${sourceTimestampSql('updateAt')} >= DATEADD(minute, -5, ${sourceTimestampSql('@cursor')}) ) `) return result.recordset } async function fetchWorkflowAggregates(pool: ConnectionPool, processInstanceIds: string[]) { if (processInstanceIds.length === 0) { return { taskByWorkflow: new Map(), taskRowsByWorkflow: new Map(), schemeByWorkflow: new Map(), schemeRowsByWorkflow: new Map(), } } const request = pool.request() request.multiple = true request.input('workflowIds', mssql.NVarChar(mssql.MAX), JSON.stringify(processInstanceIds)) const result = await request.query(` ;WITH workflow_ids AS ( SELECT CONVERT(nvarchar(255), [value]) AS processInstanceId FROM OPENJSON(@workflowIds) ) SELECT CONVERT(nvarchar(255), task.processInstanceId) AS processInstanceId, CONVERT(nvarchar(255), task.taskId) AS taskId, CONVERT(nvarchar(255), task.activityId) AS activityId, CONVERT(nvarchar(255), task.userId) AS userId, CONVERT(nvarchar(100), task.status) AS status, CONVERT(nvarchar(100), task.result) AS result, CONVERT(nvarchar(64), task.createTime) AS createTime, CONVERT(nvarchar(64), task.finishTime) AS finishTime, ROW_NUMBER() OVER ( PARTITION BY task.processInstanceId, task.taskId ORDER BY task.id DESC ) AS duplicateRow, task.id AS sourceRowId INTO #workflow_task_rows FROM ${tableRef(WORKFLOW_TASK_TABLE)} task INNER JOIN workflow_ids ids ON ids.processInstanceId = CONVERT(nvarchar(255), task.processInstanceId) ; ;WITH ranked_tasks AS ( SELECT *, ROW_NUMBER() OVER ( PARTITION BY processInstanceId ORDER BY ${sourceTimestampSql('createTime')} DESC, sourceRowId DESC ) AS latestRow FROM #workflow_task_rows WHERE duplicateRow = 1 ) SELECT processInstanceId, COUNT_BIG(*) AS taskCount, SUM(CASE WHEN UPPER(LTRIM(RTRIM(COALESCE(status, '')))) NOT IN ('COMPLETED', 'TERMINATED', 'CANCELED') AND NULLIF(LTRIM(RTRIM(COALESCE(finishTime, ''))), '') IS NULL THEN 1 ELSE 0 END) AS activeTaskCount, MAX(CASE WHEN latestRow = 1 THEN status END) AS latestTaskStatus, MAX(CASE WHEN latestRow = 1 THEN createTime END) AS latestTaskAtRaw FROM ranked_tasks GROUP BY processInstanceId; SELECT processInstanceId, taskId, activityId, userId, status, result, createTime, finishTime FROM #workflow_task_rows WHERE duplicateRow = 1; ;WITH workflow_ids AS ( SELECT CONVERT(nvarchar(255), [value]) AS processInstanceId FROM OPENJSON(@workflowIds) ) SELECT CONVERT(nvarchar(255), scheme.processInstanceId) AS processInstanceId, COUNT_BIG(*) AS schemeFieldCount FROM ${tableRef(WORKFLOW_SCHEME_TABLE)} scheme INNER JOIN workflow_ids ids ON ids.processInstanceId = CONVERT(nvarchar(255), scheme.processInstanceId) GROUP BY scheme.processInstanceId; ;WITH workflow_ids AS ( SELECT CONVERT(nvarchar(255), [value]) AS processInstanceId FROM OPENJSON(@workflowIds) ) SELECT CONVERT(nvarchar(255), scheme.processInstanceId) AS processInstanceId, CONVERT(nvarchar(500), scheme.componentType) AS componentType, CONVERT(nvarchar(2000), scheme.name) AS name, CONVERT(nvarchar(max), scheme.value) AS value, CONVERT(nvarchar(max), scheme.extValue) AS extValue FROM ${tableRef(WORKFLOW_SCHEME_TABLE)} scheme INNER JOIN workflow_ids ids ON ids.processInstanceId = CONVERT(nvarchar(255), scheme.processInstanceId) WHERE LTRIM(RTRIM(COALESCE(CONVERT(nvarchar(2000), scheme.name), ''))) IN ( '平台', '品牌', '类目', 'SPU', 'Spu', 'spu', '需求类型', '标准出图最长时效限制', '需求部门', '需求人', '任务类型', '原图需求', '原图类型', '安装视频', '安装视频分类', '安装图纸/安装视频', '【安装图纸/视频】标准', '数量', '需求数量', '拍摄数量', '摄像数量', '摄影数量', '策划任务统计', '平面任务统计', '出图任务统计' ) OR CONVERT(nvarchar(500), scheme.componentType) IN ('InnerContactField', 'TableField'); `) const taskRows = (result.recordsets?.[0] || []) as SourceTaskAggregate[] const activeTaskRows = (result.recordsets?.[1] || []) as SourceTaskRow[] const schemeRows = (result.recordsets?.[2] || []) as SourceSchemeAggregate[] const schemeFieldRows = (result.recordsets?.[3] || []) as SourceSchemeField[] const groupByWorkflow = (rows: T[]) => { const grouped = new Map() rows.forEach((row) => { const processInstanceId = clean(row.processInstanceId) if (!processInstanceId) return grouped.set(processInstanceId, [...(grouped.get(processInstanceId) || []), row]) }) return grouped } return { taskByWorkflow: new Map(taskRows.map((row) => [String(row.processInstanceId), row])), taskRowsByWorkflow: groupByWorkflow(activeTaskRows), schemeByWorkflow: new Map(schemeRows.map((row) => [String(row.processInstanceId), row])), schemeRowsByWorkflow: groupByWorkflow(schemeFieldRows), } } export function isDingWorkflowTaskOngoing(status: unknown, finishTime: unknown): boolean { // The external dashboard treats a task with a finish timestamp as history, // even when the mirrored status field still says RUNNING. This is important // for assignment nodes that have already handed work to the next node. if (clean(finishTime)) return false const normalized = clean(status)?.toUpperCase() return Boolean(normalized && !TERMINAL_WORKFLOW_STATUSES.has(normalized)) } type EmployeeDirectoryEntry = { name: string departmentName: string | null isBrandCenter: boolean isLeadership: boolean } async function readEmployeeDirectory( sourceRows: SourceWorkflowRow[], taskRowsByWorkflow: Map, ) { const ids = Array.from(new Set([ ...sourceRows.map((row) => clean(row.originatorUserId)), ...Array.from(taskRowsByWorkflow.values()).flatMap((rows) => rows.map((row) => clean(row.userId))), ].filter((value): value is string => Boolean(value) && value !== 'bpms_system'))) if (ids.length === 0) return new Map() const profiles = await prisma.performanceSurveyEmployeeProfile.findMany({ where: { dingtalkUserId: { in: ids } }, select: { dingtalkUserId: true, displayName: true, departmentName: true, departmentPath: true, isLeadership: true, levelType: true, positionLevel: true, employmentState: true, departmentMemberships: { select: { departmentPath: true } }, }, }) return new Map(profiles.flatMap((profile) => ( profile.dingtalkUserId ? [[profile.dingtalkUserId, { name: profile.displayName, departmentName: profile.departmentName, isLeadership: profile.isLeadership || profile.levelType?.toUpperCase() === 'M' || profile.positionLevel?.toUpperCase().startsWith('M') === true, isBrandCenter: profile.employmentState !== 'RESIGNED' && [ profile.departmentPath, ...profile.departmentMemberships.map((membership) => membership.departmentPath), ].some(isBrandCenterOrganizationPath), }] as const] : [] ))) } function inferActivityNodeNames( sourceRows: SourceWorkflowRow[], taskRowsByWorkflow: Map, schemeRowsByWorkflow: Map, ) { const processCodeByWorkflow = new Map(sourceRows.flatMap((row) => { const id = clean(row.processInstanceId) const code = normalizeDingWorkflowProcessCode(row.processCode) return id && code ? [[id, code] as const] : [] })) const votes = new Map>() taskRowsByWorkflow.forEach((taskRows, processInstanceId) => { const processCode = processCodeByWorkflow.get(processInstanceId) if (!processCode) return const assignments = extractContactAssignments(schemeRowsByWorkflow.get(processInstanceId) || []) taskRows.forEach((task) => { const activityId = clean(task.activityId) const userId = clean(task.userId) if (!activityId || !userId) return const nodeNames = Array.from(new Set( assignments.filter((assignment) => assignment.userId === userId).map((assignment) => assignment.nodeName), )) if (nodeNames.length !== 1) return const key = `${processCode}:${activityId}` const activityVotes = votes.get(key) || new Map() activityVotes.set(nodeNames[0], (activityVotes.get(nodeNames[0]) || 0) + 1) votes.set(key, activityVotes) }) }) return new Map(Array.from(votes.entries()).map(([key, activityVotes]) => { const [nodeName] = Array.from(activityVotes.entries()).sort((a, b) => b[1] - a[1])[0] return [key, nodeName] })) } function buildSnapshotPayload( sourceRows: SourceWorkflowRow[], taskByWorkflow: Map, taskRowsByWorkflow: Map, schemeByWorkflow: Map, schemeRowsByWorkflow: Map, employeeDirectory: Map, activityNodeNames: Map, ): WorkflowSnapshotPayload[] { return sourceRows.flatMap((row) => { const processInstanceId = clean(row.processInstanceId) const status = clean(row.status) const sourceCreatedAtRaw = sourceWorkflowTimestampToIso(row.sourceCreatedAtRaw) if (!processInstanceId || !status || !isDingWorkflowStartedOnOrAfterCutoff(row.sourceCreatedAtRaw)) return [] const ongoing = isDingWorkflowOngoing(status, row.sourceFinishedAtRaw) const task = taskByWorkflow.get(processInstanceId) const scheme = schemeByWorkflow.get(processInstanceId) const processCode = normalizeDingWorkflowProcessCode(row.processCode) const schemeFields = schemeRowsByWorkflow.get(processInstanceId) || [] const assignments = extractContactAssignments(schemeFields) const brand = normalizeDingWorkflowBrand(firstSchemeValue(schemeFields, ['品牌'])) const demandType = firstSchemeValue(schemeFields, ['需求类型']) const requesterDepartment = normalizeDingWorkflowDepartment(firstSchemeValue(schemeFields, ['需求部门'])) || normalizeDingWorkflowDepartment(row.originatorDeptName) || '未记录部门' const originatorUserId = clean(row.originatorUserId) const requesterName = firstSchemeValue(schemeFields, ['需求人']) || (originatorUserId ? employeeDirectory.get(originatorUserId)?.name : null) || titleOriginatorName(row.title) || '未记录' const workflowTasks = (taskRowsByWorkflow.get(processInstanceId) || []).flatMap((sourceTask) => { const taskId = clean(sourceTask.taskId) if (!taskId) return [] const activityId = clean(sourceTask.activityId) const assigneeUserId = clean(sourceTask.userId) const taskStatus = clean(sourceTask.status)?.toUpperCase() || null const taskResult = clean(sourceTask.result)?.toUpperCase() || null const isActive = ongoing && isDingWorkflowTaskOngoing(sourceTask.status, sourceTask.finishTime) const matchingAssignments = assigneeUserId ? assignments.filter((assignment) => assignment.userId === assigneeUserId) : [] const employee = assigneeUserId ? employeeDirectory.get(assigneeUserId) : null const uniqueAssignmentNodeNames = Array.from(new Set(matchingAssignments.map((assignment) => assignment.nodeName))) const configuredNodeName = getDingWorkflowActivityNodeName(processCode, activityId) const inferredNodeName = processCode && activityId ? activityNodeNames.get(`${processCode}:${activityId}`) : null // Inference can label otherwise unnamed production tasks, but it must // never manufacture a review milestone. Review nodes are identified by // the explicit activityId mapping above. const safeInferredNodeName = inferredNodeName && !CONFIRMED_REVIEW_NODE_NAMES.has(inferredNodeName) ? inferredNodeName : null const safeAssignmentNodeName = uniqueAssignmentNodeNames.find( (nodeName) => !CONFIRMED_REVIEW_NODE_NAMES.has(nodeName), ) || null const nodeName = configuredNodeName || safeInferredNodeName || safeAssignmentNodeName || (isActive ? '当前处理节点' : '历史处理节点') // Once the activity id resolves to a confirmed review node, completion // is determined by the task decision itself. The employee directory is // intentionally not part of this gate: a stale/missing roster entry // must not erase a valid review timestamp from the dashboard. const reviewPassed = CONFIRMED_REVIEW_NODE_NAMES.has(nodeName) && taskStatus === 'COMPLETED' && taskResult === 'AGREE' return [{ taskId, activityId, nodeName, assigneeUserId, assigneeName: matchingAssignments.find((assignment) => assignment.name)?.name || employee?.name || (assigneeUserId === 'bpms_system' ? '系统处理' : '待同步人员'), assigneeDepartment: employee?.departmentName || null, isBrandCenter: employee?.isBrandCenter === true, isLeadership: employee?.isLeadership === true, isActive, reviewPassed, result: taskResult, createdAt: sourceWorkflowTimestampToIso(sourceTask.createTime), finishedAt: sourceWorkflowTimestampToIso(sourceTask.finishTime), }] }) return [{ processInstanceId, businessId: clean(row.businessId), title: clean(row.title), result: clean(row.result), originatorUserId, originatorDeptId: clean(row.originatorDeptId), originatorDeptName: clean(row.originatorDeptName), status: status.toUpperCase(), bizAction: clean(row.bizAction), processCode, flowType: clean(row.flowType), joinedAtRaw: clean(row.joinedAtRaw), sourceCreatedAtRaw, sourceUpdatedAtRaw: sourceWorkflowTimestampToIso(row.sourceUpdatedAtRaw), sourceFinishedAtRaw: sourceWorkflowTimestampToIso(row.sourceFinishedAtRaw), isOngoing: ongoing, schemeFieldCount: nonNegativeInteger(scheme?.schemeFieldCount), taskCount: nonNegativeInteger(task?.taskCount), activeTaskCount: ongoing ? nonNegativeInteger(task?.activeTaskCount) : 0, latestTaskStatus: clean(task?.latestTaskStatus)?.toUpperCase() || null, latestTaskAtRaw: sourceWorkflowTimestampToIso(task?.latestTaskAtRaw), brand, demandType, requesterName, requesterDepartment, projectType: getDingWorkflowProjectType(processCode), formFields: buildDingWorkflowDetailFormFields(schemeFields), activeTasks: workflowTasks, }] }) } async function upsertSnapshotBatch(rows: WorkflowSnapshotPayload[], syncedAt: Date) { if (rows.length === 0) return await prisma.$executeRawUnsafe(` INSERT INTO "ding_workflow_snapshots" ( "process_instance_id", "business_id", "title", "result", "originator_user_id", "originator_dept_id", "originator_dept_name", "status", "biz_action", "process_code", "flow_type", "joined_at_raw", "source_created_at_raw", "source_updated_at_raw", "source_finished_at_raw", "is_ongoing", "scheme_field_count", "task_count", "active_task_count", "latest_task_status", "latest_task_at_raw", "brand", "demand_type", "requester_name", "requester_department", "project_type", "form_fields", "active_tasks", "synced_at", "updated_at" ) SELECT incoming."processInstanceId", incoming."businessId", incoming."title", incoming."result", incoming."originatorUserId", incoming."originatorDeptId", incoming."originatorDeptName", incoming."status", incoming."bizAction", incoming."processCode", incoming."flowType", incoming."joinedAtRaw", incoming."sourceCreatedAtRaw", incoming."sourceUpdatedAtRaw", incoming."sourceFinishedAtRaw", incoming."isOngoing", incoming."schemeFieldCount", incoming."taskCount", incoming."activeTaskCount", incoming."latestTaskStatus", incoming."latestTaskAtRaw", incoming."brand", incoming."demandType", incoming."requesterName", incoming."requesterDepartment", incoming."projectType", incoming."formFields", incoming."activeTasks", $2::timestamp, $2::timestamp FROM jsonb_to_recordset($1::jsonb) AS incoming( "processInstanceId" text, "businessId" text, "title" text, "result" text, "originatorUserId" text, "originatorDeptId" text, "originatorDeptName" text, "status" text, "bizAction" text, "processCode" text, "flowType" text, "joinedAtRaw" text, "sourceCreatedAtRaw" text, "sourceUpdatedAtRaw" text, "sourceFinishedAtRaw" text, "isOngoing" boolean, "schemeFieldCount" integer, "taskCount" integer, "activeTaskCount" integer, "latestTaskStatus" text, "latestTaskAtRaw" text, "brand" text, "demandType" text, "requesterName" text, "requesterDepartment" text, "projectType" text, "formFields" jsonb, "activeTasks" jsonb ) ON CONFLICT ("process_instance_id") DO UPDATE SET "business_id" = EXCLUDED."business_id", "title" = EXCLUDED."title", "result" = EXCLUDED."result", "originator_user_id" = EXCLUDED."originator_user_id", "originator_dept_id" = EXCLUDED."originator_dept_id", "originator_dept_name" = EXCLUDED."originator_dept_name", "status" = EXCLUDED."status", "biz_action" = EXCLUDED."biz_action", "process_code" = EXCLUDED."process_code", "flow_type" = EXCLUDED."flow_type", "joined_at_raw" = EXCLUDED."joined_at_raw", "source_created_at_raw" = EXCLUDED."source_created_at_raw", "source_updated_at_raw" = EXCLUDED."source_updated_at_raw", "source_finished_at_raw" = EXCLUDED."source_finished_at_raw", "is_ongoing" = EXCLUDED."is_ongoing", "scheme_field_count" = EXCLUDED."scheme_field_count", "task_count" = EXCLUDED."task_count", "active_task_count" = EXCLUDED."active_task_count", "latest_task_status" = EXCLUDED."latest_task_status", "latest_task_at_raw" = EXCLUDED."latest_task_at_raw", "brand" = EXCLUDED."brand", "demand_type" = EXCLUDED."demand_type", "requester_name" = EXCLUDED."requester_name", "requester_department" = EXCLUDED."requester_department", "project_type" = EXCLUDED."project_type", "form_fields" = EXCLUDED."form_fields", "active_tasks" = EXCLUDED."active_tasks", "synced_at" = EXCLUDED."synced_at", "updated_at" = EXCLUDED."updated_at" `, JSON.stringify(rows), syncedAt) } async function acquireSyncLease(leaseOwner: string, startedAt: Date) { await prisma.dingWorkflowSyncState.upsert({ where: { id: SYNC_STATE_ID }, create: { id: SYNC_STATE_ID }, update: {}, }) const claimed = await prisma.dingWorkflowSyncState.updateMany({ where: { id: SYNC_STATE_ID, OR: [ { leaseExpiresAt: null }, { leaseExpiresAt: { lt: startedAt } }, { leaseOwner }, ], }, data: { status: 'syncing', lastStartedAt: startedAt, lastError: null, leaseOwner, leaseExpiresAt: new Date(startedAt.getTime() + LEASE_DURATION_MS), }, }) if (claimed.count !== 1) return null return prisma.dingWorkflowSyncState.findUniqueOrThrow({ where: { id: SYNC_STATE_ID } }) } export async function syncDingWorkflowSnapshots() { if (!isReportWorkflowDbConfigured()) { throw new Error('未配置上游报表库(REPORT_DB_*),无法同步钉钉流程') } const startedAt = new Date() const leaseOwner = `${hostname()}:${process.pid}:${randomUUID()}` const state = await acquireSyncLease(leaseOwner, startedAt) if (!state) return { skipped: true, rowsUpserted: 0, rowsDeleted: 0, ongoingCount: null } const pool = new mssql.ConnectionPool(buildSourceConfig()) try { await pool.connect() const sourceCursor = parseDingWorkflowSourceCursor(state.sourceCursorRaw) const sourceDelta = await fetchSourceDeltaState(pool, sourceCursor) const sourceRows = await fetchSourceWorkflows( pool, sourceDelta.requiresFullSync ? null : sourceCursor.workflowUpdatedAt, sourceDelta.changedWorkflowIds, ) const workflowIds = sourceRows.flatMap((row) => { const id = clean(row.processInstanceId) return id ? [id] : [] }) const { taskByWorkflow, taskRowsByWorkflow, schemeByWorkflow, schemeRowsByWorkflow, } = await fetchWorkflowAggregates(pool, workflowIds) const employeeDirectory = await readEmployeeDirectory(sourceRows, taskRowsByWorkflow) const activityNodeNames = inferActivityNodeNames(sourceRows, taskRowsByWorkflow, schemeRowsByWorkflow) const rows = buildSnapshotPayload( sourceRows, taskByWorkflow, taskRowsByWorkflow, schemeByWorkflow, schemeRowsByWorkflow, employeeDirectory, activityNodeNames, ) const syncedAt = new Date() for (let index = 0; index < rows.length; index += UPSERT_BATCH_SIZE) { await upsertSnapshotBatch(rows.slice(index, index + UPSERT_BATCH_SIZE), syncedAt) } const deleted = await prisma.dingWorkflowSnapshot.deleteMany({ where: { OR: [ { processCode: null }, { processCode: { notIn: ALLOWED_PROCESS_CODE_VALUES } }, { sourceCreatedAtRaw: null }, { sourceCreatedAtRaw: { lt: DING_WORKFLOW_STARTED_AT_CUTOFF_ISO } }, ], }, }) const ongoingCount = await prisma.dingWorkflowSnapshot.count({ where: buildBrandCenterOngoingWorkflowWhere(), }) const sourceCursorRaw = serializeDingWorkflowSourceCursor({ version: 3, workflowUpdatedAt: maxSourceCursor(rows, sourceCursor.workflowUpdatedAt), taskId: sourceDelta.maxTaskId, schemeId: sourceDelta.maxSchemeId, }) await prisma.dingWorkflowSyncState.updateMany({ where: { id: SYNC_STATE_ID, leaseOwner }, data: { status: 'ready', sourceCursorRaw, lastCompletedAt: syncedAt, lastError: null, leaseOwner: null, leaseExpiresAt: null, rowsUpserted: rows.length, currentOngoingCount: ongoingCount, }, }) return { skipped: false, rowsUpserted: rows.length, rowsDeleted: deleted.count, ongoingCount } } catch (error) { const message = error instanceof Error ? error.message : String(error) await prisma.dingWorkflowSyncState.updateMany({ where: { id: SYNC_STATE_ID, leaseOwner }, data: { status: 'error', lastError: message.slice(0, 2_000), leaseOwner: null, leaseExpiresAt: null, }, }).catch(() => undefined) throw error } finally { await pool.close().catch(() => undefined) } } function normalizeSyncStatus(state: { status: string; lastCompletedAt: Date | null } | null): DingWorkflowSyncStatus { if (!state?.lastCompletedAt) return state?.status === 'syncing' ? 'syncing' : state?.status === 'error' ? 'error' : 'pending' if (state.status === 'syncing') return 'syncing' if (state.status === 'error') return 'error' return 'ready' } async function readSummaryWithoutAccessCheck(): Promise { const [total, state] = await prisma.$transaction([ prisma.dingWorkflowSnapshot.count({ where: buildBrandCenterOngoingWorkflowWhere(), }), prisma.dingWorkflowSyncState.findUnique({ where: { id: SYNC_STATE_ID }, select: { status: true, lastCompletedAt: true }, }), ]) return { total, lastSyncedAt: state?.lastCompletedAt?.toISOString() || null, syncStatus: normalizeSyncStatus(state), } } export async function getOngoingDingWorkflowSummary(session: Session | null) { await assertDashboardAccess(session) return readSummaryWithoutAccessCheck() } const ONGOING_WORKFLOW_SELECT = { processInstanceId: true, businessId: true, title: true, status: true, processCode: true, projectType: true, brand: true, requesterName: true, requesterDepartment: true, formFields: true, activeTasks: true, originatorUserId: true, originatorDeptName: true, sourceCreatedAtRaw: true, sourceUpdatedAtRaw: true, activeTaskCount: true, taskCount: true, schemeFieldCount: true, latestTaskStatus: true, } as const const MONTHLY_LAUNCH_WORKFLOW_SELECT = { ...ONGOING_WORKFLOW_SELECT, result: true, bizAction: true, sourceFinishedAtRaw: true, } as const type DingWorkflowInitiatorProfile = { displayName: string | null departmentName: string | null departmentPath: string | null } const COMPANY_ORGANIZATION_ROOT = '江苏简尚品牌管理有限公司' function formatInitiatorDepartmentPath(value: string | null | undefined) { const parts = String(value || '') .split('/') .map((part) => part.trim()) .filter(Boolean) if (parts[0] === COMPANY_ORGANIZATION_ROOT) parts.shift() return parts.join('-') } export function resolveDingWorkflowInitiator( profile: DingWorkflowInitiatorProfile | null | undefined, fallback: { name: string | null | undefined; department: string | null | undefined }, ) { return { name: profile?.displayName?.trim() || fallback.name?.trim() || '未记录', department: formatInitiatorDepartmentPath(profile?.departmentPath) || profile?.departmentName?.trim() || normalizeDingWorkflowDepartment(fallback.department) || '未记录部门', } } async function readInitiatorDirectory(rows: Array<{ originatorUserId: string | null }>) { const dingtalkUserIds = Array.from(new Set( rows.map((row) => row.originatorUserId?.trim()).filter((value): value is string => Boolean(value)), )) if (dingtalkUserIds.length === 0) return new Map() const profiles = await prisma.performanceSurveyEmployeeProfile.findMany({ where: { dingtalkUserId: { in: dingtalkUserIds } }, select: { dingtalkUserId: true, displayName: true, departmentName: true, departmentPath: true, }, }) return new Map(profiles.flatMap((profile) => ( profile.dingtalkUserId ? [[profile.dingtalkUserId, profile] as const] : [] ))) } function mapSnapshotListItem(row: { processInstanceId: string businessId: string | null title: string | null status: string processCode: string | null projectType: string | null brand: string | null requesterName: string | null requesterDepartment: string | null formFields: unknown activeTasks: unknown originatorUserId: string | null originatorDeptName: string | null sourceCreatedAtRaw: string | null sourceUpdatedAtRaw: string | null activeTaskCount: number taskCount: number schemeFieldCount: number latestTaskStatus: string | null }, initiatorProfile?: DingWorkflowInitiatorProfile | null): OngoingDingWorkflowListItem { const processCode = normalizeDingWorkflowProcessCode(row.processCode) || 'UNKNOWN' const initiator = resolveDingWorkflowInitiator(initiatorProfile, { name: row.requesterName, department: row.originatorDeptName || row.requesterDepartment, }) const tasks = normalizeWorkflowTasks(row.activeTasks) const milestones = resolveDingWorkflowDetailMilestones(processCode, tasks) return { id: row.processInstanceId, businessId: row.businessId, title: row.title || '未命名流程', status: row.status, processCode, projectType: row.projectType || getDingWorkflowProjectType(processCode), brand: row.brand || '其他品牌', requesterName: row.requesterName || '未记录', requesterDepartment: normalizeDingWorkflowDepartment(row.requesterDepartment) || normalizeDingWorkflowDepartment(row.originatorDeptName) || '未记录部门', initiatorName: initiator.name, initiatorDepartment: initiator.department, formFields: normalizeDetailFormFields(row.formFields), ...milestones, activeTasks: tasks.filter((task) => task.isActive), originatorUserId: row.originatorUserId, originatorDeptName: row.originatorDeptName, sourceCreatedAt: storedWorkflowTimestampToIso(row.sourceCreatedAtRaw), sourceUpdatedAt: storedWorkflowTimestampToIso(row.sourceUpdatedAtRaw), activeTaskCount: row.activeTaskCount, taskCount: row.taskCount, schemeFieldCount: row.schemeFieldCount, latestTaskStatus: row.latestTaskStatus, } } export async function getOngoingDingWorkflowInsights(session: Session | null): Promise { await assertDashboardAccess(session) const where = buildBrandCenterOngoingWorkflowWhere() const [rows, sync] = await Promise.all([ prisma.dingWorkflowSnapshot.findMany({ where, select: ONGOING_WORKFLOW_SELECT, orderBy: [ { sourceUpdatedAtRaw: { sort: 'desc', nulls: 'last' } }, { processInstanceId: 'desc' }, ], }), readSummaryWithoutAccessCheck(), ]) const initiatorDirectory = await readInitiatorDirectory(rows) return { items: rows.map((row) => mapSnapshotListItem( row, row.originatorUserId ? initiatorDirectory.get(row.originatorUserId.trim()) : null, )), total: rows.length, sync, } } export async function getOngoingDingWorkflowProjects( session: Session | null, params?: { page?: number; pageSize?: number; query?: string }, ): Promise { await assertDashboardAccess(session) const page = Math.max(1, Number(params?.page || 1) || 1) const pageSize = Math.min(50, Math.max(1, Number(params?.pageSize || 20) || 20)) const query = clean(params?.query) const where = { ...buildBrandCenterOngoingWorkflowWhere(), ...(query ? { OR: [ { title: { contains: query, mode: 'insensitive' as const } }, { businessId: { contains: query, mode: 'insensitive' as const } }, { processInstanceId: { contains: query, mode: 'insensitive' as const } }, { originatorDeptName: { contains: query, mode: 'insensitive' as const } }, { originatorUserId: { contains: query, mode: 'insensitive' as const } }, ], } : {}), } const [total, rows, sync] = await Promise.all([ prisma.dingWorkflowSnapshot.count({ where }), prisma.dingWorkflowSnapshot.findMany({ where, select: ONGOING_WORKFLOW_SELECT, orderBy: [ { sourceUpdatedAtRaw: { sort: 'desc', nulls: 'last' } }, { processInstanceId: 'desc' }, ], skip: (page - 1) * pageSize, take: pageSize, }), readSummaryWithoutAccessCheck(), ]) const initiatorDirectory = await readInitiatorDirectory(rows) return { items: rows.map((row) => mapSnapshotListItem( row, row.originatorUserId ? initiatorDirectory.get(row.originatorUserId.trim()) : null, )), pagination: { page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)), }, sync, } } export function buildMonthlyLaunchWorkflowWhere() { return { processCode: DING_WORKFLOW_ALLOWED_PROCESS_CODES[0], sourceCreatedAtRaw: { gte: DING_WORKFLOW_STARTED_AT_CUTOFF_ISO }, brand: { in: [...DING_WORKFLOW_BRANDS] }, demandType: DING_WORKFLOW_NEW_PRODUCT_DEMAND_TYPE, } } export function buildMonthlyLaunchOngoingWorkflowWhere() { return { ...buildBrandCenterOngoingArtWorkflowWhere(), brand: { in: [...DING_WORKFLOW_BRANDS] }, demandType: DING_WORKFLOW_NEW_PRODUCT_DEMAND_TYPE, } } async function readMonthlyLaunchInsightsWithoutAccessCheck(now = new Date()): Promise { const range = getShanghaiMonthRange(now) const baseWhere = buildMonthlyLaunchWorkflowWhere() const [completedCandidates, ongoingRows, state] = await Promise.all([ prisma.dingWorkflowSnapshot.findMany({ where: { ...baseWhere, status: 'COMPLETED', sourceFinishedAtRaw: { gte: range.start.toISOString(), lt: range.end.toISOString(), }, }, select: MONTHLY_LAUNCH_WORKFLOW_SELECT, }), prisma.dingWorkflowSnapshot.findMany({ where: buildMonthlyLaunchOngoingWorkflowWhere(), select: MONTHLY_LAUNCH_WORKFLOW_SELECT, }), prisma.dingWorkflowSyncState.findUnique({ where: { id: SYNC_STATE_ID }, select: { status: true, lastCompletedAt: true }, }), ]) const completedRows = completedCandidates.filter((row) => ( isDingWorkflowCompletedInRange(row.status, row.sourceFinishedAtRaw, range) )) const completedIds = new Set(completedRows.map((row) => row.processInstanceId)) const uniqueOngoingRows = ongoingRows.filter((row) => !completedIds.has(row.processInstanceId)) const allRows = [...completedRows, ...uniqueOngoingRows] const initiatorDirectory = await readInitiatorDirectory(allRows) const mapMonthlyItem = ( row: typeof allRows[number], launchStatus: MonthlyLaunchWorkflowStatus, ): MonthlyLaunchWorkflowItem => ({ ...mapSnapshotListItem( row, row.originatorUserId ? initiatorDirectory.get(row.originatorUserId.trim()) : null, ), launchStatus, result: row.result, sourceFinishedAt: storedWorkflowTimestampToIso(row.sourceFinishedAtRaw), }) const items = [ ...completedRows.map((row) => mapMonthlyItem(row, 'completed')), ...uniqueOngoingRows.map((row) => mapMonthlyItem(row, 'ongoing')), ].sort((a, b) => { const aTime = a.launchStatus === 'completed' ? a.sourceFinishedAt : a.sourceUpdatedAt const bTime = b.launchStatus === 'completed' ? b.sourceFinishedAt : b.sourceUpdatedAt return String(bTime || '').localeCompare(String(aTime || '')) || b.id.localeCompare(a.id) }) const brands = DING_WORKFLOW_BRANDS.map((brand) => { const brandItems = items.filter((item) => item.brand === brand) const completed = brandItems.filter((item) => item.launchStatus === 'completed').length const ongoing = brandItems.filter((item) => item.launchStatus === 'ongoing').length return { brand, completed, ongoing, total: completed + ongoing } }) const completed = completedRows.length const ongoing = uniqueOngoingRows.length return { items, brands, summary: { completed, ongoing, total: completed + ongoing, monthStart: range.start.toISOString(), monthEnd: range.end.toISOString(), lastSyncedAt: state?.lastCompletedAt?.toISOString() || null, syncStatus: normalizeSyncStatus(state), }, } } export async function getMonthlyLaunchSummary(session: Session | null): Promise { await assertDashboardAccess(session) return (await readMonthlyLaunchInsightsWithoutAccessCheck()).summary } export async function getMonthlyLaunchInsights(session: Session | null): Promise { await assertDashboardAccess(session) return readMonthlyLaunchInsightsWithoutAccessCheck() } async function readOnTimeDeliveryInsightsWithoutAccessCheck(now = new Date()): Promise { const range = getShanghaiMonthRange(now) const [candidates, state] = await Promise.all([ prisma.dingWorkflowSnapshot.findMany({ where: { processCode: { in: ALLOWED_PROCESS_CODE_VALUES }, sourceCreatedAtRaw: { gte: DING_WORKFLOW_STARTED_AT_CUTOFF_ISO }, }, select: MONTHLY_LAUNCH_WORKFLOW_SELECT, orderBy: [ { sourceFinishedAtRaw: { sort: 'desc', nulls: 'last' } }, { processInstanceId: 'desc' }, ], }), prisma.dingWorkflowSyncState.findUnique({ where: { id: SYNC_STATE_ID }, select: { status: true, lastCompletedAt: true }, }), ]) // Keep in-flight rows while calculating the shared content queue: the // external dashboard lets earlier in-flight assignments consume capacity, // even though only approved COMPLETED rows enter this month's denominator. const rows = candidates.filter((row) => ( String(row.status || '').trim().toUpperCase() !== 'COMPLETED' || isDingWorkflowApprovedCompletion(row.status, row.result, row.bizAction) )) const initiatorDirectory = await readInitiatorDirectory(rows) const assessmentInputs = rows.map((row) => { const processCode = normalizeDingWorkflowProcessCode(row.processCode) || 'UNKNOWN' const allTasks = normalizeWorkflowTasks(row.activeTasks) const sourceCreatedAt = storedWorkflowTimestampToIso(row.sourceCreatedAtRaw) const sourceFinishedAt = storedWorkflowTimestampToIso(row.sourceFinishedAtRaw) return { processCode, sourceCreatedAt, sourceFinishedAt, formFields: normalizeDetailFormFields(row.formFields), tasks: allTasks, businessId: clean(row.businessId), workflowId: clean(row.processInstanceId), workflowStatus: clean(row.status), } }) const assessments = assessDingWorkflowDeliveryBatch(assessmentInputs) const assessedItems = rows.map((row, index): OnTimeDeliveryWorkflowItem => { const sourceFinishedAt = storedWorkflowTimestampToIso(row.sourceFinishedAtRaw) return { ...mapSnapshotListItem( row, row.originatorUserId ? initiatorDirectory.get(row.originatorUserId.trim()) : null, ), result: row.result, sourceFinishedAt, assessment: assessments[index], } }) const items = assessedItems.filter((item) => ( String(item.status || '').trim().toUpperCase() === 'COMPLETED' && isDingWorkflowDeliveryAssessmentInRange(item.assessment, range) )) const onTime = items.filter((item) => item.assessment.status === 'on_time').length const overtime = items.filter((item) => item.assessment.status === 'overtime').length const pending = items.filter((item) => item.assessment.status === 'pending').length const total = items.length return { items, summary: { onTime, overtime, pending, total, rate: total > 0 ? Math.round((onTime / total) * 1_000) / 10 : null, monthStart: range.start.toISOString(), monthEnd: range.end.toISOString(), lastSyncedAt: state?.lastCompletedAt?.toISOString() || null, syncStatus: normalizeSyncStatus(state), }, } } export async function getOnTimeDeliverySummary(session: Session | null): Promise { await assertDashboardAccess(session) return (await readOnTimeDeliveryInsightsWithoutAccessCheck()).summary } export async function getOnTimeDeliveryInsights(session: Session | null): Promise { await assertDashboardAccess(session) return readOnTimeDeliveryInsightsWithoutAccessCheck() } type DingWorkflowSchedulerGlobal = typeof globalThis & { __dingWorkflowHourlySyncStarted?: boolean } export function ensureHourlyDingWorkflowSyncStarted() { const schedulerGlobal = globalThis as DingWorkflowSchedulerGlobal if (schedulerGlobal.__dingWorkflowHourlySyncStarted) return schedulerGlobal.__dingWorkflowHourlySyncStarted = true if (!isReportWorkflowDbConfigured()) { console.info('[ding-workflow-sync] 未配置上游报表库,跳过每小时同步') return } const configuredHours = Number(process.env.DING_WORKFLOW_SYNC_INTERVAL_HOURS || 1) const intervalMs = Number.isFinite(configuredHours) && configuredHours > 0 ? Math.max(5 * 60 * 1000, configuredHours * 60 * 60 * 1000) : DEFAULT_SYNC_INTERVAL_MS const runOnce = () => { void syncDingWorkflowSnapshots() .then((result) => { if (result.skipped) { console.info('[ding-workflow-sync] 另一实例正在同步,本轮跳过') return } console.info(`[ding-workflow-sync] 同步完成:更新 ${result.rowsUpserted} 条,清理 ${result.rowsDeleted} 条,进行中 ${result.ongoingCount} 条`) }) .catch((error) => console.warn('[ding-workflow-sync] 同步失败:', error)) } const initialTimer = setTimeout(runOnce, INITIAL_SYNC_DELAY_MS) initialTimer.unref?.() const interval = setInterval(runOnce, intervalMs) interval.unref?.() console.info(`[ding-workflow-sync] 已调度每小时增量同步,周期 ${Math.round(intervalMs / 60_000)} 分钟`) }