chenpei 887d911b76 fix: 权限组部门树改走花名册组织口径
跟数据口径同一套在职花名册和一人多部门,不再只按有素材库账号的人裁树。
2026-08-20 15:43:28 +08:00

251 lines
9.3 KiB
TypeScript
Raw 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.

/**
* 组织架构 API(品牌中心人员视图)
*
* 数据口径:
* - 以 HR 花名册(PerformanceSurveyEmployeeProfile)为人员主数据;
* - 素材库账号及其活跃绑定只作为可选补充,不作为人员进入组织架构的前置条件;
* - 无钉钉 ID 无法绑定的活跃账号单独归入"未绑定/待人工确认"组;
* - HR 档案部门路径为"未确定"的单独归入"未确定"组;
* - 默认只展示花名册在职(employmentState 非 RESIGNED)。离职档案仍留在库里做托管/作者追溯,
* 不进日常组织树;?includeInactive=true 才把离职的人加回名单;
* - 一人多部门:按全部任职路径显示,A 同时在 1、2 部门时两边名单都有 A;上级节点仍按人去重。
*
* 返回:
* - tree:真实组织树(OrganizationUnit,isActive),节点带该部门直接+间接人员数;
* - employees:当前可见人员的明细(含 M/P 等级、归属、直属主管、在职状态);
* - unbound:未绑定账号;unclassified:部门未确定的人。
*/
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { isSystemAdmin, isDepartmentAdmin, isAdmin } from '@/lib/department-permission'
import { isRosterEmployed, selectVisibleOrganizationPeople } from '@/lib/auto-personnel-rules'
import {
countUniquePeopleOnOrganizationPaths,
personBelongsToOrganizationPath,
resolveEmployeeOrganizationPaths,
} from '@/lib/organization-material-permissions'
interface OrgNode {
id: string
name: string
path: string
level: number
parentId: string | null
/** 该节点(含子孙)的人数 */
count: number
children: OrgNode[]
}
interface EmployeeRow {
employeeProfileId: string
userId: string | null
username: string | null
realName: string | null
displayName: string | null
jobNumber: string | null
departmentPath: string | null
departmentPaths: string[]
departmentName: string | null
positionTitle: string | null
positionLevel: string | null
levelType: string | null
managerName: string | null
isLeadership: boolean
employmentState: string | null
accountIsActive: boolean | null
}
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session || !session.user || !isAdmin((session.user as any).role)) {
return NextResponse.json({ error: '无权限' }, { status: 403 })
}
const url = new URL(request.url)
const includeInactive = url.searchParams.get('includeInactive') === 'true'
// 作用域:系统管理员看全部;部门管理员(DEPT_ADMIN)只看本部门分支。
// 本部门用 session 里的 managedDepartment(旧部门名)匹配组织树节点路径。
// 匹配不到时必须拒绝,避免部门管理员看到公司全量花名册。
const sessionUser = session.user as any
const isSysAdmin = isSystemAdmin(sessionUser.role)
let scopePrefix: string | null = null
if (!isSysAdmin && isDepartmentAdmin(sessionUser.role)) {
const managedName = String(sessionUser.managedDepartment || '').trim()
if (!managedName) {
return NextResponse.json({ error: '未配置可访问部门范围' }, { status: 403 })
}
const scopeUnit = await prisma.organizationUnit.findFirst({
where: { isActive: true, name: managedName },
select: { path: true },
})
if (!scopeUnit) {
return NextResponse.json({ error: '未找到可访问部门范围' }, { status: 403 })
}
scopePrefix = scopeUnit.path
}
// 1. 完整 HR 花名册;素材库账号绑定仅作为可选补充。
const profiles = await prisma.performanceSurveyEmployeeProfile.findMany({
select: {
id: true,
displayName: true,
realName: true,
jobNumber: true,
departmentPath: true,
departmentName: true,
positionTitle: true,
positionLevel: true,
levelType: true,
managerName: true,
isLeadership: true,
employmentState: true,
sourcePayload: true,
departmentMemberships: {
select: {
sourceDeptId: true,
departmentPath: true,
departmentName: true,
isPrimary: true,
},
},
userBindings: {
where: { status: 'ACTIVE' },
take: 1,
select: {
userId: true,
user: {
select: {
username: true,
isActive: true,
},
},
},
},
},
orderBy: [{ displayName: 'asc' }, { id: 'asc' }],
})
// 2. 组织树(全部有效节点)
const units = await prisma.organizationUnit.findMany({
where: { isActive: true },
select: { id: true, name: true, path: true, level: true, parentId: true, sourceDeptId: true },
orderBy: [{ level: 'asc' }, { name: 'asc' }],
})
const pathBySourceDeptId = new Map(units.map((unit) => [String(unit.sourceDeptId), unit.path]))
// 3. 无钉钉 ID 的活跃账号(未绑定组)
const unboundUsers = isSysAdmin
? await prisma.user.findMany({
where: { isActive: true, dingtalkUserId: null },
select: { id: true, username: true, realName: true, department: true },
orderBy: { createdAt: 'asc' },
})
: []
// 组装人员行(一人多部门:departmentPaths 含全部任职路径,departmentPath 仍是主部门)
const employees: EmployeeRow[] = profiles.map((profile) => {
const binding = profile.userBindings[0] ?? null
const departmentPaths = resolveEmployeeOrganizationPaths(profile, pathBySourceDeptId)
return {
employeeProfileId: profile.id,
userId: binding?.userId ?? null,
username: binding?.user?.username ?? null,
realName: profile.realName,
displayName: profile.displayName,
jobNumber: profile.jobNumber,
departmentPath: profile.departmentPath ?? departmentPaths[0] ?? null,
departmentPaths,
departmentName: profile.departmentName,
positionTitle: profile.positionTitle,
positionLevel: profile.positionLevel,
levelType: profile.levelType,
managerName: profile.managerName,
isLeadership: Boolean(profile.isLeadership),
employmentState: profile.employmentState,
accountIsActive: binding?.user?.isActive ?? null,
}
})
// 统计每个部门路径前缀下的人数(本级+子孙,每人只计一次)
// 若为部门管理员,只统计本部门分支
const scopeEmployees = scopePrefix
? employees.filter((e) => personBelongsToOrganizationPath(e.departmentPaths, scopePrefix))
: employees
const visibleEmployees = selectVisibleOrganizationPeople(scopeEmployees, includeInactive)
const pathCount = countUniquePeopleOnOrganizationPaths(visibleEmployees.map((e) => e.departmentPaths))
// 构建组织树(只保留有人的分支 + 未确定组)
const unitMap = new Map<string, OrgNode>()
for (const u of units) {
unitMap.set(u.id, {
id: u.id,
name: u.name,
path: u.path,
level: u.level,
parentId: u.parentId,
count: pathCount.get(u.path) ?? 0,
children: [],
})
}
const roots: OrgNode[] = []
for (const node of Array.from(unitMap.values())) {
if (node.parentId && unitMap.has(node.parentId)) {
unitMap.get(node.parentId)!.children.push(node)
} else {
roots.push(node)
}
}
// pathCount 已按路径前缀累计,节点人数本身含子孙,不必再滚加一层。
// 只保留有人的子树(count > 0),无人的分支剪掉
const prune = (node: OrgNode): OrgNode | null => {
const children = node.children.map(prune).filter((c): c is OrgNode => c !== null)
if (children.length === 0 && node.count === 0) return null
return { ...node, children }
}
const tree = roots.map(prune).filter((n): n is OrgNode => n !== null)
// 未确定组(HR 档案部门路径缺失或以"未确定"开头)
const unclassified = visibleEmployees.filter(
(e) =>
e.departmentPaths.length === 0 ||
e.departmentPaths.every((path) => path.startsWith('未确定')),
)
// 未绑定组(无钉钉 ID 的活跃账号)
const unbound = unboundUsers.map((u) => ({
userId: u.id,
username: u.username,
realName: u.realName,
oldDepartment: u.department,
}))
const summary = {
totalEmployees: scopeEmployees.length,
activeEmployees: scopeEmployees.filter((e) => isRosterEmployed(e.employmentState)).length,
inactiveEmployees: scopeEmployees.filter((e) => !isRosterEmployed(e.employmentState)).length,
unboundCount: unbound.length,
unclassifiedCount: unclassified.length,
visibleCount: visibleEmployees.length,
scope: scopePrefix ?? 'all',
}
return NextResponse.json({
tree,
employees: visibleEmployees,
unclassified,
unbound,
summary,
})
} catch (error) {
console.error('Error fetching org data:', error)
return NextResponse.json({ error: '获取组织架构数据失败' }, { status: 500 })
}
}