فهرست منبع

feat(环评): 实现同事评价与隐私材料(任务 4/10)

wangkangyjy 2 روز پیش
والد
کامیت
1f69985a92

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

@@ -69,4 +69,24 @@ public class PeerReviewController {
         return ApiResult.success(peerReviewService.declineInvitation(
                 id, SecurityUtils.getCurrentUserId(), request.getReason()));
     }
+
+    @GetMapping("/invitations/{id}")
+    public ApiResult<PeerReviewDto.ReviewerWorkspace> workspace(@PathVariable Long id) {
+        return ApiResult.success(peerReviewService.getReviewerWorkspace(
+                id, SecurityUtils.getCurrentUserId()));
+    }
+
+    @PutMapping("/invitations/{id}/draft")
+    public ApiResult<PeerReviewDto.InvitationSummary> saveDraft(
+            @PathVariable Long id, @Valid @RequestBody PeerReviewDto.SaveResponseRequest request) {
+        return ApiResult.success(peerReviewService.saveResponse(
+                id, SecurityUtils.getCurrentUserId(), request, false));
+    }
+
+    @PutMapping("/invitations/{id}/submit")
+    public ApiResult<PeerReviewDto.InvitationSummary> submitResponse(
+            @PathVariable Long id, @Valid @RequestBody PeerReviewDto.SaveResponseRequest request) {
+        return ApiResult.success(peerReviewService.saveResponse(
+                id, SecurityUtils.getCurrentUserId(), request, true));
+    }
 }

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

@@ -3,6 +3,8 @@ package com.emoon.okr.dto;
 import jakarta.validation.constraints.NotEmpty;
 import jakarta.validation.constraints.NotBlank;
 import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
 import jakarta.validation.constraints.Size;
 import lombok.Data;
 
@@ -46,6 +48,51 @@ public final class PeerReviewDto {
         private Long reviewerId;
     }
 
