fix: 修复权限组继承与花名表人员同步

This commit is contained in:
chenpei 2026-08-21 11:10:35 +08:00
parent eb10f8246b
commit 524024f7d6
14 changed files with 439 additions and 42 deletions

View File

@ -428,7 +428,7 @@ export async function GET(
userDepartment,
userRole,
managedDepartment,
'详情模板',
(material as any).materialType,
userId
)
: true;

View File

@ -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
);

View File

@ -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;

View File

@ -209,7 +209,7 @@ async function authorizeSourceAccess(
user.department,
user.role,
user.managedDepartment,
'详情模板',
material.materialType,
String(sessionUser.id),
)
if (!canDownloadSource) {

View File

@ -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 })

View File

@ -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)
})
})

View File

@ -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 () => {

View File

@ -154,7 +154,7 @@ describe('materials download route video wait period', () => {
'测试部门',
'USER',
null,
'详情模板',
'任务-3D原图',
'user-1',
)
})

View File

@ -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<string, unknown>()
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<string, unknown>()
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'

View File

@ -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<Record<string, MaterialPermissionFlags> | 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<string, MaterialPermissionFlags>
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)
}

View File

@ -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<string[]> {
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<string | null> {
const binding = await prisma.userEmployeeBinding.findFirst({

View File

@ -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<string, unknown> } | null)?.syncedFields ||
{}) as Record<string, unknown>
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<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)),
...collectEmployeeOrganizationSourceDeptIds(profile).map((id) => lookup.get(id)),
])
}

View File

@ -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

View File

@ -89,12 +89,20 @@ export async function seedDefaultGroupDepartments() {
}
export async function loadDepartmentGroupPack(path: string): Promise<PermissionPackMap | null> {
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<Map<string, PermissionPackMap>> {
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<string, PermissionPackMap>()
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<PermissionP
},
},
})
const nearest = findNearestGroupMemberships(path, memberships)
if (!nearest) return null
return unionPermissionPacks(...nearest.items.map((row) => 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
}
}