fix: 修复权限组继承与花名表人员同步
This commit is contained in:
parent
eb10f8246b
commit
524024f7d6
@ -428,7 +428,7 @@ export async function GET(
|
||||
userDepartment,
|
||||
userRole,
|
||||
managedDepartment,
|
||||
'详情模板',
|
||||
(material as any).materialType,
|
||||
userId
|
||||
)
|
||||
: true;
|
||||
|
||||
@ -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
|
||||
);
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -209,7 +209,7 @@ async function authorizeSourceAccess(
|
||||
user.department,
|
||||
user.role,
|
||||
user.managedDepartment,
|
||||
'详情模板',
|
||||
material.materialType,
|
||||
String(sessionUser.id),
|
||||
)
|
||||
if (!canDownloadSource) {
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -154,7 +154,7 @@ describe('materials download route video wait period', () => {
|
||||
'测试部门',
|
||||
'USER',
|
||||
null,
|
||||
'详情模板',
|
||||
'任务-3D原图',
|
||||
'user-1',
|
||||
)
|
||||
})
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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)),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user