浏览代码

feat(环评): 实现评价人资格与邀请名单(任务 2/10)

wangkangyjy 2 天之前
父节点
当前提交
075f2ccee7

+ 65 - 0
backend/src/main/java/com/emoon/okr/dto/PeerReviewDto.java

@@ -0,0 +1,65 @@
+package com.emoon.okr.dto;
+
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public final class PeerReviewDto {
+
+    private PeerReviewDto() {
+    }
+
+    @Data
+    public static class SubmitListRequest {
+        @NotNull
+        private Long periodId;
+        @NotEmpty
+        @Size(min = 3, max = 5)
+        private List<Long> reviewerIds;
+    }
+
+    @Data
+    public static class Candidate {
+        private Long userId;
+        private String realName;
+        private Long departmentId;
+        private String relationType;
+    }
+
+    @Data
+    public static class EmployeeProgress {
+        private Long requestId;
+        private String status;
+        private String rejectionReason;
+        private int invitedCount;
+        private int submittedCount;
+        private List<InvitationSummary> invitations = new ArrayList<>();
+    }
+
+    @Data
+    public static class InvitationSummary {
+        private Long invitationId;
+        private Long reviewerId;
+        private String reviewerName;
+        private String relationType;
+        private String status;
+        private String declineReason;
+    }
+
+    @Data
+    public static class LeaderSummary {
+        private Long requestId;
+        private Long subjectUserId;
+        private String status;
+    }
+
+    @Data
+    public static class AdminStatistics {
+        private int requestedCount;
+        private int submittedReviewCount;
+    }
+}

+ 243 - 0
backend/src/main/java/com/emoon/okr/service/PeerReviewService.java

