Explorar el Código

feat(环评): 实现名单审核与邀请响应(任务 3/10)

wangkangyjy hace 1 día
padre
commit
075a1fad9e

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

@@ -0,0 +1,72 @@
+package com.emoon.okr.controller;
+
+import com.emoon.okr.dto.ApiResult;
+import com.emoon.okr.dto.PeerReviewDto;
+import com.emoon.okr.security.SecurityUtils;
+import com.emoon.okr.service.PeerReviewService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/peer-reviews")
+@RequiredArgsConstructor
+public class PeerReviewController {
+
+    private final PeerReviewService peerReviewService;
+
+    @GetMapping("/my/candidates")
+    public ApiResult<List<PeerReviewDto.Candidate>> candidates(@RequestParam Long periodId) {
+        return ApiResult.success(peerReviewService.listCandidates(
+                periodId, SecurityUtils.getCurrentUserId()));
+    }
+
+    @GetMapping("/my/progress")
+    public ApiResult<PeerReviewDto.EmployeeProgress> progress(@RequestParam Long periodId) {
+        return ApiResult.success(peerReviewService.getEmployeeProgress(
+                periodId, SecurityUtils.getCurrentUserId()));
+    }
+
+    @PostMapping("/my/requests")
+    public ApiResult<PeerReviewDto.EmployeeProgress> submitList(
+            @Valid @RequestBody PeerReviewDto.SubmitListRequest request) {
+        return ApiResult.success(peerReviewService.submitReviewerList(
+                request.getPeriodId(), SecurityUtils.getCurrentUserId(), request.getReviewerIds()));
+    }
+
+    @PostMapping("/my/supplements")
+    public ApiResult<PeerReviewDto.EmployeeProgress> supplement(
+            @Valid @RequestBody PeerReviewDto.SupplementRequest request) {
+        return ApiResult.success(peerReviewService.supplementReviewer(
+                request.getPeriodId(), SecurityUtils.getCurrentUserId(), request.getReviewerId()));
+    }
+
+    @PutMapping("/leader/requests/{id}/review")
+    public ApiResult<PeerReviewDto.LeaderSummary> review(
+            @PathVariable Long id, @Valid @RequestBody PeerReviewDto.ReviewListRequest request) {
+        return ApiResult.success(peerReviewService.reviewList(
+                id, SecurityUtils.getCurrentUserId(), request.getApproved(), request.getReason()));
+    }
+
+    @PostMapping("/invitations/{id}/accept")
+    public ApiResult<PeerReviewDto.InvitationSummary> accept(@PathVariable Long id) {
+        return ApiResult.success(peerReviewService.acceptInvitation(
+                id, SecurityUtils.getCurrentUserId()));
+    }
+
+    @PostMapping("/invitations/{id}/decline")
+    public ApiResult<PeerReviewDto.InvitationSummary> decline(
+            @PathVariable Long id, @Valid @RequestBody PeerReviewDto.DeclineRequest request) {
+        return ApiResult.success(peerReviewService.declineInvitation(
+                id, SecurityUtils.getCurrentUserId(), request.getReason()));
+    }
+}

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

@@ -1,6 +1,7 @@
 package com.emoon.okr.dto;
 
 import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotBlank;
 import jakarta.validation.constraints.NotNull;
 import jakarta.validation.constraints.Size;
 import lombok.Data;
@@ -22,6 +23,29 @@ public final class PeerReviewDto {
         private List<Long> reviewerIds;
     }
 
