Browse Source

feat(环评): 接入 Leader 终评门禁(任务 5/10)

wangkangyjy 1 day ago
parent
commit
80946ed496

+ 13 - 0
backend/src/main/java/com/emoon/okr/controller/PeerReviewController.java

@@ -57,6 +57,19 @@ public class PeerReviewController {
                 id, SecurityUtils.getCurrentUserId(), request.getApproved(), request.getReason()));
                 id, SecurityUtils.getCurrentUserId(), request.getApproved(), request.getReason()));
     }
     }
 
 
+    @GetMapping("/leader/summary")
+    public ApiResult<PeerReviewDto.LeaderSummary> leaderSummary(
+            @RequestParam Long periodId, @RequestParam Long userId) {
+        return ApiResult.success(peerReviewService.getLeaderSummary(
+                periodId, userId, SecurityUtils.getCurrentUserId()));
+    }
+
+    @GetMapping("/leader/queue")
+    public ApiResult<List<PeerReviewDto.LeaderQueueItem>> leaderQueue(@RequestParam Long periodId) {
+        return ApiResult.success(peerReviewService.getLeaderQueue(
+                periodId, SecurityUtils.getCurrentUserId()));
+    }
+
     @PostMapping("/invitations/{id}/accept")
     @PostMapping("/invitations/{id}/accept")
     public ApiResult<PeerReviewDto.InvitationSummary> accept(@PathVariable Long id) {
     public ApiResult<PeerReviewDto.InvitationSummary> accept(@PathVariable Long id) {
         return ApiResult.success(peerReviewService.acceptInvitation(
         return ApiResult.success(peerReviewService.acceptInvitation(

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

@@ -126,6 +126,33 @@ public final class PeerReviewDto {
         private Long requestId;
         private Long requestId;
         private Long subjectUserId;
         private Long subjectUserId;
         private String status;
         private String status;
+        private int invitedCount;
+        private int submittedCount;
+        private boolean canFinalize;
+        private Double averageGoalContributionScore;
+        private Double averageCollaborationScore;
+        private Double averageAccountabilityScore;
+        private List<LeaderResponse> responses = new ArrayList<>();
+    }
+
+    @Data
+    public static class LeaderResponse {
+        private Long reviewerId;
+        private String reviewerName;
+        private Integer goalContributionScore;
+        private Integer collaborationScore;
+        private Integer accountabilityScore;
+        private String strengths;
+        private String improvementSuggestion;
+    }
+
+    @Data
+    public static class LeaderQueueItem {
+        private Long requestId;
+        private Long subjectUserId;
+        private String status;
+        private int submittedCount;
+        private boolean canFinalize;
     }
     }
 
 
     @Data
     @Data

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

@@ -292,6 +292,61 @@ public class PeerReviewService {
         return reviewerInvitationSummary(invitation);
         return reviewerInvitationSummary(invitation);
     }
     }
 
 
+    public void requireCanFinalize(Long periodId, Long subjectUserId, Long leaderId) {
+        PeerReviewRequest request = requireLeaderRequest(periodId, subjectUserId, leaderId);
+        List<PeerReviewInvitation> invitations = requestInvitations(request.getId());
+        long submitted = invitations.stream()
+                .filter(item -> INVITATION_SUBMITTED.equals(item.getStatus())).count();
+        if (submitted < 2) {
+            throw new BusinessException("至少需要 2 份有效环评才能终评");
+        }
+        AssessmentPeriod period = periodMapper.selectById(periodId);
+        boolean deadlinePassed = period != null && period.getPeerReviewDeadline() != null
+                && LocalDateTime.now().isAfter(period.getPeerReviewDeadline());
+        if (!deadlinePassed && invitations.stream().anyMatch(this::isOutstandingEffectiveInvitation)) {
+            throw new BusinessException("评价截止前须等待全部有效邀请提交后才能终评");
+        }
+    }
+
+    public PeerReviewDto.LeaderSummary getLeaderSummary(Long periodId, Long subjectUserId, Long leaderId) {
+        PeerReviewRequest request = requireLeaderRequest(periodId, subjectUserId, leaderId);
+        List<PeerReviewInvitation> invitations = requestInvitations(request.getId());
+        Map<Long, SysUser> users = usersById(invitations.stream()
+                .filter(item -> INVITATION_SUBMITTED.equals(item.getStatus()))
+                .map(PeerReviewInvitation::getReviewerId).toList());
+
+        PeerReviewDto.LeaderSummary summary = leaderSummary(request);
+        summary.setInvitedCount(invitations.size());
+        summary.setSubmittedCount((int) invitations.stream()
+                .filter(item -> INVITATION_SUBMITTED.equals(item.getStatus())).count());
+        summary.setCanFinalize(canFinalize(periodId, invitations));
+        List<PeerReviewInvitation> submitted = invitations.stream()
+                .filter(item -> INVITATION_SUBMITTED.equals(item.getStatus())).toList();
+        summary.setResponses(submitted.stream().map(item -> leaderResponse(item, users.get(item.getReviewerId())))
+                .toList());
+        summary.setAverageGoalContributionScore(average(submitted, PeerReviewInvitation::getGoalContributionScore));
+        summary.setAverageCollaborationScore(average(submitted, PeerReviewInvitation::getCollaborationScore));
+        summary.setAverageAccountabilityScore(average(submitted, PeerReviewInvitation::getAccountabilityScore));
+        return summary;
+    }
+
+    public List<PeerReviewDto.LeaderQueueItem> getLeaderQueue(Long periodId, Long leaderId) {
+        return requestMapper.selectList(new LambdaQueryWrapper<PeerReviewRequest>()
+                        .eq(PeerReviewRequest::getPeriodId, periodId)
+                        .eq(PeerReviewRequest::getLeaderId, leaderId))
+                .stream().map(request -> {
+                    PeerReviewDto.LeaderSummary summary = getLeaderSummary(
+                            periodId, request.getSubjectUserId(), leaderId);
+                    PeerReviewDto.LeaderQueueItem item = new PeerReviewDto.LeaderQueueItem();
+                    item.setRequestId(summary.getRequestId());
+                    item.setSubjectUserId(summary.getSubjectUserId());
+                    item.setStatus(summary.getStatus());
+                    item.setSubmittedCount(summary.getSubmittedCount());
+                    item.setCanFinalize(summary.isCanFinalize());
+                    return item;
+                }).toList();
+    }
+
     @Transactional
     @Transactional
     public PeerReviewDto.LeaderSummary reviewList(Long requestId, Long leaderId,
     public PeerReviewDto.LeaderSummary reviewList(Long requestId, Long leaderId,
                                                   boolean approved, String reason) {
                                                   boolean approved, String reason) {
@@ -398,6 +453,42 @@ public class PeerReviewService {
         return request;
         return request;
     }
     }
 
 
+    private PeerReviewRequest requireLeaderRequest(Long periodId, Long subjectUserId, Long leaderId) {
+        PeerReviewRequest request = requestMapper.selectOne(new LambdaQueryWrapper<PeerReviewRequest>()
+                .eq(PeerReviewRequest::getPeriodId, periodId)
+                .eq(PeerReviewRequest::getSubjectUserId, subjectUserId));
+        if (request == null) {
+            throw new BusinessException("该员工尚未发起 360 环评");
+        }
+        if (!Objects.equals(request.getLeaderId(), leaderId)) {
+            throw new BusinessException(403, "仅周期冻结 Leader 可查看环评结果");
+        }
+        return request;
+    }
+
+    private List<PeerReviewInvitation> requestInvitations(Long requestId) {
+        return invitationMapper.selectList(new LambdaQueryWrapper<PeerReviewInvitation>()
+                .eq(PeerReviewInvitation::getRequestId, requestId));
+    }
+
+    private boolean isOutstandingEffectiveInvitation(PeerReviewInvitation invitation) {
+        return !INVITATION_SUBMITTED.equals(invitation.getStatus())
+                && !INVITATION_DECLINED.equals(invitation.getStatus())
+                && !INVITATION_INVALIDATED.equals(invitation.getStatus());
+    }
+
+    private boolean canFinalize(Long periodId, List<PeerReviewInvitation> invitations) {
+        long submitted = invitations.stream()
+                .filter(item -> INVITATION_SUBMITTED.equals(item.getStatus())).count();
+        if (submitted < 2) {
+            return false;
+        }
+        AssessmentPeriod period = periodMapper.selectById(periodId);
+        boolean deadlinePassed = period != null && period.getPeerReviewDeadline() != null
+                && LocalDateTime.now().isAfter(period.getPeerReviewDeadline());
+        return deadlinePassed || invitations.stream().noneMatch(this::isOutstandingEffectiveInvitation);
+    }
+
     private PeerReviewInvitation requireReviewerInvitation(Long invitationId, Long reviewerId,
     private PeerReviewInvitation requireReviewerInvitation(Long invitationId, Long reviewerId,
                                                             boolean allowSubmitted) {
                                                             boolean allowSubmitted) {
         PeerReviewInvitation invitation = invitationMapper.selectById(invitationId);
         PeerReviewInvitation invitation = invitationMapper.selectById(invitationId);
@@ -513,6 +604,24 @@ public class PeerReviewService {
         return summary;
         return summary;
     }
     }
 
 
+    private PeerReviewDto.LeaderResponse leaderResponse(PeerReviewInvitation invitation, SysUser reviewer) {
+        PeerReviewDto.LeaderResponse response = new PeerReviewDto.LeaderResponse();
+        response.setReviewerId(invitation.getReviewerId());
+        response.setReviewerName(reviewer == null ? null : reviewer.getRealName());
+        response.setGoalContributionScore(invitation.getGoalContributionScore());
+        response.setCollaborationScore(invitation.getCollaborationScore());
+        response.setAccountabilityScore(invitation.getAccountabilityScore());
+        response.setStrengths(invitation.getStrengths());
+        response.setImprovementSuggestion(invitation.getImprovementSuggestion());
+        return response;
+    }
+
+    private Double average(List<PeerReviewInvitation> invitations,
+                           Function<PeerReviewInvitation, Integer> score) {
+        return invitations.stream().map(score).filter(Objects::nonNull)
+                .mapToInt(Integer::intValue).average().orElse(0);
+    }
+
     private PeerReviewDto.InvitationSummary reviewerInvitationSummary(PeerReviewInvitation invitation) {
     private PeerReviewDto.InvitationSummary reviewerInvitationSummary(PeerReviewInvitation invitation) {
         PeerReviewDto.InvitationSummary summary = new PeerReviewDto.InvitationSummary();
         PeerReviewDto.InvitationSummary summary = new PeerReviewDto.InvitationSummary();
         summary.setInvitationId(invitation.getId());
         summary.setInvitationId(invitation.getId());

+ 2 - 0
backend/src/main/java/com/emoon/okr/service/PerformanceService.java

@@ -45,6 +45,7 @@ public class PerformanceService {
     private final OkrService okrService;
     private final OkrService okrService;
     private final AuthorizationService authorizationService;
     private final AuthorizationService authorizationService;
     private final ApplicationEventPublisher publisher;
     private final ApplicationEventPublisher publisher;
+    private final PeerReviewService peerReviewService;
     private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
     private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
 
 
     // Self evaluation
     // Self evaluation
@@ -112,6 +113,7 @@ public class PerformanceService {
         if (selfScore == null) {
         if (selfScore == null) {
             throw new BusinessException("员工尚未提交自评,暂不能进行上级评分");
             throw new BusinessException("员工尚未提交自评,暂不能进行上级评分");
         }
         }
+        peerReviewService.requireCanFinalize(periodId, userId, evaluatorId);
 
 
         PerformanceScore existing = findExisting(periodId, userId, ScoreType.SUPERIOR);
         PerformanceScore existing = findExisting(periodId, userId, ScoreType.SUPERIOR);
         if (existing != null) {
         if (existing != null) {

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

@@ -32,6 +32,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
 import org.springframework.context.ApplicationEventPublisher;
 import org.springframework.context.ApplicationEventPublisher;
 
 
 import java.util.List;
 import java.util.List;
+import java.time.LocalDateTime;
 
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -352,6 +353,73 @@ class PeerReviewServiceTest {
                 "SUBMITTED".equals(item.getStatus()) && item.getSubmittedAt() != null));
                 "SUBMITTED".equals(item.getStatus()) && item.getSubmittedAt() != null));
     }
     }
 
 
+    @Test
+    void finalizationRequiresAtLeastTwoSubmittedResponses() {
+        when(requestMapper.selectOne(any())).thenReturn(request(20L, "IN_PROGRESS"));
+        when(invitationMapper.selectList(any())).thenReturn(List.of(
+                invitation(30L, 3L, "SUBMITTED"),
+                invitation(31L, 4L, "DECLINED"),
+                invitation(32L, 5L, "INVALIDATED")));
+
+        BusinessException error = assertThrows(BusinessException.class,
+                () -> service.requireCanFinalize(1L, 2L, 9L));
+
+        assertTrue(error.getMessage().contains("2 份"));
+    }
+
+    @Test
+    void finalizationBeforeDeadlineRequiresEveryEffectiveInvitationSubmitted() {
+        AssessmentPeriod period = new AssessmentPeriod();
+        period.setId(1L);
+        period.setStatus(PeriodStatus.ASSESSING);
+        period.setPeerReviewDeadline(LocalDateTime.now().plusDays(1));
+        when(periodMapper.selectById(1L)).thenReturn(period);
+        when(requestMapper.selectOne(any())).thenReturn(request(20L, "IN_PROGRESS"));
+        when(invitationMapper.selectList(any())).thenReturn(List.of(
+                invitation(30L, 3L, "SUBMITTED"),
+                invitation(31L, 4L, "SUBMITTED"),
+                invitation(32L, 5L, "PENDING")));
+
+        BusinessException error = assertThrows(BusinessException.class,
+                () -> service.requireCanFinalize(1L, 2L, 9L));
+
+        assertTrue(error.getMessage().contains("全部有效邀请"));
+    }
+
+    @Test
+    void finalizationAfterDeadlineOnlyRequiresTwoSubmittedResponses() {
+        AssessmentPeriod period = new AssessmentPeriod();
+        period.setId(1L);
+        period.setStatus(PeriodStatus.ASSESSING);
+        period.setPeerReviewDeadline(LocalDateTime.now().minusMinutes(1));
+        when(periodMapper.selectById(1L)).thenReturn(period);
+        when(requestMapper.selectOne(any())).thenReturn(request(20L, "IN_PROGRESS"));
+        when(invitationMapper.selectList(any())).thenReturn(List.of(
+                invitation(30L, 3L, "SUBMITTED"),
+                invitation(31L, 4L, "SUBMITTED"),
+                invitation(32L, 5L, "PENDING")));
+
+        org.junit.jupiter.api.Assertions.assertDoesNotThrow(
+                () -> service.requireCanFinalize(1L, 2L, 9L));
+    }
+
+    @Test
+    void onlyFrozenLeaderCanReadIdentifiedResponses() {
+        PeerReviewInvitation first = submittedInvitation(30L, 3L, 5, "协作主动,目标推进结果稳定", "建议加强跨部门风险同步机制");
+        PeerReviewInvitation second = submittedInvitation(31L, 4L, 3, "能够按期完成承诺并主动补位", "建议更早暴露项目依赖和风险");
+        when(requestMapper.selectOne(any())).thenReturn(request(20L, "IN_PROGRESS"));
+        when(invitationMapper.selectList(any())).thenReturn(List.of(first, second));
+        when(userMapper.selectBatchIds(any())).thenReturn(List.of(
+                user(3L, "评价人甲"), user(4L, "评价人乙")));
+
+        assertThrows(BusinessException.class, () -> service.getLeaderSummary(1L, 2L, 8L));
+        PeerReviewDto.LeaderSummary summary = service.getLeaderSummary(1L, 2L, 9L);
+
+        assertEquals("评价人甲", summary.getResponses().get(0).getReviewerName());
+        assertEquals("协作主动,目标推进结果稳定", summary.getResponses().get(0).getStrengths());
+        assertEquals(4.0, summary.getAverageGoalContributionScore());
+    }
+
     private PeriodParticipant participant(Long userId, Long departmentId, Long superiorId,
     private PeriodParticipant participant(Long userId, Long departmentId, Long superiorId,
                                           Long evaluatorId, String status) {
                                           Long evaluatorId, String status) {
         PeriodParticipant participant = new PeriodParticipant();
         PeriodParticipant participant = new PeriodParticipant();
@@ -400,4 +468,15 @@ class PeerReviewServiceTest {
         response.setImprovementSuggestion("建议进一步加强风险前置识别和同步");
         response.setImprovementSuggestion("建议进一步加强风险前置识别和同步");
         return response;
         return response;
     }
     }
+
+    private PeerReviewInvitation submittedInvitation(Long id, Long reviewerId, int score,
+                                                      String strengths, String suggestion) {
+        PeerReviewInvitation invitation = invitation(id, reviewerId, "SUBMITTED");
+        invitation.setGoalContributionScore(score);
+        invitation.setCollaborationScore(score);
+        invitation.setAccountabilityScore(score);
+        invitation.setStrengths(strengths);
+        invitation.setImprovementSuggestion(suggestion);
+        return invitation;
+    }
 }
 }

+ 18 - 0
backend/src/test/java/com/emoon/okr/service/PerformanceServiceTest.java

@@ -44,6 +44,7 @@ class PerformanceServiceTest {
     @Mock OkrService okrService;
     @Mock OkrService okrService;
     @Mock AuthorizationService authorizationService;
     @Mock AuthorizationService authorizationService;
     @Mock ApplicationEventPublisher publisher;
     @Mock ApplicationEventPublisher publisher;
+    @Mock PeerReviewService peerReviewService;
     private PerformanceScore selfScore;
     private PerformanceScore selfScore;
     private PerformanceScore finalScore;
     private PerformanceScore finalScore;
     private PerformanceFeedback feedback;
     private PerformanceFeedback feedback;
@@ -78,6 +79,23 @@ class PerformanceServiceTest {
                 1L, 2L, 3L, 50.0, 30.0, 0.0, 0.0, "{}"));
                 1L, 2L, 3L, 50.0, 30.0, 0.0, 0.0, "{}"));
     }
     }
 
 
+    @Test
+    void submitSuperiorScoreRejectsUntilPeerReviewCanFinalize() {
+        when(periodMapper.selectById(1L)).thenReturn(period());
+        when(userMapper.selectById(2L)).thenReturn(user(2L, 3L));
+        when(userMapper.selectById(3L)).thenReturn(user(3L, null));
+        when(scoreMapper.selectOne(any())).thenReturn(score(ScoreType.SELF, "SELF_SUBMITTED"));
+        doThrow(new BusinessException("至少需要 2 份有效环评"))
+                .when(peerReviewService).requireCanFinalize(1L, 2L, 3L);
+
+        BusinessException error = assertThrows(BusinessException.class,
+                () -> performanceService.submitSuperiorScore(
+                        1L, 2L, 3L, 50.0, 30.0, 0.0, 0.0, "{}"));
+
+        assertTrue(error.getMessage().contains("2 份"));
+        verify(scoreMapper, never()).insert(any());
+    }
+
     @Test
     @Test
     void submitSelfScoreRejectsOkrScoreAboveSixty() {
     void submitSelfScoreRejectsOkrScoreAboveSixty() {
         when(periodMapper.selectById(1L)).thenReturn(period());
         when(periodMapper.selectById(1L)).thenReturn(period());