@@ -0,0 +1,243 @@
+package com.emoon.okr.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.emoon.okr.dto.PeerReviewDto;
+import com.emoon.okr.entity.AssessmentPeriod;
+import com.emoon.okr.entity.PeerReviewInvitation;
+import com.emoon.okr.entity.PeerReviewRequest;
+import com.emoon.okr.entity.PerformanceScore;
+import com.emoon.okr.entity.PeriodParticipant;
+import com.emoon.okr.entity.SysUser;
+import com.emoon.okr.enums.PeriodStatus;
+import com.emoon.okr.enums.ScoreType;
+import com.emoon.okr.exception.BusinessException;
+import com.emoon.okr.mapper.AssessmentPeriodMapper;
+import com.emoon.okr.mapper.PeerReviewInvitationMapper;
+import com.emoon.okr.mapper.PeerReviewRequestMapper;
+import com.emoon.okr.mapper.PerformanceScoreMapper;
+import com.emoon.okr.mapper.PeriodParticipantMapper;
+import com.emoon.okr.mapper.SysUserMapper;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class PeerReviewService {
+
+    private static final String ACTIVE = "ACTIVE";
+    private static final String REQUEST_PENDING_APPROVAL = "PENDING_APPROVAL";
+    private static final String INVITATION_PROPOSED = "PROPOSED";
+    private static final String INVITATION_DECLINED = "DECLINED";
+    private static final String INVITATION_SUBMITTED = "SUBMITTED";
+    private static final String SAME_DEPARTMENT = "SAME_DEPARTMENT";
+    private static final String CROSS_DEPARTMENT = "CROSS_DEPARTMENT";
+
+    private final AssessmentPeriodMapper periodMapper;
+    private final PeriodParticipantMapper participantMapper;
+    private final PerformanceScoreMapper scoreMapper;
+    private final PeerReviewRequestMapper requestMapper;
+    private final PeerReviewInvitationMapper invitationMapper;
+    private final SysUserMapper userMapper;
+
+    public List<PeerReviewDto.Candidate> listCandidates(Long periodId, Long subjectUserId) {
+        AssessmentPeriod period = requireAssessingPeriod(periodId);
+        requireInvitationOpen(period);
+        List<PeriodParticipant> participants = periodParticipants(periodId);
+        PeriodParticipant subject = requireActiveParticipant(participants, subjectUserId);
+
+        List<PeriodParticipant> eligible = participants.stream()
+                .filter(item -> isEligible(item, subject))
+                .toList();
+        Map<Long, SysUser> users = usersById(eligible.stream().map(PeriodParticipant::getUserId).toList());
+
+        return eligible.stream().map(item -> {
+            PeerReviewDto.Candidate candidate = new PeerReviewDto.Candidate();
+            candidate.setUserId(item.getUserId());
+            candidate.setRealName(users.containsKey(item.getUserId())
+                    ? users.get(item.getUserId()).getRealName() : null);
+            candidate.setDepartmentId(item.getDepartmentIdSnapshot());
+            candidate.setRelationType(relationType(subject, item));
+            return candidate;
+        }).toList();
+    }
+
+    @Transactional
+    public PeerReviewDto.EmployeeProgress submitReviewerList(Long periodId, Long subjectUserId,
+                                                              List<Long> reviewerIds) {
+        AssessmentPeriod period = requireAssessingPeriod(periodId);
+        requireInvitationOpen(period);
+        List<PeriodParticipant> participants = periodParticipants(periodId);
+        PeriodParticipant subject = requireActiveParticipant(participants, subjectUserId);
+        requireSubmittedSelfScore(periodId, subjectUserId);
+
+        if (reviewerIds == null || reviewerIds.size() < 3 || reviewerIds.size() > 5) {
+            throw new BusinessException("评价人须为 3 至 5 人");
+        }
+        Set<Long> distinctIds = new HashSet<>(reviewerIds);
+        if (distinctIds.size() != reviewerIds.size()) {
+            throw new BusinessException("评价人名单不能重复");
+        }
+        if (requestMapper.selectOne(new LambdaQueryWrapper<PeerReviewRequest>()
+                .eq(PeerReviewRequest::getPeriodId, periodId)
+                .eq(PeerReviewRequest::getSubjectUserId, subjectUserId)) != null) {
+            throw new BusinessException("本周期已提交评价人名单");
+        }
+
+        Map<Long, PeriodParticipant> byUserId = participants.stream().collect(Collectors.toMap(
+                PeriodParticipant::getUserId, Function.identity(), (left, right) -> left, LinkedHashMap::new));
+        List<PeriodParticipant> reviewers = new ArrayList<>();
+        for (Long reviewerId : reviewerIds) {
+            PeriodParticipant reviewer = byUserId.get(reviewerId);
+            if (Objects.equals(reviewerId, subjectUserId)
+                    || Objects.equals(reviewerId, subject.getEvaluatorId())
+                    || reviewer != null && Objects.equals(reviewer.getSuperiorIdSnapshot(), subjectUserId)) {
+                throw new BusinessException("评价人不能是本人、直属 Leader 或直属下属");
+            }
+            if (reviewer == null || !ACTIVE.equals(reviewer.getStatus())) {
+                throw new BusinessException("评价人必须是本周期有效参与人");
+            }
+            reviewers.add(reviewer);
+        }
+
+        boolean hasSameDepartment = reviewers.stream()
+                .anyMatch(item -> Objects.equals(item.getDepartmentIdSnapshot(), subject.getDepartmentIdSnapshot()));
+        boolean hasCrossDepartment = reviewers.stream()
+                .anyMatch(item -> !Objects.equals(item.getDepartmentIdSnapshot(), subject.getDepartmentIdSnapshot()));
+        if (!hasSameDepartment) {
+            throw new BusinessException("评价人名单至少包含 1 名同部门同事");
+        }
+        if (!hasCrossDepartment) {
+            throw new BusinessException("评价人名单至少包含 1 名跨部门同事");
+        }
+
+        LocalDateTime now = LocalDateTime.now();
+        PeerReviewRequest request = new PeerReviewRequest();
+        request.setPeriodId(periodId);
+        request.setSubjectUserId(subjectUserId);
+        request.setLeaderId(subject.getEvaluatorId());
+        request.setStatus(REQUEST_PENDING_APPROVAL);
+        request.setSubmittedAt(now);
+        requestMapper.insert(request);
+
+        List<PeerReviewInvitation> invitations = reviewers.stream().map(reviewer -> {
+            PeerReviewInvitation invitation = new PeerReviewInvitation();
+            invitation.setRequestId(request.getId());
+            invitation.setReviewerId(reviewer.getUserId());
+            invitation.setReviewerDepartmentIdSnapshot(reviewer.getDepartmentIdSnapshot());
+            invitation.setRelationTypeSnapshot(relationType(subject, reviewer));
+            invitation.setStatus(INVITATION_PROPOSED);
+            invitationMapper.insert(invitation);
+            return invitation;
+        }).toList();
+        return employeeProgress(request, invitations);
+    }
+
+    public PeerReviewDto.EmployeeProgress getEmployeeProgress(Long periodId, Long subjectUserId) {
+        PeerReviewRequest request = requestMapper.selectOne(new LambdaQueryWrapper<PeerReviewRequest>()
+                .eq(PeerReviewRequest::getPeriodId, periodId)
+                .eq(PeerReviewRequest::getSubjectUserId, subjectUserId));
+        if (request == null) {
+            PeerReviewDto.EmployeeProgress progress = new PeerReviewDto.EmployeeProgress();
+            progress.setStatus("NOT_STARTED");
+            return progress;
+        }
+        List<PeerReviewInvitation> invitations = invitationMapper.selectList(
+                new LambdaQueryWrapper<PeerReviewInvitation>()
+                        .eq(PeerReviewInvitation::getRequestId, request.getId()));
+        return employeeProgress(request, invitations);
+    }
+
+    private AssessmentPeriod requireAssessingPeriod(Long periodId) {
+        AssessmentPeriod period = periodMapper.selectById(periodId);
+        if (period == null || period.getStatus() != PeriodStatus.ASSESSING) {
+            throw new BusinessException("仅绩效考评阶段可发起 360 环评");
+        }
+        return period;
+    }
+
+    private void requireInvitationOpen(AssessmentPeriod period) {
+        if (period.getPeerInvitationDeadline() != null
+                && LocalDateTime.now().isAfter(period.getPeerInvitationDeadline())) {
+            throw new BusinessException("评价人邀请已截止");
+        }
+    }
+
+    private List<PeriodParticipant> periodParticipants(Long periodId) {
+        return participantMapper.selectList(new LambdaQueryWrapper<PeriodParticipant>()
+                .eq(PeriodParticipant::getPeriodId, periodId));
+    }
+
+    private PeriodParticipant requireActiveParticipant(List<PeriodParticipant> participants, Long userId) {
+        return participants.stream()
+                .filter(item -> Objects.equals(item.getUserId(), userId) && ACTIVE.equals(item.getStatus()))
+                .findFirst()
+                .orElseThrow(() -> new BusinessException("当前员工不是本周期有效参与人"));
+    }
+
+    private void requireSubmittedSelfScore(Long periodId, Long subjectUserId) {
+        PerformanceScore score = scoreMapper.selectOne(new LambdaQueryWrapper<PerformanceScore>()
+                .eq(PerformanceScore::getPeriodId, periodId)
+                .eq(PerformanceScore::getUserId, subjectUserId)
+                .eq(PerformanceScore::getType, ScoreType.SELF));
+        if (score == null || !"SELF_SUBMITTED".equals(score.getStatus())) {
+            throw new BusinessException("请先完成并提交自评");
+        }
+    }
+
+    private boolean isEligible(PeriodParticipant candidate, PeriodParticipant subject) {
+        return ACTIVE.equals(candidate.getStatus())
+                && !Objects.equals(candidate.getUserId(), subject.getUserId())
+                && !Objects.equals(candidate.getUserId(), subject.getEvaluatorId())
+                && !Objects.equals(candidate.getSuperiorIdSnapshot(), subject.getUserId());
+    }
+
+    private String relationType(PeriodParticipant subject, PeriodParticipant reviewer) {
+        return Objects.equals(subject.getDepartmentIdSnapshot(), reviewer.getDepartmentIdSnapshot())
+                ? SAME_DEPARTMENT : CROSS_DEPARTMENT;
+    }
+
+    private Map<Long, SysUser> usersById(List<Long> userIds) {
+        if (userIds.isEmpty()) {
+            return Map.of();
+        }
+        return userMapper.selectBatchIds(userIds).stream()
+                .collect(Collectors.toMap(SysUser::getId, Function.identity()));
+    }
+
+    private PeerReviewDto.EmployeeProgress employeeProgress(PeerReviewRequest request,
+                                                             List<PeerReviewInvitation> invitations) {
+        Map<Long, SysUser> users = usersById(invitations.stream()
+                .map(PeerReviewInvitation::getReviewerId).toList());
+        PeerReviewDto.EmployeeProgress progress = new PeerReviewDto.EmployeeProgress();
+        progress.setRequestId(request.getId());
+        progress.setStatus(request.getStatus());
+        progress.setRejectionReason(request.getRejectionReason());
+        progress.setInvitedCount(invitations.size());
+        progress.setSubmittedCount((int) invitations.stream()
+                .filter(item -> INVITATION_SUBMITTED.equals(item.getStatus())).count());
+        progress.setInvitations(invitations.stream().map(item -> {
+            PeerReviewDto.InvitationSummary summary = new PeerReviewDto.InvitationSummary();
+            summary.setInvitationId(item.getId());
+            summary.setReviewerId(item.getReviewerId());
+            summary.setReviewerName(users.containsKey(item.getReviewerId())
+                    ? users.get(item.getReviewerId()).getRealName() : null);
+            summary.setRelationType(item.getRelationTypeSnapshot());
+            summary.setStatus(INVITATION_DECLINED.equals(item.getStatus()) ? INVITATION_DECLINED : "IN_PROGRESS");
+            summary.setDeclineReason(item.getDeclineReason());
+            return summary;
+        }).toList());
+        return progress;
+    }
+}

