gongxiangp2/lib/employee-directory.ts

347 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 通用人员目录服务
*
* 平台级的人员主数据入口。素材库、部门看板等功能统一通过本模块访问人员信息,
* 不再直接读取带 `PerformanceSurvey` 前缀的模型——那套表虽然是当前的物理载体,
* 但语义上已经是全平台共享的权威人员档案(数据源为 HR 报表库,非钉钉通讯录)。
*
* 身份关联原则(与《素材库店铺与多层权限改造实施计划》3.1 节一致):
* - 钉钉唯一值是唯一的自动匹配依据
* - 姓名、花名、邮箱只用于人工排错,不能自动建立权威绑定
* - 上游原始 JSON 只作审计快照,不参与运行时身份判断
*/
import { prisma } from '@/lib/prisma'
import {
collectEmployeeOrganizationSourceDeptIds,
resolveEmployeeOrganizationPaths,
} from '@/lib/organization-material-permissions'
/** 绑定来源 */
export const BINDING_SOURCE = {
/** 钉钉登录时自动建立 */
DINGTALK_LOGIN: 'DINGTALK_LOGIN',
/** 管理员人工核验后建立 */
MANUAL_VERIFIED: 'MANUAL_VERIFIED',
/** 存量数据迁移建立 */
MIGRATION: 'MIGRATION',
} as const
export const BINDING_STATUS = {
ACTIVE: 'ACTIVE',
/** 历史绑定,保留可追溯性 */
RETIRED: 'RETIRED',
} as const
/** 统一的人员视图,屏蔽底层表结构 */
export interface EmployeeDirectoryEntry {
employeeProfileId: string
/** 关联的素材库账号;未开通账号的人员为 null */
userId: string | null
displayName: string
realName: string | null
flowerName: string | null
jobNumber: string | null
dingtalkUserId: string | null
departmentName: string | null
/** 部门全路径,如「品牌中心/品牌创意A部」 */
departmentPath: string | null
positionTitle: string | null
positionLevel: string | null
/** 职级类型,P(专业)/ M(管理) */
levelType: string | null
isLeadership: boolean
/** 直属上级 */
managerName: string | null
managerDingtalkUserId: string | null
managerJobNumber?: string | null
/** 规范化在职状态:ACTIVE / RESIGNED / UNKNOWN */
employmentState: string | null
employmentStatus: string | null
}
const PROFILE_SELECT = {
id: true,
displayName: true,
realName: true,
flowerName: true,
jobNumber: true,
dingtalkUserId: true,
departmentName: true,
departmentPath: true,
positionTitle: true,
positionLevel: true,
levelType: true,
isLeadership: true,
managerName: true,
managerDingtalkUserId: true,
managerJobNumber: true,
employmentState: true,
employmentStatus: true,
} as const
type RawProfile = {
[K in keyof typeof PROFILE_SELECT]: any
}
function toEntry(profile: RawProfile, userId: string | null): EmployeeDirectoryEntry {
return {
employeeProfileId: profile.id,
userId,
displayName: profile.displayName,
realName: profile.realName,
flowerName: profile.flowerName,
jobNumber: profile.jobNumber,
dingtalkUserId: profile.dingtalkUserId,
departmentName: profile.departmentName,
departmentPath: profile.departmentPath,
positionTitle: profile.positionTitle,
positionLevel: profile.positionLevel,
levelType: profile.levelType,
isLeadership: Boolean(profile.isLeadership),
managerName: profile.managerName,
managerDingtalkUserId: profile.managerDingtalkUserId,
managerJobNumber: profile.managerJobNumber,
employmentState: profile.employmentState,
employmentStatus: profile.employmentStatus,
}
}
/**
* 按素材库账号查询其权威人员档案。
*
* 优先读取正式绑定关系;迁移期内若尚无绑定记录,回退到按钉钉唯一值查询
* (仅钉钉值,不使用姓名或邮箱)。绑定回填完成后可关闭回退。
*/
export async function getEmployeeByUserId(
userId: string,
options: { allowDingtalkFallback?: boolean } = {}
): Promise<EmployeeDirectoryEntry | null> {
const { allowDingtalkFallback = true } = options
const binding = await prisma.userEmployeeBinding.findFirst({
where: { userId, status: BINDING_STATUS.ACTIVE },
select: { employeeProfile: { select: PROFILE_SELECT } },
})
if (binding?.employeeProfile) {
return toEntry(binding.employeeProfile, userId)
}
if (!allowDingtalkFallback) return null
const user = await prisma.user.findUnique({
where: { id: userId },
select: { dingtalkUserId: true },
})
if (!user?.dingtalkUserId) return null
const profile = await prisma.performanceSurveyEmployeeProfile.findUnique({
where: { dingtalkUserId: user.dingtalkUserId },
select: PROFILE_SELECT,
})
return profile ? toEntry(profile, userId) : null
}
export const PERMISSION_PATH_PROFILE_SELECT = {
departmentPath: true,
sourcePayload: true,
departmentMemberships: {
select: {
sourceDeptId: true,
departmentPath: true,
},
},
} as const
/** 按素材库账号查询花名册中的全部任职路径,身份匹配仍只认正式绑定或钉钉唯一值。 */
export async function getEmployeeOrganizationPathsByUserId(
userId: string,
options: { allowDingtalkFallback?: boolean } = {},
): Promise<string[]> {
const { allowDingtalkFallback = true } = options
const binding = await prisma.userEmployeeBinding.findFirst({
where: { userId, status: BINDING_STATUS.ACTIVE },
select: { employeeProfile: { select: PERMISSION_PATH_PROFILE_SELECT } },
})
let profile = binding?.employeeProfile ?? null
if (!profile && allowDingtalkFallback) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { dingtalkUserId: true },
})
if (user?.dingtalkUserId) {
profile = await prisma.performanceSurveyEmployeeProfile.findUnique({
where: { dingtalkUserId: user.dingtalkUserId },
select: PERMISSION_PATH_PROFILE_SELECT,
})
}
}
if (!profile) return []
const sourceDeptIds = collectEmployeeOrganizationSourceDeptIds(profile)
.map((id) => Number(id))
.filter((id) => Number.isInteger(id))
const units = sourceDeptIds.length > 0
? await prisma.organizationUnit.findMany({
where: { isActive: true, sourceDeptId: { in: sourceDeptIds } },
select: { sourceDeptId: true, path: true },
})
: []
const pathBySourceDeptId = new Map(
units.map((unit) => [String(unit.sourceDeptId), unit.path]),
)
return resolveEmployeeOrganizationPaths(profile, pathBySourceDeptId)
}
/** 按人员档案反查其素材库账号 ID,无账号返回 null */
export async function getUserIdByEmployeeId(employeeProfileId: string): Promise<string | null> {
const binding = await prisma.userEmployeeBinding.findFirst({
where: { employeeProfileId, status: BINDING_STATUS.ACTIVE },
select: { userId: true },
})
return binding?.userId ?? null
}
/**
* 建立账号与人员的绑定。
*
* 同一账号或同一人员已存在活跃绑定时,旧绑定会被置为历史状态而非删除,
* 保证可追溯。数据库层面的部分唯一索引是最终防线。
*/
export async function bindUserToEmployee(params: {
userId: string
employeeProfileId: string
source: (typeof BINDING_SOURCE)[keyof typeof BINDING_SOURCE]
note?: string
}): Promise<void> {
const { userId, employeeProfileId, source, note } = params
await prisma.$transaction(async (tx) => {
const existing = await tx.userEmployeeBinding.findFirst({
where: { userId, employeeProfileId, status: BINDING_STATUS.ACTIVE },
select: { id: true },
})
if (existing) return
// 退休掉该账号或该人员上的其它活跃绑定
await tx.userEmployeeBinding.updateMany({
where: {
status: BINDING_STATUS.ACTIVE,
OR: [{ userId }, { employeeProfileId }],
},
data: { status: BINDING_STATUS.RETIRED },
})
await tx.userEmployeeBinding.create({
data: {
userId,
employeeProfileId,
bindingSource: source,
status: BINDING_STATUS.ACTIVE,
verifiedAt: new Date(),
note: note ?? null,
},
})
})
}
/**
* 列出已开通素材库账号的人员。
*
* 这是平台的收窄口径(决策 D-A):只有具备素材库账号的人员参与权限与展示,
* 未开通账号的花名册人员不纳入。
*/
export async function listEmployeesWithAccount(options: {
departmentPathPrefix?: string
keyword?: string
take?: number
} = {}): Promise<EmployeeDirectoryEntry[]> {
const { departmentPathPrefix, keyword, take = 500 } = options
const bindings = await prisma.userEmployeeBinding.findMany({
where: {
status: BINDING_STATUS.ACTIVE,
employeeProfile: {
...(departmentPathPrefix
? { departmentPath: { startsWith: departmentPathPrefix } }
: {}),
...(keyword
? {
OR: [
{ displayName: { contains: keyword } },
{ realName: { contains: keyword } },
{ flowerName: { contains: keyword } },
{ jobNumber: { contains: keyword } },
],
}
: {}),
},
},
select: { userId: true, employeeProfile: { select: PROFILE_SELECT } },
take,
})
return bindings
.filter((b) => b.employeeProfile)
.map((b) => toEntry(b.employeeProfile, b.userId))
}
/**
* 钉钉登录后确保账号与权威人员之间存在绑定。
*
* 严格以钉钉唯一值匹配人员档案;档案尚未同步或绑定已存在时静默跳过。
* 供登录链路以 fire-and-forget 方式调用,任何情况下都不应阻塞登录本身。
*/
export async function ensureBindingByDingtalkUserId(
userId: string,
dingtalkUserId: string | null | undefined
): Promise<void> {
if (!userId || !dingtalkUserId) return
const existing = await prisma.userEmployeeBinding.findFirst({
where: { userId, status: BINDING_STATUS.ACTIVE },
select: { id: true },
})
if (existing) return
const profile = await prisma.performanceSurveyEmployeeProfile.findUnique({
where: { dingtalkUserId },
select: { id: true },
})
if (!profile) return
await bindUserToEmployee({
userId,
employeeProfileId: profile.id,
source: BINDING_SOURCE.DINGTALK_LOGIN,
})
}
/** 绑定覆盖情况统计,用于迁移对账与健康检查 */
export async function getBindingCoverage(): Promise<{
activeUsers: number
boundUsers: number
unboundUsers: number
coverageRate: number
}> {
const activeUsers = await prisma.user.count({
where: { isActive: true, archivedAt: null },
})
const boundUsers = await prisma.userEmployeeBinding.count({
where: {
status: BINDING_STATUS.ACTIVE,
user: { isActive: true, archivedAt: null },
},
})
return {
activeUsers,
boundUsers,
unboundUsers: activeUsers - boundUsers,
coverageRate: activeUsers > 0 ? Number(((boundUsers / activeUsers) * 100).toFixed(1)) : 0,
}
}