fix: harden direct manager resolution
This commit is contained in:
parent
1d4231d868
commit
bbebe24685
@ -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)
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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级主管,请先校准人员档案`)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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<Awaited<ReturnType<typeof getEmp
|
||||
async function resolveManager(employee: NonNullable<Awaited<ReturnType<typeof getEmployeeByUserId>>>) {
|
||||
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<Awaited<ReturnType<typeof ge
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!manager || manager.employmentState !== 'ACTIVE') {
|
||||
if (!manager || !isRosterEmployed(manager.employmentState)) {
|
||||
throw new TaskDetailChangeApprovalError('直属主管账号无有效在职档案,暂不能提交图片变更申请', 409)
|
||||
}
|
||||
return {
|
||||
@ -447,8 +451,11 @@ async function loadRequestForActor(id: string, actor: Actor) {
|
||||
if (!(await canAccessMaterialPrivacy(request.material, actor.id))) {
|
||||
throw new TaskDetailChangeApprovalError('无权限访问该私密素材', 403)
|
||||
}
|
||||
const managerIsEmployed = Boolean(employee && isRosterEmployed(employee.employmentState))
|
||||
const canDecide = Boolean(
|
||||
employee?.dingtalkUserId && employee.dingtalkUserId === request.managerDingtalkUserId,
|
||||
managerIsEmployed
|
||||
&& employee?.dingtalkUserId
|
||||
&& employee.dingtalkUserId === request.managerDingtalkUserId,
|
||||
)
|
||||
const canView = canDecide
|
||||
|| request.requesterUserId === actor.id
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user