+ 161 - 0
backend/src/test/java/com/emoon/okr/service/PeerReviewServiceTest.java

@@ -0,0 +1,161 @@
+package com.emoon.okr.service;
+
+import com.emoon.okr.dto.PeerReviewDto;
+import com.emoon.okr.entity.AssessmentPeriod;
+import com.emoon.okr.entity.PeerReviewInvitation;
+import com.emoon.okr.entity.PeerReviewRequest;
+import com.emoon.okr.entity.PerformanceScore;
+import com.emoon.okr.entity.PeriodParticipant;
+import com.emoon.okr.entity.SysUser;
+import com.emoon.okr.enums.PeriodStatus;
+import com.emoon.okr.enums.ScoreType;
+import com.emoon.okr.exception.BusinessException;
+import com.emoon.okr.mapper.AssessmentPeriodMapper;
+import com.emoon.okr.mapper.PeerReviewInvitationMapper;
+import com.emoon.okr.mapper.PeerReviewRequestMapper;
+import com.emoon.okr.mapper.PerformanceScoreMapper;
+import com.emoon.okr.mapper.PeriodParticipantMapper;
+import com.emoon.okr.mapper.SysUserMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class PeerReviewServiceTest {
+
+    @Mock AssessmentPeriodMapper periodMapper;
+    @Mock PeriodParticipantMapper participantMapper;
+    @Mock PerformanceScoreMapper scoreMapper;
+    @Mock PeerReviewRequestMapper requestMapper;
+    @Mock PeerReviewInvitationMapper invitationMapper;
+    @Mock SysUserMapper userMapper;
+    @InjectMocks PeerReviewService service;
+
+    @BeforeEach
+    void setUp() {
+        AssessmentPeriod period = new AssessmentPeriod();
+        period.setId(1L);
+        period.setStatus(PeriodStatus.ASSESSING);
+        when(periodMapper.selectById(1L)).thenReturn(period);
+
+        PerformanceScore selfScore = new PerformanceScore();
+        selfScore.setPeriodId(1L);
+        selfScore.setUserId(2L);
+        selfScore.setType(ScoreType.SELF);
+        selfScore.setStatus("SELF_SUBMITTED");
+        lenient().when(scoreMapper.selectOne(any())).thenReturn(selfScore);
+        lenient().when(requestMapper.selectOne(any())).thenReturn(null);
+    }
+
+    @Test
+    void listCandidatesExcludesSelfFrozenLeaderDirectReportAndInactiveParticipant() {
+        when(participantMapper.selectList(any())).thenReturn(List.of(
+                participant(2L, 10L, null, 9L, "ACTIVE"),
+                participant(9L, 11L, null, null, "ACTIVE"),
+                participant(3L, 11L, 2L, null, "ACTIVE"),
+                participant(4L, 10L, null, null, "ACTIVE"),
+                participant(5L, 11L, null, null, "ACTIVE"),
+                participant(6L, 12L, null, null, "EXEMPTED")));
+        when(userMapper.selectBatchIds(any())).thenReturn(List.of(
+                user(4L, "同部门同事"), user(5L, "跨部门同事")));
+
+        List<PeerReviewDto.Candidate> candidates = service.listCandidates(1L, 2L);
+
+        assertEquals(List.of(4L, 5L), candidates.stream().map(PeerReviewDto.Candidate::getUserId).toList());
+        assertEquals(List.of("SAME_DEPARTMENT", "CROSS_DEPARTMENT"),
+                candidates.stream().map(PeerReviewDto.Candidate::getRelationType).toList());
+    }
+
+    @Test
+    void submitReviewerListRequiresBothDepartmentRelations() {
+        when(participantMapper.selectList(any())).thenReturn(List.of(
+                participant(2L, 10L, null, 9L, "ACTIVE"),
+                participant(3L, 10L, null, null, "ACTIVE"),
+                participant(4L, 10L, null, null, "ACTIVE"),
+                participant(5L, 10L, null, null, "ACTIVE")));
+
+        BusinessException error = assertThrows(BusinessException.class,
+                () -> service.submitReviewerList(1L, 2L, List.of(3L, 4L, 5L)));
+
+        assertTrue(error.getMessage().contains("跨部门"));
+        verify(requestMapper, never()).insert(any());
+    }
+
+    @Test
+    void submitReviewerListRejectsSelfFrozenLeaderAndDirectReport() {
+        when(participantMapper.selectList(any())).thenReturn(List.of(
+                participant(2L, 10L, null, 9L, "ACTIVE"),
+                participant(9L, 11L, null, null, "ACTIVE"),
+                participant(3L, 11L, 2L, null, "ACTIVE")));
+
+        BusinessException error = assertThrows(BusinessException.class,
+                () -> service.submitReviewerList(1L, 2L, List.of(2L, 9L, 3L)));
+
+        assertTrue(error.getMessage().contains("本人、直属 Leader 或直属下属"));
+        verify(requestMapper, never()).insert(any());
+    }
+
+    @Test
+    void submitReviewerListCreatesPendingRequestAndProposedInvitations() {
+        when(participantMapper.selectList(any())).thenReturn(List.of(
+                participant(2L, 10L, null, 9L, "ACTIVE"),
+                participant(3L, 10L, null, null, "ACTIVE"),
+                participant(4L, 11L, null, null, "ACTIVE"),
+                participant(5L, 12L, null, null, "ACTIVE")));
+        when(userMapper.selectBatchIds(any())).thenReturn(List.of(
+                user(3L, "同部门同事"), user(4L, "跨部门同事甲"), user(5L, "跨部门同事乙")));
+        when(requestMapper.insert(any())).thenAnswer(invocation -> {
+            ((PeerReviewRequest) invocation.getArgument(0)).setId(20L);
+            return 1;
+        });
+
+        PeerReviewDto.EmployeeProgress result =
+                service.submitReviewerList(1L, 2L, List.of(3L, 4L, 5L));
+
+        ArgumentCaptor<PeerReviewRequest> request = ArgumentCaptor.forClass(PeerReviewRequest.class);
+        verify(requestMapper).insert(request.capture());
+        assertEquals(9L, request.getValue().getLeaderId());
+        assertEquals("PENDING_APPROVAL", request.getValue().getStatus());
+
+        ArgumentCaptor<PeerReviewInvitation> invitations =
+                ArgumentCaptor.forClass(PeerReviewInvitation.class);
+        verify(invitationMapper, org.mockito.Mockito.times(3)).insert(invitations.capture());
+        assertTrue(invitations.getAllValues().stream().allMatch(item -> "PROPOSED".equals(item.getStatus())));
+        assertEquals(3, result.getInvitedCount());
+        assertEquals(0, result.getSubmittedCount());
+    }
+
+    private PeriodParticipant participant(Long userId, Long departmentId, Long superiorId,
+                                          Long evaluatorId, String status) {
+        PeriodParticipant participant = new PeriodParticipant();
+        participant.setPeriodId(1L);
+        participant.setUserId(userId);
+        participant.setDepartmentIdSnapshot(departmentId);
+        participant.setSuperiorIdSnapshot(superiorId);
+        participant.setEvaluatorId(evaluatorId);
+        participant.setStatus(status);
+        return participant;
+    }
+
+    private SysUser user(Long id, String realName) {
+        SysUser user = new SysUser();
+        user.setId(id);
+        user.setRealName(realName);
+        return user;
+    }
+}