diff --git a/app/api/materials/[id]/route.ts b/app/api/materials/[id]/route.ts index fd3829b..1506ef1 100644 --- a/app/api/materials/[id]/route.ts +++ b/app/api/materials/[id]/route.ts @@ -428,7 +428,7 @@ export async function GET( userDepartment, userRole, managedDepartment, - '详情模板', + (material as any).materialType, userId ) : true; diff --git a/app/api/materials/download/route.ts b/app/api/materials/download/route.ts index 56ee236..97da5c0 100644 --- a/app/api/materials/download/route.ts +++ b/app/api/materials/download/route.ts @@ -134,7 +134,7 @@ export async function GET(request: NextRequest) { ); } - // 任务-详情 PSD 和任务原图源文件统一检查 canDownloadSource。 + // 任务-详情 PSD 和任务原图源文件按各自素材类型检查 canDownloadSource。 const requiresSourcePermission = is3DOriginalType(material.materialType) || (isProductPackageType(material.materialType) && downloadType === 'psd'); @@ -143,7 +143,7 @@ export async function GET(request: NextRequest) { userDepartment, userRole, managedDepartment, - '详情模板', + material.materialType, userId ); diff --git a/app/api/materials/share/route.ts b/app/api/materials/share/route.ts index 3b08d79..a564ff3 100644 --- a/app/api/materials/share/route.ts +++ b/app/api/materials/share/route.ts @@ -280,8 +280,7 @@ export async function GET(request: NextRequest) { currentUser = user; } - // 任务-详情:检查 PSD 下载权限(参考"详情模板"类型的权限) - // 任务-3D原图等:检查 ZIP 下载权限(参考"详情模板"类型的权限) + // 任务-详情 PSD 和任务原图 ZIP 按各自素材类型检查源文件权限。 let canAccessSource = false; if (userId && currentUser) { if (hasSystemAdminPermission(currentUser.role, Boolean(currentUser.isSuperAdmin))) { @@ -302,7 +301,7 @@ export async function GET(request: NextRequest) { currentUser.department, currentUser.role, currentUser.managedDepartment, - '详情模板', + material.materialType as unknown as string, userId ) : true; diff --git a/app/api/uploads/[...path]/route.ts b/app/api/uploads/[...path]/route.ts index 734145b..325ff96 100644 --- a/app/api/uploads/[...path]/route.ts +++ b/app/api/uploads/[...path]/route.ts @@ -209,7 +209,7 @@ async function authorizeSourceAccess( user.department, user.role, user.managedDepartment, - '详情模板', + material.materialType, String(sessionUser.id), ) if (!canDownloadSource) { diff --git a/lib/__tests__/department-permission-roster.test.ts b/lib/__tests__/department-permission-roster.test.ts index a7a9eff..9e5703c 100644 --- a/lib/__tests__/department-permission-roster.test.ts +++ b/lib/__tests__/department-permission-roster.test.ts @@ -20,6 +20,9 @@ jest.mock('@/lib/prisma', () => ({ materialPermissionPackDepartment: { findMany: jest.fn(), }, + organizationUnit: { + findMany: jest.fn(), + }, }, })) @@ -29,6 +32,7 @@ describe('roster material visibility', () => { ;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ isSuperAdmin: false }) ;(prisma.userPermissionInherit.findMany as jest.Mock).mockResolvedValue([]) ;(prisma.materialPermissionPackDepartment.findMany as jest.Mock).mockResolvedValue([]) + ;(prisma.organizationUnit.findMany as jest.Mock).mockResolvedValue([]) ;(prisma.departmentMaterialPermission.findMany as jest.Mock).mockResolvedValue([ { department: '运营中心', @@ -171,6 +175,58 @@ describe('roster material visibility', () => { ]) }) + test('一人多部门时,各任职路径最近权限组的素材权限取并集', async () => { + ;(prisma.userEmployeeBinding.findFirst as jest.Mock).mockResolvedValue({ + employeeProfile: { + departmentPath: '江苏简尚品牌管理有限公司/运营中心A/事业一部/品类十九部', + sourcePayload: { syncedFields: { departmentIds: '[20]' } }, + departmentMemberships: [{ sourceDeptId: '20', departmentPath: null }], + }, + }) + ;(prisma.organizationUnit.findMany as jest.Mock).mockResolvedValue([ + { + sourceDeptId: 20, + path: '江苏简尚品牌管理有限公司/品牌中心/空间视觉部', + }, + ]) + ;(prisma.materialPermissionPackDepartment.findMany as jest.Mock).mockResolvedValue([ + { + departmentPath: '江苏简尚品牌管理有限公司/运营中心A', + pack: { + items: [ + { + materialType: '3D场景', + canView: true, + canDownload: true, + canDownloadSource: false, + canShare: true, + }, + ], + }, + }, + { + departmentPath: '江苏简尚品牌管理有限公司/品牌中心', + pack: { + items: [ + { + materialType: '详情模板', + canView: true, + canDownload: true, + canDownloadSource: true, + canShare: true, + }, + ], + }, + }, + ]) + + await expect(getUserViewableMaterialTypes('', 'USER', null, 'multi-department')).resolves.toEqual([ + '3D场景', + '详情模板', + ]) + expect(prisma.materialPermissionPackDepartment.findMany).toHaveBeenCalledTimes(1) + }) + test('花名册没有部门路径时看不见素材', async () => { ;(prisma.userEmployeeBinding.findFirst as jest.Mock).mockResolvedValue(null) ;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ isSuperAdmin: false, dingtalkUserId: null }) diff --git a/lib/__tests__/department-permission-source.test.ts b/lib/__tests__/department-permission-source.test.ts index 14e3e22..3f16de5 100644 --- a/lib/__tests__/department-permission-source.test.ts +++ b/lib/__tests__/department-permission-source.test.ts @@ -87,4 +87,18 @@ describe('canUserDownloadSource', () => { canUserDownloadSource('视觉三部', 'USER', null, '详情模板', 'user-1'), ).resolves.toBe(false) }) + + test('任务详情和任务原图分别读取本组对应素材类型的源文件开关', async () => { + mockPermissionLookup({ + '视觉中心:任务-详情': { canDownloadSource: true }, + '视觉中心:任务-3D原图': { canDownloadSource: false }, + }) + + await expect( + canUserDownloadSource('视觉三部', 'USER', null, '任务-详情', 'user-1'), + ).resolves.toBe(true) + await expect( + canUserDownloadSource('视觉三部', 'USER', null, '任务-3D原图', 'user-1'), + ).resolves.toBe(false) + }) }) diff --git a/lib/__tests__/material-edit-routes.test.ts b/lib/__tests__/material-edit-routes.test.ts index e691aba..5595615 100644 --- a/lib/__tests__/material-edit-routes.test.ts +++ b/lib/__tests__/material-edit-routes.test.ts @@ -298,6 +298,13 @@ describe('material edit routes', () => { expect(body.taskDetailRequesterName).toBe('运营甲') expect(body.sourceFileUrl).toBe('/api/materials/download?id=material-1') expect(body.canEdit).toBe(true) + expect(canUserDownloadSource).toHaveBeenCalledWith( + '测试部门', + 'USER', + null, + '任务-AI原图', + 'user-1', + ) }) test('视频等待期内,素材所有者仍可读取元数据编辑信息,但不能取得视频预览地址', async () => { diff --git a/lib/__tests__/materials-download-video-wait-route.test.ts b/lib/__tests__/materials-download-video-wait-route.test.ts index 81d92aa..add9dd0 100644 --- a/lib/__tests__/materials-download-video-wait-route.test.ts +++ b/lib/__tests__/materials-download-video-wait-route.test.ts @@ -154,7 +154,7 @@ describe('materials download route video wait period', () => { '测试部门', 'USER', null, - '详情模板', + '任务-3D原图', 'user-1', ) }) diff --git a/lib/__tests__/performance-survey-employee-sync-preview.test.ts b/lib/__tests__/performance-survey-employee-sync-preview.test.ts index 4ff86b0..a7372b0 100644 --- a/lib/__tests__/performance-survey-employee-sync-preview.test.ts +++ b/lib/__tests__/performance-survey-employee-sync-preview.test.ts @@ -493,6 +493,218 @@ describe('performance survey employee sync preview', () => { expect(pool.close).toHaveBeenCalledTimes(1) }) + test('整批正式同步会纳入只存在于花名表的新员工', async () => { + process.env.REPORT_DB_SERVER = 'report.example.test' + process.env.REPORT_DB_DATABASE = 'report' + process.env.REPORT_DB_USER = 'readonly' + process.env.REPORT_DB_PASSWORD = 'secret' + + ;(prisma.user.findMany as jest.Mock).mockResolvedValue([{ + id: 'material-user-shuqi', + email: 'shuqi@example.com', + username: '叔齐', + realName: '叔齐', + dingtalkUserId: '17871916566057990', + department: '朴美运营', + }]) + ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([]) + + const profileCreate = jest.fn().mockResolvedValue({ id: 'profile-shuqi' }) + const membershipCreateMany = jest.fn().mockResolvedValue({ count: 1 }) + ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback({ + performanceSurveyEmployeeProfile: { + findMany: jest.fn().mockResolvedValue([]), + findFirst: jest.fn().mockResolvedValue(null), + create: profileCreate, + update: jest.fn(), + }, + performanceSurveyEmployeeDepartmentMembership: { + deleteMany: jest.fn().mockResolvedValue({ count: 0 }), + createMany: membershipCreateMany, + }, + })) + + const pool = { + request: jest.fn(() => { + const inputs = new Map() + const request: { input: jest.Mock; query: jest.Mock } = { + input: jest.fn(), + query: jest.fn(), + } + request.input.mockImplementation((name: string, ...args: unknown[]) => { + inputs.set(name, args[args.length - 1]) + return request + }) + request.query.mockImplementation(async (query: string) => { + if (query.includes('INFORMATION_SCHEMA.COLUMNS')) { + const table = inputs.get('table') + const columns = table === 'hr_employee' + ? ['userid', 'job_number', 'name', 'active'] + : table === 'hr_flowerName_book' + ? ['flowerNameId', 'jobNum', 'name', 'flowerName', 'dept', 'deptId', 'job', 'jobLevel', 'empStatus', 'status', 'leaveDate'] + : [] + return { recordset: columns.map((COLUMN_NAME) => ({ COLUMN_NAME })) } + } + if (query.includes(';WITH input_depts')) { + return { + recordset: [{ + dept_id: 1049297345, + path: '江苏简尚品牌管理有限公司/运营中心G/朴美项目部/运营一组', + }], + } + } + if (query.includes('[rpt].[hr_employee]')) return { recordset: [] } + if (query.includes('[rpt].[hr_flowerName_book]')) { + expect(query).toContain('[flowerNameId]') + return { + recordset: [{ + dingtalkUserId: '17871916566057990', + registerJobNumber: 'JS26082002', + flowerBookName: '黄鸿翔', + flowerBookFlowerName: '叔齐', + flowerBookDept: '["运营中心G","朴美项目部","运营一组"]', + flowerBookDeptId: '[911666246,1007911425,1049297345]', + flowerBookJob: '运营', + flowerBookJobLevel: 'P4', + flowerBookEmployeeStatus: '试用', + }], + } + } + throw new Error(`未预期的查询:${query}`) + }) + return request + }), + close: jest.fn(), + } + ;(mssql.connect as jest.Mock).mockResolvedValue(pool) + + await expect(syncPerformanceSurveyEmployees({ limit: 3000 })).resolves.toMatchObject({ + scannedAccounts: 1, + scannedProfiles: 1, + upserted: 1, + fallback: 0, + failed: 0, + }) + expect(profileCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + dingtalkUserId: '17871916566057990', + jobNumber: 'JS26082002', + realName: '黄鸿翔', + flowerName: '叔齐', + displayName: '叔齐', + departmentName: '运营一组', + departmentPath: '江苏简尚品牌管理有限公司/运营中心G/朴美项目部/运营一组', + positionTitle: '运营', + positionLevel: 'P4', + sourcePayload: expect.objectContaining({ + materialUserId: 'material-user-shuqi', + }), + }), + }) + expect(membershipCreateMany).toHaveBeenCalledWith({ + data: [expect.objectContaining({ + employeeProfileId: 'profile-shuqi', + sourceDeptId: '1049297345', + isPrimary: true, + })], + }) + }) + + test('单人刷新会按钉钉 ID 精确补查花名表独有员工', async () => { + process.env.REPORT_DB_SERVER = 'report.example.test' + process.env.REPORT_DB_DATABASE = 'report' + process.env.REPORT_DB_USER = 'readonly' + process.env.REPORT_DB_PASSWORD = 'secret' + + ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([]) + const profileCreate = jest.fn().mockResolvedValue({ id: 'profile-shuqi' }) + ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback({ + performanceSurveyEmployeeProfile: { + findMany: jest.fn().mockResolvedValue([]), + findFirst: jest.fn().mockResolvedValue(null), + create: profileCreate, + update: jest.fn(), + }, + performanceSurveyEmployeeDepartmentMembership: { + deleteMany: jest.fn().mockResolvedValue({ count: 0 }), + createMany: jest.fn().mockResolvedValue({ count: 1 }), + }, + })) + + const pool = { + request: jest.fn(() => { + const inputs = new Map() + const request: { input: jest.Mock; query: jest.Mock } = { + input: jest.fn(), + query: jest.fn(), + } + request.input.mockImplementation((name: string, ...args: unknown[]) => { + inputs.set(name, args[args.length - 1]) + return request + }) + request.query.mockImplementation(async (query: string) => { + if (query.includes('INFORMATION_SCHEMA.COLUMNS')) { + const table = inputs.get('table') + const columns = table === 'hr_employee' + ? ['userid', 'job_number', 'name', 'active'] + : table === 'hr_flowerName_book' + ? ['flowerNameId', 'jobNum', 'name', 'flowerName', 'dept', 'deptId', 'job', 'jobLevel', 'empStatus', 'status', 'leaveDate'] + : [] + return { recordset: columns.map((COLUMN_NAME) => ({ COLUMN_NAME })) } + } + if (query.includes(';WITH input_depts')) { + return { + recordset: [{ + dept_id: 1049297345, + path: '江苏简尚品牌管理有限公司/运营中心G/朴美项目部/运营一组', + }], + } + } + if (query.includes('[rpt].[hr_employee] e')) return { recordset: [] } + if (query.includes('[rpt].[hr_flowerName_book] f')) { + expect(query).toContain('[flowerNameId] = @dingtalkUserId') + expect(inputs.get('dingtalkUserId')).toBe('17871916566057990') + return { + recordset: [{ + dingtalkUserId: '17871916566057990', + registerJobNumber: 'JS26082002', + flowerBookName: '黄鸿翔', + flowerBookFlowerName: '叔齐', + flowerBookDept: '["运营中心G","朴美项目部","运营一组"]', + flowerBookDeptId: '[911666246,1007911425,1049297345]', + flowerBookJob: '运营', + flowerBookJobLevel: 'P4', + }], + } + } + throw new Error(`未预期的查询:${query}`) + }) + return request + }), + close: jest.fn(), + } + ;(mssql.connect as jest.Mock).mockResolvedValue(pool) + + await syncPerformanceSurveyEmployeeByIdentity({ + userId: 'material-user-shuqi', + dingtalkUserId: '17871916566057990', + email: 'shuqi@example.com', + username: '叔齐', + }) + + expect(profileCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + dingtalkUserId: '17871916566057990', + realName: '黄鸿翔', + flowerName: '叔齐', + departmentPath: '江苏简尚品牌管理有限公司/运营中心G/朴美项目部/运营一组', + sourcePayload: expect.objectContaining({ + materialUserId: 'material-user-shuqi', + }), + }), + }) + }) + test('单人正式同步会保存全部所属部门和钉钉身份字段,且不映射为系统权限', async () => { process.env.REPORT_DB_SERVER = 'report.example.test' process.env.REPORT_DB_DATABASE = 'report' diff --git a/lib/department-permission.ts b/lib/department-permission.ts index 0d9d703..0ffa683 100644 --- a/lib/department-permission.ts +++ b/lib/department-permission.ts @@ -1,12 +1,13 @@ import { prisma } from './prisma'; -import { getEmployeeByUserId } from './employee-directory'; +import { getEmployeeOrganizationPathsByUserId } from './employee-directory'; import { filterSelectableMaterialTypes, MATERIAL_TYPE_OPTIONS } from './material-type-options'; import { mergeDepartmentAndInheritPacks, resolveOrganizationPermissionKey, + unionPermissionPacks, type MaterialPermissionFlags, } from './organization-material-permissions'; -import { loadDepartmentGroupPack } from './permission-groups'; +import { loadDepartmentGroupPacks } from './permission-groups'; import { loadUserInheritPack } from './user-permission-inherit'; import { getCompatibleVideoMaterialTypes, isVideoMaterialType } from './video-material-types'; @@ -564,20 +565,18 @@ function pickPermissionFlags( async function resolveRosterPermissionPack( userId: string, ): Promise | null> { - const employee = await getEmployeeByUserId(userId) - const path = employee?.departmentPath - if (!path) return null + const paths = await getEmployeeOrganizationPathsByUserId(userId) + if (paths.length === 0) return null - const groupPack = await loadDepartmentGroupPack(path) - let departmentPack: Record - if (groupPack) { - departmentPack = groupPack - } else { - const stored = await loadStoredPermissionPacks() - const resolved = resolveOrganizationPermissionKey(path, stored.keys()) - departmentPack = - !resolved || resolved.source === 'none' ? {} : stored.get(resolved.key) || {} - } + const groupPacks = await loadDepartmentGroupPacks(paths) + const needsLegacyFallback = paths.some((path) => !groupPacks.has(path)) + const stored = needsLegacyFallback ? await loadStoredPermissionPacks() : null + const departmentPack = unionPermissionPacks(...paths.map((path) => { + const groupPack = groupPacks.get(path) + if (groupPack) return groupPack + const resolved = stored ? resolveOrganizationPermissionKey(path, stored.keys()) : null + return !resolved || resolved.source === 'none' ? {} : stored?.get(resolved.key) || {} + })) const inheritPack = await loadUserInheritPack(userId) return mergeDepartmentAndInheritPacks(departmentPack, inheritPack) } diff --git a/lib/employee-directory.ts b/lib/employee-directory.ts index 56d2d5a..2089550 100644 --- a/lib/employee-directory.ts +++ b/lib/employee-directory.ts @@ -12,6 +12,10 @@ */ import { prisma } from '@/lib/prisma' +import { + collectEmployeeOrganizationSourceDeptIds, + resolveEmployeeOrganizationPaths, +} from '@/lib/organization-material-permissions' /** 绑定来源 */ export const BINDING_SOURCE = { @@ -133,6 +137,58 @@ export async function getEmployeeByUserId( return profile ? toEntry(profile, userId) : null } +const PERMISSION_PATH_PROFILE_SELECT = { + departmentPath: true, + sourcePayload: true, + departmentMemberships: { + select: { + sourceDeptId: true, + departmentPath: true, + }, + }, +} as const + +/** 按素材库账号查询花名册中的全部任职路径,身份匹配仍只认正式绑定或钉钉唯一值。 */ +export async function getEmployeeOrganizationPathsByUserId( + userId: string, + options: { allowDingtalkFallback?: boolean } = {}, +): Promise { + const { allowDingtalkFallback = true } = options + const binding = await prisma.userEmployeeBinding.findFirst({ + where: { userId, status: BINDING_STATUS.ACTIVE }, + select: { employeeProfile: { select: PERMISSION_PATH_PROFILE_SELECT } }, + }) + + let profile = binding?.employeeProfile ?? null + if (!profile && allowDingtalkFallback) { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { dingtalkUserId: true }, + }) + if (user?.dingtalkUserId) { + profile = await prisma.performanceSurveyEmployeeProfile.findUnique({ + where: { dingtalkUserId: user.dingtalkUserId }, + select: PERMISSION_PATH_PROFILE_SELECT, + }) + } + } + if (!profile) return [] + + const sourceDeptIds = collectEmployeeOrganizationSourceDeptIds(profile) + .map((id) => Number(id)) + .filter((id) => Number.isInteger(id)) + const units = sourceDeptIds.length > 0 + ? await prisma.organizationUnit.findMany({ + where: { isActive: true, sourceDeptId: { in: sourceDeptIds } }, + select: { sourceDeptId: true, path: true }, + }) + : [] + const pathBySourceDeptId = new Map( + units.map((unit) => [String(unit.sourceDeptId), unit.path]), + ) + return resolveEmployeeOrganizationPaths(profile, pathBySourceDeptId) +} + /** 按人员档案反查其素材库账号 ID,无账号返回 null */ export async function getUserIdByEmployeeId(employeeProfileId: string): Promise { const binding = await prisma.userEmployeeBinding.findFirst({ diff --git a/lib/organization-material-permissions.ts b/lib/organization-material-permissions.ts index acd8c7c..086a9ae 100644 --- a/lib/organization-material-permissions.ts +++ b/lib/organization-material-permissions.ts @@ -220,6 +220,23 @@ export type EmployeeOrganizationPathSource = { }> } +export function collectEmployeeOrganizationSourceDeptIds( + profile: EmployeeOrganizationPathSource | null | undefined, +): string[] { + if (!profile) return [] + const synced = ((profile.sourcePayload as { syncedFields?: Record } | null)?.syncedFields || + {}) as Record + return Array.from(new Set([ + ...(profile.departmentMemberships || []).flatMap((membership) => + parseOrganizationDepartmentIds(membership.sourceDeptId), + ), + ...parseOrganizationDepartmentIds(synced.departmentIds), + ...parseOrganizationDepartmentIds(synced.employeeDeptIdList), + ...parseOrganizationDepartmentIds(synced.registerDeptIds), + ...parseOrganizationDepartmentIds(synced.flowerBookDeptId), + ])) +} + /** 和数据口径同一套任职路径:主部门 + 兼任 + 花名册部门 ID。 */ export function resolveEmployeeOrganizationPaths( profile: EmployeeOrganizationPathSource | null | undefined, @@ -230,20 +247,11 @@ export function resolveEmployeeOrganizationPaths( 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)), + ...collectEmployeeOrganizationSourceDeptIds(profile).map((id) => lookup.get(id)), ]) } diff --git a/lib/performance-survey-report-sync.ts b/lib/performance-survey-report-sync.ts index 3ae56e8..6cf7da3 100644 --- a/lib/performance-survey-report-sync.ts +++ b/lib/performance-survey-report-sync.ts @@ -162,6 +162,7 @@ const FIELD_CANDIDATES = { managerJobNumber: ['manager_job_number', 'managerJobNumber', 'leader_job_number', '主管工号'], managerName: ['manager_name', 'managerName', 'leader_name', '主管'], flowerBookJobNumber: ['jobNum', 'job_number', 'jobNumber', '工号'], + flowerBookDingtalkUserId: ['flowerNameId', 'flower_name_id'], flowerBookName: ['name', '姓名'], flowerBookFlowerName: ['flowerName', 'flower_name', '花名'], flowerBookDept: ['dept', 'department', 'department_name', '部门'], @@ -646,6 +647,7 @@ function buildSelectSql( managerName: pickColumn(registerColumns, FIELD_CANDIDATES.managerName), } const f = { + dingtalkUserId: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookDingtalkUserId), jobNumber: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookJobNumber), name: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookName), flowerName: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookFlowerName), @@ -676,7 +678,7 @@ function buildSelectSql( return ` SELECT TOP (${limitSql}) - COALESCE(${columnExpr('e', e.dingtalkUserId)}, ${columnExpr('r', r.dingtalkUserId)}) AS "dingtalkUserId", + COALESCE(${columnExpr('e', e.dingtalkUserId)}, ${columnExpr('r', r.dingtalkUserId)}, ${columnExpr('f', f.dingtalkUserId)}) AS "dingtalkUserId", COALESCE(${columnExpr('e', e.email)}, ${columnExpr('r', r.email)}) AS "dingEmail", ${columnExpr('e', e.jobNumber)} AS "jobNumber", ${columnExpr('e', e.name)} AS "contactName", @@ -819,6 +821,7 @@ function buildRegisterListSql(registerColumns: TableColumns, whereSql?: string) function buildFlowerListSql(flowerColumns: TableColumns, whereSql?: string) { const f = { + dingtalkUserId: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookDingtalkUserId), jobNumber: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookJobNumber), name: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookName), flowerName: pickColumn(flowerColumns, FIELD_CANDIDATES.flowerBookFlowerName), @@ -842,6 +845,7 @@ function buildFlowerListSql(flowerColumns: TableColumns, whereSql?: string) { return ` SELECT TOP (@limit) + ${columnExpr('f', f.dingtalkUserId)} AS "dingtalkUserId", ${columnExpr('f', f.jobNumber)} AS "registerJobNumber", ${columnExpr('f', f.name)} AS "flowerBookName", ${columnExpr('f', f.flowerName)} AS "flowerBookFlowerName", @@ -1091,6 +1095,12 @@ async function fetchFullSyncRows( pushMerged(mergeReportRows(register, flower)) } + // 新入职员工可能先进入花名表,稍后才进入钉钉人员表或登记表。 + // 花名表里的在职记录必须独立成档,否则登录兜底档案没有部门路径,会被素材权限拒绝。 + for (const flower of flowerRows) { + pushMerged(flower) + } + return applyResolvedDepartmentPaths(pool, mergedRows.slice(0, limit)) } @@ -1852,7 +1862,32 @@ async function fetchNormalizedEmployeeByIdentityWithContext( '@limit', ), ) - const rows = await applyResolvedDepartmentPaths(pool, result.recordset || []) + let reportRows = (result.recordset || []) as ReportEmployeeRow[] + + // 花名表独有的新员工无法从以 hr_employee 为主表的查询返回。 + // 这里只允许按钉钉唯一值精确补查,禁止按姓名或花名自动绑定。 + if (reportRows.length === 0 && clean(identity.dingtalkUserId)) { + const flowerDingtalkUserId = pickColumn( + schema.flowerColumns, + FIELD_CANDIDATES.flowerBookDingtalkUserId, + ) + const flowerWhere = eqCondition('f', flowerDingtalkUserId, 'dingtalkUserId') + if (flowerWhere) { + reportRows = await queryReportRows( + pool, + sql, + buildFlowerListSql(schema.flowerColumns, flowerWhere), + 1, + (flowerRequest) => flowerRequest.input( + 'dingtalkUserId', + sql.NVarChar(200), + clean(identity.dingtalkUserId), + ), + ) + } + } + + const rows = await applyResolvedDepartmentPaths(pool, reportRows) const row = rows[0] as ReportEmployeeRow | undefined if (!row) return null diff --git a/lib/permission-groups.ts b/lib/permission-groups.ts index bd89893..9e831fb 100644 --- a/lib/permission-groups.ts +++ b/lib/permission-groups.ts @@ -89,12 +89,20 @@ export async function seedDefaultGroupDepartments() { } export async function loadDepartmentGroupPack(path: string): Promise { - const chain = organizationPathChain(path) - if (chain.length === 0) return null + const packs = await loadDepartmentGroupPacks([path]) + return packs.get(normalizeOrganizationPath(path)) ?? null +} + +export async function loadDepartmentGroupPacks(paths: string[]): Promise> { + const normalizedPaths = Array.from(new Set(paths.map(normalizeOrganizationPath).filter(Boolean))) + const chains = new Map(normalizedPaths.map((path) => [path, organizationPathChain(path)])) + const candidatePaths = Array.from(new Set(Array.from(chains.values()).flat())) + const result = new Map() + if (candidatePaths.length === 0) return result try { const memberships = await prisma.materialPermissionPackDepartment.findMany({ - where: { departmentPath: { in: chain } }, + where: { departmentPath: { in: candidatePaths } }, select: { departmentPath: true, pack: { @@ -104,11 +112,14 @@ export async function loadDepartmentGroupPack(path: string): Promise packItemsToMap(row.pack.items))) + for (const path of normalizedPaths) { + const nearest = findNearestGroupMemberships(path, memberships) + if (!nearest) continue + result.set(path, unionPermissionPacks(...nearest.items.map((row) => packItemsToMap(row.pack.items)))) + } + return result } catch (error) { - if (isMissingGroupTable(error)) return null + if (isMissingGroupTable(error)) return result throw error } }