diff --git a/lib/__tests__/employee-manager.test.ts b/lib/__tests__/employee-manager.test.ts index 3e731cd..857fda0 100644 --- a/lib/__tests__/employee-manager.test.ts +++ b/lib/__tests__/employee-manager.test.ts @@ -5,28 +5,124 @@ import { resolveEmployeeDirectManagerProfile } from '@/lib/employee-manager' jest.mock('@/lib/prisma', () => ({ prisma: { performanceSurveyEmployeeProfile: { findMany: jest.fn() }, performanceSurveyEmployeeDepartmentMembership: { findMany: jest.fn() }, + organizationUnit: { findUnique: jest.fn(), findMany: jest.fn() }, } })) -const subject = { employeeProfileId: 'employee-1', displayName: '测试人员' } -const manager = { id: 'manager-1', dingtalkUserId: 'manager-ding', jobNumber: 'M001' } +const subject = { + employeeProfileId: 'employee-1', + displayName: '测试人员', + dingtalkUserId: 'employee-ding', + jobNumber: 'P001', +} +const manager = { + id: 'manager-1', + dingtalkUserId: 'manager-ding', + jobNumber: 'M001', + departmentMemberships: [{ sourceDeptId: '123', isPrimary: true }], +} beforeEach(() => { jest.clearAllMocks() ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([manager]) - ;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock).mockResolvedValue([{ sourceDeptId: '123' }]) + ;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock).mockResolvedValue([{ sourceDeptId: '123', departmentPath: '集团/中心/部门' }]) + ;(prisma.organizationUnit.findUnique as jest.Mock).mockResolvedValue(null) + ;(prisma.organizationUnit.findMany as jest.Mock).mockResolvedValue([]) }) test('主管钉钉ID和工号均存在时必须同时匹配,不能降级组织推断', async () => { expect(await resolveEmployeeDirectManagerProfile({ ...subject, managerDingtalkUserId: 'manager-ding', managerJobNumber: 'M001' })).toEqual(manager) - expect(prisma.performanceSurveyEmployeeProfile.findMany).toHaveBeenCalledWith({ where: { AND: [{ employmentState: 'ACTIVE' }, { dingtalkUserId: 'manager-ding', jobNumber: 'M001' }] }, take: 2 }) + expect(prisma.performanceSurveyEmployeeProfile.findMany).toHaveBeenCalledWith({ where: { AND: [{ employmentState: 'ACTIVE' }, { id: { not: 'employee-1' } }, { dingtalkUserId: 'manager-ding', jobNumber: 'M001' }] }, take: 2 }) expect(prisma.performanceSurveyEmployeeDepartmentMembership.findMany).not.toHaveBeenCalled() }) test('只有主管工号也支持精确解析', async () => { await resolveEmployeeDirectManagerProfile({ ...subject, managerJobNumber: 'M001' }) - expect(prisma.performanceSurveyEmployeeProfile.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { AND: [{ employmentState: 'ACTIVE' }, { jobNumber: 'M001' }] } })) + expect(prisma.performanceSurveyEmployeeProfile.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { AND: [ + { employmentState: 'ACTIVE' }, + { id: { not: 'employee-1' } }, + { jobNumber: 'M001' }, + ] } })) }) test('主管唯一值缺失时,仅按同一主组织唯一在职管理人员解析', async () => { expect(await resolveEmployeeDirectManagerProfile(subject)).toEqual(manager) expect(prisma.performanceSurveyEmployeeProfile.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { AND: expect.arrayContaining([ - { employmentState: 'ACTIVE' }, { id: { not: 'employee-1' } }, { departmentMemberships: { some: { sourceDeptId: '123', isPrimary: true } } }, + { employmentState: 'ACTIVE' }, { id: { not: 'employee-1' } }, { departmentMemberships: { some: { sourceDeptId: { in: ['123'] } } } }, ]) } })) + const query = (prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mock.calls[0][0] + expect(query.where.AND).toContainEqual({ OR: [ + { levelType: { equals: 'M', mode: 'insensitive' } }, + { positionLevel: { startsWith: 'M', mode: 'insensitive' } }, + ] }) + expect(JSON.stringify(query)).not.toContain('isLeadership') +}) +test('当前部门没有M级人员时,沿真实组织树采用最近上级组织的唯一M级人员', async () => { + ;(prisma.organizationUnit.findUnique as jest.Mock).mockResolvedValue({ path: '集团/中心/部门' }) + ;(prisma.organizationUnit.findMany as jest.Mock).mockResolvedValue([ + { sourceDeptId: 123, path: '集团/中心/部门' }, + { sourceDeptId: 12, path: '集团/中心' }, + { sourceDeptId: 1, path: '集团' }, + ]) + const parentManager = { + ...manager, + id: 'parent-manager', + departmentMemberships: [{ sourceDeptId: '12', isPrimary: true }], + } + ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([parentManager]) + + await expect(resolveEmployeeDirectManagerProfile(subject)).resolves.toEqual(parentManager) + const query = (prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mock.calls[0][0] + expect(query.where.AND).toContainEqual({ + departmentMemberships: { + some: { sourceDeptId: { in: ['123', '12'] } }, + }, + }) +}) +test('同层级有主部门M和兼任M时优先主部门M', async () => { + const primaryManager = { + ...manager, + id: 'primary-manager', + departmentMemberships: [{ sourceDeptId: '123', isPrimary: true }], + } + const concurrentManager = { + ...manager, + id: 'concurrent-manager', + departmentMemberships: [{ sourceDeptId: '123', isPrimary: false }], + } + ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([ + concurrentManager, + primaryManager, + ]) + + await expect(resolveEmployeeDirectManagerProfile(subject)).resolves.toEqual(primaryManager) +}) +test('同层级没有主部门M但只有一个兼任M时采用该兼任M', async () => { + const concurrentManager = { + ...manager, + id: 'concurrent-manager', + departmentMemberships: [{ sourceDeptId: '123', isPrimary: false }], + } + ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([concurrentManager]) + + await expect(resolveEmployeeDirectManagerProfile(subject)).resolves.toEqual(concurrentManager) +}) +test('组织回退不会跨到公司根节点猜测主管', async () => { + ;(prisma.organizationUnit.findUnique as jest.Mock).mockResolvedValue({ path: '集团/中心/部门' }) + ;(prisma.organizationUnit.findMany as jest.Mock).mockResolvedValue([ + { sourceDeptId: 123, path: '集团/中心/部门' }, + { sourceDeptId: 12, path: '集团/中心' }, + { sourceDeptId: 1, path: '集团' }, + ]) + ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([{ + ...manager, + id: 'root-manager', + departmentMemberships: [{ sourceDeptId: '1', isPrimary: true }], + }]) + + await expect(resolveEmployeeDirectManagerProfile(subject)).rejects.toThrow('没有可用的M级主管') +}) +test.each([ + { managerDingtalkUserId: 'employee-ding' }, + { managerJobNumber: 'P001' }, +])('明确主管身份指向本人时直接拒绝且不做组织猜测', async (managerIdentity) => { + await expect(resolveEmployeeDirectManagerProfile({ ...subject, ...managerIdentity })).rejects.toThrow('不能是本人') + expect(prisma.performanceSurveyEmployeeProfile.findMany).not.toHaveBeenCalled() + expect(prisma.performanceSurveyEmployeeDepartmentMembership.findMany).not.toHaveBeenCalled() }) test.each([{ rows: [] }, { rows: [{ id: 'm1' }, { id: 'm2' }] }])('明确身份无结果或冲突时禁止组织降级', async ({ rows }) => { ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue(rows) diff --git a/lib/__tests__/performance-survey-service-policy.test.ts b/lib/__tests__/performance-survey-service-policy.test.ts index a02f541..0f3f7c8 100644 --- a/lib/__tests__/performance-survey-service-policy.test.ts +++ b/lib/__tests__/performance-survey-service-policy.test.ts @@ -57,6 +57,8 @@ jest.mock('@/lib/prisma', () => ({ performanceSurveyEmployeeDepartmentMembership: { findMany: jest.fn(), }, + organizationUnit: { findUnique: jest.fn(), findMany: jest.fn() }, + userEmployeeBinding: { findMany: jest.fn() }, $transaction: jest.fn(), }, })) @@ -238,6 +240,12 @@ describe('performance survey service policies', () => { ;(prisma.performanceSurveyEmployeeProfile.count as jest.Mock).mockResolvedValue(0) ;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock).mockReset() ;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock).mockResolvedValue([]) + ;(prisma.organizationUnit.findUnique as jest.Mock).mockReset() + ;(prisma.organizationUnit.findUnique as jest.Mock).mockResolvedValue(null) + ;(prisma.organizationUnit.findMany as jest.Mock).mockReset() + ;(prisma.organizationUnit.findMany as jest.Mock).mockResolvedValue([]) + ;(prisma.userEmployeeBinding.findMany as jest.Mock).mockReset() + ;(prisma.userEmployeeBinding.findMany as jest.Mock).mockResolvedValue([]) ;(prisma.performanceSurveyTarget.findMany as jest.Mock).mockResolvedValue([]) ;(prisma.user.findMany as jest.Mock).mockReset() ;(prisma.user.findMany as jest.Mock).mockResolvedValue([]) @@ -1616,6 +1624,7 @@ describe('performance survey service policies', () => { displayName: '直属主管', dingtalkUserId: 'ding-direct-manager', }, + boundUserId: 'user-bound-direct-manager', }, { label: '综合支持部M级人员', @@ -1632,6 +1641,7 @@ describe('performance survey service policies', () => { displayName: '直属主管', dingtalkUserId: 'ding-other-manager', }, + boundUserId: null, }, { label: '观世(档案为P级)', @@ -1648,8 +1658,9 @@ describe('performance survey service policies', () => { displayName: '直属主管', dingtalkUserId: 'ding-other-manager', }, + boundUserId: null, }, - ])('$label发起工作配合度时按模板读取人员档案中的直属主管', async ({ employee, evaluator }) => { + ])('$label发起工作配合度时按模板读取人员档案中的直属主管', async ({ employee, evaluator, boundUserId }) => { const target = { ...baseEmployee, ...employee, @@ -1680,6 +1691,15 @@ describe('performance survey service policies', () => { id: 'user-evaluator', dingtalkUserId: evaluator.dingtalkUserId, }]) + if (boundUserId) { + ;(prisma.userEmployeeBinding.findMany as jest.Mock).mockResolvedValue([{ + user: { + id: boundUserId, + dingtalkUserId: evaluator.dingtalkUserId, + email: null, + }, + }]) + } ;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue(buildSurvey({ templateCode: 'work_cooperation', creatorEmployeeId: target.id, @@ -1704,11 +1724,15 @@ describe('performance survey service policies', () => { }) const evaluatorQuery = (prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mock.calls[1][0] - expect(evaluatorQuery.where.AND[1]).toEqual({ dingtalkUserId: evaluator.dingtalkUserId }) + expect(evaluatorQuery.where.AND).toEqual(expect.arrayContaining([ + { id: { not: target.id } }, + { dingtalkUserId: evaluator.dingtalkUserId }, + ])) const assignmentRows = transactionClient.performanceSurveyAssignment.createMany.mock.calls[0][0].data expect(assignmentRows).toEqual([ expect.objectContaining({ evaluatorEmployeeId: evaluator.id, + ...(boundUserId ? { evaluatorUserId: boundUserId } : {}), evaluatorDingtalkUserId: evaluator.dingtalkUserId, evaluatorName: evaluator.displayName, workflowStageKey: 'manager_review', @@ -1878,6 +1902,7 @@ describe('performance survey service policies', () => { positionLevel: 'M1', isLeadership: true, sourcePayload: { materialUserId: 'user-wuxie' }, + departmentMemberships: [{ sourceDeptId: '1085808623', isPrimary: true }], } ;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue(target) ;(getPerformanceSurveyTemplate as jest.Mock).mockResolvedValue({ @@ -1890,7 +1915,7 @@ describe('performance survey service policies', () => { scoreBands: [], }) ;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock) - .mockResolvedValueOnce([{ sourceDeptId: '1085808623' }]) + .mockResolvedValueOnce([{ sourceDeptId: '1085808623', departmentPath: target.departmentPath }]) ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock) .mockResolvedValueOnce([target]) .mockResolvedValueOnce([uniqueManager]) @@ -1930,15 +1955,14 @@ describe('performance survey service policies', () => { employeeProfileId: target.id, isPrimary: true, }, - select: { sourceDeptId: true }, + select: { sourceDeptId: true, departmentPath: true }, take: 2, }) const managerQuery = (prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mock.calls[1][0] expect(managerQuery.where.AND).toContainEqual({ departmentMemberships: { some: { - sourceDeptId: '1085808623', - isPrimary: true, + sourceDeptId: { in: ['1085808623'] }, }, }, }) @@ -2068,12 +2092,12 @@ describe('performance survey service policies', () => { scoreBands: [], }) ;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock) - .mockResolvedValueOnce([{ sourceDeptId: '1085808623' }]) + .mockResolvedValueOnce([{ sourceDeptId: '1085808623', departmentPath: target.departmentPath }]) ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock) .mockResolvedValueOnce([target]) .mockResolvedValueOnce([ - { ...baseEmployee, id: 'manager-1', levelType: 'M', positionLevel: 'M1' }, - { ...baseEmployee, id: 'manager-2', levelType: 'M', positionLevel: 'M2' }, + { ...baseEmployee, id: 'manager-1', levelType: 'M', positionLevel: 'M1', departmentMemberships: [{ sourceDeptId: '1085808623', isPrimary: true }] }, + { ...baseEmployee, id: 'manager-2', levelType: 'M', positionLevel: 'M2', departmentMemberships: [{ sourceDeptId: '1085808623', isPrimary: true }] }, ]) await expect(createPerformanceSurvey(session, { @@ -2379,7 +2403,10 @@ describe('performance survey service policies', () => { }) const managerQuery = (prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mock.calls[1][0] - expect(managerQuery.where.AND[1]).toEqual({ dingtalkUserId: '17642061233583361' }) + expect(managerQuery.where.AND).toEqual(expect.arrayContaining([ + { id: { not: 'employee-photographer' } }, + { dingtalkUserId: '17642061233583361' }, + ])) const assignmentRows = transactionClient.performanceSurveyAssignment.createMany.mock.calls[0][0].data expect(assignmentRows).toEqual(expect.arrayContaining([ expect.objectContaining({ diff --git a/lib/__tests__/task-detail-change-approval.test.ts b/lib/__tests__/task-detail-change-approval.test.ts index 404387c..7d1e0ca 100644 --- a/lib/__tests__/task-detail-change-approval.test.ts +++ b/lib/__tests__/task-detail-change-approval.test.ts @@ -23,6 +23,7 @@ jest.mock('@/lib/prisma', () => ({ material: { findFirst: jest.fn(), findUnique: jest.fn() }, performanceSurveyEmployeeProfile: { findUnique: jest.fn(), findMany: jest.fn() }, performanceSurveyEmployeeDepartmentMembership: { findMany: jest.fn() }, + organizationUnit: { findUnique: jest.fn(), findMany: jest.fn() }, taskDetailChangeRequest: { create: jest.fn(), findUnique: jest.fn(), @@ -38,6 +39,16 @@ jest.mock('@/lib/employee-directory', () => ({ getEmployeeByUserId: jest.fn(), })) +jest.mock('@/lib/performance-survey-report-sync', () => ({ + getActivePerformanceSurveyEmployeeProfileWhere: jest.fn(() => ({ + OR: [ + { employmentState: 'ACTIVE' }, + { employmentState: 'UNKNOWN' }, + { employmentState: null }, + ], + })), +})) + jest.mock('@/lib/material-authorization', () => ({ canManageMaterial: jest.fn() })) jest.mock('@/lib/user-permission-inherit', () => ({ canUserEditTaskDetailAssets: jest.fn() })) jest.mock('@/lib/department-permission', () => ({ @@ -92,6 +103,7 @@ function employee(levelType: 'M' | 'P', dingtalkUserId = 'employee-ding') { isLeadership: levelType === 'M', managerName: '直属主管', managerDingtalkUserId: 'manager-ding', + employmentState: 'ACTIVE', employmentStatus: 'active', } } @@ -101,9 +113,20 @@ describe('任务详情图片变更主管审批', () => { jest.clearAllMocks() ;(prisma.material.findFirst as jest.Mock).mockResolvedValue(material) ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([ - { id: 'manager-employee', displayName: '直属主管', dingtalkUserId: 'manager-ding', employmentState: 'ACTIVE' }, + { + id: 'manager-employee', + displayName: '直属主管', + dingtalkUserId: 'manager-ding', + employmentState: 'ACTIVE', + departmentMemberships: [{ sourceDeptId: 'department-1', isPrimary: true }], + }, ]) - ;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock).mockResolvedValue([{ sourceDeptId: 'department-1' }]) + ;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock).mockResolvedValue([{ + sourceDeptId: 'department-1', + departmentPath: '集团/品牌中心', + }]) + ;(prisma.organizationUnit.findUnique as jest.Mock).mockResolvedValue(null) + ;(prisma.organizationUnit.findMany as jest.Mock).mockResolvedValue([]) ;(canManageMaterial as jest.Mock).mockResolvedValue(true) ;(batchEditTaskDetailImages as jest.Mock).mockResolvedValue({ success: true }) ;(queueTaskDetailChangeNotification as jest.Mock).mockResolvedValue('notification-1') @@ -310,6 +333,62 @@ describe('任务详情图片变更主管审批', () => { expect(batchEditTaskDetailImages).not.toHaveBeenCalled() }) + test('已离职的原直属主管不能继续批准待处理申请', async () => { + const pending = { + id: 'request-resigned-manager', + materialId: 'material-1', + requesterUserId: 'requester-user', + managerDingtalkUserId: 'manager-ding', + status: 'pending_manager_approval', + expiresAt: new Date(Date.now() + 60_000), + material, + requesterEmployee: { dingtalkUserId: 'requester-ding' }, + } + ;(getEmployeeByUserId as jest.Mock).mockResolvedValue({ + ...employee('M', 'manager-ding'), + employmentState: 'RESIGNED', + }) + ;(prisma.taskDetailChangeRequest.findUnique as jest.Mock).mockResolvedValue(pending) + + await expect(decideTaskDetailChange('request-resigned-manager', { + id: 'former-manager-user', + role: 'USER', + }, 'approve')).rejects.toThrow('您无权查看这条图片变更申请') + + expect(prisma.taskDetailChangeRequest.updateMany).not.toHaveBeenCalled() + expect(batchEditTaskDetailImages).not.toHaveBeenCalled() + }) + + test.each(['UNKNOWN', null])('在册状态为 %s 的直属主管仍可处理已有申请', async (employmentState) => { + const pending = { + id: `request-manager-${employmentState || 'null'}`, + materialId: 'material-1', + requesterUserId: 'requester-user', + requesterName: '申请人', + managerDingtalkUserId: 'manager-ding', + status: 'pending_manager_approval', + expiresAt: new Date(Date.now() + 60_000), + baseMaterialUpdatedAt: material.updatedAt, + operations: [{ kind: 'delete', assetId: 'asset-1' }], + material, + requesterEmployee: { dingtalkUserId: 'requester-ding' }, + } + ;(getEmployeeByUserId as jest.Mock).mockResolvedValue({ + ...employee('M', 'manager-ding'), + employmentState, + }) + ;(prisma.taskDetailChangeRequest.findUnique as jest.Mock) + .mockResolvedValueOnce(pending) + .mockResolvedValueOnce({ ...pending, status: 'rejected' }) + ;(prisma.taskDetailChangeRequest.updateMany as jest.Mock).mockResolvedValue({ count: 1 }) + ;(prisma.taskDetailChangeRequest.update as jest.Mock).mockResolvedValue({}) + + await expect(decideTaskDetailChange(pending.id, { + id: 'manager-user', + role: 'USER', + }, 'reject')).resolves.toMatchObject({ status: 'rejected' }) + }) + test('批准时正式素材已变化会停止应用、清理暂存并通知申请人', async () => { const pending = { id: 'request-stale', diff --git a/lib/employee-directory.ts b/lib/employee-directory.ts index c506973..c125bf2 100644 --- a/lib/employee-directory.ts +++ b/lib/employee-directory.ts @@ -55,6 +55,8 @@ export interface EmployeeDirectoryEntry { managerName: string | null managerDingtalkUserId: string | null managerJobNumber?: string | null + /** 规范化在职状态:ACTIVE / RESIGNED / UNKNOWN */ + employmentState: string | null employmentStatus: string | null } @@ -74,6 +76,7 @@ const PROFILE_SELECT = { managerName: true, managerDingtalkUserId: true, managerJobNumber: true, + employmentState: true, employmentStatus: true, } as const @@ -99,6 +102,7 @@ function toEntry(profile: RawProfile, userId: string | null): EmployeeDirectoryE managerName: profile.managerName, managerDingtalkUserId: profile.managerDingtalkUserId, managerJobNumber: profile.managerJobNumber, + employmentState: profile.employmentState, employmentStatus: profile.employmentStatus, } } diff --git a/lib/employee-manager.ts b/lib/employee-manager.ts index e47c916..3ba2cf7 100644 --- a/lib/employee-manager.ts +++ b/lib/employee-manager.ts @@ -7,29 +7,43 @@ export interface EmployeeManagerSubject { id?: string | null employeeProfileId?: string | null displayName?: string | null + dingtalkUserId?: string | null + jobNumber?: string | null managerDingtalkUserId?: string | null managerJobNumber?: string | null } const clean = (value: unknown) => String(value ?? '').trim() -/** 共享主管候选解析:唯一标识优先;完全缺失时只认同一主部门的唯一在职管理人员。 */ +/** 共享主管候选解析:唯一标识优先;完全缺失时沿主组织向上查找最近层级的唯一在册 M 级人员。 */ export async function resolveEmployeeDirectManagerProfile( subject: EmployeeManagerSubject, options: { activeWhere?: Prisma.PerformanceSurveyEmployeeProfileWhereInput } = {}, ) { const name = clean(subject.displayName) || '该人员' const employeeId = clean(subject.employeeProfileId || subject.id) + const employeeDingtalkUserId = clean(subject.dingtalkUserId) + const employeeJobNumber = clean(subject.jobNumber) const managerDingtalkUserId = clean(subject.managerDingtalkUserId) const managerJobNumber = clean(subject.managerJobNumber) const activeWhere = options.activeWhere || { employmentState: 'ACTIVE' } if (managerDingtalkUserId || managerJobNumber) { + if ( + (managerDingtalkUserId && managerDingtalkUserId === employeeDingtalkUserId) + || (managerJobNumber && managerJobNumber === employeeJobNumber) + ) { + throw new EmployeeManagerResolutionError(`${name}的直属主管不能是本人,请先校准人员档案`) + } const managers = await prisma.performanceSurveyEmployeeProfile.findMany({ - where: { AND: [activeWhere, { - ...(managerDingtalkUserId ? { dingtalkUserId: managerDingtalkUserId } : {}), - ...(managerJobNumber ? { jobNumber: managerJobNumber } : {}), - }] }, + where: { AND: [ + activeWhere, + ...(employeeId ? [{ id: { not: employeeId } }] : []), + { + ...(managerDingtalkUserId ? { dingtalkUserId: managerDingtalkUserId } : {}), + ...(managerJobNumber ? { jobNumber: managerJobNumber } : {}), + }, + ] }, take: 2, }) if (managers.length !== 1) { @@ -43,31 +57,73 @@ export async function resolveEmployeeDirectManagerProfile( } const primaryMemberships = await prisma.performanceSurveyEmployeeDepartmentMembership.findMany({ where: { employeeProfileId: employeeId, isPrimary: true }, - select: { sourceDeptId: true }, + select: { sourceDeptId: true, departmentPath: true }, take: 2, }) const departmentIds = Array.from(new Set(primaryMemberships.map(row => clean(row.sourceDeptId)).filter(Boolean))) if (departmentIds.length !== 1) { throw new EmployeeManagerResolutionError(`无法确定${name}所属的主组织,请先校准人员档案`) } + const primaryMembership = primaryMemberships.find(row => clean(row.sourceDeptId) === departmentIds[0]) + const numericSourceDeptId = Number(departmentIds[0]) + const primaryOrganizationUnit = Number.isInteger(numericSourceDeptId) + ? await prisma.organizationUnit.findUnique({ + where: { sourceDeptId: numericSourceDeptId }, + select: { path: true }, + }) + : null + const departmentPath = clean(primaryOrganizationUnit?.path) || clean(primaryMembership?.departmentPath) + const pathPrefixes = departmentPath + ? departmentPath.split('/').map(part => part.trim()).filter(Boolean) + .map((_part, index, parts) => parts.slice(0, index + 1).join('/')) + : [] + // 不跨到公司根节点猜主管;保留当前组织及根节点以下的直属上级链路。 + const scopedPathPrefixes = pathPrefixes.length > 1 ? pathPrefixes.slice(1) : pathPrefixes + const organizationUnits = scopedPathPrefixes.length > 0 + ? await prisma.organizationUnit.findMany({ + where: { isActive: true, path: { in: scopedPathPrefixes } }, + select: { sourceDeptId: true, path: true }, + }) + : [] + const sourceDeptIdByPath = new Map( + organizationUnits.map(unit => [clean(unit.path), clean(unit.sourceDeptId)]), + ) + const lineageDepartmentIds = Array.from(new Set([ + departmentIds[0], + ...scopedPathPrefixes.slice().reverse().map(path => sourceDeptIdByPath.get(path)).filter(Boolean), + ])) as string[] + const managers = await prisma.performanceSurveyEmployeeProfile.findMany({ where: { AND: [ activeWhere, { id: { not: employeeId } }, { OR: [ - { isLeadership: true }, { levelType: { equals: 'M', mode: 'insensitive' } }, { positionLevel: { startsWith: 'M', mode: 'insensitive' } }, ] }, - { departmentMemberships: { some: { sourceDeptId: departmentIds[0], isPrimary: true } } }, + { departmentMemberships: { some: { sourceDeptId: { in: lineageDepartmentIds } } } }, ] }, - take: 2, + include: { + departmentMemberships: { + where: { sourceDeptId: { in: lineageDepartmentIds } }, + select: { sourceDeptId: true, isPrimary: true }, + }, + }, }) - if (managers.length === 0) { - throw new EmployeeManagerResolutionError(`${name}所属组织内没有可用的M级主管,请先校准人员档案`) + for (const sourceDeptId of lineageDepartmentIds) { + const candidates = managers.filter(manager => manager.departmentMemberships?.some( + membership => clean(membership.sourceDeptId) === sourceDeptId, + ) === true) + if (candidates.length === 0) continue + const primaryCandidates = candidates.filter(manager => manager.departmentMemberships.some( + membership => clean(membership.sourceDeptId) === sourceDeptId && membership.isPrimary, + )) + if (primaryCandidates.length === 1) return primaryCandidates[0] + if (primaryCandidates.length > 1 || candidates.length > 1) { + throw new EmployeeManagerResolutionError(`${name}所属组织内存在多名M级人员,请明确配置直属主管`) + } + // 当前层级没有主部门 M,仅有一个兼任 M 时才采用;多个兼任仍拒绝猜测。 + return candidates[0] } - if (managers.length > 1) { - throw new EmployeeManagerResolutionError(`${name}所属组织内存在多名M级人员,请明确配置直属主管`) - } - return managers[0] + throw new EmployeeManagerResolutionError(`${name}所属组织内没有可用的M级主管,请先校准人员档案`) } diff --git a/lib/performance-survey-service.ts b/lib/performance-survey-service.ts index 78ebf5f..bd0face 100644 --- a/lib/performance-survey-service.ts +++ b/lib/performance-survey-service.ts @@ -5222,10 +5222,40 @@ async function resolveSurveyEvaluators(body: any, ctx: UserContext) { return Array.from(deduped.values()) } +async function resolveActiveBoundAccountForEmployee(employeeId: string | null) { + if (!employeeId) return null + const bindings = await prisma.userEmployeeBinding.findMany({ + where: { + employeeProfileId: employeeId, + status: 'ACTIVE', + user: { + archivedAt: null, + status: 'active', + isActive: true, + }, + }, + select: { + user: { + select: { + id: true, + dingtalkUserId: true, + email: true, + }, + }, + }, + take: 2, + }) + if (bindings.length > 1) { + throw new PerformanceSurveyApiError('人员存在多个有效账号绑定,请先校准人员档案', 409) + } + return bindings[0]?.user || null +} + async function resolveEmployeeEvaluator(employee: any) { const dingtalkUserId = clean(employee?.dingtalkUserId) const dingEmail = clean(employee?.dingEmail) - const matchedUser = dingtalkUserId || dingEmail + const boundUser = await resolveActiveBoundAccountForEmployee(clean(employee?.id)) + const matchedUser = boundUser || (dingtalkUserId || dingEmail ? await prisma.user.findFirst({ where: { OR: [ @@ -5235,7 +5265,7 @@ async function resolveEmployeeEvaluator(employee: any) { }, select: { id: true, dingtalkUserId: true }, }) - : null + : null) const userId = matchedUser?.id || null const resolvedDingtalkUserId = dingtalkUserId || clean(matchedUser?.dingtalkUserId) return { @@ -5255,34 +5285,39 @@ async function resolveAvailableDirectManagerProfile(targetEmployee: any, manager const linkedUserId = clean(sourcePayload.materialUserId) || clean(sourcePayload.userId) const managerDingtalkIdentity = clean(manager?.dingtalkUserId) const managerEmail = clean(manager?.dingEmail) - const accountIdentityWhere = linkedUserId - ? { id: linkedUserId } - : managerDingtalkIdentity - ? { dingtalkUserId: managerDingtalkIdentity } - : managerEmail - ? { email: { equals: managerEmail, mode: 'insensitive' as const } } - : null - if (!accountIdentityWhere) { + const boundAccount = await resolveActiveBoundAccountForEmployee(clean(manager?.id)) + const accountIdentityWhere = boundAccount + ? null + : linkedUserId + ? { id: linkedUserId } + : managerDingtalkIdentity + ? { dingtalkUserId: managerDingtalkIdentity } + : managerEmail + ? { email: { equals: managerEmail, mode: 'insensitive' as const } } + : null + if (!boundAccount && !accountIdentityWhere) { throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}的直属主管缺少可用登录身份,请先校准人员档案`, 409) } - const activeAccounts = await prisma.user.findMany({ - where: { - AND: [ - { - archivedAt: null, - status: 'active', - isActive: true, + const activeAccounts = boundAccount + ? [boundAccount] + : await prisma.user.findMany({ + where: { + AND: [ + { + archivedAt: null, + status: 'active', + isActive: true, + }, + accountIdentityWhere!, + ], }, - accountIdentityWhere, - ], - }, - select: { - id: true, - dingtalkUserId: true, - }, - take: 2, - }) + select: { + id: true, + dingtalkUserId: true, + }, + take: 2, + }) if (activeAccounts.length !== 1) { throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}的直属主管尚无可用账户,请先校准人员档案`, 409) } diff --git a/lib/task-detail-change-approval.ts b/lib/task-detail-change-approval.ts index 21159c7..e182732 100644 --- a/lib/task-detail-change-approval.ts +++ b/lib/task-detail-change-approval.ts @@ -1,9 +1,11 @@ import { randomUUID } from 'crypto' import { getEmployeeByUserId, BINDING_STATUS } from '@/lib/employee-directory' +import { isRosterEmployed } from '@/lib/auto-personnel-rules' import { canUserEditTaskDetailAssets } from '@/lib/user-permission-inherit' import { canManageMaterial } from '@/lib/material-authorization' import { EmployeeManagerResolutionError, resolveEmployeeDirectManagerProfile } from '@/lib/employee-manager' +import { getActivePerformanceSurveyEmployeeProfileWhere } from '@/lib/performance-survey-report-sync' import { canAccessMaterialPrivacy, TASK_DETAIL_PRIVACY_SELECT } from '@/lib/task-detail-privacy' import { isProductPackageType } from '@/lib/material-type-mapper' import { prisma } from '@/lib/prisma' @@ -229,7 +231,9 @@ function employeeSnapshot(employee: NonNullable>>) { let resolvedManager try { - resolvedManager = await resolveEmployeeDirectManagerProfile(employee) + resolvedManager = await resolveEmployeeDirectManagerProfile(employee, { + activeWhere: getActivePerformanceSurveyEmployeeProfileWhere(), + }) } catch (error) { if (error instanceof EmployeeManagerResolutionError) { throw new TaskDetailChangeApprovalError(error.message, 409) @@ -250,7 +254,7 @@ async function resolveManager(employee: NonNullable