feat: refine team capacity workflow filtering and roster history

This commit is contained in:
江林铖 2026-09-02 16:06:13 +08:00
parent e3cb07f15d
commit 78e97e0756
17 changed files with 571 additions and 27 deletions

View File

@ -135,7 +135,7 @@ components:
- 进行中项目明细采用可键盘聚焦的横向滚动数据表:桌面固定“审批单标题”首列并保留 14 项业务字段,移动端转换为同字段的语义化定义列表;节点时间在数值下方标注对应流程节点名称。
- 按时交付明细使用紧凑的三项流程筛选条;当前项用浅蓝底和蓝字表达,未选项悬停为灰底,切换时同步更新公式、数量和横向明细表。
- 团队负荷明细按部门展开人员和流程,商品企划部从人员、流程归属和平均负荷统计中排除;页面明确标注排除部门与指定人员。
- 团队产能页面使用固定五项部门二级导航(品牌创意A部、品牌创意B部、综合支持部、品牌设计部、空间视觉部),按该顺序排列;右侧以近 8 个自然周的收到/完结双柱和可展开周明细承载部门产出,人员数以当前在职花名册为基数,流程按历史任务负责人归属。
- 团队产能页面使用固定五项部门二级导航(品牌创意A部、品牌创意B部、综合支持部、品牌设计部、空间视觉部),按该顺序排列;右侧以近 8 个自然周的收到/完结双柱和可展开周明细承载部门产出,周卡片显示每周结束前最近一次完整员工组织快照中的历史在职人数;当前在职人数单独标注,快照覆盖前的周次显示暂无历史快照,流程按节点负责人归属并排除美术设计的策划主管分配/审核、内容视频的内容主管分配/审核节点。
## Colors

View File

@ -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

View File

@ -35,7 +35,7 @@ web
- “按时交付”只统计对应主管/规则审核节点在本月完成、审批状态为已完结且审批结论未拒绝的美术设计与内容视频流程;待计算流程不进入分母。详情页可在全部流程、美术设计流程和内容视频流程之间筛选,公式、数量与明细必须共用当前筛选口径。
- “首轮通过”读取作品过稿数据,分子为 V1 版本审核通过的作品数,分母为当前已通过的作品数;详情页列出部门范围内所有含 V1 的作品,搜索只过滤明细,不改变顶部完整口径。
- “团队负荷”统计品牌中心在职人员当前进行中的流程负荷,排除指定管理人员和商品企划部;该部门及其人员不进入流程归属、部门明细、人员分母或平均负荷。
- “团队产能”仅展示品牌创意A部、品牌创意B部、综合支持部、品牌设计部、空间视觉部五个二级部门,并按该顺序展示近 8 个上海自然周的在职人数、收到流程数和完结流程数;流程按历史任务负责人映射到部门,点击周次查看进行中与已完结明细。
- “团队产能”仅展示品牌创意A部、品牌创意B部、综合支持部、品牌设计部、空间视觉部五个二级部门,并按该顺序展示近 8 个上海自然周的历史在职人数、收到流程数和完结流程数;历史人数取每周结束前最近一次完整员工组织快照,流程按历史任务负责人映射到部门,但美术设计流程不计“品牌策划主管分配任务/品牌策划主管审核”,内容视频流程不计“内容主管分配任务/内容主管审核”等管理节点,点击周次查看进行中与已完结明细。快照覆盖前的周次明确显示暂无历史快照,不得用当前花名册回填。
- 除素材资产库、自动化工作流、人员架构、数据口径和已接入的中心指标外,其余板块现阶段只展示可理解、可扩展的模块框架,不虚构业务数据。
- 沿用项目现有 Next.js、NextAuth、Tailwind CSS 和权限体系。

View File

