fix: 权限组部门树改走花名册组织口径

跟数据口径同一套在职花名册和一人多部门,不再只按有素材库账号的人裁树。
This commit is contained in:
chenpei 2026-08-20 15:43:28 +08:00
parent 0fa583ee8f
commit 887d911b76
5 changed files with 90 additions and 35 deletions

View File

@ -6,11 +6,13 @@ import { prisma } from '@/lib/prisma'
import { getAllMaterialTypes, isSuperAdmin } from '@/lib/department-permission'
import { filterSelectableMaterialTypes } from '@/lib/material-type-options'
import { BINDING_STATUS } from '@/lib/employee-directory'
import { selectVisibleOrganizationPeople } from '@/lib/auto-personnel-rules'
import {
collectOccupiedOrganizationPaths,
lastOrganizationPathSegment,
normalizeOrganizationPath,
pruneOrganizationTree,
resolveEmployeeOrganizationPaths,
} from '@/lib/organization-material-permissions'
import {
findConflictingDepartmentGroup,
@ -60,7 +62,7 @@ export async function GET() {
await ensureDefaultPermissionPacks()
await seedDefaultGroupDepartments()
const [packs, units, bindings, materialTypes, departmentRows] = await Promise.all([
const [packs, units, rosterProfiles, materialTypes, departmentRows] = await Promise.all([
prisma.materialPermissionPack.findMany({
orderBy: { name: 'asc' },
select: {
@ -101,12 +103,18 @@ export async function GET() {
}),
prisma.organizationUnit.findMany({
where: { isActive: true },
select: { id: true, name: true, path: true, level: true, parentId: true },
select: { id: true, name: true, path: true, level: true, parentId: true, sourceDeptId: true },
orderBy: [{ level: 'asc' }, { name: 'asc' }],
}),
prisma.userEmployeeBinding.findMany({
where: { status: BINDING_STATUS.ACTIVE, user: { isActive: true } },
select: { employeeProfile: { select: { departmentPath: true } } },
prisma.performanceSurveyEmployeeProfile.findMany({
select: {
departmentPath: true,
employmentState: true,
sourcePayload: true,
departmentMemberships: {
select: { sourceDeptId: true, departmentPath: true },
},
},
}),
getAllMaterialTypes().then(filterSelectableMaterialTypes),
prisma.materialPermissionPackDepartment
@ -124,7 +132,14 @@ export async function GET() {
const nodeMap = new Map<string, OrgNode>()
for (const unit of units) {
nodeMap.set(unit.id, { ...unit, children: [] })
nodeMap.set(unit.id, {
id: unit.id,
name: unit.name,
path: unit.path,
level: unit.level,
parentId: unit.parentId,
children: [],
})
}
const roots: OrgNode[] = []
for (const node of Array.from(nodeMap.values())) {
@ -134,9 +149,13 @@ export async function GET() {
roots.push(node)
}
}
const occupiedPaths = collectOccupiedOrganizationPaths(
bindings.map((binding) => binding.employeeProfile?.departmentPath),
)
const pathBySourceDeptId = new Map(units.map((unit) => [String(unit.sourceDeptId), unit.path]))
const occupiedPaths = collectOccupiedOrganizationPaths([
...selectVisibleOrganizationPeople(rosterProfiles).flatMap((profile) =>
resolveEmployeeOrganizationPaths(profile, pathBySourceDeptId),
),
...departmentRows.map((row) => row.departmentPath),
])
const tree = pruneOrganizationTree(roots, occupiedPaths)
const departmentsByPack = new Map<string, string[]>()
for (const row of departmentRows) {

View File

@ -23,10 +23,9 @@ import { prisma } from '@/lib/prisma'
import { isSystemAdmin, isDepartmentAdmin, isAdmin } from '@/lib/department-permission'
import { isRosterEmployed, selectVisibleOrganizationPeople } from '@/lib/auto-personnel-rules'
import {
collectEmployeeOrganizationPaths,
countUniquePeopleOnOrganizationPaths,
parseOrganizationDepartmentIds,
personBelongsToOrganizationPath,
resolveEmployeeOrganizationPaths,
} from '@/lib/organization-material-permissions'
interface OrgNode {
@ -139,28 +138,6 @@ export async function GET(request: NextRequest) {
})
const pathBySourceDeptId = new Map(units.map((unit) => [String(unit.sourceDeptId), unit.path]))
const resolveEmployeePaths = (profile: {
departmentPath: string | null
sourcePayload: unknown
departmentMemberships: Array<{ sourceDeptId: string; departmentPath: string | null }>
} | null | undefined): string[] => {
if (!profile) return []
const synced = ((profile.sourcePayload as { syncedFields?: Record<string, unknown> } | null)?.syncedFields ||
{}) as Record<string, unknown>
const extraIds = [
...parseOrganizationDepartmentIds(synced.departmentIds),
...parseOrganizationDepartmentIds(synced.employeeDeptIdList),
...parseOrganizationDepartmentIds(synced.registerDeptIds),
...parseOrganizationDepartmentIds(synced.flowerBookDeptId),
]
return collectEmployeeOrganizationPaths([
profile.departmentPath,
...profile.departmentMemberships.map((membership) => membership.departmentPath),
...profile.departmentMemberships.map((membership) => pathBySourceDeptId.get(String(membership.sourceDeptId))),
...extraIds.map((id) => pathBySourceDeptId.get(id)),
])
}
// 3. 无钉钉 ID 的活跃账号(未绑定组)
const unboundUsers = isSysAdmin
? await prisma.user.findMany({
@ -173,7 +150,7 @@ export async function GET(request: NextRequest) {
// 组装人员行(一人多部门:departmentPaths 含全部任职路径,departmentPath 仍是主部门)
const employees: EmployeeRow[] = profiles.map((profile) => {
const binding = profile.userBindings[0] ?? null
const departmentPaths = resolveEmployeePaths(profile)
const departmentPaths = resolveEmployeeOrganizationPaths(profile, pathBySourceDeptId)
return {
employeeProfileId: profile.id,
userId: binding?.userId ?? null,

View File

@ -361,7 +361,7 @@ export default function PermissionGroupsDashboard() {
<div>
<h1 className="text-2xl font-bold tracking-[-0.03em] text-[#101828]">权限组</h1>
<p className="mt-1 max-w-3xl text-sm leading-6 text-[#66758b]">
一组就是一份权限。一个部门只能进一组,树上会标明已在哪组。人可以进多组,能看的取并集。
一组就是一份权限。部门树跟数据口径同一套花名册组织。一个部门只能进一组,树上会标明已在哪组。人可以进多组,能看的取并集。
</p>
</div>
<Link

View File

@ -13,6 +13,7 @@ import {
parseOrganizationDepartmentIds,
personBelongsToOrganizationPath,
pruneOrganizationTree,
resolveEmployeeOrganizationPaths,
resolveOrganizationPermissionKey,
unionPermissionPacks,
} from '@/lib/organization-material-permissions'
@ -110,6 +111,28 @@ describe('organization material permission keys', () => {
})
})
test('employee org paths follow roster primary, extra posts and department ids', () => {
const company = '江苏简尚品牌管理有限公司'
const brand = `${company}/品牌中心`
const ops = `${company}/运营中心G`
const lookup = new Map([
['2', brand],
['9', ops],
])
expect(
resolveEmployeeOrganizationPaths(
{
departmentPath: brand,
departmentMemberships: [{ sourceDeptId: '9', departmentPath: ops }],
sourcePayload: { syncedFields: { departmentIds: '[2,9]' } },
},
lookup,
),
).toEqual([brand, ops])
expect(resolveEmployeeOrganizationPaths(null, lookup)).toEqual([])
})
test('permission tree drops revoked empty branches but keeps occupied ancestors', () => {
const occupied = collectOccupiedOrganizationPaths([
'江苏简尚品牌管理有限公司/运营中心A/事业一部/品类十九部',

View File

@ -211,6 +211,42 @@ export function collectEmployeeOrganizationPaths(
return collapseNestedOrganizationPaths(unique)
}
export type EmployeeOrganizationPathSource = {
departmentPath?: string | null
sourcePayload?: unknown
departmentMemberships?: Array<{
sourceDeptId?: string | number | null
departmentPath?: string | null
}>
}
/** 和数据口径同一套任职路径:主部门 + 兼任 + 花名册部门 ID。 */
export function resolveEmployeeOrganizationPaths(
profile: EmployeeOrganizationPathSource | null | undefined,
pathBySourceDeptId: Map<string, string> | Record<string, string>,
): string[] {
if (!profile) return []
const lookup =
pathBySourceDeptId instanceof Map
? pathBySourceDeptId
: new Map(Object.entries(pathBySourceDeptId))
const synced = ((profile.sourcePayload as { syncedFields?: Record<string, unknown> } | null)?.syncedFields ||
{}) as Record<string, unknown>
const extraIds = [
...parseOrganizationDepartmentIds(synced.departmentIds),
...parseOrganizationDepartmentIds(synced.employeeDeptIdList),
...parseOrganizationDepartmentIds(synced.registerDeptIds),
...parseOrganizationDepartmentIds(synced.flowerBookDeptId),
]
const memberships = profile.departmentMemberships || []
return collectEmployeeOrganizationPaths([
profile.departmentPath,
...memberships.map((membership) => membership.departmentPath),
...memberships.map((membership) => lookup.get(String(membership.sourceDeptId))),
...extraIds.map((id) => lookup.get(id)),
])
}
/** 花名册常同时给上级和本组。只留最深的任职,避免「品牌创意A部 + 品牌中心」被当成两个职位。 */
export function collapseNestedOrganizationPaths(paths: string[]): string[] {
return paths.filter(