343 lines
10 KiB
TypeScript
343 lines
10 KiB
TypeScript
/**
|
||
* 通用人员目录服务
|
||
*
|
||
* 平台级的人员主数据入口。素材库、部门看板等功能统一通过本模块访问人员信息,
|
||
* 不再直接读取带 `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
|
||
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,
|
||
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,
|
||
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
|
||
}
|
||
|
||
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,
|
||
}
|
||
}
|