diff --git a/DESIGN.md b/DESIGN.md
index 46507d7..786e3c8 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -135,7 +135,7 @@ components:
- 进行中项目明细采用可键盘聚焦的横向滚动数据表:桌面固定“审批单标题”首列并保留 14 项业务字段,移动端转换为同字段的语义化定义列表;节点时间在数值下方标注对应流程节点名称。
- 按时交付明细使用紧凑的三项流程筛选条;当前项用浅蓝底和蓝字表达,未选项悬停为灰底,切换时同步更新公式、数量和横向明细表。
- 团队负荷明细按部门展开人员和流程,商品企划部从人员、流程归属和平均负荷统计中排除;页面明确标注排除部门与指定人员。
-- 团队产能页面使用固定五项部门二级导航(品牌创意A部、品牌创意B部、综合支持部、品牌设计部、空间视觉部),按该顺序排列;右侧以近 8 个自然周的收到/完结双柱和可展开周明细承载部门产出,人员数以当前在职花名册为基数,流程按历史任务负责人归属。
+- 团队产能页面使用固定五项部门二级导航(品牌创意A部、品牌创意B部、综合支持部、品牌设计部、空间视觉部),按该顺序排列;右侧以近 8 个自然周的收到/完结双柱和可展开周明细承载部门产出,周卡片显示每周结束前最近一次完整员工组织快照中的历史在职人数;当前在职人数单独标注,快照覆盖前的周次显示暂无历史快照,流程按节点负责人归属并排除美术设计的策划主管分配/审核、内容视频的内容主管分配/审核节点。
## Colors
diff --git a/Dockerfile b/Dockerfile
index 9cde4e1..d762df3 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -90,7 +90,8 @@ RUN test -s "/app/public/brand/header-logo.png" && \
test -f "/app/prisma/migrations/20260828_add_pack_item_can_edit_assets/migration.sql" && \
test -f "/app/prisma/migrations/20260828_add_task_detail_change_approvals/migration.sql" && \
test -f "/app/prisma/migrations/20260829_add_recoverable_api_key_secret/migration.sql" && \
- test -f "/app/prisma/migrations/20260831_add_private_task_detail/migration.sql"
+ test -f "/app/prisma/migrations/20260831_add_private_task_detail/migration.sql" && \
+ test -f "/app/prisma/migrations/20260902_add_employee_roster_history/migration.sql"
# 这两个动态路由负责兜底访问本地上传文件;如果它们在构建上下文里丢失,
# 线上音频/视频就会直接 404。这里提前失败,避免再把缺路由的镜像发出去。
@@ -214,6 +215,7 @@ COPY --from=builder --chown=root:root /app/prisma/migrations/20260828_add_pack_i
COPY --from=builder --chown=root:root /app/prisma/migrations/20260828_add_task_detail_change_approvals ./prisma/migrations/20260828_add_task_detail_change_approvals
COPY --from=builder --chown=root:root /app/prisma/migrations/20260829_add_recoverable_api_key_secret ./prisma/migrations/20260829_add_recoverable_api_key_secret
COPY --from=builder --chown=root:root /app/prisma/migrations/20260831_add_private_task_detail ./prisma/migrations/20260831_add_private_task_detail
+COPY --from=builder --chown=root:root /app/prisma/migrations/20260902_add_employee_roster_history ./prisma/migrations/20260902_add_employee_roster_history
COPY --from=builder --chown=nextjs:nodejs /app/lib ./lib
COPY --from=builder --chown=nextjs:nodejs /app/scripts/public-traffic-rollup-from-nginx.py ./scripts/public-traffic-rollup-from-nginx.py
COPY --from=builder --chown=nextjs:nodejs /app/scripts/collapse-performance-survey-template-versions.mjs ./scripts/collapse-performance-survey-template-versions.mjs
diff --git a/PRODUCT.md b/PRODUCT.md
index 1727e01..23d33a2 100644
--- a/PRODUCT.md
+++ b/PRODUCT.md
@@ -35,7 +35,7 @@ web
- “按时交付”只统计对应主管/规则审核节点在本月完成、审批状态为已完结且审批结论未拒绝的美术设计与内容视频流程;待计算流程不进入分母。详情页可在全部流程、美术设计流程和内容视频流程之间筛选,公式、数量与明细必须共用当前筛选口径。
- “首轮通过”读取作品过稿数据,分子为 V1 版本审核通过的作品数,分母为当前已通过的作品数;详情页列出部门范围内所有含 V1 的作品,搜索只过滤明细,不改变顶部完整口径。
- “团队负荷”统计品牌中心在职人员当前进行中的流程负荷,排除指定管理人员和商品企划部;该部门及其人员不进入流程归属、部门明细、人员分母或平均负荷。
-- “团队产能”仅展示品牌创意A部、品牌创意B部、综合支持部、品牌设计部、空间视觉部五个二级部门,并按该顺序展示近 8 个上海自然周的在职人数、收到流程数和完结流程数;流程按历史任务负责人映射到部门,点击周次查看进行中与已完结明细。
+- “团队产能”仅展示品牌创意A部、品牌创意B部、综合支持部、品牌设计部、空间视觉部五个二级部门,并按该顺序展示近 8 个上海自然周的历史在职人数、收到流程数和完结流程数;历史人数取每周结束前最近一次完整员工组织快照,流程按历史任务负责人映射到部门,但美术设计流程不计“品牌策划主管分配任务/品牌策划主管审核”,内容视频流程不计“内容主管分配任务/内容主管审核”等管理节点,点击周次查看进行中与已完结明细。快照覆盖前的周次明确显示暂无历史快照,不得用当前花名册回填。
- 除素材资产库、自动化工作流、人员架构、数据口径和已接入的中心指标外,其余板块现阶段只展示可理解、可扩展的模块框架,不虚构业务数据。
- 沿用项目现有 Next.js、NextAuth、Tailwind CSS 和权限体系。
diff --git a/components/department-dashboard/TeamCapacityDashboard.test.tsx b/components/department-dashboard/TeamCapacityDashboard.test.tsx
index 6c8ba4f..ae12a91 100644
--- a/components/department-dashboard/TeamCapacityDashboard.test.tsx
+++ b/components/department-dashboard/TeamCapacityDashboard.test.tsx
@@ -15,8 +15,9 @@ const makeWeeks = (department: string, active = false) => Array.from({ length: 8
const data = {
summary: {
- weekStart: '2026-07-13T00:00:00.000Z', weekEnd: '2026-09-07T00:00:00.000Z', departmentCount: 2,
+ 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') },
@@ -45,7 +46,7 @@ describe('TeamCapacityDashboard', () => {
expect(screen.getByRole('button', { name: '空间视觉部' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '商品企划部' })).not.toBeInTheDocument()
expect(screen.getAllByRole('button', { name: /收到.*完结/ })).toHaveLength(8)
- expect(screen.getByText('在职人数')).toBeInTheDocument()
+ expect(screen.getByText('当前在职人数')).toBeInTheDocument()
})
it('switches department and selected week detail', async () => {
diff --git a/components/department-dashboard/TeamCapacityDashboard.tsx b/components/department-dashboard/TeamCapacityDashboard.tsx
index 2c91549..d52cef6 100644
--- a/components/department-dashboard/TeamCapacityDashboard.tsx
+++ b/components/department-dashboard/TeamCapacityDashboard.tsx
@@ -36,6 +36,18 @@ 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 '已完结'
@@ -194,18 +206,22 @@ export default function TeamCapacityDashboard() {
- 当前部门
{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)}
+ 当前部门
{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)} />)}
+
+
+
历史人员数:{rosterHistoryLabel(state.data)} 历史快照从首次成功采集后开始累计。
+
{weeks.map((week) => setSelectedWeekKey(week.key)} />)}
+
{selectedWeek && }
)}
- 数据来自钉钉流程本地快照,每小时增量同步
返回部门看板
+
)
@@ -214,5 +230,5 @@ export default function TeamCapacityDashboard() {
function WeekButton({ week, maxValue, selected, onSelect }: { week: TeamCapacityWeek; maxValue: number; selected: boolean; 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/docs/生产数据库安全增量迁移SOP-2026-08-12.md b/docs/生产数据库安全增量迁移SOP-2026-08-12.md
index cf57f3c..8d73ee3 100644
--- a/docs/生产数据库安全增量迁移SOP-2026-08-12.md
+++ b/docs/生产数据库安全增量迁移SOP-2026-08-12.md
@@ -2,7 +2,7 @@
## 结论
-当前应用发布工具验证十七份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。
+当前应用发布工具验证十八份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。
目标 migration:
@@ -23,8 +23,9 @@
- `20260828_add_task_detail_change_approvals`
- `20260829_add_recoverable_api_key_secret`
- `20260831_add_private_task_detail`
+- `20260902_add_employee_roster_history`
-它们新增可空租约字段/索引、修改未来 Logo 默认值、精确把 `system_settings` 品牌两列中的退役 `s3.1080p.hk` 值改为本地品牌图,并把 `materials_uploadId_key` 重建为“`uploadId` 非空且素材未软删除”范围内的唯一 btree 索引;同时新增人员组织树、素材托管字段、素材不完整空壳标记、账号—人员绑定表、钉钉流程快照字段,以及任务详情图片变更审批单和通知 outbox。审批迁移只新增表、约束与索引,不改写现有素材和资产行。`20260829_add_recoverable_api_key_secret` 只新增 `api_keys.secret_ciphertext` / `secret_nonce` / `secret_auth_tag` 三个可空加密列、`api_key_reveal_audits` 审计表和 `api_key_id+created_at`、`viewer_user_id+created_at` 两个非唯一 btree 索引,不回填旧 key。`20260831_add_private_task_detail` 只新增 `materials.task_detail_demand_type`、`materials.task_detail_private default false`、`material_permission_pack_items.can_access_private default false`,并加上 `materials_private_optimization_check`,确保私密任务详情只能落在“任务-详情 + 优化”语义下。新增 `form_fields` 后会将同步游标重置一次,让下一轮同步完整补齐明细字段。索引迁移会先检测未删除素材中的重复 `uploadId`;人员绑定迁移会先检测一个钉钉身份对应多个账号的冲突。任一预检失败都会直接停止,不会猜测性合并、删除素材或留下一半回填。迁移没有自动 down;执行失败时停止发布,恢复依赖执行前的数据库备份。
+它们新增可空租约字段/索引、修改未来 Logo 默认值、精确把 `system_settings` 品牌两列中的退役 `s3.1080p.hk` 值改为本地品牌图,并把 `materials_uploadId_key` 重建为“`uploadId` 非空且素材未软删除”范围内的唯一 btree 索引;同时新增人员组织树、素材托管字段、素材不完整空壳标记、账号—人员绑定表、钉钉流程快照字段,以及任务详情图片变更审批单和通知 outbox。审批迁移只新增表、约束与索引,不改写现有素材和资产行。`20260829_add_recoverable_api_key_secret` 只新增 `api_keys.secret_ciphertext` / `secret_nonce` / `secret_auth_tag` 三个可空加密列、`api_key_reveal_audits` 审计表和 `api_key_id+created_at`、`viewer_user_id+created_at` 两个非唯一 btree 索引,不回填旧 key。`20260831_add_private_task_detail` 只新增 `materials.task_detail_demand_type`、`materials.task_detail_private default false`、`material_permission_pack_items.can_access_private default false`,并加上 `materials_private_optimization_check`,确保私密任务详情只能落在“任务-详情 + 优化”语义下。`20260902_add_employee_roster_history` 新增员工完整花名册的时间快照表及查询索引,团队产能按每周结束前最近一份快照统计在职人数;不会用当前花名册回填没有快照的历史周。新增 `form_fields` 后会将同步游标重置一次,让下一轮同步完整补齐明细字段。索引迁移会先检测未删除素材中的重复 `uploadId`;人员绑定迁移会先检测一个钉钉身份对应多个账号的冲突。任一预检失败都会直接停止,不会猜测性合并、删除素材或留下一半回填。迁移没有自动 down;执行失败时停止发布,恢复依赖执行前的数据库备份。
## 强制前提
@@ -39,7 +40,7 @@
1. 构建并固定同一 exact SHA/digest 的 runner 与 migrator;
2. legacy bootstrap `Audit`;
3. legacy bootstrap `Apply`,外部验收 503,确认旧容器停止且保留;
-4. 在同一维护窗内运行本文的真实备份、隔离恢复、十七条目标 migration 和迁移后审计;
+4. 在同一维护窗内运行本文的真实备份、隔离恢复、十八条目标 migration 和迁移后审计;
5. 仅在所有门禁通过后,运行普通同 SHA 应用部署。
顺序不得改为“先 migration,后 bootstrap”。本文备份工具的生产前后摘要若有变化,说明维护窗内仍可能存在写入,会直接失败且不生成 receipt。
@@ -363,7 +364,7 @@ docker run --rm \
"$MIGRATOR_IMAGE" --apply
```
-工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对十七条目标 migration 的数据库 checksum、完成状态、关键人员组织表、素材空壳标记、钉钉流程快照表及需求类型和表单字段、字段类型与可空性、`api_keys` 三个可空加密列、`api_key_reveal_audits` 关键列、租约/需求类型/API Key reveal 审计索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及 `materials.task_detail_demand_type` 无默认值、`materials.task_detail_private` / `material_permission_pack_items.can_access_private` 默认值为 `false`,并用稳定语义核对 `materials_private_optimization_check`,确认私密任务详情仍被精确限制在“任务-详情 + 优化”条件下;最后原子写审计 JSON。
+工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对十八条目标 migration 的数据库 checksum、完成状态、关键人员组织表、素材空壳标记、钉钉流程快照表及需求类型和表单字段、员工花名册历史快照表、字段类型与可空性、`api_keys` 三个可空加密列、`api_key_reveal_audits` 关键列、租约/需求类型/API Key reveal 审计索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及 `materials.task_detail_demand_type` 无默认值、`materials.task_detail_private` / `material_permission_pack_items.can_access_private` 默认值为 `false`,并用稳定语义核对 `materials_private_optimization_check`,确认私密任务详情仍被精确限制在“任务-详情 + 优化”条件下;最后原子写审计 JSON。
apply 成功后立即固定审计文件本身;后续“迁移批次部署”读取这一个文件,不能手工重写:
@@ -379,7 +380,7 @@ test -n "$MIGRATION_AUDIT_SHA256"
只有以下全部成立,才进入应用首次安全上线步骤:
- `applied.json` 的 `verified=true`;
-- 十七条 target migration 均 `completed=true`,source/database checksum 一致;
+- 十八条 target migration 均 `completed=true`,source/database checksum 一致;
- `_prisma_migrations.finished_at` 非空、`rolled_back_at` 为空;
- 视频租约列/索引、任务详情作业租约列/索引、active-only `uploadId` 唯一索引、品牌默认值精确匹配;
- 再次运行 `--audit` 仍通过;
diff --git a/instrumentation.ts b/instrumentation.ts
index bf4a817..79744e0 100644
--- a/instrumentation.ts
+++ b/instrumentation.ts
@@ -120,6 +120,16 @@ 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/__tests__/ding-workflow-team-workload.test.ts b/lib/__tests__/ding-workflow-team-workload.test.ts
index 791589d..b981957 100644
--- a/lib/__tests__/ding-workflow-team-workload.test.ts
+++ b/lib/__tests__/ding-workflow-team-workload.test.ts
@@ -97,7 +97,11 @@ describe('buildTeamWorkloadInsights', () => {
it('只统计当前活跃节点,并以当前花名册组织归属为准', () => {
const insights = buildTeamWorkloadInsights(
- [rosterRow()],
+ [rosterRow({
+ departmentName: '品牌中心',
+ departmentPath: '品牌中心',
+ departmentMemberships: [{ departmentName: '设计部', departmentPath: '品牌中心/设计部', isPrimary: false }],
+ })],
[{
...snapshotRow('project-1', ['ding-1']),
activeTasks: [
@@ -109,6 +113,7 @@ describe('buildTeamWorkloadInsights', () => {
)
expect(insights.summary.activeProjectAssignments).toBe(1)
expect(insights.summary.averageLoadPercent).toBe(50)
+ expect(insights.departments[0]).toMatchObject({ name: '设计部', personnelCount: 1 })
})
it('排除商品企划部及其流程归属,不纳入人员和负荷平均值', () => {
diff --git a/lib/ding-workflow-sync.ts b/lib/ding-workflow-sync.ts
index 12cb996..1da903f 100644
--- a/lib/ding-workflow-sync.ts
+++ b/lib/ding-workflow-sync.ts
@@ -21,6 +21,7 @@ import {
type TeamCapacityRosterRow,
type TeamCapacityWorkflowRow,
} from '@/lib/ding-workflow-team-capacity'
+import { readEmployeeRosterHistory } from '@/lib/employee-roster-history'
export type {
TeamCapacityDepartment,
TeamCapacityInsights,
@@ -772,7 +773,11 @@ function profileDepartmentForTeamWorkload(profile: TeamWorkloadRosterRow) {
{ departmentPath: profile.departmentPath, departmentName: profile.departmentName, isPrimary: true },
...profile.departmentMemberships,
].filter((entry) => isBrandCenterOrganizationPath(entry.departmentPath))
- const selected = matchingPaths.sort((a, b) => Number(b.isPrimary) - Number(a.isPrimary))[0]
+ const selected = matchingPaths.sort((a, b) => {
+ const depth = (value: string | null) => String(value || '').split('/').map((part) => part.trim()).filter(Boolean).length
+ return depth(b.departmentPath) - depth(a.departmentPath)
+ || Number(b.isPrimary) - Number(a.isPrimary)
+ })[0]
const parts = String(selected?.departmentPath || profile.departmentPath || '')
.split('/')
.map((part) => part.trim())
@@ -2320,7 +2325,8 @@ async function readTeamCapacityInsightsWithoutAccessCheck(
): Promise {
const weeks = buildBusinessTrendWeeks(now)
const earliestWeekStart = weeks[0]?.start || DING_WORKFLOW_STARTED_AT_CUTOFF_ISO
- const [rosterCandidates, rows, state] = await Promise.all([
+ const historyStart = new Date(Date.parse(earliestWeekStart) - 8 * 24 * 60 * 60 * 1000)
+ const [rosterCandidates, rows, state, rosterHistoryRows] = await Promise.all([
prisma.performanceSurveyEmployeeProfile.findMany({
where: {
OR: [
@@ -2351,6 +2357,7 @@ async function readTeamCapacityInsightsWithoutAccessCheck(
where: { id: SYNC_STATE_ID },
select: { status: true, lastCompletedAt: true },
}),
+ readEmployeeRosterHistory(historyStart, now),
])
const roster: TeamCapacityRosterRow[] = rosterCandidates
@@ -2397,7 +2404,7 @@ async function readTeamCapacityInsightsWithoutAccessCheck(
return aggregateTeamCapacity(roster, workflows, {
lastSyncedAt: state?.lastCompletedAt?.toISOString() || null,
syncStatus: normalizeSyncStatus(state),
- }, now)
+ }, now, rosterHistoryRows)
}
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 7e2eab3..5b47783 100644
--- a/lib/ding-workflow-team-capacity.test.ts
+++ b/lib/ding-workflow-team-capacity.test.ts
@@ -2,6 +2,8 @@ import { aggregateTeamCapacity } from './ding-workflow-team-capacity'
function task(overrides: Partial<{
taskId: string
+ activityId: string | null
+ nodeName: string
assigneeUserId: string
assigneeName: string
createdAt: string | null
@@ -29,12 +31,14 @@ function task(overrides: Partial<{
function workflow(overrides: Partial<{
id: string
status: string
+ processCode: string
+ projectType: string
sourceCreatedAt: string | null
sourceFinishedAt: string | null
activeTasks: ReturnType[]
}> = {}) {
return {
- id: 'flow-1', businessId: 'business-1', title: '流程一', status: 'RUNNING', processCode: 'art',
+ id: 'flow-1', businessId: 'business-1', title: '流程一', status: 'RUNNING', processCode: 'PROC-3571C915-0506-4490-A91A-8AA7EA33F838',
projectType: '美术设计流程', brand: '亿家达', requesterName: '需求人', requesterDepartment: '品牌中心',
initiatorName: '需求人', initiatorDepartment: '品牌中心', sourceCreatedAt: '2026-08-31T01:00:00.000Z',
sourceFinishedAt: null, activeTasks: [task()], ...overrides,
@@ -96,4 +100,98 @@ describe('aggregateTeamCapacity', () => {
expect(currentWeek.involvedPersonnelCount).toBe(1)
expect(currentWeek.items).toHaveLength(1)
})
+
+ it('按流程类型排除主管分配和审核节点,不计入负责人和参与人数', () => {
+ const result = aggregateTeamCapacity(
+ [
+ { id: 'worker-art', dingtalkUserId: 'worker-art', name: '美术员工', departmentName: '品牌创意A部' },
+ { id: 'manager-art', dingtalkUserId: 'manager-art', name: '策划主管', departmentName: '品牌创意A部' },
+ { id: 'worker-content', dingtalkUserId: 'worker-content', name: '内容员工', departmentName: '品牌创意A部' },
+ { id: 'manager-content', dingtalkUserId: 'manager-content', name: '内容主管', departmentName: '品牌创意A部' },
+ ],
+ [
+ workflow({
+ activeTasks: [
+ task({ taskId: 'art-worker', assigneeUserId: 'worker-art', assigneeName: '美术员工', nodeName: '出图' }),
+ task({ taskId: 'art-assignment', assigneeUserId: 'manager-art', assigneeName: '策划主管', activityId: '38da_544a', nodeName: '品牌策划主管分配任务' }),
+ task({ taskId: 'art-review', assigneeUserId: 'manager-art', assigneeName: '策划主管', activityId: 'c255_84b3', nodeName: '品牌策划主管审核', finishedAt: '2026-08-31T02:00:00.000Z', isActive: false }),
+ ],
+ }),
+ workflow({
+ id: 'content-flow', processCode: 'PROC-AE9FF3E1-A442-4E6A-85CF-0F2B86409673', projectType: '内容视频和图片拍摄',
+ activeTasks: [
+ task({ taskId: 'content-worker', assigneeUserId: 'worker-content', assigneeName: '内容员工', nodeName: '剪辑' }),
+ task({ taskId: 'content-owner-assignment', assigneeUserId: 'manager-content', assigneeName: '内容主管', activityId: 'cc59_3e5a', nodeName: '内容负责人分配任务' }),
+ task({ taskId: 'content-assignment', assigneeUserId: 'manager-content', assigneeName: '内容主管', activityId: '9fb8_32e7', nodeName: '内容主管分配任务' }),
+ task({ taskId: 'content-review', assigneeUserId: 'manager-content', assigneeName: '内容主管', activityId: '6c43_575f', nodeName: '内容主管审核', finishedAt: '2026-08-31T02:00:00.000Z', isActive: false }),
+ ],
+ }),
+ ],
+ { lastSyncedAt: null, syncStatus: 'ready' },
+ new Date('2026-09-01T02:00:00.000Z'),
+ )
+ const week = result.departments[0].weeks.at(-1)!
+ expect(week.receivedCount).toBe(2)
+ expect(week.involvedPersonnelCount).toBe(2)
+ expect(week.items.map((item) => item.assigneeNames)).toEqual([
+ ['美术员工'],
+ ['内容员工'],
+ ])
+ })
+
+ it('已知 activityId 优先于旧节点名称,未知 activityId 不按文字猜测', () => {
+ const result = aggregateTeamCapacity(
+ [{ id: 'worker', dingtalkUserId: 'worker', name: '员工', departmentName: '品牌创意A部' }],
+ [workflow({
+ activeTasks: [
+ task({ taskId: 'known-production', assigneeUserId: 'worker', assigneeName: '员工', activityId: 'a9e1_5be0', nodeName: '品牌策划主管审核' }),
+ task({ taskId: 'unknown-label', assigneeUserId: 'worker', assigneeName: '员工', activityId: 'future_activity', nodeName: '品牌策划主管审核' }),
+ ],
+ })],
+ { lastSyncedAt: null, syncStatus: 'ready' },
+ new Date('2026-09-01T02:00:00.000Z'),
+ )
+ const week = result.departments[0].weeks.at(-1)!
+ expect(week.receivedCount).toBe(1)
+ expect(week.involvedPersonnelCount).toBe(1)
+ expect(week.items[0].assigneeNames).toEqual(['员工'])
+ })
+
+ it('按周末前最近的完整员工快照还原历史人数,不用当前花名册倒灌', () => {
+ const result = aggregateTeamCapacity(
+ [
+ { id: 'current-1', dingtalkUserId: 'user-1', name: '现在员工一', departmentName: '品牌创意A部' },
+ { id: 'current-2', dingtalkUserId: 'user-2', name: '现在员工二', departmentName: '品牌创意A部' },
+ ],
+ [],
+ { 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')
+ })
})
diff --git a/lib/ding-workflow-team-capacity.ts b/lib/ding-workflow-team-capacity.ts
index acfa2e0..fb4c091 100644
--- a/lib/ding-workflow-team-capacity.ts
+++ b/lib/ding-workflow-team-capacity.ts
@@ -2,6 +2,7 @@ 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 = {
@@ -59,7 +60,9 @@ export type TeamCapacityWeek = {
label: string
start: string
end: string
- personnelCount: number
+ /** 周末前最近一次完整花名册快照中的在职人数;缺少快照时为 null。 */
+ personnelCount: number | null
+ personnelCountSource: 'historical_snapshot' | 'unavailable'
involvedPersonnelCount: number
receivedCount: number
completedCount: number
@@ -81,6 +84,13 @@ 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
+ }
}
}
@@ -99,6 +109,46 @@ const TEAM_CAPACITY_DEPARTMENT_SET = new Set(TEAM_CAPACITY_DEPARTMENT_OR
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)
@@ -138,8 +188,9 @@ function workflowItemFor(
}
/**
- * 聚合团队产能的纯函数:部门人员以当前 HR 花名册为准,流程按历史任务
- * 的处理人部门归属拆分。接收按任务 createdAt 统计,完结按流程
+ * 聚合团队产能的纯函数:当前部门人员用于导航和当前人数,历史周人数优先
+ * 取每周结束前最近的完整员工快照;没有快照时保持 null,禁止用当前人数回填。
+ * 流程按历史任务的处理人部门归属拆分。接收按任务 createdAt 统计,完结按流程
* sourceFinishedAt 统计;同一部门同一流程在同一周只计一次。
*/
export function aggregateTeamCapacity(
@@ -147,8 +198,55 @@ export function aggregateTeamCapacity(
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()
@@ -180,7 +278,11 @@ export function aggregateTeamCapacity(
label: week.label,
start: week.start,
end: week.end,
- personnelCount: people.length,
+ 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,
@@ -194,7 +296,9 @@ export function aggregateTeamCapacity(
workflowRows.forEach((row) => {
const tasksByDepartment = new Map()
- row.activeTasks.forEach((task) => {
+ row.activeTasks
+ .filter((task) => !isCapacityExcludedTask(row, task))
+ .forEach((task) => {
if (!task.assigneeUserId) return
const assignment = personByDingtalkUserId.get(task.assigneeUserId)
if (!assignment) return
@@ -258,6 +362,17 @@ 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,
+ },
},
}
}
diff --git a/lib/employee-roster-history.ts b/lib/employee-roster-history.ts
new file mode 100644
index 0000000..4c15ce5
--- /dev/null
+++ b/lib/employee-roster-history.ts
@@ -0,0 +1,205 @@
+import { randomUUID } from 'node:crypto'
+import { prisma } from '@/lib/prisma'
+import { isBrandCenterOrganizationPath } from '@/lib/brand-center-access-policy'
+import { TEAM_CAPACITY_DEPARTMENT_ORDER } from '@/lib/ding-workflow-team-capacity'
+
+export type EmployeeRosterHistoryRow = {
+ capturedAt: string
+ profileCount: number
+ employeeProfileId: string
+ dingtalkUserId: string | null
+ name: string
+ departmentName: string | null
+ departmentPath: string | null
+ employmentState: string | null
+ isBrandCenter: boolean
+}
+
+type EmployeeProfileForSnapshot = {
+ id: string
+ dingtalkUserId: string | null
+ displayName: string
+ flowerName: string | null
+ realName: string | null
+ departmentName: string | null
+ departmentPath: string | null
+ employmentState: string | null
+ departmentMemberships: Array<{
+ departmentName: string | null
+ departmentPath: string | null
+ isPrimary: boolean
+ }>
+}
+
+const TEAM_CAPACITY_DEPARTMENT_SET = new Set(TEAM_CAPACITY_DEPARTMENT_ORDER)
+
+function clean(value: unknown) {
+ const normalized = String(value ?? '').trim()
+ return normalized || null
+}
+
+function departmentParts(value: string | null) {
+ return String(value || '')
+ .split('/')
+ .map((part) => part.trim())
+ .filter(Boolean)
+}
+
+function resolveSnapshotDepartment(profile: EmployeeProfileForSnapshot) {
+ const matchingPaths = [
+ { departmentPath: profile.departmentPath, departmentName: profile.departmentName, isPrimary: true },
+ ...profile.departmentMemberships,
+ ].filter((entry) => isBrandCenterOrganizationPath(entry.departmentPath))
+ const selected = matchingPaths.sort((a, b) => {
+ const depth = (value: string | null) => departmentParts(value).length
+ return depth(b.departmentPath) - depth(a.departmentPath)
+ || Number(b.isPrimary) - Number(a.isPrimary)
+ })[0]
+ const parts = departmentParts(selected?.departmentPath || profile.departmentPath)
+ const brandCenterIndex = parts.indexOf('品牌中心')
+ const departmentName = brandCenterIndex >= 0
+ ? parts[brandCenterIndex + 1] || clean(selected?.departmentName) || '品牌中心'
+ : clean(selected?.departmentName) || clean(profile.departmentName) || '品牌中心'
+ return {
+ departmentName,
+ departmentPath: selected?.departmentPath || profile.departmentPath || null,
+ isBrandCenter: Boolean(selected),
+ }
+}
+
+function snapshotTimestamp(value: Date) {
+ // Minute precision makes retries idempotent without losing the fact that this
+ // is a complete point-in-time roster capture.
+ return new Date(Math.floor(value.getTime() / 60_000) * 60_000)
+}
+
+function isMissingRosterHistoryTable(error: unknown) {
+ const message = String(error instanceof Error ? error.message : error)
+ return /does not exist|不存在|relation .*performance_survey_employee_roster_snapshots/i.test(message)
+}
+
+/**
+ * 保存一次完整人员快照。快照包含范围外人员,避免“离开品牌中心”与“本轮未同步”
+ * 被混为一谈;看板读取时再按品牌中心与五个目标部门过滤。
+ */
+export async function captureEmployeeRosterSnapshot(capturedAt = new Date()) {
+ const timestamp = snapshotTimestamp(capturedAt)
+ const profiles = await prisma.performanceSurveyEmployeeProfile.findMany({
+ select: {
+ id: true,
+ dingtalkUserId: true,
+ displayName: true,
+ flowerName: true,
+ realName: true,
+ departmentName: true,
+ departmentPath: true,
+ employmentState: true,
+ departmentMemberships: {
+ select: {
+ departmentName: true,
+ departmentPath: true,
+ isPrimary: true,
+ },
+ },
+ },
+ })
+
+ // An empty result is not a valid point-in-time roster. Avoid creating a
+ // misleading empty snapshot during the first seconds of a fresh container.
+ if (profiles.length === 0) return { capturedAt: timestamp.toISOString(), rowCount: 0, skipped: true }
+
+ const rows = profiles.map((profile) => {
+ const department = resolveSnapshotDepartment(profile)
+ return {
+ id: randomUUID(),
+ capturedAt: timestamp,
+ profileCount: profiles.length,
+ employeeProfileId: profile.id,
+ dingtalkUserId: profile.dingtalkUserId,
+ name: clean(profile.flowerName) || clean(profile.displayName) || clean(profile.realName) || '未命名人员',
+ departmentName: department.departmentName,
+ departmentPath: department.departmentPath,
+ employmentState: profile.employmentState,
+ isBrandCenter: department.isBrandCenter,
+ }
+ })
+
+ try {
+ await prisma.performanceSurveyEmployeeRosterSnapshot.createMany({
+ data: rows,
+ skipDuplicates: true,
+ })
+ } catch (error) {
+ if (isMissingRosterHistoryTable(error)) {
+ console.warn('[employee-roster-history] 历史快照表尚未迁移,跳过本轮保存')
+ return { capturedAt: timestamp.toISOString(), rowCount: 0, skipped: true }
+ }
+ throw error
+ }
+
+ return { capturedAt: timestamp.toISOString(), rowCount: rows.length, skipped: false }
+}
+
+export async function readEmployeeRosterHistory(start: Date, end: Date): Promise {
+ try {
+ const rows = await prisma.performanceSurveyEmployeeRosterSnapshot.findMany({
+ where: { capturedAt: { gte: start, lt: end } },
+ select: {
+ capturedAt: true,
+ profileCount: true,
+ employeeProfileId: true,
+ dingtalkUserId: true,
+ name: true,
+ departmentName: true,
+ departmentPath: true,
+ employmentState: true,
+ isBrandCenter: true,
+ },
+ orderBy: [{ capturedAt: 'asc' }, { employeeProfileId: 'asc' }],
+ })
+ return rows.map((row) => ({ ...row, capturedAt: row.capturedAt.toISOString() }))
+ } catch (error) {
+ if (isMissingRosterHistoryTable(error)) {
+ console.warn('[employee-roster-history] 历史快照表尚未迁移,历史人员数暂不可用')
+ return []
+ }
+ throw error
+ }
+}
+
+export function isTeamCapacityHistoryDepartment(value: string | null | undefined) {
+ return TEAM_CAPACITY_DEPARTMENT_SET.has(String(value || '').trim())
+}
+
+let hourlySnapshotStarted = false
+
+/** 应用启动后每小时保存一次完整花名册,供团队产能按历史周还原人数。 */
+export function ensureHourlyEmployeeRosterSnapshotStarted() {
+ if (hourlySnapshotStarted) return
+ hourlySnapshotStarted = true
+
+ const configuredIntervalHours = Number(process.env.EMPLOYEE_ROSTER_HISTORY_INTERVAL_HOURS || 1)
+ const intervalHours = Number.isFinite(configuredIntervalHours) && configuredIntervalHours > 0
+ ? Math.max(1, configuredIntervalHours)
+ : 1
+ const configuredInitialDelaySeconds = Number(process.env.EMPLOYEE_ROSTER_HISTORY_INITIAL_DELAY_SECONDS || 15)
+ const initialDelaySeconds = Number.isFinite(configuredInitialDelaySeconds) && configuredInitialDelaySeconds > 0
+ ? Math.max(5, configuredInitialDelaySeconds)
+ : 15
+ const initialDelayMs = initialDelaySeconds * 1000
+ const run = async () => {
+ try {
+ const result = await captureEmployeeRosterSnapshot()
+ if (!result.skipped) {
+ console.info(`[employee-roster-history] 已保存 ${result.rowCount} 人快照:${result.capturedAt}`)
+ }
+ } catch (error) {
+ console.warn('[employee-roster-history] 花名册历史快照失败:', error)
+ }
+ }
+ const bootstrap = setTimeout(() => void run(), initialDelayMs)
+ bootstrap.unref?.()
+ const timer = setInterval(() => void run(), intervalHours * 60 * 60 * 1000)
+ timer.unref?.()
+ console.info(`[employee-roster-history] 已调度每小时快照,周期 ${intervalHours} 小时`)
+}
diff --git a/prisma/migrations/20260902_add_employee_roster_history/migration.sql b/prisma/migrations/20260902_add_employee_roster_history/migration.sql
new file mode 100644
index 0000000..773d770
--- /dev/null
+++ b/prisma/migrations/20260902_add_employee_roster_history/migration.sql
@@ -0,0 +1,29 @@
+-- 保存员工在职状态与部门归属的完整时间快照,供团队产能还原历史人数。
+-- 不引用当前人员主档外键:人员合并、离职或删除后,历史事实仍需可读。
+
+CREATE TABLE IF NOT EXISTS performance_survey_employee_roster_snapshots (
+ id TEXT NOT NULL,
+ captured_at TIMESTAMP(3) NOT NULL,
+ profile_count INTEGER NOT NULL,
+ employee_profile_id TEXT NOT NULL,
+ dingtalk_user_id TEXT,
+ name TEXT NOT NULL,
+ department_name TEXT,
+ department_path TEXT,
+ employment_state TEXT,
+ is_brand_center BOOLEAN NOT NULL DEFAULT false,
+ created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT performance_survey_employee_roster_snapshots_pkey PRIMARY KEY (id)
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS ps_employee_roster_snapshot_capture_profile_key
+ ON performance_survey_employee_roster_snapshots(captured_at, employee_profile_id);
+
+CREATE INDEX IF NOT EXISTS ps_employee_roster_snapshot_captured_at_idx
+ ON performance_survey_employee_roster_snapshots(captured_at);
+
+CREATE INDEX IF NOT EXISTS ps_employee_roster_snapshot_department_time_idx
+ ON performance_survey_employee_roster_snapshots(department_name, captured_at);
+
+CREATE INDEX IF NOT EXISTS ps_employee_roster_snapshot_dingtalk_time_idx
+ ON performance_survey_employee_roster_snapshots(dingtalk_user_id, captured_at);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 5ba0aa4..675243f 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -762,6 +762,31 @@ model PerformanceSurveyEmployeeDepartmentMembership {
@@map("performance_survey_employee_department_memberships")
}
+/// 员工组织与在职状态的时间快照。
+///
+/// 人员主档会被每日同步更新,不能据此还原历史周的编制。该表按小时保存
+/// 完整人员状态,团队产能按周末前最近一份快照计算历史人数;快照不依赖
+/// 当前主档是否仍存在,保留离职与调岗前的事实记录。
+model PerformanceSurveyEmployeeRosterSnapshot {
+ id String @id @default(uuid())
+ capturedAt DateTime @map("captured_at")
+ profileCount Int @map("profile_count")
+ employeeProfileId String @map("employee_profile_id")
+ dingtalkUserId String? @map("dingtalk_user_id")
+ name String
+ departmentName String? @map("department_name")
+ departmentPath String? @map("department_path")
+ employmentState String? @map("employment_state")
+ isBrandCenter Boolean @default(false) @map("is_brand_center")
+ createdAt DateTime @default(now()) @map("created_at")
+
+ @@unique([capturedAt, employeeProfileId], map: "ps_employee_roster_snapshot_capture_profile_key")
+ @@index([capturedAt], map: "ps_employee_roster_snapshot_captured_at_idx")
+ @@index([departmentName, capturedAt], map: "ps_employee_roster_snapshot_department_time_idx")
+ @@index([dingtalkUserId, capturedAt], map: "ps_employee_roster_snapshot_dingtalk_time_idx")
+ @@map("performance_survey_employee_roster_snapshots")
+}
+
model PerformanceSurveyDepartmentProfile {
id String @id @default(uuid())
name String @unique
diff --git a/scripts/git-incremental-deploy.ps1 b/scripts/git-incremental-deploy.ps1
index 41e391d..8b0edea 100644
--- a/scripts/git-incremental-deploy.ps1
+++ b/scripts/git-incremental-deploy.ps1
@@ -1510,6 +1510,7 @@ const expectedMigrationNames = new Set([
'20260828_add_task_detail_change_approvals',
'20260829_add_recoverable_api_key_secret',
'20260831_add_private_task_detail',
+ '20260902_add_employee_roster_history',
]);
if (migrations.length !== expectedMigrationNames.size
|| migrations.some((item) => !expectedMigrationNames.has(item && item.name)
diff --git a/scripts/safe-production-migrate.mjs b/scripts/safe-production-migrate.mjs
index 03d7931..df6ceda 100644
--- a/scripts/safe-production-migrate.mjs
+++ b/scripts/safe-production-migrate.mjs
@@ -26,6 +26,7 @@ const TARGET_MIGRATIONS = Object.freeze([
'20260828_add_task_detail_change_approvals',
'20260829_add_recoverable_api_key_secret',
'20260831_add_private_task_detail',
+ '20260902_add_employee_roster_history',
])
const VERIFIED_BACKUP_GENERATOR = 'scripts/create-verified-production-backup.mjs'
const VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION = 'NO_WRITES_DURING_SNAPSHOT'
@@ -305,6 +306,7 @@ async function queryEvidence(prisma, localMigrations) {
OR (table_name = 'system_settings' AND column_name IN ('headerLogoUrl', 'loginLogoUrl', 'logoUrl'))
OR (table_name = 'organization_units' AND column_name IN ('id', 'source_dept_id', 'parent_id', 'path', 'level', 'is_active'))
OR (table_name = 'performance_survey_employee_profiles' AND column_name IN ('employment_state', 'resigned_at', 'handover_dingtalk_user_id'))
+ OR (table_name = 'performance_survey_employee_roster_snapshots' AND column_name IN ('id', 'captured_at', 'profile_count', 'employee_profile_id', 'dingtalk_user_id', 'name', 'department_name', 'department_path', 'employment_state', 'is_brand_center', 'created_at'))
OR (table_name = 'user_employee_bindings' AND column_name IN ('id', 'user_id', 'employee_profile_id', 'binding_source', 'status'))
OR (table_name = 'ding_workflow_snapshots' AND column_name IN ('process_instance_id', 'status', 'is_ongoing', 'source_updated_at_raw', 'scheme_field_count', 'task_count', 'active_task_count', 'synced_at', 'brand', 'demand_type', 'requester_name', 'requester_department', 'project_type', 'active_tasks', 'form_fields'))
OR (table_name = 'ding_workflow_sync_states' AND column_name IN ('id', 'status', 'source_cursor_raw', 'last_completed_at', 'lease_owner', 'lease_expires_at', 'current_ongoing_count'))
@@ -454,6 +456,17 @@ export function assertTargetMigrationEvidence(localMigrations, evidence) {
requireColumn('performance_survey_employee_profiles', 'employment_state', 'text', 'YES')
requireColumn('performance_survey_employee_profiles', 'resigned_at', 'timestamp without time zone', 'YES')
requireColumn('performance_survey_employee_profiles', 'handover_dingtalk_user_id', 'text', 'YES')
+ requireColumn('performance_survey_employee_roster_snapshots', 'id', 'text', 'NO')
+ requireColumn('performance_survey_employee_roster_snapshots', 'captured_at', 'timestamp without time zone', 'NO')
+ requireColumn('performance_survey_employee_roster_snapshots', 'profile_count', 'integer', 'NO')
+ requireColumn('performance_survey_employee_roster_snapshots', 'employee_profile_id', 'text', 'NO')
+ requireColumn('performance_survey_employee_roster_snapshots', 'dingtalk_user_id', 'text', 'YES')
+ requireColumn('performance_survey_employee_roster_snapshots', 'name', 'text', 'NO')
+ requireColumn('performance_survey_employee_roster_snapshots', 'department_name', 'text', 'YES')
+ requireColumn('performance_survey_employee_roster_snapshots', 'department_path', 'text', 'YES')
+ requireColumn('performance_survey_employee_roster_snapshots', 'employment_state', 'text', 'YES')
+ requireColumn('performance_survey_employee_roster_snapshots', 'is_brand_center', 'boolean', 'NO')
+ requireColumn('performance_survey_employee_roster_snapshots', 'created_at', 'timestamp without time zone', 'NO')
requireColumn('user_employee_bindings', 'id', 'text', 'NO')
requireColumn('user_employee_bindings', 'user_id', 'text', 'NO')
requireColumn('user_employee_bindings', 'employee_profile_id', 'text', 'NO')
diff --git a/scripts/safe-production-migrate.test.mjs b/scripts/safe-production-migrate.test.mjs
index b124ff0..da4b92d 100644
--- a/scripts/safe-production-migrate.test.mjs
+++ b/scripts/safe-production-migrate.test.mjs
@@ -342,6 +342,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index
['20260828_add_task_detail_change_approvals', { checksum: 'o' }],
['20260829_add_recoverable_api_key_secret', { checksum: 'p' }],
['20260831_add_private_task_detail', { checksum: 'q' }],
+ ['20260902_add_employee_roster_history', { checksum: 'r' }],
])
const evidence = {
migrationRows: [
@@ -362,6 +363,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index
{ migration_name: '20260828_add_task_detail_change_approvals', checksum: 'o', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260829_add_recoverable_api_key_secret', checksum: 'p', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260831_add_private_task_detail', checksum: 'q', finished_at: 'now', rolled_back_at: null },
+ { migration_name: '20260902_add_employee_roster_history', checksum: 'r', finished_at: 'now', rolled_back_at: null },
],
columns: [
{ table_name: 'materials', column_name: 'transcode_lease_token', data_type: 'text', is_nullable: 'YES' },
@@ -393,6 +395,17 @@ test('target evidence rejects nullable logo columns and structurally wrong index
{ table_name: 'performance_survey_employee_profiles', column_name: 'employment_state', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'performance_survey_employee_profiles', column_name: 'resigned_at', data_type: 'timestamp without time zone', is_nullable: 'YES' },
{ table_name: 'performance_survey_employee_profiles', column_name: 'handover_dingtalk_user_id', data_type: 'text', is_nullable: 'YES' },
+ { table_name: 'performance_survey_employee_roster_snapshots', column_name: 'id', data_type: 'text', is_nullable: 'NO' },
+ { table_name: 'performance_survey_employee_roster_snapshots', column_name: 'captured_at', data_type: 'timestamp without time zone', is_nullable: 'NO' },
+ { table_name: 'performance_survey_employee_roster_snapshots', column_name: 'profile_count', data_type: 'integer', is_nullable: 'NO' },
+ { table_name: 'performance_survey_employee_roster_snapshots', column_name: 'employee_profile_id', data_type: 'text', is_nullable: 'NO' },
+ { table_name: 'performance_survey_employee_roster_snapshots', column_name: 'dingtalk_user_id', data_type: 'text', is_nullable: 'YES' },
+ { table_name: 'performance_survey_employee_roster_snapshots', column_name: 'name', data_type: 'text', is_nullable: 'NO' },
+ { table_name: 'performance_survey_employee_roster_snapshots', column_name: 'department_name', data_type: 'text', is_nullable: 'YES' },
+ { table_name: 'performance_survey_employee_roster_snapshots', column_name: 'department_path', data_type: 'text', is_nullable: 'YES' },
+ { table_name: 'performance_survey_employee_roster_snapshots', column_name: 'employment_state', data_type: 'text', is_nullable: 'YES' },
+ { table_name: 'performance_survey_employee_roster_snapshots', column_name: 'is_brand_center', data_type: 'boolean', is_nullable: 'NO' },
+ { table_name: 'performance_survey_employee_roster_snapshots', column_name: 'created_at', data_type: 'timestamp without time zone', is_nullable: 'NO' },
{ table_name: 'user_employee_bindings', column_name: 'id', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'user_employee_bindings', column_name: 'user_id', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'user_employee_bindings', column_name: 'employee_profile_id', data_type: 'text', is_nullable: 'NO' },
@@ -678,6 +691,7 @@ test('container supply chain and migrator image are immutable while SOP keeps co
'20260828_add_pack_item_can_edit_assets',
'20260828_add_task_detail_change_approvals',
'20260831_add_private_task_detail',
+ '20260902_add_employee_roster_history',
]) {
assert.match(dockerfile, new RegExp(migrationName))
assert.match(migratorSource, new RegExp(`'${migrationName}'`))
@@ -689,6 +703,8 @@ test('container supply chain and migrator image are immutable while SOP keeps co
assert.ok(sop.includes('`20260829_add_recoverable_api_key_secret`'))
assert.match(deploymentSource, /'20260831_add_private_task_detail'/)
assert.ok(sop.includes('`20260831_add_private_task_detail`'))
+ assert.match(deploymentSource, /'20260902_add_employee_roster_history'/)
+ assert.ok(sop.includes('`20260902_add_employee_roster_history`'))
assert.match(sop, /materials_private_optimization_check/)
assert.match(sop, /task_detail_private/)
assert.match(sop, /can_access_private/)
@@ -756,8 +772,8 @@ test('container supply chain and migrator image are immutable while SOP keeps co
assert.match(sop, /-v "\$HOST_RESULT_DIR:\/audit"/)
assert.match(sop, /test -f "\$PREFLIGHT_AUDIT_FILE" && test ! -L "\$PREFLIGHT_AUDIT_FILE"/)
assert.doesNotMatch(sop, /-v \/root\/gongxiangp2-migration-audit:\/audit/)
- assert.match(sop, /十七份目标 migration/)
- assert.match(sop, /十七条目标 migration/)
+ assert.match(sop, /十八份目标 migration/)
+ assert.match(sop, /十八条目标 migration/)
const applyMigration = migratorSource.indexOf("['migrate', 'deploy'")
const postEvidence = migratorSource.indexOf('after = await queryEvidence(verifyClient, localMigrations)', applyMigration)