363 lines
13 KiB
TypeScript
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,
|
|
},
|
|
}
|
|
}
|