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

363 lines
13 KiB
TypeScript

import type {
DingWorkflowSyncStatus,
OngoingDingWorkflowActiveTask,
} from '@/lib/ding-workflow-sync'
import {
buildBusinessTrendWeeks,
BUSINESS_TREND_PROCESS_TYPES,
type BusinessTrendProcessKey,
} 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
/** 当前钉钉组织架构中的在职人数;所有历史周统一使用当前口径。 */
personnelCount: number
involvedPersonnelCount: number
receivedCount: number
completedCount: number
items: TeamCapacityWorkflowItem[]
}
export type TeamCapacityProcessWeek = {
key: string
label: string
start: string
end: string
receivedCount: number
completedCount: number
involvedPersonnelCount: number
items: TeamCapacityWorkflowItem[]
}
export type TeamCapacityProcess = {
key: BusinessTrendProcessKey
label: string
weeks: TeamCapacityProcessWeek[]
}
export type TeamCapacityDepartment = {
name: string
personnelCount: number
people: TeamCapacityPerson[]
processes: TeamCapacityProcess[]
weeks: TeamCapacityWeek[]
}
export type TeamCapacityInsights = {
departments: TeamCapacityDepartment[]
summary: {
weekStart: string
weekEnd: string
departmentCount: number
lastSyncedAt: string | null
syncStatus: DingWorkflowSyncStatus
}
}
/**
* 团队产能侧栏只展示品牌中心当前看板约定的五个二级部门,顺序与业务侧栏一致。
*/
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()),
}
}
/**
* 聚合团队产能的纯函数:人员数始终来自当前钉钉组织架构中的在职花名册,
* 不再按历史周快照回填人数。流程按任务处理人的当前部门归属拆分,并同时
* 按美术设计、内容视频两类流程统计;接收按任务 createdAt 统计,完结按流程
* sourceFinishedAt 统计,同一部门同一流程在同一周只计一次。
*/
export function aggregateTeamCapacity(
rosterRows: TeamCapacityRosterRow[],
workflowRows: TeamCapacityWorkflowRow[],
sync: { lastSyncedAt: string | null; syncStatus: DingWorkflowSyncStatus },
now = new Date(),
): TeamCapacityInsights {
const weekTemplate = buildBusinessTrendWeeks(now)
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 involvedPersonnelByProcessWeek = 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)),
processes: BUSINESS_TREND_PROCESS_TYPES.map((process) => ({
key: process.key,
label: process.label,
weeks: weekTemplate.map((week): TeamCapacityProcessWeek => ({
key: week.key,
label: week.label,
start: week.start,
end: week.end,
receivedCount: 0,
completedCount: 0,
involvedPersonnelCount: 0,
items: [],
})),
})),
weeks: weekTemplate.map((week): TeamCapacityWeek => ({
key: week.key,
label: week.label,
start: week.start,
end: week.end,
personnelCount: people.length,
involvedPersonnelCount: 0,
receivedCount: 0,
completedCount: 0,
items: [],
})),
}
})
const departmentByName = new Map<string, TeamCapacityDepartment>(
departments.map((department) => [department.name, department]),
)
workflowRows.forEach((row) => {
const processKey = capacityProcessKind(row)
if (!processKey) return
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
const process = department.processes.find((candidate) => candidate.key === processKey)
if (!process) 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)
const processWeek = process.weeks.find((candidate) => candidate.key === week.key)
if (!existing || !processWeek) return
if (receivedInWeek) existing.receivedCount += 1
if (completedInWeek) existing.completedCount += 1
if (receivedInWeek) processWeek.receivedCount += 1
if (completedInWeek) processWeek.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 processInvolvedKey = `${departmentName}:${processKey}:${week.key}`
const processInvolvedPersonnel = involvedPersonnelByProcessWeek.get(processInvolvedKey) || new Set<string>()
involved.forEach((personId) => processInvolvedPersonnel.add(personId))
involvedPersonnelByProcessWeek.set(processInvolvedKey, processInvolvedPersonnel)
processWeek.involvedPersonnelCount = processInvolvedPersonnel.size
const weekTasks = completedInWeek && completedTasks.length === 0
? departmentTasks
: Array.from(new Map(
[...receivedTasks, ...completedTasks].map((task) => [task.taskId, task] as const),
).values())
const item = workflowItemFor(row, weekTasks, receivedInWeek, completedInWeek)
existing.items.push(item)
processWeek.items.push(item)
})
})
})
const sortItems = (items: TeamCapacityWorkflowItem[]) => {
items.sort((a, b) => String(b.sourceCreatedAt || '').localeCompare(String(a.sourceCreatedAt || '')) || b.id.localeCompare(a.id))
}
departments.forEach((department) => {
department.weeks.forEach((week) => sortItems(week.items))
department.processes.forEach((process) => process.weeks.forEach((week) => sortItems(week.items)))
})
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,
},
}
}