package com.emoon.okr.service; import com.emoon.okr.entity.KpiConfig; import com.emoon.okr.entity.KpiTemplate; import com.emoon.okr.entity.KpiTemplateAssignment; import com.emoon.okr.entity.OkrObjective; import com.emoon.okr.entity.AssessmentPeriod; import com.emoon.okr.entity.PeriodParticipant; import com.emoon.okr.entity.SysUser; import com.emoon.okr.enums.PeriodStatus; import com.emoon.okr.enums.ScoreType; import com.emoon.okr.enums.UserRole; import com.emoon.okr.exception.BusinessException; import com.emoon.okr.mapper.*; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; 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; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class AssessmentPeriodServiceTest { @Mock AssessmentPeriodMapper periodMapper; @Mock SysUserMapper userMapper; @Mock OkrObjectiveMapper objectiveMapper; @Mock KrKeyResultMapper krMapper; @Mock KpiConfigMapper kpiConfigMapper; @Mock KpiTemplateMapper kpiTemplateMapper; @Mock KpiTemplateAssignmentMapper kpiTemplateAssignmentMapper; @Mock PerformanceScoreMapper scoreMapper; @Mock PerformanceFeedbackMapper feedbackMapper; @Mock PeriodParticipantMapper participantMapper; @Mock AssessmentDimensionMapper dimensionMapper; @Mock PeriodDimensionMapper periodDimensionMapper; @Mock ScoreAppealMapper appealMapper; @Mock ApplicationEventPublisher publisher; @InjectMocks AssessmentPeriodService periodService; @BeforeEach void defaultFortyPointDimensions() { var dimensions = new ArrayList(); for (int i = 0; i < 4; i++) { var dimension = new com.emoon.okr.entity.AssessmentDimension(); dimension.setId((long) i + 1); dimension.setName("维度" + i); dimension.setMaxScore(10); dimension.setSortOrder(i); dimensions.add(dimension); } lenient().when(dimensionMapper.selectList(any())).thenReturn(dimensions); } @Test void transitionToExecutingRejectsPendingOkrReview() { AssessmentPeriod period = period(1L, PeriodStatus.OKR_ALIGN); when(periodMapper.selectById(1L)).thenReturn(period); when(objectiveMapper.selectCount(any())).thenReturn(1L); BusinessException ex = assertThrows(BusinessException.class, () -> periodService.transitionStatus(1L, PeriodStatus.EXECUTING)); assertTrue(ex.getMessage().contains("待审核OKR")); verify(periodMapper, never()).updateById(any()); } @Test void transitionToArchivedRejectsUnconfirmedFinalScores() { AssessmentPeriod period = period(2L, PeriodStatus.ASSESSING); when(periodMapper.selectById(2L)).thenReturn(period); when(scoreMapper.selectCount(any())).thenReturn(1L, 1L); BusinessException ex = assertThrows(BusinessException.class, () -> periodService.transitionStatus(2L, PeriodStatus.ARCHIVED)); assertTrue(ex.getMessage().contains("未确认最终评分")); verify(periodMapper, never()).updateById(any()); } @Test void transitionFromExecutingCannotSkipAssessmentAndArchiveDirectly() { AssessmentPeriod period = period(9L, PeriodStatus.EXECUTING); when(periodMapper.selectById(9L)).thenReturn(period); BusinessException ex = assertThrows(BusinessException.class, () -> periodService.transitionStatus(9L, PeriodStatus.ARCHIVED)); assertTrue(ex.getMessage().contains("进入考评")); verify(periodMapper, never()).updateById(any()); } @Test void transitionToExecutingRejectsParticipantsWithoutApprovedOkr() { AssessmentPeriod period = period(3L, PeriodStatus.OKR_ALIGN); when(periodMapper.selectById(3L)).thenReturn(period); when(objectiveMapper.selectCount(any())).thenReturn(0L); when(kpiConfigMapper.selectCount(any())).thenReturn(0L); OkrObjective approved = new OkrObjective(); approved.setUserId(10L); approved.setStatus("APPROVED"); when(objectiveMapper.selectList(any())).thenReturn(List.of(approved)); KpiConfig locked10 = lockedKpi(10L); KpiConfig locked11 = lockedKpi(11L); when(kpiConfigMapper.selectList(any())).thenReturn(List.of(locked10, locked11)); BusinessException ex = assertThrows(BusinessException.class, () -> periodService.transitionStatus(3L, PeriodStatus.EXECUTING)); assertTrue(ex.getMessage().contains("未提交或未通过OKR")); verify(periodMapper, never()).updateById(any()); } @Test void transitionToArchivedRejectsMissingSelfScoreAndFeedbackReply() { AssessmentPeriod period = period(4L, PeriodStatus.ASSESSING); when(periodMapper.selectById(4L)).thenReturn(period); when(scoreMapper.selectCount(any())).thenReturn(1L, 0L); when(kpiConfigMapper.selectList(any())).thenReturn(List.of(lockedKpi(10L))); BusinessException ex = assertThrows(BusinessException.class, () -> periodService.transitionStatus(4L, PeriodStatus.ARCHIVED)); assertTrue(ex.getMessage().contains("未完成自评")); verify(periodMapper, never()).updateById(any()); } @Test void readinessUsesOnlyUsersWithPeriodDataAsParticipants() { AssessmentPeriod period = period(5L, PeriodStatus.OKR_ALIGN); when(periodMapper.selectById(5L)).thenReturn(period); when(objectiveMapper.selectCount(any())).thenReturn(0L); when(kpiConfigMapper.selectCount(any())).thenReturn(0L); OkrObjective approved = new OkrObjective(); approved.setUserId(10L); approved.setStatus("APPROVED"); when(objectiveMapper.selectList(any())).thenReturn(List.of(approved)); when(kpiConfigMapper.selectList(any())).thenReturn(List.of(lockedKpi(10L))); var readiness = periodService.checkReadiness(5L, PeriodStatus.EXECUTING); assertTrue(readiness.isReady()); assertTrue(readiness.getBlockers().isEmpty()); verify(userMapper, never()).selectList(any()); } @Test void transitionToOkrAlignCreatesActiveParticipantSnapshotForActiveDepartmentUsers() { AssessmentPeriod period = period(6L, PeriodStatus.DRAFT); when(periodMapper.selectById(6L)).thenReturn(period); SysUser active = participant(10L); active.setStatus("ACTIVE"); SysUser disabled = participant(11L); disabled.setStatus("DISABLED"); SysUser ceo = participant(99L); ceo.setSuperiorId(null); ceo.setStatus("ACTIVE"); when(userMapper.selectList(any())).thenReturn(List.of(ceo, active, disabled)); periodService.transitionStatus(6L, PeriodStatus.OKR_ALIGN); verify(participantMapper).insert(argThat(p -> p instanceof PeriodParticipant && ((PeriodParticipant) p).getPeriodId().equals(6L) && ((PeriodParticipant) p).getUserId().equals(10L) && "ACTIVE".equals(((PeriodParticipant) p).getStatus()))); verify(participantMapper, never()).insert(argThat(p -> p instanceof PeriodParticipant && ((PeriodParticipant) p).getUserId().equals(11L))); } @Test void transitionToOkrAlignFreezesReportingLineAndExemptsUserWithoutEvaluator() { AssessmentPeriod period = period(8L, PeriodStatus.DRAFT); when(periodMapper.selectById(8L)).thenReturn(period); SysUser ceo = participant(20L); ceo.setPosition("CEO"); ceo.setStatus("ACTIVE"); ceo.setSuperiorId(null); SysUser employee = participant(21L); employee.setPosition("研发工程师"); employee.setStatus("ACTIVE"); employee.setSuperiorId(20L); when(userMapper.selectList(any())).thenReturn(List.of(ceo, employee)); List inserted = new ArrayList<>(); doAnswer(invocation -> { inserted.add(invocation.getArgument(0)); return 1; }).when(participantMapper).insert(any()); periodService.transitionStatus(8L, PeriodStatus.OKR_ALIGN); PeriodParticipant ceoSnapshot = inserted.stream() .filter(p -> p.getUserId().equals(20L)).findFirst().orElseThrow(); PeriodParticipant employeeSnapshot = inserted.stream() .filter(p -> p.getUserId().equals(21L)).findFirst().orElseThrow(); assertEquals("EXEMPT_NO_EVALUATOR", ceoSnapshot.getStatus()); assertEquals("ACTIVE", employeeSnapshot.getStatus()); assertEquals(20L, readField(employeeSnapshot, "superiorIdSnapshot")); assertEquals(20L, readField(employeeSnapshot, "evaluatorId")); assertEquals(1L, readField(employeeSnapshot, "departmentIdSnapshot")); assertEquals("研发工程师", readField(employeeSnapshot, "positionSnapshot")); assertEquals("EMPLOYEE", readField(employeeSnapshot, "roleSnapshot")); } @Test void transitionToOkrAlignInstantiatesAndLocksExplicitlyAssignedPerformanceTemplate() { AssessmentPeriod period = period(10L, PeriodStatus.DRAFT); period.setCompanyOwnerUserId(20L); when(periodMapper.selectById(10L)).thenReturn(period); SysUser ceo = participant(20L); ceo.setSuperiorId(null); ceo.setStatus("ACTIVE"); ceo.setPosition("CEO"); SysUser employee = participant(21L); employee.setSuperiorId(20L); employee.setStatus("ACTIVE"); employee.setPosition("研发工程师"); when(userMapper.selectList(any())).thenReturn(List.of(ceo, employee)); PeriodParticipant assignedEmployee = participantRecord(10L, 21L, "ACTIVE"); assignedEmployee.setDepartmentIdSnapshot(employee.getDepartmentId()); assignedEmployee.setPositionSnapshot(employee.getPosition()); when(participantMapper.selectList(any())).thenReturn(List.of(assignedEmployee)); KpiTemplateAssignment assignment = new KpiTemplateAssignment(); assignment.setTemplateId(5L); assignment.setTargetType("POSITION"); assignment.setTargetValue("研发工程师"); assignment.setPriority(10); when(kpiTemplateAssignmentMapper.selectList(any())).thenReturn(List.of(assignment)); KpiTemplate template = new KpiTemplate(); template.setId(5L); template.setItemsJson("[{\"name\":\"交付质量\",\"maxScore\":40}]"); when(kpiTemplateMapper.selectById(5L)).thenReturn(template); periodService.transitionStatus(10L, PeriodStatus.OKR_ALIGN); verify(kpiConfigMapper).insert(argThat(config -> { KpiConfig value = (KpiConfig) config; return Long.valueOf(10L).equals(value.getPeriodId()) && Long.valueOf(21L).equals(value.getUserId()) && value.getLockedAt() != null && template.getItemsJson().equals(value.getItemsJson()); })); } @Test void readinessIgnoresExemptParticipant() { AssessmentPeriod period = period(7L, PeriodStatus.OKR_ALIGN); when(periodMapper.selectById(7L)).thenReturn(period); when(objectiveMapper.selectCount(any())).thenReturn(0L); when(kpiConfigMapper.selectCount(any())).thenReturn(0L); PeriodParticipant active = participantRecord(7L, 10L, "ACTIVE"); PeriodParticipant exempt = participantRecord(7L, 11L, "EXEMPT"); when(participantMapper.selectList(any())).thenReturn(List.of(active, exempt)); OkrObjective approved = new OkrObjective(); approved.setUserId(10L); approved.setStatus("APPROVED"); when(objectiveMapper.selectList(any())).thenReturn(List.of(approved)); when(kpiConfigMapper.selectList(any())).thenReturn(List.of(lockedKpi(10L))); var readiness = periodService.checkReadiness(7L, PeriodStatus.EXECUTING); assertTrue(readiness.isReady()); } @Test void applyKpiTemplateAndLockUsesParticipantSnapshotPosition() { PeriodParticipant participant = participantRecord(7L, 12L, "ACTIVE"); participant.setPositionSnapshot("研发工程师"); when(participantMapper.selectOne(any())).thenReturn(participant); KpiTemplateAssignment assignment = new KpiTemplateAssignment(); assignment.setTemplateId(5L); assignment.setTargetType("POSITION"); assignment.setTargetValue("研发工程师"); assignment.setPriority(10); when(kpiTemplateAssignmentMapper.selectList(any())).thenReturn(List.of(assignment)); KpiTemplate template = new KpiTemplate(); template.setId(5L); template.setItemsJson("[{\"name\":\"交付质量\",\"maxScore\":40}]"); when(kpiTemplateMapper.selectById(5L)).thenReturn(template); when(kpiConfigMapper.selectOne(any())).thenReturn(null); KpiConfig config = periodService.applyKpiTemplateAndLock(7L, 12L); assertNotNull(config.getLockedAt()); assertEquals(template.getItemsJson(), config.getItemsJson()); verify(kpiConfigMapper).insert(argThat(value -> { KpiConfig inserted = (KpiConfig) value; return Long.valueOf(7L).equals(inserted.getPeriodId()) && Long.valueOf(12L).equals(inserted.getUserId()) && inserted.getLockedAt() != null; })); } @Test void applyKpiTemplateAndLockBackfillsMissingLegacyPositionSnapshot() { PeriodParticipant participant = participantRecord(7L, 12L, "ACTIVE"); when(participantMapper.selectOne(any())).thenReturn(participant); SysUser user = participant(12L); user.setPosition("后端开发工程师"); when(userMapper.selectById(12L)).thenReturn(user); KpiTemplateAssignment assignment = new KpiTemplateAssignment(); assignment.setTemplateId(2L); assignment.setTargetType("POSITION"); assignment.setTargetValue("后端开发工程师"); assignment.setPriority(10); when(kpiTemplateAssignmentMapper.selectList(any())).thenReturn(List.of(assignment)); KpiTemplate template = new KpiTemplate(); template.setId(2L); template.setItemsJson("[{\"name\":\"交付质量\",\"maxScore\":40}]"); when(kpiTemplateMapper.selectById(2L)).thenReturn(template); when(kpiConfigMapper.selectOne(any())).thenReturn(null); KpiConfig config = periodService.applyKpiTemplateAndLock(7L, 12L); assertNotNull(config.getLockedAt()); assertEquals("后端开发工程师", participant.getPositionSnapshot()); verify(participantMapper).updateById(participant); } @Test void addParticipantCreatesActiveParticipantWhenUserExists() { when(userMapper.selectById(12L)).thenReturn(participant(12L)); when(participantMapper.selectOne(any())).thenReturn(null); PeriodParticipant participant = periodService.addParticipant(7L, 12L); assertEquals("ACTIVE", participant.getStatus()); assertEquals(7L, participant.getPeriodId()); assertEquals(12L, participant.getUserId()); verify(participantMapper).insert(participant); } @Test void activateParticipantRevertsExemption() { PeriodParticipant participant = participantRecord(7L, 12L, "EXEMPT"); participant.setReason("休假"); when(participantMapper.selectOne(any())).thenReturn(participant); PeriodParticipant active = periodService.activateParticipant(7L, 12L); assertEquals("ACTIVE", active.getStatus()); assertNull(active.getReason()); assertNull(active.getExemptedAt()); verify(participantMapper).updateById(participant); } @Test void batchCreateKeepsExplicitCompanyOwnerForEveryPeriod() { List periods = periodService.batchCreate(Map.of( "namePrefix", "2026-Q", "periodType", "QUARTERLY", "startDate", "2026-01-01", "companyOwnerUserId", 99 )); assertEquals(6, periods.size()); assertTrue(periods.stream().allMatch(period -> Long.valueOf(99L).equals(period.getCompanyOwnerUserId()))); verify(periodMapper, times(6)).insert(argThat(period -> 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); period.setName("2026年6月"); period.setStatus(status); return period; } private SysUser participant(Long id) { SysUser user = new SysUser(); user.setId(id); user.setUsername("u" + id); user.setRealName("员工" + id); user.setDepartmentId(1L); user.setSuperiorId(99L); user.setRole(UserRole.EMPLOYEE); return user; } private PeriodParticipant participantRecord(Long periodId, Long userId, String status) { PeriodParticipant participant = new PeriodParticipant(); participant.setPeriodId(periodId); participant.setUserId(userId); participant.setStatus(status); return participant; } private KpiConfig lockedKpi(Long userId) { KpiConfig config = new KpiConfig(); config.setUserId(userId); config.setLockedAt(java.time.LocalDateTime.now()); return config; } private Object readField(Object target, String fieldName) { try { var field = target.getClass().getDeclaredField(fieldName); field.setAccessible(true); return field.get(target); } catch (ReflectiveOperationException ignored) { return null; } } }