From 887d911b76c2e530b9cfe209f062950915099b0a Mon Sep 17 00:00:00 2001 From: chenpei Date: Thu, 20 Aug 2026 15:43:28 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=9D=83=E9=99=90=E7=BB=84=E9=83=A8?= =?UTF-8?q?=E9=97=A8=E6=A0=91=E6=94=B9=E8=B5=B0=E8=8A=B1=E5=90=8D=E5=86=8C?= =?UTF-8?q?=E7=BB=84=E7=BB=87=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 跟数据口径同一套在职花名册和一人多部门,不再只按有素材库账号的人裁树。 --- .../organization/permission-groups/route.ts | 37 ++++++++++++++----- app/api/organization/route.ts | 27 +------------- .../PermissionGroupsDashboard.tsx | 2 +- .../organization-material-permissions.test.ts | 23 ++++++++++++ lib/organization-material-permissions.ts | 36 ++++++++++++++++++ 5 files changed, 90 insertions(+), 35 deletions(-) diff --git a/app/api/organization/permission-groups/route.ts b/app/api/organization/permission-groups/route.ts index 55ed06e..6736781 100644 --- a/app/api/organization/permission-groups/route.ts +++ b/app/api/organization/permission-groups/route.ts @@ -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() 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() for (const row of departmentRows) { diff --git a/app/api/organization/route.ts b/app/api/organization/route.ts index 57ff928..698d188 100644 --- a/app/api/organization/route.ts +++ b/app/api/organization/route.ts @@ -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 } | null)?.syncedFields || - {}) as Record - 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, diff --git a/components/department-dashboard/PermissionGroupsDashboard.tsx b/components/department-dashboard/PermissionGroupsDashboard.tsx index 9663b57..0a9dfb9 100644 --- a/components/department-dashboard/PermissionGroupsDashboard.tsx +++ b/components/department-dashboard/PermissionGroupsDashboard.tsx @@ -361,7 +361,7 @@ export default function PermissionGroupsDashboard() {

权限组

- 一组就是一份权限。一个部门只能进一组,树上会标明已在哪组。人可以进多组,能看的取并集。 + 一组就是一份权限。部门树跟数据口径同一套花名册组织。一个部门只能进一组,树上会标明已在哪组。人可以进多组,能看的取并集。

{ }) }) + 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/事业一部/品类十九部', diff --git a/lib/organization-material-permissions.ts b/lib/organization-material-permissions.ts index 35b059c..acd8c7c 100644 --- a/lib/organization-material-permissions.ts +++ b/lib/organization-material-permissions.ts @@ -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 | Record, +): string[] { + if (!profile) return [] + const lookup = + pathBySourceDeptId instanceof Map + ? pathBySourceDeptId + : new Map(Object.entries(pathBySourceDeptId)) + const synced = ((profile.sourcePayload as { syncedFields?: Record } | null)?.syncedFields || + {}) as Record + 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(