diff --git a/DESIGN.md b/DESIGN.md index 786e3c8..f02a662 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -135,7 +135,7 @@ components: - 进行中项目明细采用可键盘聚焦的横向滚动数据表:桌面固定“审批单标题”首列并保留 14 项业务字段,移动端转换为同字段的语义化定义列表;节点时间在数值下方标注对应流程节点名称。 - 按时交付明细使用紧凑的三项流程筛选条;当前项用浅蓝底和蓝字表达,未选项悬停为灰底,切换时同步更新公式、数量和横向明细表。 - 团队负荷明细按部门展开人员和流程,商品企划部从人员、流程归属和平均负荷统计中排除;页面明确标注排除部门与指定人员。 -- 团队产能页面使用固定五项部门二级导航(品牌创意A部、品牌创意B部、综合支持部、品牌设计部、空间视觉部),按该顺序排列;右侧以近 8 个自然周的收到/完结双柱和可展开周明细承载部门产出,周卡片显示每周结束前最近一次完整员工组织快照中的历史在职人数;当前在职人数单独标注,快照覆盖前的周次显示暂无历史快照,流程按节点负责人归属并排除美术设计的策划主管分配/审核、内容视频的内容主管分配/审核节点。 +- 团队产能页面使用固定五项部门二级导航(品牌创意A部、品牌创意B部、综合支持部、品牌设计部、空间视觉部),按该顺序排列;右侧先标注当前钉钉组织架构在职人数,再参照业务趋势界面按“美术设计流程”和“内容视频流程”分成两个近 8 周收到/完结趋势区,并可展开每周明细。流程按当前节点负责人归属并排除美术设计的策划主管分配/审核、内容视频的内容主管分配/审核节点;不展示历史人员快照口径。 ## Colors diff --git a/PRODUCT.md b/PRODUCT.md index 23d33a2..f923905 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -35,7 +35,7 @@ web - “按时交付”只统计对应主管/规则审核节点在本月完成、审批状态为已完结且审批结论未拒绝的美术设计与内容视频流程;待计算流程不进入分母。详情页可在全部流程、美术设计流程和内容视频流程之间筛选,公式、数量与明细必须共用当前筛选口径。 - “首轮通过”读取作品过稿数据,分子为 V1 版本审核通过的作品数,分母为当前已通过的作品数;详情页列出部门范围内所有含 V1 的作品,搜索只过滤明细,不改变顶部完整口径。 - “团队负荷”统计品牌中心在职人员当前进行中的流程负荷,排除指定管理人员和商品企划部;该部门及其人员不进入流程归属、部门明细、人员分母或平均负荷。 -- “团队产能”仅展示品牌创意A部、品牌创意B部、综合支持部、品牌设计部、空间视觉部五个二级部门,并按该顺序展示近 8 个上海自然周的历史在职人数、收到流程数和完结流程数;历史人数取每周结束前最近一次完整员工组织快照,流程按历史任务负责人映射到部门,但美术设计流程不计“品牌策划主管分配任务/品牌策划主管审核”,内容视频流程不计“内容主管分配任务/内容主管审核”等管理节点,点击周次查看进行中与已完结明细。快照覆盖前的周次明确显示暂无历史快照,不得用当前花名册回填。 +- “团队产能”仅展示品牌创意A部、品牌创意B部、综合支持部、品牌设计部、空间视觉部五个二级部门,并按该顺序展示近 8 个上海自然周的当前在职人数、收到流程数和完结流程数;人数统一读取当前钉钉组织架构,不还原历史人员快照。每个部门再按美术设计流程、内容视频流程分别展示近 8 周趋势与明细;流程按当前任务负责人部门归属映射,但美术设计流程不计“品牌策划主管分配任务/品牌策划主管审核”,内容视频流程不计“内容主管分配任务/内容主管审核”等管理节点,点击周次查看进行中与已完结明细。 - 除素材资产库、自动化工作流、人员架构、数据口径和已接入的中心指标外,其余板块现阶段只展示可理解、可扩展的模块框架,不虚构业务数据。 - 沿用项目现有 Next.js、NextAuth、Tailwind CSS 和权限体系。 diff --git a/components/department-dashboard/TeamCapacityDashboard.test.tsx b/components/department-dashboard/TeamCapacityDashboard.test.tsx index ae12a91..5935644 100644 --- a/components/department-dashboard/TeamCapacityDashboard.test.tsx +++ b/components/department-dashboard/TeamCapacityDashboard.test.tsx @@ -13,19 +13,24 @@ const makeWeeks = (department: string, active = false) => Array.from({ length: 8 items: active && index === 7 ? [{ id: 'flow-1', businessId: 'A1', title: '美术流程一', status: 'RUNNING', projectType: '美术设计流程', brand: '亿家达', requesterName: '甲', requesterDepartment: '品牌中心', initiatorName: '甲', initiatorDepartment: '品牌中心', assigneeNames: ['甲'], assigneeUserIds: ['u1'], sourceCreatedAt: '2026-09-01T01:00:00.000Z', sourceFinishedAt: null, receivedInWeek: true, completedInWeek: false, isOngoing: true }] : [], })) +const makeProcess = (department: string, key: 'art' | 'content', active = false) => ({ + key, + label: key === 'art' ? '美术设计流程' : '内容视频流程', + weeks: makeWeeks(`${department}-${key}`, active).map(({ personnelCount: _personnelCount, ...week }) => week), +}) + const data = { summary: { weekStart: '2026-07-13T00:00:00.000Z', weekEnd: '2026-09-07T00:00:00.000Z', departmentCount: 5, lastSyncedAt: '2026-09-01T01:00:00.000Z', syncStatus: 'ready', - rosterHistory: { source: 'historical_snapshot', firstCapturedAt: '2026-08-01T01:00:00.000Z', lastCapturedAt: '2026-09-01T01:00:00.000Z', coveredWeeks: 8, totalWeeks: 8 }, }, departments: [ - { name: '商品企划部', personnelCount: 7, people: [], weeks: makeWeeks('excluded') }, - { name: '品牌创意A部', personnelCount: 5, people: [], weeks: makeWeeks('art', true) }, - { name: '空间视觉部', personnelCount: 12, people: [], weeks: makeWeeks('space') }, - { name: '品牌设计部', personnelCount: 1, people: [], weeks: makeWeeks('design') }, - { name: '品牌创意B部', personnelCount: 8, people: [], weeks: makeWeeks('content') }, - { name: '综合支持部', personnelCount: 12, people: [], weeks: makeWeeks('support') }, + { name: '商品企划部', personnelCount: 7, people: [], processes: [makeProcess('excluded', 'art'), makeProcess('excluded', 'content')], weeks: makeWeeks('excluded') }, + { name: '品牌创意A部', personnelCount: 5, people: [], processes: [makeProcess('art', 'art', true), makeProcess('art', 'content')], weeks: makeWeeks('art', true) }, + { name: '空间视觉部', personnelCount: 12, people: [], processes: [makeProcess('space', 'art'), makeProcess('space', 'content')], weeks: makeWeeks('space') }, + { name: '品牌设计部', personnelCount: 1, people: [], processes: [makeProcess('design', 'art'), makeProcess('design', 'content')], weeks: makeWeeks('design') }, + { name: '品牌创意B部', personnelCount: 8, people: [], processes: [makeProcess('content', 'art'), makeProcess('content', 'content')], weeks: makeWeeks('content') }, + { name: '综合支持部', personnelCount: 12, people: [], processes: [makeProcess('support', 'art'), makeProcess('support', 'content')], weeks: makeWeeks('support') }, ], } @@ -45,7 +50,7 @@ describe('TeamCapacityDashboard', () => { expect(screen.getByRole('button', { name: '品牌设计部' })).toBeInTheDocument() expect(screen.getByRole('button', { name: '空间视觉部' })).toBeInTheDocument() expect(screen.queryByRole('button', { name: '商品企划部' })).not.toBeInTheDocument() - expect(screen.getAllByRole('button', { name: /收到.*完结/ })).toHaveLength(8) + expect(screen.getAllByRole('button', { name: /收到.*完结/ })).toHaveLength(16) expect(screen.getByText('当前在职人数')).toBeInTheDocument() }) @@ -56,7 +61,7 @@ describe('TeamCapacityDashboard', () => { expect(screen.getAllByText('美术流程一').length).toBeGreaterThan(0) fireEvent.click(screen.getByRole('button', { name: '品牌创意B部' })) expect(await screen.findByRole('heading', { name: '品牌创意B部' })).toBeInTheDocument() - expect(screen.getByText('本周暂无流程明细')).toBeInTheDocument() + expect(screen.getAllByText('本周暂无流程明细')).toHaveLength(2) }) it('renders an error recovery state', async () => { diff --git a/components/department-dashboard/TeamCapacityDashboard.tsx b/components/department-dashboard/TeamCapacityDashboard.tsx index d52cef6..d3acb0e 100644 --- a/components/department-dashboard/TeamCapacityDashboard.tsx +++ b/components/department-dashboard/TeamCapacityDashboard.tsx @@ -10,13 +10,16 @@ import { Database, RefreshCw, UsersRound, + Video, + Workflow, } from 'lucide-react' import DepartmentShell from './DepartmentShell' import { TEAM_CAPACITY_DEPARTMENT_ORDER } from './team-capacity' import type { TeamCapacityDepartment, TeamCapacityInsights, - TeamCapacityWeek, + TeamCapacityProcess, + TeamCapacityProcessWeek, TeamCapacityWorkflowItem, } from './team-capacity' @@ -36,18 +39,6 @@ function formatDate(value: string | null, includeTime = false) { }).format(date) } -function personnelCountLabel(value: number | null) { - return value === null ? '暂无历史快照' : `${value}人` -} - -function rosterHistoryLabel(insights: TeamCapacityInsights) { - const history = insights.summary.rosterHistory - if (history.source !== 'historical_snapshot') { - return '尚未采集到员工组织历史快照;历史周人数暂不展示,避免用当前花名册冒充过去。' - } - return `已覆盖 ${history.coveredWeeks}/${history.totalWeeks} 周;每周人数取该周结束前最近一次完整快照。` -} - function statusLabel(value: string) { const status = value.trim().toUpperCase() if (status === 'COMPLETED') return '已完结' @@ -62,13 +53,13 @@ function syncLabel(insights: TeamCapacityInsights) { return '等待首轮同步' } -function WeekDetail({ week, department }: { week: TeamCapacityWeek; department: TeamCapacityDepartment }) { +function WeekDetail({ week, department, process }: { week: TeamCapacityProcessWeek; department: TeamCapacityDepartment; process: TeamCapacityProcess }) { const items = week.items return ( -
+
-