@ -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 () => {

View File

@ -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() {
<section className="min-w-0">
<header className="flex flex-col gap-4 border-b border-slate-200 px-4 py-5 sm:px-6 lg:flex-row lg:items-end lg:justify-between">
<div><p className="text-xs font-bold text-[#1677ff]">当前部门</p><h2 className="mt-1 text-xl font-bold text-slate-950">{selectedDepartment.name}</h2><p className="mt-1 text-sm text-slate-500">按部门架构归属统计 · {selectedDepartment.people.length} 名在职人员</p></div>
<dl className="flex flex-wrap gap-x-6 gap-y-2 text-sm tabular-nums"><div><dt className="inline text-slate-500">在职人数 </dt><dd className="inline font-bold text-slate-950">{selectedDepartment.personnelCount}</dd></div><div><dt className="inline text-slate-500">近8周收到 </dt><dd className="inline font-bold text-slate-950">{weeks.reduce((sum, week) => sum + week.receivedCount, 0)}</dd></div><div><dt className="inline text-slate-500">近8周完结 </dt><dd className="inline font-bold text-[#176fc1]">{weeks.reduce((sum, week) => sum + week.completedCount, 0)}</dd></div></dl>
<div><p className="text-xs font-bold text-[#1677ff]">当前部门</p><h2 className="mt-1 text-xl font-bold text-slate-950">{selectedDepartment.name}</h2><p className="mt-1 text-sm text-slate-500">按部门架构归属统计 · 当前 {selectedDepartment.people.length} 名在职人员</p></div>
<dl className="flex flex-wrap gap-x-6 gap-y-2 text-sm tabular-nums"><div><dt className="inline text-slate-500">当前在职人数 </dt><dd className="inline font-bold text-slate-950">{selectedDepartment.personnelCount}</dd></div><div><dt className="inline text-slate-500">近8周收到 </dt><dd className="inline font-bold text-slate-950">{weeks.reduce((sum, week) => sum + week.receivedCount, 0)}</dd></div><div><dt className="inline text-slate-500">近8周完结 </dt><dd className="inline font-bold text-[#176fc1]">{weeks.reduce((sum, week) => sum + week.completedCount, 0)}</dd></div></dl>
</header>
<div className="border-b border-slate-200 bg-slate-50/70 px-4 py-5 sm:px-6"><div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"><div className="inline-flex items-center gap-2 text-xs font-semibold text-slate-500"><CalendarRange className="h-4 w-4" aria-hidden="true" />近 8 个自然周</div><div className="flex items-center gap-4 text-xs font-semibold text-slate-500" aria-label="图例"><span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-[#89b9f4]" aria-hidden="true" />收到</span><span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-[#22a06b]" aria-hidden="true" />完结</span></div></div><div className="mt-4 overflow-x-auto" tabIndex={0} aria-label="团队产能近8周数量横向滚动区"><div className="grid min-w-[760px] grid-cols-8 gap-2">{weeks.map((week) => <WeekButton key={week.key} week={week} maxValue={weekMaxValue} selected={week.key === selectedWeek?.key} onSelect={() => setSelectedWeekKey(week.key)} />)}</div></div></div>
<div className="border-b border-slate-200 bg-slate-50/70 px-4 py-5 sm:px-6">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"><div className="inline-flex items-center gap-2 text-xs font-semibold text-slate-500"><CalendarRange className="h-4 w-4" aria-hidden="true" />近 8 个自然周</div><div className="flex items-center gap-4 text-xs font-semibold text-slate-500" aria-label="图例"><span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-[#89b9f4]" aria-hidden="true" />收到</span><span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-[#22a06b]" aria-hidden="true" />完结</span></div></div>
<div role="note" className="mt-4 flex items-start gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2.5 text-xs leading-5 text-slate-600"><Database className="mt-0.5 h-4 w-4 shrink-0 text-[#1677ff]" aria-hidden="true" /><p><span className="font-bold text-slate-800">历史人员数:</span>{rosterHistoryLabel(state.data)} 历史快照从首次成功采集后开始累计。</p></div>
<div className="mt-4 overflow-x-auto" tabIndex={0} aria-label="团队产能近8周数量横向滚动区"><div className="grid min-w-[760px] grid-cols-8 gap-2">{weeks.map((week) => <WeekButton key={week.key} week={week} maxValue={weekMaxValue} selected={week.key === selectedWeek?.key} onSelect={() => setSelectedWeekKey(week.key)} />)}</div></div>
</div>
{selectedWeek && <WeekDetail week={selectedWeek} department={selectedDepartment} />}
</section>
</div>
)}
<div className="mt-8 flex flex-col gap-3 border-t border-slate-200 pt-5 sm:flex-row sm:items-center sm:justify-between"><p className="inline-flex items-center gap-2 text-xs text-slate-500"><CheckCircle2 className="h-4 w-4 text-emerald-600" aria-hidden="true" />数据来自钉钉流程本地快照,每小时增量同步</p><Link href="/" className="inline-flex min-h-10 items-center gap-2 self-start rounded-lg px-3 text-sm font-bold text-[#176fc1] hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><ArrowLeft className="h-4 w-4" aria-hidden="true" />返回部门看板</Link></div>
<div className="mt-8 flex flex-col gap-3 border-t border-slate-200 pt-5 sm:flex-row sm:items-center sm:justify-between"><p className="inline-flex items-center gap-2 text-xs text-slate-500"><CheckCircle2 className="h-4 w-4 text-emerald-600" aria-hidden="true" />流程与员工组织快照每小时增量同步</p><Link href="/" className="inline-flex min-h-10 items-center gap-2 self-start rounded-lg px-3 text-sm font-bold text-[#176fc1] hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><ArrowLeft className="h-4 w-4" aria-hidden="true" />返回部门看板</Link></div>
</main>
</DepartmentShell>
)
@ -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 <button type="button" aria-expanded={selected} aria-controls="team-capacity-week-detail" aria-label={`${week.label},${week.personnelCount}人,收到${week.receivedCount}个,完结${week.completedCount}个`} onClick={onSelect} className={`group min-w-0 rounded-lg border px-2 pb-3 pt-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] ${selected ? 'border-[#1677ff] bg-white' : 'border-transparent hover:border-slate-300 hover:bg-white'}`}><div className="flex h-24 items-end justify-center gap-2" aria-hidden="true"><span className="w-4 rounded-t-sm bg-[#89b9f4]" style={{ height: `${receivedHeight}%` }} /><span className="w-4 rounded-t-sm bg-[#22a06b]" style={{ height: `${completedHeight}%` }} /></div><p className="mt-2 truncate text-center text-xs font-semibold text-slate-600">{week.label}</p><p className="mt-1 text-center text-xs tabular-nums text-slate-500">人员 {week.personnelCount} · 参与 {week.involvedPersonnelCount}</p><p className="mt-1 text-center text-xs tabular-nums text-slate-500"><span className="font-bold text-[#176fc1]">{week.receivedCount}</span><span className="px-1">/</span><span className="font-bold text-[#168252]">{week.completedCount}</span></p></button>
return <button type="button" aria-expanded={selected} aria-controls="team-capacity-week-detail" aria-label={`${week.label},${personnelCountLabel(week.personnelCount)},收到${week.receivedCount}个,完结${week.completedCount}个`} onClick={onSelect} className={`group min-w-0 rounded-lg border px-2 pb-3 pt-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] ${selected ? 'border-[#1677ff] bg-white' : 'border-transparent hover:border-slate-300 hover:bg-white'}`}><div className="flex h-24 items-end justify-center gap-2" aria-hidden="true"><span className="w-4 rounded-t-sm bg-[#89b9f4]" style={{ height: `${receivedHeight}%` }} /><span className="w-4 rounded-t-sm bg-[#22a06b]" style={{ height: `${completedHeight}%` }} /></div><p className="mt-2 truncate text-center text-xs font-semibold text-slate-600">{week.label}</p><p className="mt-1 text-center text-xs tabular-nums text-slate-500">人员 {personnelCountLabel(week.personnelCount)} · 参与 {week.involvedPersonnelCount}</p><p className="mt-1 text-center text-xs tabular-nums text-slate-500"><span className="font-bold text-[#176fc1]">{week.receivedCount}</span><span className="px-1">/</span><span className="font-bold text-[#168252]">{week.completedCount}</span></p></button>
}

View File

@ -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` 仍通过;

View File

@ -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 {

View File

@ -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('排除商品企划部及其流程归属,不纳入人员和负荷平均值', () => {

View File

@ -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<TeamCapacityInsights> {
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<TeamCapacityInsights> {

View File

@ -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<typeof task>[]
}> = {}) {
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')
})
})

View File

@ -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<string>(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<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 }>()
@ -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<string, OngoingDingWorkflowActiveTask[]>()
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,
},
},
}
}

View File

@ -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<string>(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<EmployeeRosterHistoryRow[]> {
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} 小时`)
}

View File

@ -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);

View File

@ -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

View File

@ -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)

View File

@ -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')

View File

@ -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)