+    @Data
+    public static class ReviewListRequest {
+        @NotNull
+        private Boolean approved;
+        @Size(max = 300)
+        private String reason;
+    }
+
+    @Data
+    public static class DeclineRequest {
+        @NotBlank
+        @Size(max = 300)
+        private String reason;
+    }
+
+    @Data
+    public static class SupplementRequest {
+        @NotNull
+        private Long periodId;
+        @NotNull
+        private Long reviewerId;
+    }
+
     @Data
     public static class Candidate {
         private Long userId;

+ 206 - 6
backend/src/main/java/com/emoon/okr/service/PeerReviewService.java

@@ -11,6 +11,7 @@ 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.event.NotificationEvent;
 import com.emoon.okr.mapper.AssessmentPeriodMapper;
 import com.emoon.okr.mapper.PeerReviewInvitationMapper;
 import com.emoon.okr.mapper.PeerReviewRequestMapper;
@@ -19,6 +20,7 @@ import com.emoon.okr.mapper.PeriodParticipantMapper;
 import com.emoon.okr.mapper.SysUserMapper;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Service;
+import org.springframework.context.ApplicationEventPublisher;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.time.LocalDateTime;
@@ -38,9 +40,14 @@ public class PeerReviewService {
 
     private static final String ACTIVE = "ACTIVE";
     private static final String REQUEST_PENDING_APPROVAL = "PENDING_APPROVAL";
+    private static final String REQUEST_REJECTED = "REJECTED";
+    private static final String REQUEST_IN_PROGRESS = "IN_PROGRESS";
     private static final String INVITATION_PROPOSED = "PROPOSED";
+    private static final String INVITATION_PENDING = "PENDING";
+    private static final String INVITATION_ACCEPTED = "ACCEPTED";
     private static final String INVITATION_DECLINED = "DECLINED";
     private static final String INVITATION_SUBMITTED = "SUBMITTED";
+    private static final String INVITATION_INVALIDATED = "INVALIDATED";
     private static final String SAME_DEPARTMENT = "SAME_DEPARTMENT";
     private static final String CROSS_DEPARTMENT = "CROSS_DEPARTMENT";
 
@@ -50,6 +57,7 @@ public class PeerReviewService {
     private final PeerReviewRequestMapper requestMapper;
     private final PeerReviewInvitationMapper invitationMapper;
     private final SysUserMapper userMapper;
+    private final ApplicationEventPublisher publisher;
 
     public List<PeerReviewDto.Candidate> listCandidates(Long periodId, Long subjectUserId) {
         AssessmentPeriod period = requireAssessingPeriod(periodId);
@@ -89,9 +97,10 @@ public class PeerReviewService {
         if (distinctIds.size() != reviewerIds.size()) {
             throw new BusinessException("评价人名单不能重复");
         }
-        if (requestMapper.selectOne(new LambdaQueryWrapper<PeerReviewRequest>()
+        PeerReviewRequest existingRequest = requestMapper.selectOne(new LambdaQueryWrapper<PeerReviewRequest>()
                 .eq(PeerReviewRequest::getPeriodId, periodId)
-                .eq(PeerReviewRequest::getSubjectUserId, subjectUserId)) != null) {
+                .eq(PeerReviewRequest::getSubjectUserId, subjectUserId));
+        if (existingRequest != null && !REQUEST_REJECTED.equals(existingRequest.getStatus())) {
             throw new BusinessException("本周期已提交评价人名单");
         }
 
@@ -123,22 +132,39 @@ public class PeerReviewService {
         }
 
         LocalDateTime now = LocalDateTime.now();
-        PeerReviewRequest request = new PeerReviewRequest();
+        PeerReviewRequest request = existingRequest == null ? new PeerReviewRequest() : existingRequest;
         request.setPeriodId(periodId);
         request.setSubjectUserId(subjectUserId);
         request.setLeaderId(subject.getEvaluatorId());
         request.setStatus(REQUEST_PENDING_APPROVAL);
+        request.setRejectionReason(null);
         request.setSubmittedAt(now);
-        requestMapper.insert(request);
+        request.setApprovedAt(null);
+        if (existingRequest == null) {
+            requestMapper.insert(request);
+        } else {
+            requestMapper.updateById(request);
+        }
 
+        Map<Long, PeerReviewInvitation> reusable = existingRequest == null ? Map.of()
+                : invitationMapper.selectList(new LambdaQueryWrapper<PeerReviewInvitation>()
+                                .eq(PeerReviewInvitation::getRequestId, request.getId())).stream()
+                        .collect(Collectors.toMap(PeerReviewInvitation::getReviewerId, Function.identity()));
         List<PeerReviewInvitation> invitations = reviewers.stream().map(reviewer -> {
-            PeerReviewInvitation invitation = new PeerReviewInvitation();
+            PeerReviewInvitation invitation = reusable.getOrDefault(
+                    reviewer.getUserId(), 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);
+            invitation.setDeclineReason(null);
+            invitation.setRespondedAt(null);
+            if (invitation.getId() == null) {
+                invitationMapper.insert(invitation);
+            } else {
+                invitationMapper.updateById(invitation);
+            }
             return invitation;
         }).toList();
         return employeeProgress(request, invitations);
@@ -159,6 +185,140 @@ public class PeerReviewService {
         return employeeProgress(request, invitations);
     }
 
+    @Transactional
+    public PeerReviewDto.EmployeeProgress supplementReviewer(Long periodId, Long subjectUserId,
+                                                              Long reviewerId) {
+        AssessmentPeriod period = requireAssessingPeriod(periodId);
+        requireInvitationOpen(period);
+        PeerReviewRequest request = requestMapper.selectOne(new LambdaQueryWrapper<PeerReviewRequest>()
+                .eq(PeerReviewRequest::getPeriodId, periodId)
+                .eq(PeerReviewRequest::getSubjectUserId, subjectUserId));
+        if (request == null || !REQUEST_IN_PROGRESS.equals(request.getStatus())) {
+            throw new BusinessException("当前环评不可补邀");
+        }
+
+        List<PeerReviewInvitation> invitations = new ArrayList<>(invitationMapper.selectList(
+                new LambdaQueryWrapper<PeerReviewInvitation>()
+                        .eq(PeerReviewInvitation::getRequestId, request.getId())));
+        if (invitations.size() >= 5) {
+            throw new BusinessException("每周期累计最多 5 人,已无法继续补邀");
+        }
+        if (invitations.stream().noneMatch(item -> INVITATION_DECLINED.equals(item.getStatus())
+                || INVITATION_INVALIDATED.equals(item.getStatus()))) {
+            throw new BusinessException("仅有邀请被拒绝或失效后才可补邀");
+        }
+        if (invitations.stream().anyMatch(item -> Objects.equals(item.getReviewerId(), reviewerId))) {
+            throw new BusinessException("该同事本周期已被邀请,不能重复补邀");
+        }
+
+        List<PeriodParticipant> participants = periodParticipants(periodId);
+        PeriodParticipant subject = requireActiveParticipant(participants, subjectUserId);
+        PeriodParticipant reviewer = participants.stream()
+                .filter(item -> Objects.equals(item.getUserId(), reviewerId))
+                .findFirst().orElse(null);
+        if (reviewer == null || !isEligible(reviewer, subject)) {
+            throw new BusinessException("补邀对象必须是符合条件的本周期有效参与人");
+        }
+
+        PeerReviewInvitation invitation = new PeerReviewInvitation();
+        invitation.setRequestId(request.getId());
+        invitation.setReviewerId(reviewerId);
+        invitation.setReviewerDepartmentIdSnapshot(reviewer.getDepartmentIdSnapshot());
+        invitation.setRelationTypeSnapshot(relationType(subject, reviewer));
+        invitation.setStatus(INVITATION_PROPOSED);
+        invitationMapper.insert(invitation);
+        invitations.add(invitation);
+
+        request.setStatus(REQUEST_PENDING_APPROVAL);
+        request.setSubmittedAt(LocalDateTime.now());
+        request.setApprovedAt(null);
+        requestMapper.updateById(request);
+        publisher.publishEvent(new NotificationEvent(
+                this, request.getLeaderId(), "PEER_REVIEW_LIST_PENDING",
+                "360 环评补邀待审核", "员工补充了一名评价人,请及时审核",
+                "peer_review_request", request.getId()));
+        return employeeProgress(request, invitations);
+    }
+
+    @Transactional
+    public PeerReviewDto.LeaderSummary reviewList(Long requestId, Long leaderId,
+                                                  boolean approved, String reason) {
+        PeerReviewRequest request = requirePendingRequest(requestId);
+        if (!Objects.equals(request.getLeaderId(), leaderId)) {
+            throw new BusinessException(403, "仅周期冻结 Leader 可审核评价人名单");
+        }
+        String trimmedReason = reason == null ? "" : reason.trim();
+        if (!approved && trimmedReason.isEmpty()) {
+            throw new BusinessException("驳回评价人名单时必须填写原因");
+        }
+
+        List<PeerReviewInvitation> invitations = invitationMapper.selectList(
+                new LambdaQueryWrapper<PeerReviewInvitation>()
+                        .eq(PeerReviewInvitation::getRequestId, requestId));
+        LocalDateTime now = LocalDateTime.now();
+        if (approved) {
+            request.setStatus(REQUEST_IN_PROGRESS);
+            request.setApprovedAt(now);
+            request.setRejectionReason(null);
+            for (PeerReviewInvitation invitation : invitations) {
+                if (INVITATION_PROPOSED.equals(invitation.getStatus())) {
+                    invitation.setStatus(INVITATION_PENDING);
+                    invitationMapper.updateById(invitation);
+                    publisher.publishEvent(new NotificationEvent(
+                            this, invitation.getReviewerId(), "PEER_REVIEW_INVITED",
+                            "收到 360 环评邀请", "你收到一份 360 环评邀请,请及时处理",
+                            "peer_review_invitation", invitation.getId()));
+                }
+            }
+        } else {
+            request.setStatus(REQUEST_REJECTED);
+            request.setRejectionReason(trimmedReason);
+            for (PeerReviewInvitation invitation : invitations) {
+                if (INVITATION_PROPOSED.equals(invitation.getStatus())) {
+                    invitation.setStatus(INVITATION_INVALIDATED);
+                    invitationMapper.updateById(invitation);
+                }
+            }
+            publisher.publishEvent(new NotificationEvent(
+                    this, request.getSubjectUserId(), "PEER_REVIEW_LIST_REJECTED",
+                    "360 环评名单需调整", trimmedReason,
+                    "peer_review_request", request.getId()));
+        }
+        requestMapper.updateById(request);
+        return leaderSummary(request);
+    }
+
+    @Transactional
+    public PeerReviewDto.InvitationSummary acceptInvitation(Long invitationId, Long reviewerId) {
+        PeerReviewInvitation invitation = requirePendingInvitation(invitationId, reviewerId);
+        invitation.setStatus(INVITATION_ACCEPTED);
+        invitation.setRespondedAt(LocalDateTime.now());
+        invitationMapper.updateById(invitation);
+        return reviewerInvitationSummary(invitation);
+    }
+
+    @Transactional
+    public PeerReviewDto.InvitationSummary declineInvitation(Long invitationId, Long reviewerId, String reason) {
+        String trimmedReason = reason == null ? "" : reason.trim();
+        if (trimmedReason.isEmpty()) {
+            throw new BusinessException("拒绝邀请时必须填写原因");
+        }
+        PeerReviewInvitation invitation = requirePendingInvitation(invitationId, reviewerId);
+        invitation.setStatus(INVITATION_DECLINED);
+        invitation.setDeclineReason(trimmedReason);
+        invitation.setRespondedAt(LocalDateTime.now());
+        invitationMapper.updateById(invitation);
+
+        PeerReviewRequest request = requestMapper.selectById(invitation.getRequestId());
+        if (request != null) {
+            publisher.publishEvent(new NotificationEvent(
+                    this, request.getSubjectUserId(), "PEER_REVIEW_INVITATION_DECLINED",
+                    "360 环评邀请被拒绝", "一名同事拒绝了邀请,请及时补充评价人",
+                    "peer_review_request", request.getId()));
+        }
+        return reviewerInvitationSummary(invitation);
+    }
+
     private AssessmentPeriod requireAssessingPeriod(Long periodId) {
         AssessmentPeriod period = periodMapper.selectById(periodId);
         if (period == null || period.getStatus() != PeriodStatus.ASSESSING) {
@@ -167,6 +327,28 @@ public class PeerReviewService {
         return period;
     }
 
+    private PeerReviewRequest requirePendingRequest(Long requestId) {
+        PeerReviewRequest request = requestMapper.selectById(requestId);
+        if (request == null) {
+            throw new BusinessException("360 环评请求不存在");
+        }
+        if (!REQUEST_PENDING_APPROVAL.equals(request.getStatus())) {
+            throw new BusinessException("当前评价人名单不可审核");
+        }
+        return request;
+    }
+
+    private PeerReviewInvitation requirePendingInvitation(Long invitationId, Long reviewerId) {
+        PeerReviewInvitation invitation = invitationMapper.selectById(invitationId);
+        if (invitation == null || !Objects.equals(invitation.getReviewerId(), reviewerId)) {
+            throw new BusinessException(403, "无权处理该环评邀请");
+        }
+        if (!INVITATION_PENDING.equals(invitation.getStatus())) {
+            throw new BusinessException("当前环评邀请不可处理");
+        }
+        return invitation;
+    }
+
     private void requireInvitationOpen(AssessmentPeriod period) {
         if (period.getPeerInvitationDeadline() != null
                 && LocalDateTime.now().isAfter(period.getPeerInvitationDeadline())) {
@@ -240,4 +422,22 @@ public class PeerReviewService {
         }).toList());
         return progress;
     }
+
+    private PeerReviewDto.LeaderSummary leaderSummary(PeerReviewRequest request) {
+        PeerReviewDto.LeaderSummary summary = new PeerReviewDto.LeaderSummary();
+        summary.setRequestId(request.getId());
+        summary.setSubjectUserId(request.getSubjectUserId());
+        summary.setStatus(request.getStatus());
+        return summary;
+    }
+
+    private PeerReviewDto.InvitationSummary reviewerInvitationSummary(PeerReviewInvitation invitation) {
+        PeerReviewDto.InvitationSummary summary = new PeerReviewDto.InvitationSummary();
+        summary.setInvitationId(invitation.getId());
+        summary.setReviewerId(invitation.getReviewerId());
+        summary.setRelationType(invitation.getRelationTypeSnapshot());
+        summary.setStatus(invitation.getStatus());
+        summary.setDeclineReason(invitation.getDeclineReason());
+        return summary;
+    }
 }

+ 157 - 1
backend/src/test/java/com/emoon/okr/service/PeerReviewServiceTest.java

@@ -10,6 +10,7 @@ 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.event.NotificationEvent;
 import com.emoon.okr.mapper.AssessmentPeriodMapper;
 import com.emoon.okr.mapper.PeerReviewInvitationMapper;
 import com.emoon.okr.mapper.PeerReviewRequestMapper;
@@ -23,6 +24,7 @@ import org.mockito.ArgumentCaptor;
 import org.mockito.InjectMocks;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.context.ApplicationEventPublisher;
 
 import java.util.List;
 
@@ -44,6 +46,7 @@ class PeerReviewServiceTest {
     @Mock PeerReviewRequestMapper requestMapper;
     @Mock PeerReviewInvitationMapper invitationMapper;
     @Mock SysUserMapper userMapper;
+    @Mock ApplicationEventPublisher publisher;
     @InjectMocks PeerReviewService service;
 
     @BeforeEach
@@ -51,7 +54,7 @@ class PeerReviewServiceTest {
         AssessmentPeriod period = new AssessmentPeriod();
         period.setId(1L);
         period.setStatus(PeriodStatus.ASSESSING);
-        when(periodMapper.selectById(1L)).thenReturn(period);
+        lenient().when(periodMapper.selectById(1L)).thenReturn(period);
 
         PerformanceScore selfScore = new PerformanceScore();
         selfScore.setPeriodId(1L);
@@ -140,6 +143,139 @@ class PeerReviewServiceTest {
         assertEquals(0, result.getSubmittedCount());
     }
 
+    @Test
+    void approveReviewerListRequiresFrozenLeaderAndCreatesPendingInvitations() {
+        PeerReviewRequest request = request(20L, "PENDING_APPROVAL");
+        when(requestMapper.selectById(20L)).thenReturn(request);
+        when(invitationMapper.selectList(any())).thenReturn(List.of(
+                invitation(30L, 3L, "PROPOSED"),
+                invitation(31L, 4L, "PROPOSED"),
+                invitation(32L, 5L, "PROPOSED")));
+
+        assertThrows(BusinessException.class, () -> service.reviewList(20L, 8L, true, ""));
+        service.reviewList(20L, 9L, true, "");
+
+        verify(requestMapper).updateById(org.mockito.ArgumentMatchers.argThat(item ->
+                "IN_PROGRESS".equals(item.getStatus())));
+        verify(invitationMapper, org.mockito.Mockito.times(3)).updateById(
+                org.mockito.ArgumentMatchers.argThat(item -> "PENDING".equals(item.getStatus())));
+        verify(publisher, org.mockito.Mockito.times(3)).publishEvent(any(NotificationEvent.class));
+    }
+
+    @Test
+    void rejectReviewerListRequiresReason() {
+        PeerReviewRequest request = request(20L, "PENDING_APPROVAL");
+        when(requestMapper.selectById(20L)).thenReturn(request);
+        when(invitationMapper.selectList(any())).thenReturn(List.of(
+                invitation(30L, 3L, "PROPOSED")));
+
+        assertThrows(BusinessException.class, () -> service.reviewList(20L, 9L, false, " "));
+        service.reviewList(20L, 9L, false, "名单需要调整");
+
+        verify(requestMapper).updateById(org.mockito.ArgumentMatchers.argThat(item ->
+                "REJECTED".equals(item.getStatus()) && "名单需要调整".equals(item.getRejectionReason())));
+        verify(invitationMapper).updateById(org.mockito.ArgumentMatchers.argThat(item ->
+                "INVALIDATED".equals(item.getStatus())));
+        verify(publisher).publishEvent(any(NotificationEvent.class));
+    }
+
+    @Test
+    void acceptInvitationRequiresInvitedReviewer() {
+        when(invitationMapper.selectById(30L)).thenReturn(invitation(30L, 3L, "PENDING"));
+
+        assertThrows(BusinessException.class, () -> service.acceptInvitation(30L, 4L));
+        PeerReviewDto.InvitationSummary result = service.acceptInvitation(30L, 3L);
+
+        assertEquals("ACCEPTED", result.getStatus());
+        verify(invitationMapper).updateById(org.mockito.ArgumentMatchers.argThat(item ->
+                "ACCEPTED".equals(item.getStatus()) && item.getRespondedAt() != null));
+    }
+
+    @Test
+    void declineInvitationRequiresReasonAndNotifiesSubject() {
+        when(invitationMapper.selectById(30L)).thenReturn(invitation(30L, 3L, "PENDING"));
+        when(requestMapper.selectById(20L)).thenReturn(request(20L, "IN_PROGRESS"));
+
+        assertThrows(BusinessException.class, () -> service.declineInvitation(30L, 3L, " "));
+        service.declineInvitation(30L, 3L, "合作接触不足");
+
+        verify(invitationMapper).updateById(org.mockito.ArgumentMatchers.argThat(item ->
+                "DECLINED".equals(item.getStatus())
+                        && "合作接触不足".equals(item.getDeclineReason())));
+        verify(publisher).publishEvent(org.mockito.ArgumentMatchers.argThat(event ->
+                event instanceof NotificationEvent notification && notification.getUserId().equals(2L)));
+    }
+
+    @Test
+    void supplementReviewerAfterDeclineReopensLeaderApproval() {
+        PeerReviewRequest request = request(20L, "IN_PROGRESS");
+        when(requestMapper.selectOne(any())).thenReturn(request);
+        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"),
+                participant(6L, 11L, null, null, "ACTIVE")));
+        when(invitationMapper.selectList(any())).thenReturn(List.of(
+                invitation(30L, 3L, "ACCEPTED"),
+                invitation(31L, 4L, "DECLINED"),
+                invitation(32L, 5L, "PENDING")));
+        when(userMapper.selectBatchIds(any())).thenReturn(List.of(
+                user(3L, "同部门同事"), user(4L, "已拒绝同事"),
+                user(5L, "跨部门同事"), user(6L, "补邀同事")));
+
+        PeerReviewDto.EmployeeProgress result = service.supplementReviewer(1L, 2L, 6L);
+
+        verify(invitationMapper).insert(org.mockito.ArgumentMatchers.argThat(item ->
+                item.getReviewerId().equals(6L) && "PROPOSED".equals(item.getStatus())));
+        verify(requestMapper).updateById(org.mockito.ArgumentMatchers.argThat(item ->
+                "PENDING_APPROVAL".equals(item.getStatus())));
+        assertEquals(4, result.getInvitedCount());
+    }
+
+    @Test
+    void supplementReviewerHonorsFivePersonCumulativeLimit() {
+        when(requestMapper.selectOne(any())).thenReturn(request(20L, "IN_PROGRESS"));
+        when(invitationMapper.selectList(any())).thenReturn(List.of(
+                invitation(30L, 3L, "DECLINED"), invitation(31L, 4L, "DECLINED"),
+                invitation(32L, 5L, "PENDING"), invitation(33L, 6L, "PENDING"),
+                invitation(34L, 7L, "PENDING")));
+
+        BusinessException error = assertThrows(BusinessException.class,
+                () -> service.supplementReviewer(1L, 2L, 8L));
+
+        assertTrue(error.getMessage().contains("累计最多 5 人"));
+        verify(invitationMapper, never()).insert(any());
+    }
+
+    @Test
+    void rejectedListCanBeCorrectedAndResubmitted() {
+        PeerReviewRequest rejected = request(20L, "REJECTED");
+        rejected.setRejectionReason("名单需要调整");
+        when(requestMapper.selectOne(any())).thenReturn(rejected);
+        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(invitationMapper.selectList(any())).thenReturn(List.of(
+                invitation(30L, 3L, "INVALIDATED"),
+                invitation(31L, 4L, "INVALIDATED"),
+                invitation(32L, 5L, "INVALIDATED")));
+        when(userMapper.selectBatchIds(any())).thenReturn(List.of(
+                user(3L, "同部门同事"), user(4L, "跨部门同事甲"), user(5L, "跨部门同事乙")));
+
+        PeerReviewDto.EmployeeProgress result =
+                service.submitReviewerList(1L, 2L, List.of(3L, 4L, 5L));
+
+        verify(requestMapper).updateById(org.mockito.ArgumentMatchers.argThat(item ->
+                "PENDING_APPROVAL".equals(item.getStatus()) && item.getRejectionReason() == null));
+        verify(invitationMapper, org.mockito.Mockito.times(3)).updateById(
+                org.mockito.ArgumentMatchers.argThat(item -> "PROPOSED".equals(item.getStatus())));
+        verify(invitationMapper, never()).insert(any());
+        assertEquals("PENDING_APPROVAL", result.getStatus());
+    }
+
     private PeriodParticipant participant(Long userId, Long departmentId, Long superiorId,
                                           Long evaluatorId, String status) {
         PeriodParticipant participant = new PeriodParticipant();
@@ -158,4 +294,24 @@ class PeerReviewServiceTest {
         user.setRealName(realName);
         return user;
     }
+
+    private PeerReviewRequest request(Long id, String status) {
+        PeerReviewRequest request = new PeerReviewRequest();
+        request.setId(id);
+        request.setPeriodId(1L);
+        request.setSubjectUserId(2L);
+        request.setLeaderId(9L);
+        request.setStatus(status);
+        return request;
+    }
+
+    private PeerReviewInvitation invitation(Long id, Long reviewerId, String status) {
+        PeerReviewInvitation invitation = new PeerReviewInvitation();
+        invitation.setId(id);
+        invitation.setRequestId(20L);
+        invitation.setReviewerId(reviewerId);
+        invitation.setRelationTypeSnapshot("SAME_DEPARTMENT");
+        invitation.setStatus(status);
+        return invitation;
+    }
 }