+    @Data
+    public static class SaveResponseRequest {
+        @Min(1) @Max(5)
+        private Integer goalContributionScore;
+        @Min(1) @Max(5)
+        private Integer collaborationScore;
+        @Min(1) @Max(5)
+        private Integer accountabilityScore;
+        @Size(max = 300)
+        private String strengths;
+        @Size(max = 300)
+        private String improvementSuggestion;
+    }
+
+    @Data
+    public static class ReviewerWorkspace {
+        private Long invitationId;
+        private Long subjectUserId;
+        private String status;
+        private List<ObjectiveSummary> objectives = new ArrayList<>();
+        private List<WorkResultSummary> workResults = new ArrayList<>();
+        private SaveResponseRequest response;
+    }
+
+    @Data
+    public static class ObjectiveSummary {
+        private String title;
+        private Double progress;
+        private List<KrSummary> keyResults = new ArrayList<>();
+    }
+
+    @Data
+    public static class KrSummary {
+        private String title;
+        private Double currentValue;
+        private Double targetValue;
+        private String status;
+    }
+
+    @Data
+    public static class WorkResultSummary {
+        private String name;
+        private String description;
+    }
+
     @Data
     public static class Candidate {
         private Long userId;

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

@@ -2,10 +2,12 @@ package com.emoon.okr.service;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.emoon.okr.dto.PeerReviewDto;
+import com.emoon.okr.dto.OkrDetailDto;
 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.PerformanceScoreItem;
 import com.emoon.okr.entity.PeriodParticipant;
 import com.emoon.okr.entity.SysUser;
 import com.emoon.okr.enums.PeriodStatus;
@@ -16,6 +18,7 @@ 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.PerformanceScoreItemMapper;
 import com.emoon.okr.mapper.PeriodParticipantMapper;
 import com.emoon.okr.mapper.SysUserMapper;
 import lombok.RequiredArgsConstructor;
@@ -58,6 +61,8 @@ public class PeerReviewService {
     private final PeerReviewInvitationMapper invitationMapper;
     private final SysUserMapper userMapper;
     private final ApplicationEventPublisher publisher;
+    private final OkrService okrService;
+    private final PerformanceScoreItemMapper scoreItemMapper;
 
     public List<PeerReviewDto.Candidate> listCandidates(Long periodId, Long subjectUserId) {
         AssessmentPeriod period = requireAssessingPeriod(periodId);
@@ -240,6 +245,53 @@ public class PeerReviewService {
         return employeeProgress(request, invitations);
     }
 
+    public PeerReviewDto.ReviewerWorkspace getReviewerWorkspace(Long invitationId, Long reviewerId) {
+        PeerReviewInvitation invitation = requireReviewerInvitation(invitationId, reviewerId, true);
+        PeerReviewRequest request = requireRequest(invitation.getRequestId());
+
+        PeerReviewDto.ReviewerWorkspace workspace = new PeerReviewDto.ReviewerWorkspace();
+        workspace.setInvitationId(invitation.getId());
+        workspace.setSubjectUserId(request.getSubjectUserId());
+        workspace.setStatus(invitation.getStatus());
+        workspace.setObjectives(okrService.getMyOkrSet(request.getPeriodId(), request.getSubjectUserId())
+                .stream().map(this::objectiveSummary).toList());
+
+        PerformanceScore selfScore = scoreMapper.selectOne(new LambdaQueryWrapper<PerformanceScore>()
+                .eq(PerformanceScore::getPeriodId, request.getPeriodId())
+                .eq(PerformanceScore::getUserId, request.getSubjectUserId())
+                .eq(PerformanceScore::getType, ScoreType.SELF));
+        if (selfScore != null) {
+            workspace.setWorkResults(scoreItemMapper.selectList(
+                            new LambdaQueryWrapper<PerformanceScoreItem>()
+                                    .eq(PerformanceScoreItem::getScoreId, selfScore.getId()))
+                    .stream().map(this::workResultSummary).toList());
+        }
+        workspace.setResponse(responseFrom(invitation));
+        return workspace;
+    }
+
+    @Transactional
+    public PeerReviewDto.InvitationSummary saveResponse(Long invitationId, Long reviewerId,
+                                                         PeerReviewDto.SaveResponseRequest body,
+                                                         boolean submit) {
+        PeerReviewInvitation invitation = requireReviewerInvitation(invitationId, reviewerId, false);
+        PeerReviewRequest request = requireRequest(invitation.getRequestId());
+        requireReviewOpen(request.getPeriodId());
+        validateResponse(body, submit);
+
+        invitation.setGoalContributionScore(body.getGoalContributionScore());
+        invitation.setCollaborationScore(body.getCollaborationScore());
+        invitation.setAccountabilityScore(body.getAccountabilityScore());
+        invitation.setStrengths(trimToNull(body.getStrengths()));
+        invitation.setImprovementSuggestion(trimToNull(body.getImprovementSuggestion()));
+        if (submit) {
+            invitation.setStatus(INVITATION_SUBMITTED);
+            invitation.setSubmittedAt(LocalDateTime.now());
+        }
+        invitationMapper.updateById(invitation);
+        return reviewerInvitationSummary(invitation);
+    }
+
     @Transactional
     public PeerReviewDto.LeaderSummary reviewList(Long requestId, Long leaderId,
                                                   boolean approved, String reason) {
@@ -338,6 +390,36 @@ public class PeerReviewService {
         return request;
     }
 
+    private PeerReviewRequest requireRequest(Long requestId) {
+        PeerReviewRequest request = requestMapper.selectById(requestId);
+        if (request == null) {
+            throw new BusinessException("360 环评请求不存在");
+        }
+        return request;
+    }
+
+    private PeerReviewInvitation requireReviewerInvitation(Long invitationId, Long reviewerId,
+                                                            boolean allowSubmitted) {
+        PeerReviewInvitation invitation = invitationMapper.selectById(invitationId);
+        if (invitation == null || !Objects.equals(invitation.getReviewerId(), reviewerId)) {
+            throw new BusinessException(403, "无权查看或填写该环评");
+        }
+        boolean available = INVITATION_ACCEPTED.equals(invitation.getStatus())
+                || allowSubmitted && INVITATION_SUBMITTED.equals(invitation.getStatus());
+        if (!available) {
+            throw new BusinessException("请先接受邀请,或该环评已提交");
+        }
+        return invitation;
+    }
+
+    private void requireReviewOpen(Long periodId) {
+        AssessmentPeriod period = requireAssessingPeriod(periodId);
+        if (period.getPeerReviewDeadline() != null
+                && LocalDateTime.now().isAfter(period.getPeerReviewDeadline())) {
+            throw new BusinessException("360 环评填写已截止");
+        }
+    }
+
     private PeerReviewInvitation requirePendingInvitation(Long invitationId, Long reviewerId) {
         PeerReviewInvitation invitation = invitationMapper.selectById(invitationId);
         if (invitation == null || !Objects.equals(invitation.getReviewerId(), reviewerId)) {
@@ -440,4 +522,81 @@ public class PeerReviewService {
         summary.setDeclineReason(invitation.getDeclineReason());
         return summary;
     }
+
+    private PeerReviewDto.ObjectiveSummary objectiveSummary(OkrDetailDto objective) {
+        PeerReviewDto.ObjectiveSummary summary = new PeerReviewDto.ObjectiveSummary();
+        summary.setTitle(objective.getTitle());
+        summary.setProgress(objective.getProgress());
+        if (objective.getKeyResults() != null) {
+            summary.setKeyResults(objective.getKeyResults().stream().map(kr -> {
+                PeerReviewDto.KrSummary item = new PeerReviewDto.KrSummary();
+                item.setTitle(kr.getTitle());
+                item.setCurrentValue(kr.getCurrentValue());
+                item.setTargetValue(kr.getTargetValue());
+                item.setStatus(kr.getStatus());
+                return item;
+            }).toList());
+        }
+        return summary;
+    }
+
+    private PeerReviewDto.WorkResultSummary workResultSummary(PerformanceScoreItem item) {
+        PeerReviewDto.WorkResultSummary summary = new PeerReviewDto.WorkResultSummary();
+        summary.setName(item.getName());
+        summary.setDescription(item.getDescription());
+        return summary;
+    }
+
+    private PeerReviewDto.SaveResponseRequest responseFrom(PeerReviewInvitation invitation) {
+        PeerReviewDto.SaveResponseRequest response = new PeerReviewDto.SaveResponseRequest();
+        response.setGoalContributionScore(invitation.getGoalContributionScore());
+        response.setCollaborationScore(invitation.getCollaborationScore());
+        response.setAccountabilityScore(invitation.getAccountabilityScore());
+        response.setStrengths(invitation.getStrengths());
+        response.setImprovementSuggestion(invitation.getImprovementSuggestion());
+        return response;
+    }
+
+    private void validateResponse(PeerReviewDto.SaveResponseRequest body, boolean submit) {
+        if (body == null) {
+            throw new BusinessException("评价内容不能为空");
+        }
+        for (Integer score : List.of(
+                body.getGoalContributionScore() == null ? 0 : body.getGoalContributionScore(),
+                body.getCollaborationScore() == null ? 0 : body.getCollaborationScore(),
+                body.getAccountabilityScore() == null ? 0 : body.getAccountabilityScore())) {
+            if (score != 0 && (score < 1 || score > 5)) {
+                throw new BusinessException("评分必须在 1 至 5 分之间");
+            }
+        }
+        validateMaxLength(body.getStrengths());
+        validateMaxLength(body.getImprovementSuggestion());
+        if (submit && (body.getGoalContributionScore() == null
+                || body.getCollaborationScore() == null
+                || body.getAccountabilityScore() == null)) {
+            throw new BusinessException("正式提交必须完成三项评分");
+        }
+        if (submit && (!validComment(body.getStrengths())
+                || !validComment(body.getImprovementSuggestion()))) {
+            throw new BusinessException("两项简评均须为 10 至 300 个字符");
+        }
+    }
+
+    private void validateMaxLength(String text) {
+        if (text != null && text.trim().length() > 300) {
+            throw new BusinessException("简评不能超过 300 个字符");
+        }
+    }
+
+    private boolean validComment(String text) {
+        int length = text == null ? 0 : text.trim().length();
+        return length >= 10 && length <= 300;
+    }
+
+    private String trimToNull(String text) {
+        if (text == null || text.trim().isEmpty()) {
+            return null;
+        }
+        return text.trim();
+    }
 }

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

@@ -1,10 +1,14 @@
 package com.emoon.okr.service;
 
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.emoon.okr.dto.OkrDetailDto;
 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.PerformanceScoreItem;
 import com.emoon.okr.entity.PeriodParticipant;
 import com.emoon.okr.entity.SysUser;
 import com.emoon.okr.enums.PeriodStatus;
@@ -15,6 +19,7 @@ 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.PerformanceScoreItemMapper;
 import com.emoon.okr.mapper.PeriodParticipantMapper;
 import com.emoon.okr.mapper.SysUserMapper;
 import org.junit.jupiter.api.BeforeEach;
@@ -47,6 +52,8 @@ class PeerReviewServiceTest {
     @Mock PeerReviewInvitationMapper invitationMapper;
     @Mock SysUserMapper userMapper;
     @Mock ApplicationEventPublisher publisher;
+    @Mock OkrService okrService;
+    @Mock PerformanceScoreItemMapper scoreItemMapper;
     @InjectMocks PeerReviewService service;
 
     @BeforeEach
@@ -276,6 +283,75 @@ class PeerReviewServiceTest {
         assertEquals("PENDING_APPROVAL", result.getStatus());
     }
 
+    @Test
+    void reviewerWorkspaceContainsOnlyOkrProgressAndWorkDescriptions() {
+        when(invitationMapper.selectById(30L)).thenReturn(invitation(30L, 3L, "ACCEPTED"));
+        when(requestMapper.selectById(20L)).thenReturn(request(20L, "IN_PROGRESS"));
+        OkrDetailDto objective = new OkrDetailDto();
+        objective.setTitle("提升交付质量");
+        objective.setProgress(75.0);
+        OkrDetailDto.KrWithProgress kr = new OkrDetailDto.KrWithProgress();
+        kr.setTitle("关键项目按时上线");
+        kr.setCurrentValue(3.0);
+        kr.setTargetValue(4.0);
+        kr.setStatus("IN_PROGRESS");
+        objective.setKeyResults(List.of(kr));
+        when(okrService.getMyOkrSet(1L, 2L)).thenReturn(List.of(objective));
+        PerformanceScoreItem item = new PerformanceScoreItem();
+        item.setName("重点工作");
+        item.setDescription("完成跨团队项目交付");
+        when(scoreItemMapper.selectList(any())).thenReturn(List.of(item));
+
+        PeerReviewDto.ReviewerWorkspace workspace = service.getReviewerWorkspace(30L, 3L);
+
+        assertEquals("提升交付质量", workspace.getObjectives().get(0).getTitle());
+        assertEquals("完成跨团队项目交付", workspace.getWorkResults().get(0).getDescription());
+        JsonNode json = new ObjectMapper().valueToTree(workspace);
+        assertTrue(!json.has("selfScore") && !json.has("bonus") && !json.has("penalty"));
+    }
+
+    @Test
+    void draftAllowsIncompleteResponseWithoutChangingInvitationStatus() {
+        when(invitationMapper.selectById(30L)).thenReturn(invitation(30L, 3L, "ACCEPTED"));
+        when(requestMapper.selectById(20L)).thenReturn(request(20L, "IN_PROGRESS"));
+        PeerReviewDto.SaveResponseRequest draft = new PeerReviewDto.SaveResponseRequest();
+        draft.setStrengths("合作顺畅");
+
+        service.saveResponse(30L, 3L, draft, false);
+
+        verify(invitationMapper).updateById(org.mockito.ArgumentMatchers.argThat(item ->
+                "ACCEPTED".equals(item.getStatus()) && "合作顺畅".equals(item.getStrengths())));
+    }
+
+    @Test
+    void submittedResponseRequiresThreeScoresAndTwoTenCharacterComments() {
+        when(invitationMapper.selectById(30L)).thenReturn(invitation(30L, 3L, "ACCEPTED"));
+        when(requestMapper.selectById(20L)).thenReturn(request(20L, "IN_PROGRESS"));
+        PeerReviewDto.SaveResponseRequest response = validResponse();
+        response.setStrengths("太短");
+
+        BusinessException error = assertThrows(BusinessException.class,
+                () -> service.saveResponse(30L, 3L, response, true));
+
+        assertTrue(error.getMessage().contains("10"));
+        verify(invitationMapper, never()).updateById(any());
+    }
+
+    @Test
+    void validResponseCanBeSubmittedOnlyOnce() {
+        PeerReviewInvitation invitation = invitation(30L, 3L, "ACCEPTED");
+        when(invitationMapper.selectById(30L)).thenReturn(invitation);
+        when(requestMapper.selectById(20L)).thenReturn(request(20L, "IN_PROGRESS"));
+
+        PeerReviewDto.InvitationSummary result = service.saveResponse(30L, 3L, validResponse(), true);
+
+        assertEquals("SUBMITTED", result.getStatus());
+        assertThrows(BusinessException.class,
+                () -> service.saveResponse(30L, 3L, validResponse(), true));
+        verify(invitationMapper).updateById(org.mockito.ArgumentMatchers.argThat(item ->
+                "SUBMITTED".equals(item.getStatus()) && item.getSubmittedAt() != null));
+    }
+
     private PeriodParticipant participant(Long userId, Long departmentId, Long superiorId,
                                           Long evaluatorId, String status) {
         PeriodParticipant participant = new PeriodParticipant();
@@ -314,4 +390,14 @@ class PeerReviewServiceTest {
         invitation.setStatus(status);
         return invitation;
     }
+
+    private PeerReviewDto.SaveResponseRequest validResponse() {
+        PeerReviewDto.SaveResponseRequest response = new PeerReviewDto.SaveResponseRequest();
+        response.setGoalContributionScore(4);
+        response.setCollaborationScore(5);
+        response.setAccountabilityScore(4);
+        response.setStrengths("目标推进扎实,跨团队合作沟通清晰");
+        response.setImprovementSuggestion("建议进一步加强风险前置识别和同步");
+        return response;
+    }
 }