{week.label} · {department.name}明细

+

{week.label} · {department.name} · {process.label}明细

本周收到和完结的流程,同一流程只列一次。

共 {items.length} 条

@@ -108,6 +99,48 @@ function WeekDetail({ week, department }: { week: TeamCapacityWeek; department: ) } +function ProcessTrend({ process, department }: { process: TeamCapacityProcess; department: TeamCapacityDepartment }) { + const [selectedWeekKey, setSelectedWeekKey] = useState(process.weeks.at(-1)?.key || '') + const selectedWeek = process.weeks.find((week) => week.key === selectedWeekKey) || process.weeks.at(-1) + const maxValue = Math.max(1, ...process.weeks.flatMap((week) => [week.receivedCount, week.completedCount])) + const totals = process.weeks.reduce((current, week) => ({ + received: current.received + week.receivedCount, + completed: current.completed + week.completedCount, + }), { received: 0, completed: 0 }) + const ProcessIcon = process.key === 'art' ? Workflow : Video + + return ( +
+
+
+ +
+

{process.label}

+

流程按当前负责人部门归属统计,管理分配与审核节点不计入产能。

+
+
+
+
当前人员
{department.personnelCount}
+
近 8 周收到
{totals.received}
+
完结
{totals.completed}
+
+
+ +
+
+ +
+
+
+ {process.weeks.map((week) => setSelectedWeekKey(week.key)} />)} +
+
+ + {selectedWeek && } +
+ ) +} + function WorkflowRow({ item }: { item: TeamCapacityWorkflowItem }) { return ( @@ -142,7 +175,6 @@ function LoadingView() { export default function TeamCapacityDashboard() { const [selectedDepartmentKey, setSelectedDepartmentKey] = useState('') - const [selectedWeekKey, setSelectedWeekKey] = useState('') const [state, setState] = useState({ status: 'loading', data: null, error: null }) const load = useCallback(async () => { setState({ status: 'loading', data: null, error: null }) @@ -170,15 +202,12 @@ export default function TeamCapacityDashboard() { return departments.find((department) => department.name === selectedDepartmentKey) || departments[0] }, [departments, selectedDepartmentKey]) const weeks = selectedDepartment?.weeks || [] - const selectedWeek = weeks.find((week) => week.key === selectedWeekKey) || weeks.at(-1) - const weekMaxValue = Math.max(1, ...weeks.flatMap((week) => [week.receivedCount, week.completedCount])) useEffect(() => { if (state.status === 'success') { if (!selectedDepartmentKey && departments[0]) setSelectedDepartmentKey(departments[0].name) - if (!selectedWeekKey && departments[0]?.weeks.at(-1)) setSelectedWeekKey(departments[0].weeks.at(-1)!.key) } - }, [departments, selectedDepartmentKey, selectedWeekKey, state.status]) + }, [departments, selectedDepartmentKey, state.status]) return ( @@ -186,7 +215,7 @@ export default function TeamCapacityDashboard() {

团队产能

-

按二级部门查看团队近 8 周收到与完结的流程,点击周次展开任务明细。

+

按二级部门和流程类型查看近 8 周收到与完结的流程,人数按当前钉钉组织架构统计,点击周次展开任务明细。

{state.status === 'success' &&

}
@@ -206,29 +235,25 @@ export default function TeamCapacityDashboard() {
-

当前部门

{selectedDepartment.name}

按部门架构归属统计 · 当前 {selectedDepartment.people.length} 名在职人员

+

当前部门

{selectedDepartment.name}

按当前钉钉组织架构归属统计 · {selectedDepartment.people.length} 名在职人员

当前在职人数
{selectedDepartment.personnelCount}
近8周收到
{weeks.reduce((sum, week) => sum + week.receivedCount, 0)}
近8周完结
{weeks.reduce((sum, week) => sum + week.completedCount, 0)}
-
-
-
-
{weeks.map((week) => setSelectedWeekKey(week.key)} />)}
+
+ {selectedDepartment.processes.map((process) => )}
- - {selectedWeek && }
)} -

+

) } -function WeekButton({ week, maxValue, selected, onSelect }: { week: TeamCapacityWeek; maxValue: number; selected: boolean; onSelect: () => void }) { +function WeekButton({ week, maxValue, selected, detailId, onSelect }: { week: TeamCapacityProcessWeek; maxValue: number; selected: boolean; detailId: string; onSelect: () => void }) { const receivedHeight = week.receivedCount ? Math.max(10, Math.round((week.receivedCount / maxValue) * 88)) : 0 const completedHeight = week.completedCount ? Math.max(10, Math.round((week.completedCount / maxValue) * 88)) : 0 - return + return } diff --git a/components/department-dashboard/team-capacity.ts b/components/department-dashboard/team-capacity.ts index 34c234d..875f34b 100644 --- a/components/department-dashboard/team-capacity.ts +++ b/components/department-dashboard/team-capacity.ts @@ -2,6 +2,8 @@ export type { TeamCapacityDepartment, TeamCapacityInsights, TeamCapacityPerson, + TeamCapacityProcess, + TeamCapacityProcessWeek, TeamCapacityRosterRow, TeamCapacityWeek, TeamCapacityWorkflowItem, diff --git a/instrumentation.ts b/instrumentation.ts index 79744e0..bf4a817 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -120,16 +120,6 @@ export async function register() { console.error('[Instrumentation] 调度组织架构每日同步任务失败:', error) } - // 员工花名册历史快照:每小时保存一次完整人员、部门和在职状态, - // 团队产能据此还原历史周人数,不把今天的花名册倒灌到过去的周次。 - try { - const { ensureHourlyEmployeeRosterSnapshotStarted } = await import('./lib/employee-roster-history') - ensureHourlyEmployeeRosterSnapshotStarted() - console.log('[Instrumentation] 员工花名册历史快照每小时任务已调度') - } catch (error) { - console.error('[Instrumentation] 调度员工花名册历史快照失败:', error) - } - // 钉钉审批流程快照:启动后建立基线,之后每小时增量同步。 // 看板只读本地 PostgreSQL 快照,避免用户请求直接扫描上游无索引大表。 try { diff --git a/lib/ding-workflow-sync.ts b/lib/ding-workflow-sync.ts index 1da903f..a6e8e93 100644 --- a/lib/ding-workflow-sync.ts +++ b/lib/ding-workflow-sync.ts @@ -21,7 +21,6 @@ import { type TeamCapacityRosterRow, type TeamCapacityWorkflowRow, } from '@/lib/ding-workflow-team-capacity' -import { readEmployeeRosterHistory } from '@/lib/employee-roster-history' export type { TeamCapacityDepartment, TeamCapacityInsights, @@ -2325,8 +2324,7 @@ async function readTeamCapacityInsightsWithoutAccessCheck( ): Promise { const weeks = buildBusinessTrendWeeks(now) const earliestWeekStart = weeks[0]?.start || DING_WORKFLOW_STARTED_AT_CUTOFF_ISO - const historyStart = new Date(Date.parse(earliestWeekStart) - 8 * 24 * 60 * 60 * 1000) - const [rosterCandidates, rows, state, rosterHistoryRows] = await Promise.all([ + const [rosterCandidates, rows, state] = await Promise.all([ prisma.performanceSurveyEmployeeProfile.findMany({ where: { OR: [ @@ -2357,7 +2355,6 @@ async function readTeamCapacityInsightsWithoutAccessCheck( where: { id: SYNC_STATE_ID }, select: { status: true, lastCompletedAt: true }, }), - readEmployeeRosterHistory(historyStart, now), ]) const roster: TeamCapacityRosterRow[] = rosterCandidates @@ -2404,7 +2401,7 @@ async function readTeamCapacityInsightsWithoutAccessCheck( return aggregateTeamCapacity(roster, workflows, { lastSyncedAt: state?.lastCompletedAt?.toISOString() || null, syncStatus: normalizeSyncStatus(state), - }, now, rosterHistoryRows) + }, now) } export async function getTeamCapacityInsights(session: Session | null): Promise { diff --git a/lib/ding-workflow-team-capacity.test.ts b/lib/ding-workflow-team-capacity.test.ts index 5b47783..e1808f6 100644 --- a/lib/ding-workflow-team-capacity.test.ts +++ b/lib/ding-workflow-team-capacity.test.ts @@ -157,41 +157,23 @@ describe('aggregateTeamCapacity', () => { expect(week.items[0].assigneeNames).toEqual(['员工']) }) - it('按周末前最近的完整员工快照还原历史人数,不用当前花名册倒灌', () => { + it('所有周次都使用当前钉钉组织架构中的在职人数,并按流程类型拆分', () => { const result = aggregateTeamCapacity( [ - { id: 'current-1', dingtalkUserId: 'user-1', name: '现在员工一', departmentName: '品牌创意A部' }, - { id: 'current-2', dingtalkUserId: 'user-2', name: '现在员工二', departmentName: '品牌创意A部' }, + { id: 'user-1', dingtalkUserId: 'user-1', name: '员工一', departmentName: '品牌创意A部' }, + { id: 'user-2', dingtalkUserId: 'user-2', name: '员工二', departmentName: '品牌创意A部' }, ], - [], + [workflow({ projectType: '美术设计流程' }), workflow({ + id: 'content-flow', processCode: 'PROC-AE9FF3E1-A442-4E6A-85CF-0F2B86409673', projectType: '内容视频和图片拍摄', + activeTasks: [task({ taskId: 'content-task', assigneeUserId: 'user-2', assigneeName: '员工二' })], + })], { lastSyncedAt: null, syncStatus: 'ready' }, new Date('2026-09-01T02:00:00.000Z'), - [ - { capturedAt: '2026-08-18T01:00:00.000Z', profileCount: 1, employeeProfileId: 'old-1', dingtalkUserId: 'old-user-1', name: '历史员工一', departmentName: '品牌创意A部', departmentPath: '品牌中心/品牌创意A部', employmentState: 'ACTIVE', isBrandCenter: true }, - { capturedAt: '2026-08-25T01:00:00.000Z', profileCount: 2, employeeProfileId: 'old-1', dingtalkUserId: 'old-user-1', name: '历史员工一', departmentName: '品牌创意A部', departmentPath: '品牌中心/品牌创意A部', employmentState: 'ACTIVE', isBrandCenter: true }, - { capturedAt: '2026-08-25T01:00:00.000Z', profileCount: 2, employeeProfileId: 'old-2', dingtalkUserId: 'old-user-2', name: '历史员工二', departmentName: '品牌创意A部', departmentPath: '品牌中心/品牌创意A部', employmentState: 'ACTIVE', isBrandCenter: true }, - { capturedAt: '2026-09-01T01:00:00.000Z', profileCount: 3, employeeProfileId: 'current-1', dingtalkUserId: 'user-1', name: '现在员工一', departmentName: '品牌创意A部', departmentPath: '品牌中心/品牌创意A部', employmentState: 'ACTIVE', isBrandCenter: true }, - { capturedAt: '2026-09-01T01:00:00.000Z', profileCount: 3, employeeProfileId: 'current-2', dingtalkUserId: 'user-2', name: '现在员工二', departmentName: '品牌创意A部', departmentPath: '品牌中心/品牌创意A部', employmentState: 'ACTIVE', isBrandCenter: true }, - { capturedAt: '2026-09-01T01:00:00.000Z', profileCount: 3, employeeProfileId: 'current-3', dingtalkUserId: 'user-3', name: '现在员工三', departmentName: '品牌创意A部', departmentPath: '品牌中心/品牌创意A部', employmentState: 'RESIGNED', isBrandCenter: true }, - ], ) const department = result.departments[0] - expect(department.weeks.find((week) => week.key === '2026-08-10')?.personnelCount).toBeNull() - expect(department.weeks.find((week) => week.key === '2026-08-17')?.personnelCount).toBe(1) - expect(department.weeks.find((week) => week.key === '2026-08-24')?.personnelCount).toBe(2) - expect(department.weeks.find((week) => week.key === '2026-08-31')?.personnelCount).toBe(2) - expect(department.weeks.find((week) => week.key === '2026-08-24')?.personnelCountSource).toBe('historical_snapshot') - expect(result.summary.rosterHistory).toMatchObject({ source: 'historical_snapshot', coveredWeeks: 3, totalWeeks: 8 }) - }) - - it('没有历史快照时历史周人数为空', () => { - const result = aggregateTeamCapacity( - [{ id: 'user-1', dingtalkUserId: 'user-1', name: '员工一', departmentName: '品牌创意A部' }], - [], - { lastSyncedAt: null, syncStatus: 'ready' }, - new Date('2026-09-01T02:00:00.000Z'), - ) - expect(result.departments[0].weeks.every((week) => week.personnelCount === null)).toBe(true) - expect(result.summary.rosterHistory.source).toBe('unavailable') + expect(department.weeks.every((week) => week.personnelCount === 2)).toBe(true) + expect(department.processes.map((process) => process.label)).toEqual(['美术设计流程', '内容视频流程']) + expect(department.processes[0].weeks.at(-1)).toMatchObject({ receivedCount: 1, completedCount: 0 }) + expect(department.processes[1].weeks.at(-1)).toMatchObject({ receivedCount: 1, completedCount: 0 }) }) }) diff --git a/lib/ding-workflow-team-capacity.ts b/lib/ding-workflow-team-capacity.ts index fb4c091..734c471 100644 --- a/lib/ding-workflow-team-capacity.ts +++ b/lib/ding-workflow-team-capacity.ts @@ -2,8 +2,11 @@ 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' +import { + buildBusinessTrendWeeks, + BUSINESS_TREND_PROCESS_TYPES, + type BusinessTrendProcessKey, +} from '@/components/department-dashboard/business-trends' export type TeamCapacityRosterRow = { id: string @@ -60,19 +63,36 @@ export type TeamCapacityWeek = { label: string start: string end: string - /** 周末前最近一次完整花名册快照中的在职人数;缺少快照时为 null。 */ - personnelCount: number | null - personnelCountSource: 'historical_snapshot' | 'unavailable' + /** 当前钉钉组织架构中的在职人数;所有历史周统一使用当前口径。 */ + 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[] } @@ -84,13 +104,6 @@ export type TeamCapacityInsights = { departmentCount: number lastSyncedAt: string | null syncStatus: DingWorkflowSyncStatus - rosterHistory: { - source: 'historical_snapshot' | 'unavailable' - firstCapturedAt: string | null - lastCapturedAt: string | null - coveredWeeks: number - totalWeeks: number - } } } @@ -188,65 +201,18 @@ function workflowItemFor( } /** - * 聚合团队产能的纯函数:当前部门人员用于导航和当前人数,历史周人数优先 - * 取每周结束前最近的完整员工快照;没有快照时保持 null,禁止用当前人数回填。 - * 流程按历史任务的处理人部门归属拆分。接收按任务 createdAt 统计,完结按流程 - * sourceFinishedAt 统计;同一部门同一流程在同一周只计一次。 + * 聚合团队产能的纯函数:人员数始终来自当前钉钉组织架构中的在职花名册, + * 不再按历史周快照回填人数。流程按任务处理人的当前部门归属拆分,并同时 + * 按美术设计、内容视频两类流程统计;接收按任务 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()).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>() - const coveredHistoryWeeks = new Set() - weekTemplate.forEach((week) => { - const snapshot = hasRosterHistory ? historySnapshotForWeek(week.end) : null - if (!snapshot) return - const counts = new Map>() - 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() - 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() const personByDingtalkUserId = new Map() @@ -267,22 +233,33 @@ export function aggregateTeamCapacity( }) 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: historyCountsByWeek.get(week.key)?.get(name) - ?? (coveredHistoryWeeks.has(week.key) ? 0 : null), - personnelCountSource: hasRosterHistory - ? (coveredHistoryWeeks.has(week.key) ? 'historical_snapshot' : 'unavailable') - : 'unavailable', + personnelCount: people.length, involvedPersonnelCount: 0, receivedCount: 0, completedCount: 0, @@ -295,21 +272,25 @@ export function aggregateTeamCapacity( ) 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) - }) + 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)) @@ -332,27 +313,41 @@ export function aggregateTeamCapacity( }) } const existing = department.weeks.find((candidate) => candidate.key === week.key) - if (!existing) return + 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()) - existing.items.push(workflowItemFor(row, weekTasks, receivedInWeek, completedInWeek)) + const item = workflowItemFor(row, weekTasks, receivedInWeek, completedInWeek) + existing.items.push(item) + processWeek.items.push(item) }) }) }) - departments.forEach((department) => department.weeks.forEach((week) => { - week.items.sort((a, b) => String(b.sourceCreatedAt || '').localeCompare(String(a.sourceCreatedAt || '')) || b.id.localeCompare(a.id)) - })) + 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, @@ -362,17 +357,6 @@ export function aggregateTeamCapacity( 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, - }, }, } }