gongxiangp2/lib/employee-roster-history.ts

206 lines
7.4 KiB
TypeScript

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} 小时`)
}