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(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() const personByDingtalkUserId = new Map() 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>() const involvedPersonnelByProcessWeek = new Map>() 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( departments.map((department) => [department.name, department]), ) workflowRows.forEach((row) => { const processKey = capacityProcessKind(row) if (!processKey) return const tasksByDepartment = new Map() 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([ ...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() 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() 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, }, } }