206 lines
7.4 KiB
TypeScript
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} 小时`)
|
|
}
|