gongxiangp2/lib/ding-workflow-team-capacity.ts

379 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import type {
DingWorkflowSyncStatus,
OngoingDingWorkflowActiveTask,
} from '@/lib/ding-workflow-sync'
import type { EmployeeRosterHistoryRow } from '@/lib/employee-roster-history'
import { buildBusinessTrendWeeks } from '@/components/department-dashboard/business-trends'
export type TeamCapacityRosterRow = {
id: string
dingtalkUserId: string | null
name: string
departmentName: string
}
export type TeamCapacityWorkflowRow = {
id: string
businessId: string | null
title: string
status: string
processCode: string
projectType: string
brand: string
requesterName: string
requesterDepartment: string
initiatorName: string
initiatorDepartment: string
sourceCreatedAt: string | null
sourceFinishedAt: string | null
activeTasks: OngoingDingWorkflowActiveTask[]
}
export type TeamCapacityPerson = {
id: string
dingtalkUserId: string | null
name: string
}
export type TeamCapacityWorkflowItem = {
id: string
businessId: string | null
title: string
status: string
projectType: string
brand: string
requesterName: string
requesterDepartment: string
initiatorName: string
initiatorDepartment: string
sourceCreatedAt: string | null
sourceFinishedAt: string | null
assigneeNames: string[]
assigneeUserIds: string[]
receivedInWeek: boolean
completedInWeek: boolean
isOngoing: boolean
}
export type TeamCapacityWeek = {
key: string
label: string
start: string
end: string
/** 周末前最近一次完整花名册快照中的在职人数;缺少快照时为 null。 */
personnelCount: number | null
personnelCountSource: 'historical_snapshot' | 'unavailable'
involvedPersonnelCount: number
receivedCount: number
completedCount: number
items: TeamCapacityWorkflowItem[]
}
export type TeamCapacityDepartment = {
name: string
personnelCount: number
people: TeamCapacityPerson[]
weeks: TeamCapacityWeek[]
}
export type TeamCapacityInsights = {
departments: TeamCapacityDepartment[]
summary: {
weekStart: string
weekEnd: string
departmentCount: number
lastSyncedAt: string | null
syncStatus: DingWorkflowSyncStatus
rosterHistory: {
source: 'historical_snapshot' | 'unavailable'
firstCapturedAt: string | null
lastCapturedAt: string | null
coveredWeeks: number
totalWeeks: number
}
}
}
/**
* 团队产能侧栏只展示品牌中心当前看板约定的五个二级部门,顺序与业务侧栏一致。
*/
export const TEAM_CAPACITY_DEPARTMENT_ORDER = [
'品牌创意A部',
'品牌创意B部',
'综合支持部',
'品牌设计部',
'空间视觉部',
] as const
const TEAM_CAPACITY_DEPARTMENT_SET = new Set<string>(TEAM_CAPACITY_DEPARTMENT_ORDER)
const TERMINAL_STATUSES = new Set(['COMPLETED', 'TERMINATED', 'CANCELED'])
// 管理节点只负责分派/审核,不代表实际产能。按流程类型精确排除,避免把
// 美术策划主管和内容主管的管理动作计入负责人、参与人数或部门流程归属。
const ART_PROCESS_CODE = 'PROC-3571C915-0506-4490-A91A-8AA7EA33F838'
const CONTENT_PROCESS_CODE = 'PROC-AE9FF3E1-A442-4E6A-85CF-0F2B86409673'
const CAPACITY_EXCLUDED_NODE_NAMES = {
art: new Set(['品牌策划主管分配任务', '品牌策划主管审核']),
content: new Set(['内容负责人分配任务', '内容主管分配任务', '内容主管审核']),
} as const
const CAPACITY_EXCLUDED_ACTIVITY_IDS = {
art: new Set(['38da_544a', 'c255_84b3']),
content: new Set([
'cc59_3e5a', '9fb8_32e7', 'd742_3645', 'b9a4_c03a', '9f14_7ed4',
'436a_c54c', 'fdcc_6e23', 'b596_d441', '660b_13c2',
'6c43_575f', '0abf_690f', '36a0_2698',
]),
} as const
type CapacityProcessKind = keyof typeof CAPACITY_EXCLUDED_NODE_NAMES
function capacityProcessKind(row: TeamCapacityWorkflowRow): CapacityProcessKind | null {
const processCode = row.processCode.trim().toUpperCase()
if (processCode === ART_PROCESS_CODE || row.projectType.trim() === '美术设计流程') return 'art'
if (
processCode === CONTENT_PROCESS_CODE
|| row.projectType.trim() === '内容视频和图片拍摄'
|| row.projectType.trim() === '内容视频流程'
) return 'content'
return null
}
function isCapacityExcludedTask(row: TeamCapacityWorkflowRow, task: OngoingDingWorkflowActiveTask) {
const kind = capacityProcessKind(row)
if (!kind) return false
const activityId = task.activityId?.trim() || ''
// A known activityId is authoritative. An old snapshot may carry a stale
// nodeName, so do not let its label override the process-specific mapping.
if (activityId) return CAPACITY_EXCLUDED_ACTIVITY_IDS[kind].has(activityId)
return CAPACITY_EXCLUDED_NODE_NAMES[kind].has(task.nodeName.trim())
}
function inRange(value: string | null, start: string, end: string) {
if (!value) return false
const timestamp = Date.parse(value)
return Number.isFinite(timestamp) && timestamp >= Date.parse(start) && timestamp < Date.parse(end)
}
function workflowItemFor(
row: TeamCapacityWorkflowRow,
tasks: OngoingDingWorkflowActiveTask[],
receivedInWeek: boolean,
completedInWeek: boolean,
): TeamCapacityWorkflowItem {
const assignees = Array.from(new Map(
tasks
.filter((task) => task.assigneeUserId)
.map((task) => [task.assigneeUserId as string, task.assigneeName] as const),
).entries())
return {
id: row.id,
businessId: row.businessId,
title: row.title,
status: row.status,
projectType: row.projectType,
brand: row.brand,
requesterName: row.requesterName,
requesterDepartment: row.requesterDepartment,
initiatorName: row.initiatorName,
initiatorDepartment: row.initiatorDepartment,
sourceCreatedAt: row.sourceCreatedAt,
sourceFinishedAt: row.sourceFinishedAt,
assigneeUserIds: assignees.map(([id]) => id),
assigneeNames: assignees.map(([, name]) => name),
receivedInWeek,
completedInWeek,
isOngoing: !TERMINAL_STATUSES.has(row.status.trim().toUpperCase()),
}
}
/**
* 聚合团队产能的纯函数:当前部门人员用于导航和当前人数,历史周人数优先
* 取每周结束前最近的完整员工快照;没有快照时保持 null,禁止用当前人数回填。
* 流程按历史任务的处理人部门归属拆分。接收按任务 createdAt 统计,完结按流程
* sourceFinishedAt 统计;同一部门同一流程在同一周只计一次。
*/
export function aggregateTeamCapacity(
rosterRows: TeamCapacityRosterRow[],
workflowRows: TeamCapacityWorkflowRow[],
sync: { lastSyncedAt: string | null; syncStatus: DingWorkflowSyncStatus },
now = new Date(),
rosterHistoryRows: EmployeeRosterHistoryRow[] = [],
): TeamCapacityInsights {
const weekTemplate = buildBusinessTrendWeeks(now)
const historySnapshots = Array.from(
rosterHistoryRows.reduce((groups, row) => {
const capturedAt = Date.parse(row.capturedAt)
if (!Number.isFinite(capturedAt)) return groups
const key = String(capturedAt)
const rows = groups.get(key) || []
rows.push(row)
groups.set(key, rows)
return groups
}, new Map<string, EmployeeRosterHistoryRow[]>()).entries(),
)
.map(([capturedAt, rows]) => {
const expectedCount = rows[0]?.profileCount || 0
return {
capturedAt: Number(capturedAt),
rows,
complete: expectedCount > 0 && rows.length === expectedCount && rows.every((row) => row.profileCount === expectedCount),
}
})
.sort((a, b) => a.capturedAt - b.capturedAt)
const historySnapshotForWeek = (weekEnd: string) => {
const end = Date.parse(weekEnd)
return historySnapshots.filter((snapshot) => snapshot.complete && snapshot.capturedAt < end).at(-1) || null
}
const hasRosterHistory = historySnapshots.some((snapshot) => snapshot.complete)
const historyCountsByWeek = new Map<string, Map<string, number>>()
const coveredHistoryWeeks = new Set<string>()
weekTemplate.forEach((week) => {
const snapshot = hasRosterHistory ? historySnapshotForWeek(week.end) : null
if (!snapshot) return
const counts = new Map<string, Set<string>>()
snapshot.rows.forEach((row) => {
if (!row.isBrandCenter || row.employmentState === 'RESIGNED') return
if (!TEAM_CAPACITY_DEPARTMENT_SET.has(String(row.departmentName || '').trim())) return
const departmentName = String(row.departmentName).trim()
const personKey = row.dingtalkUserId || row.employeeProfileId
const people = counts.get(departmentName) || new Set<string>()
people.add(personKey)
counts.set(departmentName, people)
})
historyCountsByWeek.set(week.key, new Map(
Array.from(counts.entries()).map(([departmentName, people]) => [departmentName, people.size]),
))
coveredHistoryWeeks.add(week.key)
})
const departmentPeople = new Map<string, TeamCapacityPerson[]>()
const personByDingtalkUserId = new Map<string, { person: TeamCapacityPerson; departmentName: string }>()
rosterRows.forEach((row) => {
const departmentName = row.departmentName.trim()
if (!TEAM_CAPACITY_DEPARTMENT_SET.has(departmentName)) return
const person: TeamCapacityPerson = {
id: row.id,
dingtalkUserId: row.dingtalkUserId,
name: row.name,
}
const people = departmentPeople.get(departmentName) || []
people.push(person)
departmentPeople.set(departmentName, people)
if (row.dingtalkUserId) {
personByDingtalkUserId.set(row.dingtalkUserId, { person, departmentName })
}
})
const involvedPersonnelByWeek = new Map<string, Set<string>>()
const departments = TEAM_CAPACITY_DEPARTMENT_ORDER.map((name) => {
const people = departmentPeople.get(name) || []
return {
name,
personnelCount: people.length,
people: [...people].sort((a, b) => a.name.localeCompare(b.name, 'zh-CN') || a.id.localeCompare(b.id)),
weeks: weekTemplate.map((week): TeamCapacityWeek => ({
key: week.key,
label: week.label,
start: week.start,
end: week.end,
personnelCount: historyCountsByWeek.get(week.key)?.get(name)
?? (coveredHistoryWeeks.has(week.key) ? 0 : null),
personnelCountSource: hasRosterHistory
? (coveredHistoryWeeks.has(week.key) ? 'historical_snapshot' : 'unavailable')
: 'unavailable',
involvedPersonnelCount: 0,
receivedCount: 0,
completedCount: 0,
items: [],
})),
}
})
const departmentByName = new Map<string, TeamCapacityDepartment>(
departments.map((department) => [department.name, department]),
)
workflowRows.forEach((row) => {
const tasksByDepartment = new Map<string, OngoingDingWorkflowActiveTask[]>()
row.activeTasks
.filter((task) => !isCapacityExcludedTask(row, task))
.forEach((task) => {
if (!task.assigneeUserId) return
const assignment = personByDingtalkUserId.get(task.assigneeUserId)
if (!assignment) return
const tasks = tasksByDepartment.get(assignment.departmentName) || []
tasks.push(task)
tasksByDepartment.set(assignment.departmentName, tasks)
})
tasksByDepartment.forEach((departmentTasks, departmentName) => {
const department = departmentByName.get(departmentName)
if (!department) return
department.weeks.forEach((week) => {
const receivedTasks = departmentTasks.filter((task) => inRange(task.createdAt, week.start, week.end))
const completedTasks = departmentTasks.filter((task) => inRange(task.finishedAt, week.start, week.end))
const receivedInWeek = receivedTasks.length > 0
const workflowCompletedInWeek = row.status.trim().toUpperCase() === 'COMPLETED'
&& inRange(row.sourceFinishedAt, week.start, week.end)
const completedInWeek = workflowCompletedInWeek && departmentTasks.length > 0
if (!receivedInWeek && !completedInWeek) return
const involved = new Set<string>([
...receivedTasks.flatMap((task) => task.assigneeUserId ? [task.assigneeUserId] : []),
...completedTasks.flatMap((task) => task.assigneeUserId ? [task.assigneeUserId] : []),
])
// Some upstream snapshots omit task.finishTime while retaining the
// workflow completion timestamp. In that case all historical handlers
// of this department are still involved in the completed workflow.
if (completedInWeek && completedTasks.length === 0) {
departmentTasks.forEach((task) => {
if (task.assigneeUserId) involved.add(task.assigneeUserId)
})
}
const existing = department.weeks.find((candidate) => candidate.key === week.key)
if (!existing) return
if (receivedInWeek) existing.receivedCount += 1
if (completedInWeek) existing.completedCount += 1
const involvedKey = `${departmentName}:${week.key}`
const involvedPersonnel = involvedPersonnelByWeek.get(involvedKey) || new Set<string>()
involved.forEach((personId) => involvedPersonnel.add(personId))
involvedPersonnelByWeek.set(involvedKey, involvedPersonnel)
existing.involvedPersonnelCount = involvedPersonnel.size
const weekTasks = completedInWeek && completedTasks.length === 0
? departmentTasks
: Array.from(new Map(
[...receivedTasks, ...completedTasks].map((task) => [task.taskId, task] as const),
).values())
existing.items.push(workflowItemFor(row, weekTasks, receivedInWeek, completedInWeek))
})
})
})
departments.forEach((department) => department.weeks.forEach((week) => {
week.items.sort((a, b) => String(b.sourceCreatedAt || '').localeCompare(String(a.sourceCreatedAt || '')) || b.id.localeCompare(a.id))
}))
return {
departments,
summary: {
weekStart: weekTemplate[0]?.start || now.toISOString(),
weekEnd: weekTemplate.at(-1)?.end || now.toISOString(),
departmentCount: departments.length,
lastSyncedAt: sync.lastSyncedAt,
syncStatus: sync.syncStatus,
rosterHistory: {
source: hasRosterHistory ? 'historical_snapshot' : 'unavailable',
firstCapturedAt: historySnapshots.find((snapshot) => snapshot.complete)
? new Date(historySnapshots.find((snapshot) => snapshot.complete)!.capturedAt).toISOString()
: null,
lastCapturedAt: historySnapshots.findLast((snapshot) => snapshot.complete)
? new Date(historySnapshots.findLast((snapshot) => snapshot.complete)!.capturedAt).toISOString()
: null,
coveredWeeks: coveredHistoryWeeks.size,
totalWeeks: weekTemplate.length,
},
},
}
}