gongxiangp2/lib/ding-workflow-sync.ts

2715 lines
100 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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'
import {
aggregateBusinessTrends,
buildBusinessTrendWeeks,
type BusinessTrendWorkflowRow,
type BusinessTrendsInsights,
} from '@/components/department-dashboard/business-trends'
import {
aggregateTeamCapacity,
type TeamCapacityInsights,
type TeamCapacityRosterRow,
type TeamCapacityWorkflowRow,
} from '@/lib/ding-workflow-team-capacity'
export type {
TeamCapacityDepartment,
TeamCapacityInsights,
TeamCapacityPerson,
TeamCapacityRosterRow,
TeamCapacityWeek,
TeamCapacityWorkflowItem,
TeamCapacityWorkflowRow,
} from '@/lib/ding-workflow-team-capacity'
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 = '新品'
export const TEAM_WORKLOAD_EXCLUDED_NAMES = ['元泽', '江陵', '无邪', '清云', '伯言', '希文', '文轩'] as const
export const TEAM_WORKLOAD_EXCLUDED_DEPARTMENT_NAMES = ['商品企划部'] as const
const ALLOWED_PROCESS_CODE_VALUES: string[] = [...DING_WORKFLOW_ALLOWED_PROCESS_CODES]
const ALLOWED_PROCESS_CODE_SET = new Set<string>(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<string, string> = {
[DING_WORKFLOW_ALLOWED_PROCESS_CODES[0]]: '美术设计流程',
[DING_WORKFLOW_ALLOWED_PROCESS_CODES[1]]: '内容视频和图片拍摄',
}
const ACTIVITY_NODE_NAMES_BY_PROCESS_CODE: Readonly<Record<string, Readonly<Record<string, string>>>> = {
[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 TeamWorkloadStatus = 'idle' | 'available' | 'full' | 'overloaded'
export type TeamWorkloadProject = {
id: string
businessId: string | null
title: string
projectType: string
nodeNames: string[]
}
export type TeamWorkloadPerson = {
id: string
dingtalkUserId: string | null
name: string
departmentName: string
departmentPath: string
activeProjectCount: number
loadPercent: number
status: TeamWorkloadStatus
projects: TeamWorkloadProject[]
}
export type TeamWorkloadDepartment = {
name: string
personnelCount: number
busyPersonnel: number
idlePersonnel: number
activeProjectAssignments: number
averageLoadPercent: number
people: TeamWorkloadPerson[]
}
export type TeamWorkloadSummary = {
averageLoadPercent: number | null
personnelCount: number
busyPersonnel: number
idlePersonnel: number
activeProjectAssignments: number
sourceOngoingProjects: number
excludedNames: readonly string[]
excludedDepartmentNames: readonly string[]
lastSyncedAt: string | null
syncStatus: DingWorkflowSyncStatus
}
export type TeamWorkloadInsights = {
summary: TeamWorkloadSummary
departments: TeamWorkloadDepartment[]
}
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
}
/** 查询本月上新指标时可选的日期筛选参数。月份使用 YYYY-MM,
* 自定义区间使用上海本地日期(YYYY-MM-DD,结束日期含当天)。 */
export type MonthlyLaunchRangeInput = {
month?: string | null
startDate?: string | null
endDate?: string | null
}
export type MonthlyLaunchDateRange = {
start: Date
end: Date
}
export type MonthlyLaunchMonthOption = {
value: string
label: string
startDate: string
endDate: string
}
export type MonthlyLaunchInsights = {
items: MonthlyLaunchWorkflowItem[]
brands: MonthlyLaunchBrandSummary[]
summary: MonthlyLaunchSummary
availableMonths?: MonthlyLaunchMonthOption[]
}
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
availableMonths?: MonthlyLaunchMonthOption[]
}
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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
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<string, unknown>
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),
}]
})
}
type TeamWorkloadRosterRow = {
id: string
dingtalkUserId: string | null
displayName: string
flowerName: string | null
realName: string | null
departmentName: string | null
departmentPath: string | null
employmentState: string | null
departmentMemberships: Array<{
departmentName: string | null
departmentPath: string | null
isPrimary: boolean
}>
}
type TeamWorkloadSnapshotRow = {
processInstanceId: string
businessId: string | null
title: string | null
processCode: string | null
projectType: string | null
activeTasks: unknown
}
function cleanTeamWorkloadName(value: unknown) {
const normalized = String(value ?? '').trim()
return normalized || null
}
function profileHasBrandCenterPath(profile: TeamWorkloadRosterRow) {
return [
profile.departmentPath,
...profile.departmentMemberships.map((membership) => membership.departmentPath),
].some(isBrandCenterOrganizationPath)
}
function profileIsExcludedFromTeamWorkload(profile: TeamWorkloadRosterRow) {
const names = [profile.displayName, profile.flowerName, profile.realName]
.map(cleanTeamWorkloadName)
.filter((name): name is string => Boolean(name))
return names.some((name) => TEAM_WORKLOAD_EXCLUDED_NAMES.includes(name as typeof TEAM_WORKLOAD_EXCLUDED_NAMES[number]))
}
function profileDepartmentForTeamWorkload(profile: TeamWorkloadRosterRow) {
const matchingPaths = [
{ departmentPath: profile.departmentPath, departmentName: profile.departmentName, isPrimary: true },
...profile.departmentMemberships,
].filter((entry) => isBrandCenterOrganizationPath(entry.departmentPath))
const selected = matchingPaths.sort((a, b) => {
const depth = (value: string | null) => String(value || '').split('/').map((part) => part.trim()).filter(Boolean).length
return depth(b.departmentPath) - depth(a.departmentPath)
|| Number(b.isPrimary) - Number(a.isPrimary)
})[0]
const parts = String(selected?.departmentPath || profile.departmentPath || '')
.split('/')
.map((part) => part.trim())
.filter(Boolean)
const brandCenterIndex = parts.indexOf('品牌中心')
const departmentName = brandCenterIndex >= 0
? parts[brandCenterIndex + 1] || selected?.departmentName || '品牌中心'
: selected?.departmentName || profile.departmentName || '品牌中心'
const departmentPath = brandCenterIndex >= 0
? parts.slice(brandCenterIndex).join('/')
: String(selected?.departmentPath || profile.departmentPath || '品牌中心')
return { departmentName, departmentPath }
}
function workloadStatus(activeProjectCount: number): TeamWorkloadStatus {
if (activeProjectCount <= 0) return 'idle'
if (activeProjectCount === 1) return 'available'
if (activeProjectCount === 2) return 'full'
return 'overloaded'
}
function roundWorkloadPercent(value: number) {
return Math.round(value * 10) / 10
}
/**
* 品牌中心团队负荷的纯计算部分,供同步读取和单元测试复用。
* 每个当前节点处理人按流程去重后,每条进行中流程计 50% 负荷;平均值的
* 分母是排除指定管理人员后的全部在职品牌中心人员,因此没有进行中流程的人
* 会以 0%(空闲)计入平均值。
*/
export function buildTeamWorkloadInsights(
rosterRows: TeamWorkloadRosterRow[],
snapshotRows: TeamWorkloadSnapshotRow[],
sync: Pick<OngoingDingWorkflowSummary, 'lastSyncedAt' | 'syncStatus'>,
): TeamWorkloadInsights {
const roster = rosterRows
.filter((profile) => profile.employmentState !== 'RESIGNED')
.filter(profileHasBrandCenterPath)
.filter((profile) => !profileIsExcludedFromTeamWorkload(profile))
.map((profile) => {
const department = profileDepartmentForTeamWorkload(profile)
return {
profile,
name: cleanTeamWorkloadName(profile.flowerName)
|| cleanTeamWorkloadName(profile.displayName)
|| cleanTeamWorkloadName(profile.realName)
|| '未命名人员',
...department,
}
})
.filter((entry) => !TEAM_WORKLOAD_EXCLUDED_DEPARTMENT_NAMES.includes(
entry.departmentName as typeof TEAM_WORKLOAD_EXCLUDED_DEPARTMENT_NAMES[number],
))
const peopleByDingtalkUserId = new Map(
roster
.filter((person) => person.profile.dingtalkUserId)
.map((person) => [person.profile.dingtalkUserId as string, person]),
)
const projectByPerson = new Map<string, Map<string, TeamWorkloadProject>>()
snapshotRows.forEach((snapshot) => {
const projectId = snapshot.processInstanceId
// 当前 HR 花名册负责判定人员是否属于品牌中心;不再依赖快照同步时物化的
// isBrandCenter 标记,避免人员调入品牌中心后、流程未更新时发生漏算。
const tasks = normalizeWorkflowTasks(snapshot.activeTasks)
.filter((task) => task.isActive && task.assigneeUserId)
const tasksByPerson = new Map<string, OngoingDingWorkflowActiveTask[]>()
tasks.forEach((task) => {
const person = peopleByDingtalkUserId.get(task.assigneeUserId as string)
if (!person) return
const current = tasksByPerson.get(person.profile.id) || []
current.push(task)
tasksByPerson.set(person.profile.id, current)
})
tasksByPerson.forEach((personTasks, personId) => {
const projects = projectByPerson.get(personId) || new Map<string, TeamWorkloadProject>()
const existing = projects.get(projectId)
const nodeNames = Array.from(new Set(personTasks.map((task) => task.nodeName).filter(Boolean)))
projects.set(projectId, {
id: projectId,
businessId: snapshot.businessId,
title: snapshot.title || '未命名流程',
projectType: snapshot.projectType || getDingWorkflowProjectType(snapshot.processCode),
nodeNames: existing ? Array.from(new Set([...existing.nodeNames, ...nodeNames])) : nodeNames,
})
projectByPerson.set(personId, projects)
})
})
const people = roster.map((entry): TeamWorkloadPerson => {
const projects = Array.from(projectByPerson.get(entry.profile.id)?.values() || [])
.sort((a, b) => a.title.localeCompare(b.title, 'zh-CN') || a.id.localeCompare(b.id))
const activeProjectCount = projects.length
return {
id: entry.profile.id,
dingtalkUserId: entry.profile.dingtalkUserId,
name: entry.name,
departmentName: entry.departmentName,
departmentPath: entry.departmentPath,
activeProjectCount,
loadPercent: activeProjectCount * 50,
status: workloadStatus(activeProjectCount),
projects,
}
}).sort((a, b) => b.loadPercent - a.loadPercent || a.name.localeCompare(b.name, 'zh-CN'))
const departments = Array.from(new Set(people.map((person) => person.departmentName)))
.map((name): TeamWorkloadDepartment => {
const departmentPeople = people
.filter((person) => person.departmentName === name)
.sort((a, b) => b.loadPercent - a.loadPercent || a.name.localeCompare(b.name, 'zh-CN'))
const activeProjectAssignments = departmentPeople.reduce((total, person) => total + person.activeProjectCount, 0)
return {
name,
personnelCount: departmentPeople.length,
busyPersonnel: departmentPeople.filter((person) => person.activeProjectCount > 0).length,
idlePersonnel: departmentPeople.filter((person) => person.activeProjectCount === 0).length,
activeProjectAssignments,
averageLoadPercent: departmentPeople.length > 0
? roundWorkloadPercent(activeProjectAssignments * 50 / departmentPeople.length)
: 0,
people: departmentPeople,
}
})
.sort((a, b) => b.averageLoadPercent - a.averageLoadPercent || a.name.localeCompare(b.name, 'zh-CN'))
const activeProjectAssignments = people.reduce((total, person) => total + person.activeProjectCount, 0)
return {
summary: {
averageLoadPercent: people.length > 0
? roundWorkloadPercent(activeProjectAssignments * 50 / people.length)
: null,
personnelCount: people.length,
busyPersonnel: people.filter((person) => person.activeProjectCount > 0).length,
idlePersonnel: people.filter((person) => person.activeProjectCount === 0).length,
activeProjectAssignments,
sourceOngoingProjects: snapshotRows.length,
excludedNames: TEAM_WORKLOAD_EXCLUDED_NAMES,
excludedDepartmentNames: TEAM_WORKLOAD_EXCLUDED_DEPARTMENT_NAMES,
lastSyncedAt: sync.lastSyncedAt,
syncStatus: sync.syncStatus,
},
departments,
}
}
function normalizeDetailFormFields(value: unknown): DingWorkflowDetailFormFields {
const record = value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {}
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<Record<string, {
assignmentNodeLabel: string
assignmentNodeNames: readonly string[]
reviewNodes: readonly {
label: string
names: readonly string[]
}[]
}>> = {
[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),
}
}
const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000
const SHANGHAI_MONTH_PATTERN = /^(\d{4})-(\d{2})$/
const SHANGHAI_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/
function parseShanghaiCalendarDate(value: unknown, label: string): { year: number; month: number; day: number } {
const normalized = clean(value)
const match = normalized?.match(SHANGHAI_DATE_PATTERN)
if (!match) throw new DingWorkflowMetricApiError(`${label}格式应为YYYY-MM-DD`, 400)
const year = Number(match[1])
const month = Number(match[2])
const day = Number(match[3])
const candidate = new Date(Date.UTC(year, month - 1, day))
if (
!Number.isFinite(candidate.getTime())
|| candidate.getUTCFullYear() !== year
|| candidate.getUTCMonth() !== month - 1
|| candidate.getUTCDate() !== day
) {
throw new DingWorkflowMetricApiError(`${label}不是有效日期`, 400)
}
return { year, month, day }
}
/** 将上海本地月份转换为 UTC Date 区间,区间结束点为下月一日(不含)。 */
export function getShanghaiMonthRangeForValue(value: unknown): MonthlyLaunchDateRange {
const normalized = clean(value)
const match = normalized?.match(SHANGHAI_MONTH_PATTERN)
if (!match) throw new DingWorkflowMetricApiError('month格式应为YYYY-MM', 400)
const year = Number(match[1])
const month = Number(match[2])
if (month < 1 || month > 12) throw new DingWorkflowMetricApiError('month不是有效月份', 400)
return {
start: new Date(Date.UTC(year, month - 1, 1) - SHANGHAI_OFFSET_MS),
end: new Date(Date.UTC(year, month, 1) - SHANGHAI_OFFSET_MS),
}
}
/** 解析月份或上海本地日期区间。自定义 endDate 含当天。 */
export function resolveMonthlyLaunchDateRange(
input: MonthlyLaunchRangeInput = {},
now = new Date(),
): MonthlyLaunchDateRange {
const month = clean(input.month)
const startDate = clean(input.startDate)
const endDate = clean(input.endDate)
if (month && (startDate || endDate)) {
throw new DingWorkflowMetricApiError('month不能与startDate/endDate同时使用', 400)
}
if (month) return getShanghaiMonthRangeForValue(month)
if (!startDate && !endDate) return getShanghaiMonthRange(now)
if (!startDate || !endDate) {
throw new DingWorkflowMetricApiError('startDate和endDate必须同时提供', 400)
}
const start = parseShanghaiCalendarDate(startDate, 'startDate')
const end = parseShanghaiCalendarDate(endDate, 'endDate')
const startInstant = new Date(Date.UTC(start.year, start.month - 1, start.day) - SHANGHAI_OFFSET_MS)
const endExclusive = new Date(Date.UTC(end.year, end.month - 1, end.day + 1) - SHANGHAI_OFFSET_MS)
if (endExclusive <= startInstant) {
throw new DingWorkflowMetricApiError('endDate不能早于startDate', 400)
}
return { start: startInstant, end: endExclusive }
}
function shanghaiMonthValue(value: Date) {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
}).formatToParts(value)
const year = parts.find((part) => part.type === 'year')?.value || '0000'
const month = parts.find((part) => part.type === 'month')?.value || '01'
return `${year}-${month}`
}
function formatShanghaiDateInput(value: Date) {
const shifted = new Date(value.getTime() + SHANGHAI_OFFSET_MS)
return shifted.toISOString().slice(0, 10)
}
/** 返回从 2026 年 4 月起到当前月份的筛选选项,供详情页下拉框使用。 */
export function getMonthlyLaunchMonthOptions(now = new Date()): MonthlyLaunchMonthOption[] {
const currentValue = shanghaiMonthValue(now)
const [currentYear, currentMonth] = currentValue.split('-').map(Number)
const cutoffValue = shanghaiMonthValue(new Date(DING_WORKFLOW_STARTED_AT_CUTOFF_ISO))
const [cutoffYear, cutoffMonth] = cutoffValue.split('-').map(Number)
const options: MonthlyLaunchMonthOption[] = []
const previousMonthValue = currentMonth === 1
? `${currentYear - 1}-12`
: `${currentYear}-${String(currentMonth - 1).padStart(2, '0')}`
let year = currentYear
let month = currentMonth
while (year > cutoffYear || (year === cutoffYear && month >= cutoffMonth)) {
const value = `${year}-${String(month).padStart(2, '0')}`
const range = getShanghaiMonthRangeForValue(value)
options.push({
value,
label: value === currentValue ? '本月' : value === previousMonthValue ? '上月' : `${year}年${month}月`,
startDate: formatShanghaiDateInput(range.start),
endDate: formatShanghaiDateInput(new Date(range.end.getTime() - 24 * 60 * 60 * 1000)),
})
month -= 1
if (month === 0) {
month = 12
year -= 1
}
}
return options
}
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<DingWorkflowDeliveryAssessment, 'status' | 'finishedAt'>,
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<string | null>((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<string, unknown>
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<SourceDeltaState> {
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<SourceWorkflowRow>(`
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<string, SourceTaskAggregate>(),
taskRowsByWorkflow: new Map<string, SourceTaskRow[]>(),
schemeByWorkflow: new Map<string, SourceSchemeAggregate>(),
schemeRowsByWorkflow: new Map<string, SourceSchemeField[]>(),
}
}
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 = <T extends { processInstanceId: unknown }>(rows: T[]) => {
const grouped = new Map<string, T[]>()
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<string, SourceTaskRow[]>,
) {
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<string, EmployeeDirectoryEntry>()
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<string, SourceTaskRow[]>,
schemeRowsByWorkflow: Map<string, SourceSchemeField[]>,
) {
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<string, Map<string, number>>()
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<string, number>()
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<string, SourceTaskAggregate>,
taskRowsByWorkflow: Map<string, SourceTaskRow[]>,
schemeByWorkflow: Map<string, SourceSchemeAggregate>,
schemeRowsByWorkflow: Map<string, SourceSchemeField[]>,
employeeDirectory: Map<string, EmployeeDirectoryEntry>,
activityNodeNames: Map<string, string>,
): 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<OngoingDingWorkflowSummary> {
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),
}
}
const TEAM_WORKLOAD_ROSTER_SELECT = {
id: true,
dingtalkUserId: true,
displayName: true,
flowerName: true,
realName: true,
departmentName: true,
departmentPath: true,
employmentState: true,
departmentMemberships: {
select: {
departmentName: true,
departmentPath: true,
isPrimary: true,
},
},
} as const
const TEAM_WORKLOAD_SNAPSHOT_SELECT = {
processInstanceId: true,
businessId: true,
title: true,
processCode: true,
projectType: true,
activeTasks: true,
} as const
async function readTeamWorkloadInsightsWithoutAccessCheck(): Promise<TeamWorkloadInsights> {
const [rosterCandidates, snapshots, sync] = await Promise.all([
prisma.performanceSurveyEmployeeProfile.findMany({
where: {
OR: [
{ departmentPath: { contains: '品牌中心' } },
{ departmentMemberships: { some: { departmentPath: { contains: '品牌中心' } } } },
],
},
select: TEAM_WORKLOAD_ROSTER_SELECT,
}),
prisma.dingWorkflowSnapshot.findMany({
where: buildBrandCenterOngoingWorkflowWhere(),
select: TEAM_WORKLOAD_SNAPSHOT_SELECT,
orderBy: [
{ sourceUpdatedAtRaw: { sort: 'desc', nulls: 'last' } },
{ processInstanceId: 'desc' },
],
}),
readSummaryWithoutAccessCheck(),
])
return buildTeamWorkloadInsights(rosterCandidates, snapshots, sync)
}
export async function getTeamWorkloadSummary(session: Session | null) {
await assertDashboardAccess(session)
return (await readTeamWorkloadInsightsWithoutAccessCheck()).summary
}
export async function getTeamWorkloadInsights(session: Session | null) {
await assertDashboardAccess(session)
return readTeamWorkloadInsightsWithoutAccessCheck()
}
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
const BUSINESS_TREND_WORKFLOW_SELECT = {
...ONGOING_WORKFLOW_SELECT,
sourceFinishedAtRaw: true,
} as const
const TEAM_CAPACITY_WORKFLOW_SELECT = {
...ONGOING_WORKFLOW_SELECT,
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<string, DingWorkflowInitiatorProfile>()
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<OngoingDingWorkflowInsights> {
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,
}
}
async function readBusinessTrendsInsightsWithoutAccessCheck(
now = new Date(),
): Promise<BusinessTrendsInsights> {
const weeks = buildBusinessTrendWeeks(now)
const earliestWeekStart = weeks[0]?.start || DING_WORKFLOW_STARTED_AT_CUTOFF_ISO
const [rows, state] = await Promise.all([
prisma.dingWorkflowSnapshot.findMany({
where: {
processCode: { in: ALLOWED_PROCESS_CODE_VALUES },
brand: { in: [...DING_WORKFLOW_BRANDS] },
OR: [
{ sourceCreatedAtRaw: { gte: earliestWeekStart } },
{ sourceFinishedAtRaw: { gte: earliestWeekStart } },
],
},
select: BUSINESS_TREND_WORKFLOW_SELECT,
orderBy: [
{ sourceCreatedAtRaw: { sort: 'desc', nulls: 'last' } },
{ processInstanceId: 'desc' },
],
}),
prisma.dingWorkflowSyncState.findUnique({
where: { id: SYNC_STATE_ID },
select: { status: true, lastCompletedAt: true },
}),
])
const initiatorDirectory = await readInitiatorDirectory(rows)
const items: BusinessTrendWorkflowRow[] = rows.map((row) => {
const item = mapSnapshotListItem(
row,
row.originatorUserId ? initiatorDirectory.get(row.originatorUserId.trim()) : null,
)
return {
...item,
sourceFinishedAt: storedWorkflowTimestampToIso(row.sourceFinishedAtRaw),
}
})
return aggregateBusinessTrends(items, {
lastSyncedAt: state?.lastCompletedAt?.toISOString() || null,
syncStatus: normalizeSyncStatus(state),
}, now)
}
export async function getBusinessTrendsInsights(session: Session | null): Promise<BusinessTrendsInsights> {
await assertDashboardAccess(session)
return readBusinessTrendsInsightsWithoutAccessCheck()
}
async function readTeamCapacityInsightsWithoutAccessCheck(
now = new Date(),
): Promise<TeamCapacityInsights> {
const weeks = buildBusinessTrendWeeks(now)
const earliestWeekStart = weeks[0]?.start || DING_WORKFLOW_STARTED_AT_CUTOFF_ISO
const [rosterCandidates, rows, state] = await Promise.all([
prisma.performanceSurveyEmployeeProfile.findMany({
where: {
OR: [
{ departmentPath: { contains: '品牌中心' } },
{ departmentMemberships: { some: { departmentPath: { contains: '品牌中心' } } } },
],
},
select: TEAM_WORKLOAD_ROSTER_SELECT,
}),
prisma.dingWorkflowSnapshot.findMany({
where: {
processCode: { in: ALLOWED_PROCESS_CODE_VALUES },
OR: [
// Keep all in-flight snapshots: an older process may receive a new
// department assignment during the selected eight-week window.
{ isOngoing: true },
{ sourceCreatedAtRaw: { gte: earliestWeekStart } },
{ sourceFinishedAtRaw: { gte: earliestWeekStart } },
],
},
select: TEAM_CAPACITY_WORKFLOW_SELECT,
orderBy: [
{ sourceCreatedAtRaw: { sort: 'desc', nulls: 'last' } },
{ processInstanceId: 'desc' },
],
}),
prisma.dingWorkflowSyncState.findUnique({
where: { id: SYNC_STATE_ID },
select: { status: true, lastCompletedAt: true },
}),
])
const roster: TeamCapacityRosterRow[] = rosterCandidates
.filter((profile) => profile.employmentState !== 'RESIGNED')
.filter(profileHasBrandCenterPath)
.map((profile) => {
const department = profileDepartmentForTeamWorkload(profile)
return {
id: profile.id,
dingtalkUserId: profile.dingtalkUserId,
name: cleanTeamWorkloadName(profile.flowerName)
|| cleanTeamWorkloadName(profile.displayName)
|| cleanTeamWorkloadName(profile.realName)
|| '未命名人员',
departmentName: department.departmentName,
}
})
const initiatorDirectory = await readInitiatorDirectory(rows)
const workflows: TeamCapacityWorkflowRow[] = rows.map((row) => {
const item = mapSnapshotListItem(
row,
row.originatorUserId ? initiatorDirectory.get(row.originatorUserId.trim()) : null,
)
return {
id: item.id,
businessId: item.businessId,
title: item.title,
status: item.status,
processCode: item.processCode,
projectType: item.projectType,
brand: item.brand,
requesterName: item.requesterName,
requesterDepartment: item.requesterDepartment,
initiatorName: item.initiatorName,
initiatorDepartment: item.initiatorDepartment,
sourceCreatedAt: item.sourceCreatedAt,
sourceFinishedAt: storedWorkflowTimestampToIso(row.sourceFinishedAtRaw),
// Capacity needs historical assignments as well as current active tasks.
activeTasks: normalizeWorkflowTasks(row.activeTasks),
}
})
return aggregateTeamCapacity(roster, workflows, {
lastSyncedAt: state?.lastCompletedAt?.toISOString() || null,
syncStatus: normalizeSyncStatus(state),
}, now)
}
export async function getTeamCapacityInsights(session: Session | null): Promise<TeamCapacityInsights> {
await assertDashboardAccess(session)
return readTeamCapacityInsightsWithoutAccessCheck()
}
export async function getOngoingDingWorkflowProjects(
session: Session | null,
params?: { page?: number; pageSize?: number; query?: string },
): Promise<OngoingDingWorkflowPage> {
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(
input: MonthlyLaunchRangeInput = {},
now = new Date(),
): Promise<MonthlyLaunchInsights> {
const range = resolveMonthlyLaunchDateRange(input, 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),
},
availableMonths: getMonthlyLaunchMonthOptions(now),
}
}
export async function getMonthlyLaunchSummary(
session: Session | null,
input: MonthlyLaunchRangeInput = {},
): Promise<MonthlyLaunchSummary> {
await assertDashboardAccess(session)
return (await readMonthlyLaunchInsightsWithoutAccessCheck(input)).summary
}
export async function getMonthlyLaunchInsights(
session: Session | null,
input: MonthlyLaunchRangeInput = {},
): Promise<MonthlyLaunchInsights> {
await assertDashboardAccess(session)
return readMonthlyLaunchInsightsWithoutAccessCheck(input)
}
async function readOnTimeDeliveryInsightsWithoutAccessCheck(
input: MonthlyLaunchRangeInput = {},
now = new Date(),
): Promise<OnTimeDeliveryInsights> {
const range = resolveMonthlyLaunchDateRange(input, 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 countableItems = items.filter((item) => item.assessment.status !== 'pending')
const onTime = countableItems.filter((item) => item.assessment.status === 'on_time').length
const overtime = countableItems.filter((item) => item.assessment.status === 'overtime').length
const pending = items.filter((item) => item.assessment.status === 'pending').length
const total = countableItems.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),
},
availableMonths: getMonthlyLaunchMonthOptions(now),
}
}
export async function getOnTimeDeliverySummary(
session: Session | null,
input: MonthlyLaunchRangeInput = {},
): Promise<OnTimeDeliverySummary> {
await assertDashboardAccess(session)
return (await readOnTimeDeliveryInsightsWithoutAccessCheck(input)).summary
}
export async function getOnTimeDeliveryInsights(
session: Session | null,
input: MonthlyLaunchRangeInput = {},
): Promise<OnTimeDeliveryInsights> {
await assertDashboardAccess(session)
return readOnTimeDeliveryInsightsWithoutAccessCheck(input)
}
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)} 分钟`)
}