Browse Source

feat(环评): 添加截止统计与提醒(任务 6/10)

wangkangyjy 1 day ago
parent
commit
a8294a5e53

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

@@ -3,6 +3,8 @@ 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.enums.UserRole;
+import com.emoon.okr.exception.BusinessException;
 import com.emoon.okr.service.PeerReviewService;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
@@ -70,6 +72,15 @@ public class PeerReviewController {
                 periodId, SecurityUtils.getCurrentUserId()));
     }
 
+    @GetMapping("/admin/statistics")
+    public ApiResult<PeerReviewDto.AdminStatistics> adminStatistics(@RequestParam Long periodId) {
+        if (!SecurityUtils.hasRole(UserRole.HR_ADMIN)) {
+            throw new BusinessException(403, "无权限,需要 HR 管理员或以上权限");
+        }
+        return ApiResult.success(peerReviewService.getAdminStatistics(
+                periodId, SecurityUtils.getCurrentUserId()));
+    }
+
     @PostMapping("/invitations/{id}/accept")
     public ApiResult<PeerReviewDto.InvitationSummary> accept(@PathVariable Long id) {
         return ApiResult.success(peerReviewService.acceptInvitation(

+ 4 - 2
backend/src/main/java/com/emoon/okr/dto/PeerReviewDto.java

@@ -157,7 +157,9 @@ public final class PeerReviewDto {
 
     @Data
     public static class AdminStatistics {
-        private int requestedCount;
-        private int submittedReviewCount;
+        private int requestedEmployeeCount;
+        private int submittedResponseCount;
+        private int blockedEmployeeCount;
+        private List<Long> blockedSubjectUserIds = new ArrayList<>();
     }
 }

+ 6 - 16
backend/src/main/java/com/emoon/okr/event/NotificationEventListener.java

@@ -1,11 +1,9 @@
 package com.emoon.okr.event;
 
-import com.emoon.okr.entity.Notification;
-import com.emoon.okr.mapper.NotificationMapper;
+import com.emoon.okr.service.NotificationService;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.dao.DataAccessException;
-import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Component;
 import org.springframework.transaction.event.TransactionPhase;
 import org.springframework.transaction.event.TransactionalEventListener;
@@ -17,26 +15,18 @@ public class NotificationEventListener {
 
     private static final int MAX_INSERT_ATTEMPTS = 3;
 
-    private final NotificationMapper notificationMapper;
+    private final NotificationService notificationService;
 
-    @Async
     @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
     public void handleNotification(NotificationEvent event) {
-        Notification notification = new Notification();
-        notification.setUserId(event.getUserId());
-        notification.setType(event.getType());
-        notification.setTitle(event.getTitle());
-        notification.setContent(event.getContent());
-        notification.setRefType(event.getRefType());
-        notification.setRefId(event.getRefId());
-        notification.setIsRead(0);
-        insertWithRetry(notification, event);
+        insertWithRetry(event);
     }
 
-    private void insertWithRetry(Notification notification, NotificationEvent event) {
+    private void insertWithRetry(NotificationEvent event) {
         for (int attempt = 1; attempt <= MAX_INSERT_ATTEMPTS; attempt++) {
             try {
-                notificationMapper.insert(notification);
+                notificationService.createOnce(event.getUserId(), event.getType(), event.getTitle(),
+                        event.getContent(), event.getRefType(), event.getRefId());
                 return;
             } catch (DataAccessException ex) {
                 if (attempt == MAX_INSERT_ATTEMPTS) {

+ 95 - 0
backend/src/main/java/com/emoon/okr/schedule/PeerReviewReminderScheduler.java

@@ -0,0 +1,95 @@
+package com.emoon.okr.schedule;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.emoon.okr.entity.AssessmentPeriod;
+import com.emoon.okr.entity.PeerReviewInvitation;
+import com.emoon.okr.entity.PeerReviewRequest;
+import com.emoon.okr.entity.PeriodParticipant;
+import com.emoon.okr.enums.PeriodStatus;
+import com.emoon.okr.mapper.AssessmentPeriodMapper;
+import com.emoon.okr.mapper.PeerReviewInvitationMapper;
+import com.emoon.okr.mapper.PeerReviewRequestMapper;
+import com.emoon.okr.mapper.PeriodParticipantMapper;
+import com.emoon.okr.service.NotificationService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Component
+@RequiredArgsConstructor
+public class PeerReviewReminderScheduler {
+
+    private final AssessmentPeriodMapper periodMapper;
+    private final PeriodParticipantMapper participantMapper;
+    private final PeerReviewRequestMapper requestMapper;
+    private final PeerReviewInvitationMapper invitationMapper;
+    private final NotificationService notificationService;
+
+    @Scheduled(cron = "0 0 * * * ?")
+    public void remindBeforeDeadlines() {
+        LocalDateTime now = LocalDateTime.now();
+        for (AssessmentPeriod period : periodMapper.selectList(
+                new LambdaQueryWrapper<AssessmentPeriod>()
+                        .eq(AssessmentPeriod::getStatus, PeriodStatus.ASSESSING))) {
+            List<PeerReviewRequest> requests = requestMapper.selectList(
+                    new LambdaQueryWrapper<PeerReviewRequest>()
+                            .eq(PeerReviewRequest::getPeriodId, period.getId()));
+            if (inReminderWindow(now, period.getPeerInvitationDeadline())) {
+                remindIncompleteLists(period, requests);
+            }
+            if (inReminderWindow(now, period.getPeerReviewDeadline())) {
+                remindIncompleteReviews(requests);
+            }
+        }
+    }
+
+    static boolean inReminderWindow(LocalDateTime now, LocalDateTime deadline) {
+        if (deadline == null) {
+            return false;
+        }
+        long minutes = Duration.between(now, deadline).toMinutes();
+        return minutes > 23 * 60 && minutes <= 24 * 60;
+    }
+
+    private void remindIncompleteLists(AssessmentPeriod period, List<PeerReviewRequest> requests) {
+        Map<Long, PeerReviewRequest> bySubject = requests.stream()
+                .collect(Collectors.toMap(PeerReviewRequest::getSubjectUserId, Function.identity()));
+        for (PeriodParticipant participant : participantMapper.selectList(
+                new LambdaQueryWrapper<PeriodParticipant>()
+                        .eq(PeriodParticipant::getPeriodId, period.getId())
+                        .eq(PeriodParticipant::getStatus, "ACTIVE"))) {
+            PeerReviewRequest request = bySubject.get(participant.getUserId());
+            if (request == null || "REJECTED".equals(request.getStatus())) {
+                notificationService.createOnce(participant.getUserId(), "PEER_REVIEW_LIST_DUE",
+                        "360 环评邀请即将截止", "请在 24 小时内提交评价人名单",
+                        "period_participant", participant.getId());
+            }
+        }
+        requests.stream().filter(request -> "PENDING_APPROVAL".equals(request.getStatus()))
+                .forEach(request -> notificationService.createOnce(request.getLeaderId(),
+                        "PEER_REVIEW_LIST_DUE", "360 环评名单审核即将截止",
+                        "请在 24 小时内完成评价人名单审核",
+                        "peer_review_request", request.getId()));
+    }
+
+    private void remindIncompleteReviews(List<PeerReviewRequest> requests) {
+        if (requests.isEmpty()) {
+            return;
+        }
+        invitationMapper.selectList(new LambdaQueryWrapper<PeerReviewInvitation>()
+                        .in(PeerReviewInvitation::getRequestId,
+                                requests.stream().map(PeerReviewRequest::getId).toList())
+                        .in(PeerReviewInvitation::getStatus, List.of("PENDING", "ACCEPTED")))
+                .forEach(invitation -> notificationService.createOnce(invitation.getReviewerId(),
+                        "PEER_REVIEW_DUE", "360 环评即将截止",
+                        "请在 24 小时内完成并提交评价",
+                        "peer_review_invitation", invitation.getId()));
+    }
+}

+ 42 - 0
backend/src/main/java/com/emoon/okr/service/AssessmentPeriodService.java

@@ -42,6 +42,8 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
@@ -91,12 +93,30 @@ public class AssessmentPeriodService {
         if (period.getPeriodType() != null && period.getEndDate() == null) {
             period.setEndDate(period.getPeriodType().calculateEndDate(period.getStartDate()));
         }
+        validatePeerReviewDeadlines(period);
         periodMapper.insert(period);
         return period;
     }
 
     public void update(AssessmentPeriod period) {
         AssessmentPeriod existing = getById(period.getId());
+        validatePeerReviewDeadlines(period);
+        if (existing.getStatus() == PeriodStatus.ASSESSING) {
+            if (existing.getPeerInvitationDeadline() != null
+                    && period.getPeerInvitationDeadline().isBefore(existing.getPeerInvitationDeadline())
+                    || existing.getPeerReviewDeadline() != null
+                    && period.getPeerReviewDeadline().isBefore(existing.getPeerReviewDeadline())
+                    || period.getPeerInvitationDeadline().isBefore(LocalDateTime.now())
+                    || period.getPeerReviewDeadline().isBefore(LocalDateTime.now())) {
+                throw new BusinessException("考评开始后只能将环评截止时间向后延长");
+            }
+            AssessmentPeriod deadlines = new AssessmentPeriod();
+            deadlines.setId(period.getId());
+            deadlines.setPeerInvitationDeadline(period.getPeerInvitationDeadline());
+            deadlines.setPeerReviewDeadline(period.getPeerReviewDeadline());
+            periodMapper.updateById(deadlines);
+            return;
+        }
         if (existing.getStatus() != PeriodStatus.DRAFT) {
             throw new BusinessException("非待发布状态不可修改");
         }
@@ -124,6 +144,9 @@ public class AssessmentPeriodService {
                     current.getLabel(), targetStatus.getLabel(), hint));
         }
 
+        if (targetStatus == PeriodStatus.ASSESSING) {
+            validatePeerReviewDeadlines(period);
+        }
         requireReadyForTransition(period, targetStatus);
 
         period.setStatus(targetStatus);
@@ -321,6 +344,25 @@ public class AssessmentPeriodService {
         target.addAll(source);
     }
 
+    private void validatePeerReviewDeadlines(AssessmentPeriod period) {
+        LocalDateTime invitation = period.getPeerInvitationDeadline();
+        LocalDateTime review = period.getPeerReviewDeadline();
+        if (invitation == null || review == null) {
+            throw new BusinessException("邀请名单截止时间和评价提交截止时间不能为空");
+        }
+        if (!invitation.isBefore(review)) {
+            throw new BusinessException("邀请名单截止时间必须早于评价提交截止时间");
+        }
+        if (period.getExecutionEndDate() != null
+                && invitation.isBefore(period.getExecutionEndDate().atStartOfDay())) {
+            throw new BusinessException("环评截止时间必须位于周期考评阶段内");
+        }
+        if (period.getConfirmationDueDate() != null
+                && review.isAfter(period.getConfirmationDueDate().atTime(LocalTime.MAX))) {
+            throw new BusinessException("环评截止时间必须位于周期考评阶段内");
+        }
+    }
+
     private Set<Long> userIdsFromObjectives(List<OkrObjective> objectives) {
         Set<Long> ids = new HashSet<>();
         if (objectives != null) {

+ 25 - 0
backend/src/main/java/com/emoon/okr/service/NotificationService.java

@@ -9,6 +9,7 @@ import com.emoon.okr.entity.Notification;
 import com.emoon.okr.mapper.NotificationMapper;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
 import com.emoon.okr.exception.BusinessException;
 
@@ -21,6 +22,30 @@ public class NotificationService {
 
     private final NotificationMapper notificationMapper;
 
+    @Transactional
+    public boolean createOnce(Long userId, String type, String title, String content,
+                              String refType, Long refId) {
+        long existing = notificationMapper.selectCount(new LambdaQueryWrapper<Notification>()
+                .eq(Notification::getUserId, userId)
+                .eq(Notification::getType, type)
+                .eq(Notification::getRefType, refType)
+                .eq(Notification::getRefId, refId));
+        if (existing > 0) {
+            return false;
+        }
+        Notification notification = new Notification();
+        notification.setUserId(userId);
+        notification.setType(type);
+        notification.setTitle(title);
+        notification.setContent(content);
+        notification.setRefType(refType);
+        notification.setRefId(refId);
+        notification.setIsRead(0);
+        // ponytail: application-level dedupe is enough for hourly reminders; add a unique index if concurrent producers emerge.
+        notificationMapper.insert(notification);
+        return true;
+    }
+
     public long getUnreadCount(Long userId) {
         return notificationMapper.selectCount(
                 new LambdaQueryWrapper<Notification>()

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

@@ -63,6 +63,7 @@ public class PeerReviewService {
     private final ApplicationEventPublisher publisher;
     private final OkrService okrService;
     private final PerformanceScoreItemMapper scoreItemMapper;
+    private final AuthorizationService authorizationService;
 
     public List<PeerReviewDto.Candidate> listCandidates(Long periodId, Long subjectUserId) {
         AssessmentPeriod period = requireAssessingPeriod(periodId);
@@ -347,6 +348,32 @@ public class PeerReviewService {
                 }).toList();
     }
 
+    public PeerReviewDto.AdminStatistics getAdminStatistics(Long periodId, Long adminId) {
+        if (!authorizationService.isHrOrAbove(adminId)) {
+            throw new BusinessException(403, "无权限查看环评统计");
+        }
+        List<PeerReviewRequest> requests = requestMapper.selectList(
+                new LambdaQueryWrapper<PeerReviewRequest>().eq(PeerReviewRequest::getPeriodId, periodId));
+        List<PeerReviewInvitation> invitations = requests.isEmpty() ? List.of()
+                : invitationMapper.selectList(new LambdaQueryWrapper<PeerReviewInvitation>()
+                        .in(PeerReviewInvitation::getRequestId,
+                                requests.stream().map(PeerReviewRequest::getId).toList()));
+        Map<Long, Long> submittedByRequest = invitations.stream()
+                .filter(item -> INVITATION_SUBMITTED.equals(item.getStatus()))
+                .collect(Collectors.groupingBy(PeerReviewInvitation::getRequestId, Collectors.counting()));
+        List<Long> blocked = requests.stream()
+                .filter(request -> submittedByRequest.getOrDefault(request.getId(), 0L) < 2)
+                .map(PeerReviewRequest::getSubjectUserId).toList();
+
+        PeerReviewDto.AdminStatistics statistics = new PeerReviewDto.AdminStatistics();
+        statistics.setRequestedEmployeeCount(requests.size());
+        statistics.setSubmittedResponseCount((int) invitations.stream()
+                .filter(item -> INVITATION_SUBMITTED.equals(item.getStatus())).count());
+        statistics.setBlockedEmployeeCount(blocked.size());
+        statistics.setBlockedSubjectUserIds(blocked);
+        return statistics;
+    }
+
     @Transactional
     public PeerReviewDto.LeaderSummary reviewList(Long requestId, Long leaderId,
                                                   boolean approved, String reason) {

+ 16 - 1
backend/src/test/java/com/emoon/okr/event/NotificationEventListenerTest.java

@@ -2,6 +2,7 @@ package com.emoon.okr.event;
 
 import com.emoon.okr.entity.Notification;
 import com.emoon.okr.mapper.NotificationMapper;
+import com.emoon.okr.service.NotificationService;
 import org.junit.jupiter.api.Test;
 import org.springframework.dao.TransientDataAccessResourceException;
 
@@ -13,11 +14,12 @@ class NotificationEventListenerTest {
     @Test
     void retriesTransientSqliteWriteLock() {
         NotificationMapper mapper = mock(NotificationMapper.class);
+        when(mapper.selectCount(any())).thenReturn(0L);
         when(mapper.insert(any(Notification.class)))
                 .thenThrow(new TransientDataAccessResourceException("SQLITE_LOCKED_SHAREDCACHE"))
                 .thenReturn(1);
 
-        NotificationEventListener listener = new NotificationEventListener(mapper);
+        NotificationEventListener listener = new NotificationEventListener(new NotificationService(mapper));
 
         listener.handleNotification(new NotificationEvent(
                 this, 7L, "SCORE_CONFIRM", "绩效结果已发布",
@@ -25,4 +27,17 @@ class NotificationEventListenerTest {
 
         verify(mapper, times(2)).insert(any(Notification.class));
     }
+
+    @Test
+    void skipsDuplicateNotificationForSameUserTypeAndReference() {
+        NotificationMapper mapper = mock(NotificationMapper.class);
+        when(mapper.selectCount(any())).thenReturn(1L);
+        NotificationEventListener listener = new NotificationEventListener(new NotificationService(mapper));
+
+        listener.handleNotification(new NotificationEvent(
+                this, 7L, "PEER_REVIEW_DUE", "环评即将截止",
+                "请及时提交", "peer_review_invitation", 12L));
+
+        verify(mapper, never()).insert(any(Notification.class));
+    }
 }

+ 38 - 2
backend/src/test/java/com/emoon/okr/integration/PerformanceLifecycleApiIntegrationTest.java

@@ -7,6 +7,8 @@ import com.emoon.okr.entity.AssessmentPeriod;
 import com.emoon.okr.entity.KpiConfig;
 import com.emoon.okr.entity.PerformanceFeedback;
 import com.emoon.okr.entity.PerformanceScore;
+import com.emoon.okr.entity.PeerReviewInvitation;
+import com.emoon.okr.entity.PeerReviewRequest;
 import com.emoon.okr.entity.ScoreAppeal;
 import com.emoon.okr.entity.SysDepartment;
 import com.emoon.okr.entity.SysUser;
@@ -18,6 +20,8 @@ import com.emoon.okr.mapper.KpiConfigMapper;
 import com.emoon.okr.mapper.OkrObjectiveMapper;
 import com.emoon.okr.mapper.PerformanceFeedbackMapper;
 import com.emoon.okr.mapper.PerformanceScoreMapper;
+import com.emoon.okr.mapper.PeerReviewInvitationMapper;
+import com.emoon.okr.mapper.PeerReviewRequestMapper;
 import com.emoon.okr.mapper.ScoreAppealMapper;
 import com.emoon.okr.mapper.SysDepartmentMapper;
 import com.emoon.okr.mapper.SysUserMapper;
@@ -33,6 +37,7 @@ import org.springframework.test.annotation.DirtiesContext;
 import org.springframework.test.web.servlet.MockMvc;
 
 import java.time.LocalDate;
+import java.time.LocalDateTime;
 import java.util.List;
 import java.util.Map;
 
@@ -64,6 +69,8 @@ class PerformanceLifecycleApiIntegrationTest {
     @Autowired PerformanceScoreMapper scoreMapper;
     @Autowired PerformanceFeedbackMapper feedbackMapper;
     @Autowired ScoreAppealMapper appealMapper;
+    @Autowired PeerReviewRequestMapper peerReviewRequestMapper;
+    @Autowired PeerReviewInvitationMapper peerReviewInvitationMapper;
 
     @Test
     void httpApiSupportsFullPerformanceLifecycle() throws Exception {
@@ -81,7 +88,9 @@ class PerformanceLifecycleApiIntegrationTest {
         Long periodId = postForData(hrToken, "/api/periods", Map.of(
                 "name", "API 端到端周期",
                 "periodType", PeriodType.MONTHLY.name(),
-                "startDate", LocalDate.of(2026, 6, 1).toString()
+                "startDate", LocalDate.of(2026, 6, 1).toString(),
+                "peerInvitationDeadline", "2026-06-30T18:00:00",
+                "peerReviewDeadline", "2026-07-02T18:00:00"
         )).get("id").asLong();
 
         putJson(hrToken, "/api/periods/" + periodId + "/status", Map.of("status", PeriodStatus.OKR_ALIGN.name()));
@@ -103,6 +112,7 @@ class PerformanceLifecycleApiIntegrationTest {
         putJson(hrToken, "/api/periods/" + periodId + "/status", Map.of("status", PeriodStatus.ASSESSING.name()));
 
         postForData(employeeToken, "/api/scores/self", scoreBody(periodId, null, 54, 32));
+        seedCompletedPeerReviews(periodId, employee.getId(), manager.getId(), hr.getId(), manager.getId());
         postForData(managerToken, "/api/scores/superior", scoreBody(periodId, employee.getId(), 55, 33));
         postForData(managerToken, "/api/scores/publish", Map.of("periodId", periodId, "userId", employee.getId()));
 
@@ -165,7 +175,9 @@ class PerformanceLifecycleApiIntegrationTest {
         Long periodId = postForData(hrToken, "/api/periods", Map.of(
                 "name", "多目标周期",
                 "periodType", PeriodType.MONTHLY.name(),
-                "startDate", LocalDate.of(2026, 8, 1).toString()
+                "startDate", LocalDate.of(2026, 8, 1).toString(),
+                "peerInvitationDeadline", "2026-08-31T18:00:00",
+                "peerReviewDeadline", "2026-09-02T18:00:00"
         )).get("id").asLong();
         putJson(hrToken, "/api/periods/" + periodId + "/status", Map.of("status", PeriodStatus.OKR_ALIGN.name()));
 
@@ -193,6 +205,30 @@ class PerformanceLifecycleApiIntegrationTest {
         assertEquals(403, objectMapper.readTree(forbidden).get("code").asInt());
     }
 
+    private void seedCompletedPeerReviews(Long periodId, Long subjectId, Long leaderId,
+                                          Long firstReviewerId, Long secondReviewerId) {
+        PeerReviewRequest request = new PeerReviewRequest();
+        request.setPeriodId(periodId);
+        request.setSubjectUserId(subjectId);
+        request.setLeaderId(leaderId);
+        request.setStatus("IN_PROGRESS");
+        peerReviewRequestMapper.insert(request);
+        for (Long reviewerId : List.of(firstReviewerId, secondReviewerId)) {
+            PeerReviewInvitation invitation = new PeerReviewInvitation();
+            invitation.setRequestId(request.getId());
+            invitation.setReviewerId(reviewerId);
+            invitation.setRelationTypeSnapshot("CROSS_DEPARTMENT");
+            invitation.setStatus("SUBMITTED");
+            invitation.setGoalContributionScore(4);
+            invitation.setCollaborationScore(4);
+            invitation.setAccountabilityScore(4);
+            invitation.setStrengths("能够稳定推进目标并主动协作补位");
+            invitation.setImprovementSuggestion("建议进一步加强风险识别和提前同步");
+            invitation.setSubmittedAt(LocalDateTime.now());
+            peerReviewInvitationMapper.insert(invitation);
+        }
+    }
+
     private JsonNode postForData(String token, String path, Object body) throws Exception {
         String response = mockMvc.perform(post(path)
                         .header("Authorization", "Bearer " + token)

+ 33 - 0
backend/src/test/java/com/emoon/okr/integration/PerformanceLifecycleIntegrationTest.java

@@ -8,6 +8,8 @@ import com.emoon.okr.entity.OkrObjective;
 import com.emoon.okr.entity.PerformanceFeedback;
 import com.emoon.okr.entity.PerformanceScore;
 import com.emoon.okr.entity.PeriodParticipant;
+import com.emoon.okr.entity.PeerReviewInvitation;
+import com.emoon.okr.entity.PeerReviewRequest;
 import com.emoon.okr.entity.ScoreAppeal;
 import com.emoon.okr.entity.SysDepartment;
 import com.emoon.okr.entity.SysUser;
@@ -20,6 +22,8 @@ import com.emoon.okr.mapper.OkrObjectiveMapper;
 import com.emoon.okr.mapper.PerformanceFeedbackMapper;
 import com.emoon.okr.mapper.PerformanceScoreMapper;
 import com.emoon.okr.mapper.PeriodParticipantMapper;
+import com.emoon.okr.mapper.PeerReviewInvitationMapper;
+import com.emoon.okr.mapper.PeerReviewRequestMapper;
 import com.emoon.okr.mapper.ScoreAppealMapper;
 import com.emoon.okr.mapper.SysDepartmentMapper;
 import com.emoon.okr.mapper.SysUserMapper;
@@ -62,6 +66,8 @@ class PerformanceLifecycleIntegrationTest {
     @Autowired PerformanceFeedbackMapper feedbackMapper;
     @Autowired PeriodParticipantMapper participantMapper;
     @Autowired ScoreAppealMapper appealMapper;
+    @Autowired PeerReviewRequestMapper peerReviewRequestMapper;
+    @Autowired PeerReviewInvitationMapper peerReviewInvitationMapper;
 
     @Test
     void fullCompanyLifecycleCanArchiveAfterAppealAndFeedbackClosure() {
@@ -76,6 +82,8 @@ class PerformanceLifecycleIntegrationTest {
         period.setName("2026年6月绩效");
         period.setPeriodType(PeriodType.MONTHLY);
         period.setStartDate(LocalDate.of(2026, 6, 1));
+        period.setPeerInvitationDeadline(LocalDateTime.of(2026, 6, 30, 18, 0));
+        period.setPeerReviewDeadline(LocalDateTime.of(2026, 7, 2, 18, 0));
         period = periodService.create(period);
 
         periodService.transitionStatus(period.getId(), PeriodStatus.OKR_ALIGN);
@@ -98,6 +106,7 @@ class PerformanceLifecycleIntegrationTest {
         periodService.transitionStatus(period.getId(), PeriodStatus.ASSESSING);
 
         performanceService.submitSelfScore(period.getId(), employee.getId(), 54.0, 32.0, 0.0, 0.0, "{}");
+        seedCompletedPeerReviews(period.getId(), employee.getId(), manager.getId(), hr.getId(), manager.getId());
         performanceService.submitSuperiorScore(period.getId(), employee.getId(), manager.getId(), 55.0, 33.0, 0.0, 0.0, "{}");
         performanceService.publishFinalScore(period.getId(), employee.getId(), manager.getId());
         ScoreAppeal appeal = appealService.submitAppeal(period.getId(), employee.getId(), "希望复核评分依据");
@@ -166,4 +175,28 @@ class PerformanceLifecycleIntegrationTest {
         kpiService.lockConfig(saved.getId(), actorId);
         return kpiConfigMapper.selectById(saved.getId());
     }
+
+    private void seedCompletedPeerReviews(Long periodId, Long subjectId, Long leaderId,
+                                          Long firstReviewerId, Long secondReviewerId) {
+        PeerReviewRequest request = new PeerReviewRequest();
+        request.setPeriodId(periodId);
+        request.setSubjectUserId(subjectId);
+        request.setLeaderId(leaderId);
+        request.setStatus("IN_PROGRESS");
+        peerReviewRequestMapper.insert(request);
+        for (Long reviewerId : List.of(firstReviewerId, secondReviewerId)) {
+            PeerReviewInvitation invitation = new PeerReviewInvitation();
+            invitation.setRequestId(request.getId());
+            invitation.setReviewerId(reviewerId);
+            invitation.setRelationTypeSnapshot("CROSS_DEPARTMENT");
+            invitation.setStatus("SUBMITTED");
+            invitation.setGoalContributionScore(4);
+            invitation.setCollaborationScore(4);
+            invitation.setAccountabilityScore(4);
+            invitation.setStrengths("能够稳定推进目标并主动协作补位");
+            invitation.setImprovementSuggestion("建议进一步加强风险识别和提前同步");
+            invitation.setSubmittedAt(LocalDateTime.now());
+            peerReviewInvitationMapper.insert(invitation);
+        }
+    }
 }

+ 12 - 4
backend/src/test/java/com/emoon/okr/integration/Phase3ApiIntegrationTest.java

@@ -57,7 +57,9 @@ class Phase3ApiIntegrationTest {
         Long periodId = postForData(hrToken, "/api/periods", Map.of(
                 "name", "Phase3 API",
                 "periodType", PeriodType.MONTHLY.name(),
-                "startDate", LocalDate.of(2026, 7, 1).toString()
+                "startDate", LocalDate.of(2026, 7, 1).toString(),
+                "peerInvitationDeadline", "2026-07-31T18:00:00",
+                "peerReviewDeadline", "2026-08-02T18:00:00"
         )).get("id").asLong();
 
         putJson(hrToken, "/api/periods/" + periodId + "/status", Map.of("status", PeriodStatus.OKR_ALIGN.name()));
@@ -103,7 +105,9 @@ class Phase3ApiIntegrationTest {
         Long periodId = postForData(hrToken, "/api/periods", Map.of(
                 "name", "Stats Test",
                 "periodType", PeriodType.MONTHLY.name(),
-                "startDate", LocalDate.of(2026, 1, 1).toString()
+                "startDate", LocalDate.of(2026, 1, 1).toString(),
+                "peerInvitationDeadline", "2026-01-31T18:00:00",
+                "peerReviewDeadline", "2026-02-02T18:00:00"
         )).get("id").asLong();
 
         // HR can access statistics
@@ -157,7 +161,9 @@ class Phase3ApiIntegrationTest {
         Long periodId = postForData(hrToken, "/api/periods", Map.of(
                 "name", "Risk Test",
                 "periodType", PeriodType.MONTHLY.name(),
-                "startDate", LocalDate.of(2026, 1, 1).toString()
+                "startDate", LocalDate.of(2026, 1, 1).toString(),
+                "peerInvitationDeadline", "2026-01-31T18:00:00",
+                "peerReviewDeadline", "2026-02-02T18:00:00"
         )).get("id").asLong();
 
         putJson(hrToken, "/api/periods/" + periodId + "/status", Map.of("status", PeriodStatus.OKR_ALIGN.name()));
@@ -198,7 +204,9 @@ class Phase3ApiIntegrationTest {
         Long periodId = postForData(hrToken, "/api/periods", Map.of(
                 "name", "Trend Test",
                 "periodType", PeriodType.MONTHLY.name(),
-                "startDate", LocalDate.of(2026, 1, 1).toString()
+                "startDate", LocalDate.of(2026, 1, 1).toString(),
+                "peerInvitationDeadline", "2026-01-31T18:00:00",
+                "peerReviewDeadline", "2026-02-02T18:00:00"
         )).get("id").asLong();
 
         // Full cycle to produce an archived final score

+ 21 - 0
backend/src/test/java/com/emoon/okr/schedule/PeerReviewReminderSchedulerTest.java

@@ -0,0 +1,21 @@
+package com.emoon.okr.schedule;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class PeerReviewReminderSchedulerTest {
+
+    @Test
+    void reminderRunsOnlyDuringTheHourlyWindowBeforeDeadline() {
+        LocalDateTime now = LocalDateTime.of(2026, 7, 16, 10, 0);
+
+        assertTrue(PeerReviewReminderScheduler.inReminderWindow(now, now.plusHours(24)));
+        assertTrue(PeerReviewReminderScheduler.inReminderWindow(now, now.plusHours(23).plusMinutes(1)));
+        assertFalse(PeerReviewReminderScheduler.inReminderWindow(now, now.plusHours(23)));
+        assertFalse(PeerReviewReminderScheduler.inReminderWindow(now, now.plusHours(24).plusMinutes(1)));
+    }
+}

+ 48 - 0
backend/src/test/java/com/emoon/okr/service/AssessmentPeriodServiceTest.java

@@ -23,6 +23,7 @@ import org.springframework.context.ApplicationEventPublisher;
 import java.util.List;
 import java.util.ArrayList;
 import java.util.Map;
+import java.time.LocalDateTime;
 
 import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.ArgumentMatchers.any;
@@ -378,6 +379,53 @@ class AssessmentPeriodServiceTest {
                 Long.valueOf(99L).equals(((AssessmentPeriod) period).getCompanyOwnerUserId())));
     }
 
+    @Test
+    void createRejectsInvalidPeerReviewDeadlineOrder() {
+        AssessmentPeriod period = period(null, PeriodStatus.DRAFT);
+        period.setPeerInvitationDeadline(LocalDateTime.of(2026, 12, 20, 18, 0));
+        period.setPeerReviewDeadline(LocalDateTime.of(2026, 12, 19, 18, 0));
+
+        BusinessException error = assertThrows(BusinessException.class,
+                () -> periodService.create(period));
+
+        assertTrue(error.getMessage().contains("邀请名单截止时间必须早于评价提交截止时间"));
+        verify(periodMapper, never()).insert(any());
+    }
+
+    @Test
+    void assessingPeriodAllowsOnlyDeadlineExtension() {
+        AssessmentPeriod existing = period(20L, PeriodStatus.ASSESSING);
+        existing.setPeerInvitationDeadline(LocalDateTime.now().plusDays(1));
+        existing.setPeerReviewDeadline(LocalDateTime.now().plusDays(2));
+        when(periodMapper.selectById(20L)).thenReturn(existing);
+
+        AssessmentPeriod shortened = period(20L, PeriodStatus.ASSESSING);
+        shortened.setPeerInvitationDeadline(existing.getPeerInvitationDeadline().minusHours(1));
+        shortened.setPeerReviewDeadline(existing.getPeerReviewDeadline());
+        assertThrows(BusinessException.class, () -> periodService.update(shortened));
+
+        AssessmentPeriod extended = period(20L, PeriodStatus.ASSESSING);
+        extended.setPeerInvitationDeadline(existing.getPeerInvitationDeadline().plusHours(1));
+        extended.setPeerReviewDeadline(existing.getPeerReviewDeadline().plusHours(1));
+        assertDoesNotThrow(() -> periodService.update(extended));
+        verify(periodMapper).updateById(argThat(item ->
+                item.getPeerReviewDeadline().equals(extended.getPeerReviewDeadline())));
+    }
+
+    @Test
+    void assessingLegacyPeriodCanConfigureMissingDeadlines() {
+        AssessmentPeriod existing = period(21L, PeriodStatus.ASSESSING);
+        when(periodMapper.selectById(21L)).thenReturn(existing);
+        AssessmentPeriod update = period(21L, PeriodStatus.ASSESSING);
+        update.setPeerInvitationDeadline(LocalDateTime.now().plusDays(1));
+        update.setPeerReviewDeadline(LocalDateTime.now().plusDays(2));
+
+        assertDoesNotThrow(() -> periodService.update(update));
+
+        verify(periodMapper).updateById(argThat(item ->
+                item.getPeerInvitationDeadline().equals(update.getPeerInvitationDeadline())));
+    }
+
     private AssessmentPeriod period(Long id, PeriodStatus status) {
         AssessmentPeriod period = new AssessmentPeriod();
         period.setId(id);

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

@@ -55,6 +55,7 @@ class PeerReviewServiceTest {
     @Mock ApplicationEventPublisher publisher;
     @Mock OkrService okrService;
     @Mock PerformanceScoreItemMapper scoreItemMapper;
+    @Mock AuthorizationService authorizationService;
     @InjectMocks PeerReviewService service;
 
     @BeforeEach
@@ -420,6 +421,29 @@ class PeerReviewServiceTest {
         assertEquals(4.0, summary.getAverageGoalContributionScore());
     }
 
+    @Test
+    void adminStatisticsContainCountsButNoReviewerIdentityOrContent() {
+        when(authorizationService.isHrOrAbove(99L)).thenReturn(true);
+        PeerReviewRequest blockedRequest = request(21L, "IN_PROGRESS");
+        blockedRequest.setSubjectUserId(21L);
+        when(requestMapper.selectList(any())).thenReturn(List.of(
+                request(20L, "IN_PROGRESS"), blockedRequest));
+        PeerReviewInvitation first = submittedInvitation(30L, 3L, 5,
+                "协作主动,目标推进结果稳定", "建议加强跨部门风险同步机制");
+        PeerReviewInvitation second = submittedInvitation(31L, 4L, 4,
+                "交付可靠并能主动协调资源", "建议提升复杂问题的提前预警");
+        first.setRequestId(20L);
+        second.setRequestId(20L);
+        when(invitationMapper.selectList(any())).thenReturn(List.of(first, second));
+
+        PeerReviewDto.AdminStatistics statistics = service.getAdminStatistics(1L, 99L);
+
+        assertEquals(2, statistics.getSubmittedResponseCount());
+        assertEquals(List.of(21L), statistics.getBlockedSubjectUserIds());
+        JsonNode json = new ObjectMapper().valueToTree(statistics);
+        assertTrue(!json.has("reviewerNames") && !json.has("comments") && !json.has("responses"));
+    }
+
     private PeriodParticipant participant(Long userId, Long departmentId, Long superiorId,
                                           Long evaluatorId, String status) {
         PeriodParticipant participant = new PeriodParticipant();