- 看板首页 / :Zen Minimal × Future Organic 融合(用户裁定),编号索引+柔光光晕+GSAP克制动效,白皮书气质 - 素材库首页整体迁移至 /materials(含colocated测试),全站返回素材库链接同步清扫 - 组织架构页上移为顶层 /organization(保留云采石场契约注释),旧路径 /admin/org 服务端跳转;API 迁移 /api/organization(逻辑零改动) - 后台删除组织架构跳转按钮(白屏问题源头消失);middleware 增 /materials 登录门、/organization 管理员门 - 品牌统一:浅色顶栏用 loginLogoUrl(深色图案版),标题「由壹点·品牌中心」;字体自托管 @fontsource - 可访问性收尾(评审fix项):全文对比度过WCAG、焦点环、加载骨架防闪灰、移动端状态可见、::selection - PRODUCT.md/DESIGN.md 记录方向契约;测试基线 1815 通过/6 既有失败(无劣化)
236 lines
8.2 KiB
TypeScript
236 lines
8.2 KiB
TypeScript
/**
|
||
* 组织架构 API(品牌中心人员视图)
|
||
*
|
||
* 数据口径(决策 D-A):
|
||
* - 只统计"在素材库开过账号(User)且已建立活跃绑定(UserEmployeeBinding)"的人;
|
||
* - 花名册里有、但没有素材库账号的人(仓储/物流等约 1200 人)不纳入;
|
||
* - 无钉钉 ID 无法绑定的活跃账号单独归入"未绑定/待人工确认"组;
|
||
* - 绑定档案部门路径为"未确定"的单独归入"未确定"组。
|
||
*
|
||
* 返回:
|
||
* - 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'
|
||
|
||
interface OrgNode {
|
||
id: string
|
||
name: string
|
||
path: string
|
||
level: number
|
||
parentId: string | null
|
||
/** 该节点(含子孙)的人数 */
|
||
count: number
|
||
children: OrgNode[]
|
||
}
|
||
|
||
interface EmployeeRow {
|
||
userId: string
|
||
username: string | null
|
||
realName: string | null
|
||
displayName: string | null
|
||
jobNumber: string | null
|
||
departmentPath: string | null
|
||
departmentName: string | null
|
||
positionTitle: string | null
|
||
positionLevel: string | null
|
||
levelType: string | null
|
||
managerName: string | null
|
||
isLeadership: boolean
|
||
employmentState: string | null
|
||
isActive: boolean
|
||
}
|
||
|
||
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(旧部门名)匹配组织树节点路径。
|
||
// 匹配不到时降级为看全部(不误伤),并在结果里标注 scope 提示。
|
||
const sessionUser = session.user as any
|
||
const isSysAdmin = isSystemAdmin(sessionUser.role)
|
||
let scopePrefix: string | null = null
|
||
let scopeFallback = false
|
||
if (!isSysAdmin && isDepartmentAdmin(sessionUser.role)) {
|
||
const managedName = String(sessionUser.managedDepartment || '').trim()
|
||
if (managedName) {
|
||
const scopeUnit = await prisma.organizationUnit.findFirst({
|
||
where: { isActive: true, name: managedName },
|
||
select: { path: true },
|
||
})
|
||
if (scopeUnit) {
|
||
scopePrefix = scopeUnit.path
|
||
} else {
|
||
scopeFallback = true
|
||
}
|
||
} else {
|
||
scopeFallback = true
|
||
}
|
||
}
|
||
|
||
// 1. 活跃绑定 → 人员档案(含账号信息)
|
||
const bindings = await prisma.userEmployeeBinding.findMany({
|
||
where: { status: 'ACTIVE' },
|
||
select: {
|
||
userId: true,
|
||
user: {
|
||
select: {
|
||
id: true,
|
||
username: true,
|
||
realName: true,
|
||
isActive: true,
|
||
},
|
||
},
|
||
employeeProfile: {
|
||
select: {
|
||
displayName: true,
|
||
jobNumber: true,
|
||
departmentPath: true,
|
||
departmentName: true,
|
||
positionTitle: true,
|
||
positionLevel: true,
|
||
levelType: true,
|
||
managerName: true,
|
||
isLeadership: true,
|
||
employmentState: true,
|
||
},
|
||
},
|
||
},
|
||
})
|
||
|
||
// 2. 组织树(全部有效节点)
|
||
const units = await prisma.organizationUnit.findMany({
|
||
where: { isActive: true },
|
||
select: { id: true, name: true, path: true, level: true, parentId: true },
|
||
orderBy: [{ level: 'asc' }, { name: 'asc' }],
|
||
})
|
||
|
||
// 3. 无钉钉 ID 的活跃账号(未绑定组)
|
||
const unboundUsers = await prisma.user.findMany({
|
||
where: { isActive: true, dingtalkUserId: null },
|
||
select: { id: true, username: true, realName: true, department: true },
|
||
orderBy: { createdAt: 'asc' },
|
||
})
|
||
|
||
// 组装人员行
|
||
const employees: EmployeeRow[] = bindings.map((b) => ({
|
||
userId: b.userId,
|
||
username: b.user?.username ?? null,
|
||
realName: b.user?.realName ?? null,
|
||
displayName: b.employeeProfile?.displayName ?? null,
|
||
jobNumber: b.employeeProfile?.jobNumber ?? null,
|
||
departmentPath: b.employeeProfile?.departmentPath ?? null,
|
||
departmentName: b.employeeProfile?.departmentName ?? null,
|
||
positionTitle: b.employeeProfile?.positionTitle ?? null,
|
||
positionLevel: b.employeeProfile?.positionLevel ?? null,
|
||
levelType: b.employeeProfile?.levelType ?? null,
|
||
managerName: b.employeeProfile?.managerName ?? null,
|
||
isLeadership: Boolean(b.employeeProfile?.isLeadership),
|
||
employmentState: b.employeeProfile?.employmentState ?? null,
|
||
isActive: Boolean(b.user?.isActive),
|
||
}))
|
||
|
||
// 统计每个部门路径前缀下的人数(用于树节点计数)
|
||
// 若为部门管理员,只统计本部门分支
|
||
const pathCount = new Map<string, number>()
|
||
const scopeEmployees = scopePrefix ? employees.filter((e) => e.departmentPath?.startsWith(scopePrefix)) : employees
|
||
for (const e of scopeEmployees) {
|
||
if (!e.departmentPath) continue
|
||
const parts = e.departmentPath.split('/')
|
||
let acc = ''
|
||
for (const part of parts) {
|
||
acc = acc ? `${acc}/${part}` : part
|
||
pathCount.set(acc, (pathCount.get(acc) ?? 0) + 1)
|
||
}
|
||
}
|
||
|
||
// 构建组织树(只保留有人的分支 + 未确定组)
|
||
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)
|
||
}
|
||
}
|
||
|
||
// 统计每个节点的总人数(含子孙)
|
||
const totalCount = (node: OrgNode): number => {
|
||
let c = node.count
|
||
for (const child of node.children) c += totalCount(child)
|
||
node.count = c
|
||
return c
|
||
}
|
||
for (const root of roots) totalCount(root)
|
||
|
||
// 只保留有人的子树(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)
|
||
|
||
// 未确定组(有绑定但部门路径缺失或以"未确定"开头)
|
||
const unclassified = scopeEmployees.filter(
|
||
(e) => !e.departmentPath || e.departmentPath.startsWith('未确定'),
|
||
)
|
||
|
||
// 未绑定组(无钉钉 ID 的活跃账号)
|
||
const unbound = unboundUsers.map((u) => ({
|
||
userId: u.id,
|
||
username: u.username,
|
||
realName: u.realName,
|
||
oldDepartment: u.department,
|
||
}))
|
||
|
||
const visibleEmployees = includeInactive ? scopeEmployees : scopeEmployees.filter((e) => e.isActive)
|
||
|
||
const summary = {
|
||
totalEmployees: employees.length,
|
||
activeEmployees: employees.filter((e) => e.isActive).length,
|
||
inactiveEmployees: employees.filter((e) => !e.isActive).length,
|
||
unboundCount: unbound.length,
|
||
unclassifiedCount: unclassified.length,
|
||
visibleCount: visibleEmployees.length,
|
||
scope: scopePrefix ?? (scopeFallback ? 'fallback-all' : '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 })
|
||
}
|
||
}
|