379 lines
14 KiB
TypeScript
379 lines
14 KiB
TypeScript
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,
|
||
},
|
||
},
|
||
}
|
||
}
|