package com.mydreamjob.careerprogress;
import com.mydreamjob.application.Application;
import com.mydreamjob.application.ApplicationRepository;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.CandidateDimensionScore;
import com.mydreamjob.candidatescore.CandidateDimensionScoreRepository;
import com.mydreamjob.candidatescore.CandidateScoringCalculator;
import com.mydreamjob.careerprogress.dto.CareerProgressResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyRepository;
import com.mydreamjob.competition.CompetitionRankingCalculator;
import com.mydreamjob.interview.Interview;
import com.mydreamjob.interview.InterviewRepository;
import com.mydreamjob.interviewscore.InterviewScore;
import com.mydreamjob.interviewscore.InterviewScoreRepository;
import com.mydreamjob.job.Job;
import com.mydreamjob.job.JobRepository;
import com.mydreamjob.notification.NotificationRepository;
import com.mydreamjob.offer.Offer;
import com.mydreamjob.offer.OfferRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 统一求职进度聚合服务单测(V34,编排 + 脱敏 + 聚合口径 + 无 N+1)。
*
*
排名/排序/百分位由真实 {@link CompetitionRankingCalculator}(配合 mock 的
* 快照仓库与综合分计算器)承载,与 V32 共用同一组件,保证口径一致。本类聚焦断言
* 权限、参数、投递分页、todo 计数、nextInterview、评分摘要、Offer 优先级、
* competition 摘要、通知仅计数与批量查询(无 N+1)。
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class CareerProgressServiceTest {
private static final Long ME = 100L;
private static final Long JOB = 9L;
private static final Long COMPANY = 1L;
private static final String PERIOD = yearMonth(LocalDate.now());
@Mock private ApplicationRepository applicationRepository;
@Mock private InterviewRepository interviewRepository;
@Mock private InterviewScoreRepository interviewScoreRepository;
@Mock private OfferRepository offerRepository;
@Mock private NotificationRepository notificationRepository;
@Mock private JobRepository jobRepository;
@Mock private CompanyRepository companyRepository;
@Mock private UserRepository userRepository;
@Mock private CandidateDimensionScoreRepository dimensionScoreRepository;
@Mock private CandidateScoringCalculator scoringCalculator;
private CompetitionRankingCalculator calculator;
private CareerProgressService service;
private Map overallByStudent = Map.of();
@BeforeEach
void setUp() {
calculator = spy(new CompetitionRankingCalculator(dimensionScoreRepository, scoringCalculator));
service = new CareerProgressService(applicationRepository, interviewRepository,
interviewScoreRepository, offerRepository, notificationRepository,
jobRepository, companyRepository, userRepository, calculator, scoringCalculator);
when(scoringCalculator.overallScoreFromSnapshots(anyList())).thenAnswer(inv -> {
List> snaps = inv.getArgument(0);
if (snaps == null || snaps.isEmpty()) return null;
Long sid = ((CandidateDimensionScore) snaps.get(0)).getStudentUserId();
return overallByStudent.get(sid);
});
}
// ── 权限与参数 ────────────────────────────────────────────
@Test
void companyRole_throwsForbidden() {
mockUser(ME, User.Role.COMPANY);
BusinessException ex = assertThrows(BusinessException.class,
() -> service.getCareerProgress(ME, 0, 20, null));
assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCode());
}
@Test
void adminRole_throwsForbidden() {
mockUser(ME, User.Role.ADMIN);
BusinessException ex = assertThrows(BusinessException.class,
() -> service.getCareerProgress(ME, 0, 20, null));
assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCode());
}
@Test
void negativePage_throwsBadRequest() {
mockUser(ME, User.Role.STUDENT);
BusinessException ex = assertThrows(BusinessException.class,
() -> service.getCareerProgress(ME, -1, 20, null));
assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCode());
}
@Test
void sizeOutOfRange_throwsBadRequest() {
mockUser(ME, User.Role.STUDENT);
assertThrows(BusinessException.class,
() -> service.getCareerProgress(ME, 0, 0, null));
assertThrows(BusinessException.class,
() -> service.getCareerProgress(ME, 0, 51, null));
}
@Test
void invalidStatus_throwsBadRequest() {
mockUser(ME, User.Role.STUDENT);
BusinessException ex = assertThrows(BusinessException.class,
() -> service.getCareerProgress(ME, 0, 20, "NONEXISTENT"));
assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCode());
}
// ── 空数据 ──────────────────────────────────────────────
@Test
void noApplications_returnsEmptyPage_andZeroTodo() {
mockUser(ME, User.Role.STUDENT);
when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class)))
.thenReturn(new PageImpl<>(List.of(), PageRequest.of(0, 20), 0));
when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection()))
.thenReturn(List.of());
when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString()))
.thenReturn(List.of());
when(notificationRepository.countUnreadByUserId(ME)).thenReturn(0L);
when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(0L);
CareerProgressResponse resp = service.getCareerProgress(ME, 0, 20, null);
assertEquals(0L, resp.getApplications().getItems().size());
assertEquals(0L, resp.getApplications().getTotal());
assertEquals(0L, resp.getTodo().getPendingInterviewResponseCount());
assertEquals(0L, resp.getTodo().getUpcomingConfirmedInterviewCount());
assertEquals(0L, resp.getTodo().getPendingOfferResponseCount());
assertEquals(0L, resp.getTodo().getUnreadNotificationCount());
assertEquals(0L, resp.getTodo().getUnreadReportDecisionCount());
}
// ── 聚合口径(完整 happy path)────────────────────────────
@Test
void fullAggregation_returnsCorrectSummaries() {
mockUser(ME, User.Role.STUDENT);
Application app = app(1L, ME, JOB, "SUBMITTED", "2026-08-20T10:00:00");
when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class)))
.thenReturn(new PageImpl<>(List.of(app), PageRequest.of(0, 20), 1));
when(jobRepository.findAllById(anyCollection()))
.thenReturn(List.of(Job.builder().id(JOB).companyId(COMPANY).title("Java 后端工程师")
.description("d").city("杭州").employmentType("FULL_TIME").status("PUBLISHED").build()));
when(companyRepository.findAllById(anyCollection()))
.thenReturn(List.of(Company.builder().id(COMPANY).ownerUserId(999L).name("示例科技").build()));
// 面试:两场 SCHEDULED(取最早为 nextInterview)+ 一场 CONFIRMED(未来)
Interview later = interview(2L, app.getId(), "SCHEDULED", "2026-08-30T14:00:00");
Interview earlier = interview(1L, app.getId(), "SCHEDULED", "2026-08-28T10:00:00");
Interview confirmed = interview(3L, app.getId(), "CONFIRMED", LocalDateTime.now().plusDays(3).toString());
when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection()))
.thenReturn(List.of(earlier, later, confirmed));
// 已提交评分:两条,取 submittedAt 最新
InterviewScore s1 = score(22L, app.getId(), "2026-08-25T16:00:00", new BigDecimal("3.60"));
InterviewScore s2 = score(23L, app.getId(), "2026-08-24T16:00:00", new BigDecimal("3.00"));
when(interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus(
anyLong(), anyCollection(), anyString())).thenReturn(List.of(s1, s2));
// Offer:一条 SENT 有效 + 一条 DRAFT(不应返回 DRAFT)
Offer sent = offer(14L, app.getId(), "SENT", "2026-09-05T23:59:59");
when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString()))
.thenReturn(List.of(sent));
// 竞争力:同岗位 cohort 含本人 + 另一有分者
Application other = app(2L, 200L, JOB, "SUBMITTED", "2026-08-19T10:00:00");
when(applicationRepository.findByJobIdInAndStatusNotInOrderByCreatedAtAsc(anyCollection(), anyCollection()))
.thenReturn(List.of(other, app));
overallByStudent = Map.of(ME, new BigDecimal("77.50"), 200L, new BigDecimal("90.00"));
when(dimensionScoreRepository.findUpToPeriod(anyCollection(), anyString()))
.thenReturn(List.of(dim(ME, "77.50"), dim(200L, "90.00")));
when(notificationRepository.countUnreadByUserId(ME)).thenReturn(5L);
when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(2L);
CareerProgressResponse resp = service.getCareerProgress(ME, 0, 20, null);
// todo
assertEquals(2L, resp.getTodo().getPendingInterviewResponseCount());
assertEquals(1L, resp.getTodo().getUpcomingConfirmedInterviewCount());
assertEquals(1L, resp.getTodo().getPendingOfferResponseCount());
assertEquals(5L, resp.getTodo().getUnreadNotificationCount());
assertEquals(2L, resp.getTodo().getUnreadReportDecisionCount());
// item
assertEquals(1L, resp.getApplications().getItems().size());
CareerProgressResponse.CareerProgressItem item = resp.getApplications().getItems().get(0);
assertEquals(1L, item.getApplicationId());
assertEquals("SUBMITTED", item.getApplicationStatus());
assertEquals("Java 后端工程师", item.getJob().getTitle());
assertEquals("示例科技", item.getJob().getCompanyName());
assertEquals("杭州", item.getJob().getCity());
assertEquals("FULL_TIME", item.getJob().getEmploymentType());
assertEquals("PUBLISHED", item.getJob().getJobStatus());
// nextInterview:取 startAt 最早 1L
assertEquals(1L, item.getNextInterview().getInterviewId());
// latest score:22(submittedAt 最新)
assertEquals(22L, item.getLatestSubmittedInterviewScore().getInterviewId());
assertEquals(new BigDecimal("3.60"), item.getLatestSubmittedInterviewScore().getOverallScore());
assertEquals("HIRE", item.getLatestSubmittedInterviewScore().getRecommendation());
// offer
assertEquals(14L, item.getOffer().getOfferId());
assertEquals("SENT", item.getOffer().getStatus());
// competition:cohort=2(other 90.00、me 77.50),me 排名 2;
// percentile = 严格低于 me 的人数(0) / (有分人数-1=1) * 100 = 0.00
assertTrue(item.getCompetition().isEligible());
assertEquals(2, item.getCompetition().getCohortSize());
assertEquals(2, item.getCompetition().getRank());
assertEquals(new BigDecimal("77.50"), item.getCompetition().getOverallScore());
assertEquals(new BigDecimal("0.00"), item.getCompetition().getPercentile());
assertEquals(PERIOD, item.getCompetition().getMySnapshotPeriod());
}
// ── DRAFT 不泄露 ────────────────────────────────────────
@Test
void draftScore_notReturned_andDraftOffer_notReturned() {
mockUser(ME, User.Role.STUDENT);
Application app = app(1L, ME, JOB, "SUBMITTED", "2026-08-20T10:00:00");
when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class)))
.thenReturn(new PageImpl<>(List.of(app), PageRequest.of(0, 20), 1));
when(jobRepository.findAllById(anyCollection()))
.thenReturn(List.of(Job.builder().id(JOB).companyId(COMPANY).title("t").description("d")
.city("c").employmentType("FULL_TIME").status("PUBLISHED").build()));
when(companyRepository.findAllById(anyCollection())).thenReturn(List.of());
when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection()))
.thenReturn(List.of());
// 只有 DRAFT 评分 → 不应出现在 latestSubmittedInterviewScore
InterviewScore draft = score(1L, app.getId(), null, null);
draft.setStatus(InterviewScore.STATUS_DRAFT);
when(interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus(
anyLong(), anyCollection(), anyString())).thenReturn(List.of());
// 只有 DRAFT offer → offer 为 null
when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString()))
.thenReturn(List.of());
when(applicationRepository.findByJobIdInAndStatusNotInOrderByCreatedAtAsc(anyCollection(), anyCollection()))
.thenReturn(List.of(app));
when(dimensionScoreRepository.findUpToPeriod(anyCollection(), anyString())).thenReturn(List.of());
when(notificationRepository.countUnreadByUserId(ME)).thenReturn(0L);
when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(0L);
CareerProgressResponse resp = service.getCareerProgress(ME, 0, 20, null);
CareerProgressResponse.CareerProgressItem item = resp.getApplications().getItems().get(0);
assertNull(item.getLatestSubmittedInterviewScore());
assertNull(item.getOffer());
}
// ── WITHDRAWN/REJECTED 保留历史但 eligible=false ─────────
@Test
void withdrawnApplication_eligibleFalse_andNoCompetitionFields() {
mockUser(ME, User.Role.STUDENT);
Application withdrawn = app(1L, ME, JOB, "WITHDRAWN", "2026-08-20T10:00:00");
when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class)))
.thenReturn(new PageImpl<>(List.of(withdrawn), PageRequest.of(0, 20), 1));
when(jobRepository.findAllById(anyCollection()))
.thenReturn(List.of(Job.builder().id(JOB).companyId(COMPANY).title("t").description("d")
.city("c").employmentType("FULL_TIME").status("PUBLISHED").build()));
when(companyRepository.findAllById(anyCollection())).thenReturn(List.of());
when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection()))
.thenReturn(List.of());
when(interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus(
anyLong(), anyCollection(), anyString())).thenReturn(List.of());
when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString()))
.thenReturn(List.of());
when(notificationRepository.countUnreadByUserId(ME)).thenReturn(0L);
when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(0L);
CareerProgressResponse resp = service.getCareerProgress(ME, 0, 20, null);
CareerProgressResponse.CareerProgressItem item = resp.getApplications().getItems().get(0);
// 保留历史
assertEquals(1L, item.getApplicationId());
assertEquals("WITHDRAWN", item.getApplicationStatus());
// competition 只返回 eligible=false,不返回其它排名字段
assertFalse(item.getCompetition().isEligible());
assertNull(item.getCompetition().getRank());
assertNull(item.getCompetition().getCohortSize());
assertNull(item.getCompetition().getOverallScore());
assertNull(item.getCompetition().getPercentile());
assertNull(item.getCompetition().getStatus());
assertNull(item.getCompetition().getMySnapshotPeriod());
assertNull(item.getCompetition().getRulesVersion());
}
// ── 有有效投递但无快照 → competition 分数/排名/百分位为 null ─
@Test
void validApplication_noSnapshot_competitionScoresNull_cohortSizeReturned() {
mockUser(ME, User.Role.STUDENT);
Application app = app(1L, ME, JOB, "SUBMITTED", "2026-08-20T10:00:00");
when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class)))
.thenReturn(new PageImpl<>(List.of(app), PageRequest.of(0, 20), 1));
when(jobRepository.findAllById(anyCollection()))
.thenReturn(List.of(Job.builder().id(JOB).companyId(COMPANY).title("t").description("d")
.city("c").employmentType("FULL_TIME").status("PUBLISHED").build()));
when(companyRepository.findAllById(anyCollection())).thenReturn(List.of());
when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection()))
.thenReturn(List.of());
when(interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus(
anyLong(), anyCollection(), anyString())).thenReturn(List.of());
when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString()))
.thenReturn(List.of());
when(applicationRepository.findByJobIdInAndStatusNotInOrderByCreatedAtAsc(anyCollection(), anyCollection()))
.thenReturn(List.of(app));
when(dimensionScoreRepository.findUpToPeriod(anyCollection(), anyString())).thenReturn(List.of());
when(notificationRepository.countUnreadByUserId(ME)).thenReturn(0L);
when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(0L);
CareerProgressResponse resp = service.getCareerProgress(ME, 0, 20, null);
CareerProgressResponse.CareerProgressItem item = resp.getApplications().getItems().get(0);
assertTrue(item.getCompetition().isEligible());
assertNull(item.getCompetition().getRank());
assertNull(item.getCompetition().getOverallScore());
assertNull(item.getCompetition().getPercentile());
assertNull(item.getCompetition().getMySnapshotPeriod());
assertEquals(1, item.getCompetition().getCohortSize());
}
// ── 无 N+1:所有批量查询各执行一次 ────────────────────────
@Test
void multipleApplications_noNPlusOne_batchQueriesSingleCallEach() {
mockUser(ME, User.Role.STUDENT);
Application a1 = app(1L, ME, 9L, "SUBMITTED", "2026-08-20T10:00:00");
Application a2 = app(2L, ME, 10L, "INTERVIEW", "2026-08-19T10:00:00");
Application a3 = app(3L, ME, 11L, "VIEWED", "2026-08-18T10:00:00");
when(applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(eq(ME), any(PageRequest.class)))
.thenReturn(new PageImpl<>(List.of(a1, a2, a3), PageRequest.of(0, 20), 3));
when(jobRepository.findAllById(anyCollection())).thenReturn(List.of(
Job.builder().id(9L).companyId(COMPANY).title("t1").description("d").city("c")
.employmentType("FULL_TIME").status("PUBLISHED").build(),
Job.builder().id(10L).companyId(COMPANY).title("t2").description("d").city("c")
.employmentType("FULL_TIME").status("PUBLISHED").build(),
Job.builder().id(11L).companyId(COMPANY).title("t3").description("d").city("c")
.employmentType("FULL_TIME").status("PUBLISHED").build()));
when(companyRepository.findAllById(anyCollection()))
.thenReturn(List.of(Company.builder().id(COMPANY).ownerUserId(9L).name("c").build()));
when(interviewRepository.findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection()))
.thenReturn(List.of());
when(interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus(
anyLong(), anyCollection(), anyString())).thenReturn(List.of());
when(offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString()))
.thenReturn(List.of());
when(applicationRepository.findByJobIdInAndStatusNotInOrderByCreatedAtAsc(anyCollection(), anyCollection()))
.thenReturn(List.of(a1, a2, a3));
when(dimensionScoreRepository.findUpToPeriod(anyCollection(), anyString())).thenReturn(List.of());
when(notificationRepository.countUnreadByUserId(ME)).thenReturn(0L);
when(notificationRepository.countUnreadByUserIdAndType(ME, "REPORT_DECISION")).thenReturn(0L);
service.getCareerProgress(ME, 0, 20, null);
verify(jobRepository, times(1)).findAllById(anyCollection());
verify(companyRepository, times(1)).findAllById(anyCollection());
verify(interviewRepository, times(1)).findByStudentUserIdAndStatusInOrderByStartAtAsc(anyLong(), anyCollection());
verify(interviewScoreRepository, times(1)).findByStudentUserIdAndApplicationIdInAndStatus(anyLong(), anyCollection(), anyString());
verify(offerRepository, times(1)).findByCandidateIdAndStatusNotOrderByCreatedAtDesc(anyLong(), anyString());
verify(applicationRepository, times(1)).findByJobIdInAndStatusNotInOrderByCreatedAtAsc(anyCollection(), anyCollection());
verify(dimensionScoreRepository, times(1)).findUpToPeriod(anyCollection(), anyString());
verify(notificationRepository, times(1)).countUnreadByUserId(ME);
verify(notificationRepository, times(1)).countUnreadByUserIdAndType(ME, "REPORT_DECISION");
// 未按投递/面试/Offer 循环查询
verify(interviewRepository, never()).findByApplicationIdOrderByCreatedAtDesc(anyLong());
}
// ── 辅助 ────────────────────────────────────────────────
private void mockUser(Long uid, User.Role role) {
User u = User.builder().id(uid).phone("1").role(role).build();
u.setRoles(new HashSet<>(Set.of(role)));
when(userRepository.findById(uid)).thenReturn(Optional.of(u));
}
private Application app(Long id, Long sid, Long jobId, String status, String created) {
return Application.builder().id(id).jobId(jobId).studentUserId(sid).status(status)
.createdAt(LocalDateTime.parse(created)).updatedAt(LocalDateTime.parse(created)).build();
}
private Interview interview(Long id, Long appId, String status, String startAt) {
return Interview.builder().id(id).applicationId(appId).companyUserId(999L).studentUserId(ME)
.roundNo(1).title("技术面试").interviewType("ONLINE")
.startAt(LocalDateTime.parse(startAt)).endAt(LocalDateTime.parse(startAt).plusHours(1))
.status(status).build();
}
private InterviewScore score(Long interviewId, Long appId, String submittedAt, BigDecimal overall) {
LocalDateTime submitted = submittedAt == null ? null : LocalDateTime.parse(submittedAt);
return InterviewScore.builder().id(interviewId).interviewId(interviewId).applicationId(appId)
.companyUserId(999L).studentUserId(ME).status(InterviewScore.STATUS_SUBMITTED)
.overallScore(overall).recommendation("HIRE").submittedAt(submitted).version(0L).build();
}
private Offer offer(Long id, Long appId, String status, String expireAt) {
return Offer.builder().id(id).applicationId(appId).jobId(JOB).companyId(COMPANY).candidateId(ME)
.title("Java 后端工程师").description("d").expireAt(LocalDateTime.parse(expireAt))
.createdBy(999L).status(status).version(0L)
.createdAt(LocalDateTime.of(2026, 8, 25, 12, 0)).build();
}
private CandidateDimensionScore dim(Long sid, String score) {
return CandidateDimensionScore.builder().studentUserId(sid).dimension("EDUCATION").period(PERIOD)
.score(new BigDecimal(score)).status("OK").weight(new BigDecimal("12.50"))
.rulesVersion("candidate-scoring-v0.1").build();
}
private static String yearMonth(LocalDate d) {
return String.format("%04d-%02d", d.getYear(), d.getMonthValue());
}
}