251 lines
9.3 KiB
TypeScript
251 lines
9.3 KiB
TypeScript
/**
|
||
* 组织架构 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 })
|
||
}
|
||
}
|