/** * 通用人员目录服务 * * 平台级的人员主数据入口。素材库、部门看板等功能统一通过本模块访问人员信息, * 不再直接读取带 `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 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, 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, employmentStatus: profile.employmentStatus, } } /** * 按素材库账号查询其权威人员档案。 * * 优先读取正式绑定关系;迁移期内若尚无绑定记录,回退到按钉钉唯一值查询 * (仅钉钉值,不使用姓名或邮箱)。绑定回填完成后可关闭回退。 */ export async function getEmployeeByUserId( userId: string, options: { allowDingtalkFallback?: boolean } = {} ): Promise { 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 { 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 { 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 { 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 { 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 { 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, } }