2715 lines
100 KiB
TypeScript
2715 lines
100 KiB
TypeScript
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)} 分钟`)
|
||
}
|