package com.mydreamjob.ability; import com.fasterxml.jackson.databind.JsonNode; import com.mydreamjob.ability.dto.AbilityAttemptResponse; import com.mydreamjob.ability.dto.AbilityHistoryItem; import com.mydreamjob.ability.dto.AbilityResultResponse; import com.mydreamjob.ability.dto.CreateAbilityAttemptRequest; import com.mydreamjob.ability.dto.SaveAbilityAnswersRequest; import com.mydreamjob.audit.AuditContext; import com.mydreamjob.audit.AuditService; import com.mydreamjob.auth.User; import com.mydreamjob.auth.UserRepository; import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService; import com.mydreamjob.common.exception.BusinessException; import com.mydreamjob.common.exception.ErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.Clock; import java.time.Duration; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Random; import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @Service @RequiredArgsConstructor public class AbilityAttemptService { private static final int MAX_PAGE_SIZE = 50; private static final String[] DIMENSIONS = {"LOGIC", "COMMUNICATION", "EXECUTION", "LEARNING"}; private final AbilityDefinitionRepository definitionRepository; private final AbilityQuestionRepository questionRepository; private final AbilityAttemptRepository attemptRepository; private final AbilityAttemptQuestionRepository attemptQuestionRepository; private final AbilityAnswerRepository answerRepository; private final AbilityResultRepository resultRepository; private final AbilityResultService resultService; private final AbilityResultComputer resultComputer; private final AbilityAttemptWriter attemptWriter; private final AbilitySnapshotCodec snapshotCodec; private final UserRepository userRepository; private final AuditService auditService; private final Clock clock; /** R01-D4-1:能力测评提交成功后登记评分快照刷新(ABILITY 维度)。 */ private final CandidateScoringRefreshRequestService scoringRefreshRequestService; // ─────────────────────────── 创建答卷 ─────────────────────────── @Transactional(isolation = Isolation.READ_COMMITTED) public AbilityAttemptResponse create(Long userId, CreateAbilityAttemptRequest req, AuditContext ctx) { requireStudent(userId); AbilityDefinition def = definitionRepository.findByTypeCode(req.getTypeCode()) .orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_TYPE_NOT_FOUND)); if (!AbilityDefinition.STATUS_ACTIVE.equals(def.getStatus())) { throw new BusinessException(ErrorCode.ABILITY_TYPE_NOT_FOUND); } LocalDateTime now = now(); String idem = normalizeIdempotencyKey(req.getIdempotencyKey()); if (idem != null) { AbilityAttempt existing = findByIdem(userId, idem); if (existing != null) { if (!def.getId().equals(existing.getDefinitionId())) { throw new BusinessException(ErrorCode.ABILITY_IDEMPOTENCY_CONFLICT); } return toAttemptResponse(existing); } } // 原子释放本学生本类型已超时的遗留活跃答卷 releaseTimedOut(userId, def.getId(), now); if (attemptRepository.existsByStudentUserIdAndDefinitionIdAndStatusIn( userId, def.getId(), List.of(AbilityAttempt.STATUS_DRAFT, AbilityAttempt.STATUS_IN_PROGRESS))) { throw new BusinessException(ErrorCode.ABILITY_DUPLICATE_ACTIVE); } // 有效结果冷却期:存在未过期结果时不可重测 AbilityAttempt recentCompleted = attemptRepository .findByStudentUserIdAndDefinitionIdAndStatusOrderByCompletedAtDesc( userId, def.getId(), AbilityAttempt.STATUS_COMPLETED) .stream().findFirst().orElse(null); if (recentCompleted != null) { AbilityResult r = resultRepository.findByAttemptId(recentCompleted.getId()).orElse(null); if (r != null && r.getValidUntil() != null && now.isBefore(r.getValidUntil())) { throw new BusinessException(ErrorCode.ABILITY_RETAKE_COOLDOWN); } } // 滚动窗口内完成次数限制 int windowDays = def.getRetakeWindowDays() == null ? 90 : def.getRetakeWindowDays(); int maxCompleted = def.getRetakeMaxCompleted() == null ? 3 : def.getRetakeMaxCompleted(); long completedInWindow = attemptRepository .countByStudentUserIdAndDefinitionIdAndStatusAndCompletedAtGreaterThanEqual( userId, def.getId(), AbilityAttempt.STATUS_COMPLETED, now.minusDays(windowDays)); if (completedInWindow >= maxCompleted) { throw new BusinessException(ErrorCode.ABILITY_RETAKE_LIMIT); } List all = questionRepository .findByDefinitionIdAndActiveTrueOrderByPositionAsc(def.getId()); List sampled = sample(all, def); int perDim = def.getQuestionsPerDimension() == null ? 6 : def.getQuestionsPerDimension(); AbilityAttempt attempt; try { attempt = attemptWriter.persistNew(userId, def, sampled, idem, activeKeyOf(userId, def.getId())); } catch (DataIntegrityViolationException e) { if (idem != null) { AbilityAttempt existing = findByIdem(userId, idem); if (existing != null) { if (!def.getId().equals(existing.getDefinitionId())) { throw new BusinessException(ErrorCode.ABILITY_IDEMPOTENCY_CONFLICT); } return toAttemptResponse(existing); } } if (attemptRepository.existsByStudentUserIdAndDefinitionIdAndStatusIn( userId, def.getId(), List.of(AbilityAttempt.STATUS_DRAFT, AbilityAttempt.STATUS_IN_PROGRESS))) { throw new BusinessException(ErrorCode.ABILITY_DUPLICATE_ACTIVE); } throw e; } auditService.log(userId, "ABILITY_ATTEMPT_CREATED", "ABILITY_ATTEMPT", attempt.getId(), "attemptId=" + attempt.getId() + ", typeCode=" + def.getTypeCode() + ", status=" + attempt.getStatus() + ", answeredCount=0" + ", questionSetVersion=" + attempt.getQuestionSetVersion() + ", rulesVersion=" + def.getRulesVersion(), ctx); return toAttemptResponse(attempt, def); } // ─────────────────────────── 答卷详情 ─────────────────────────── @Transactional public AbilityAttemptResponse get(Long userId, Long attemptId) { requireStudent(userId); AbilityAttempt attempt = loadOwned(userId, attemptId); expireIfTimedOut(attempt); return toAttemptResponse(attempt); } // ─────────────────────────── 查看结果 ─────────────────────────── @Transactional(readOnly = true) public AbilityResultResponse loadResult(Long userId, Long attemptId) { requireStudent(userId); AbilityAttempt attempt = loadOwned(userId, attemptId); AbilityResult result = resultRepository.findByAttemptId(attemptId) .orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_RESULT_NOT_FOUND)); AbilityDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null); return resultService.toResponse(attempt, result, def, now()); } // ─────────────────────────── 保存答案 ─────────────────────────── @Transactional public AbilityAttemptResponse saveAnswers(Long userId, Long attemptId, SaveAbilityAnswersRequest req, AuditContext ctx) { requireStudent(userId); AbilityAttempt attempt = loadOwned(userId, attemptId); if (expireIfTimedOut(attempt)) { throw new BusinessException(ErrorCode.ABILITY_ATTEMPT_INVALID_STATE); } if (!isMutable(attempt.getStatus())) { throw new BusinessException(ErrorCode.ABILITY_ATTEMPT_INVALID_STATE); } AbilityDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null); // 同一 PATCH 内不得重复 attemptQuestionId if (req.getAnswers().size() != req.getAnswers().stream() .map(a -> a.getAttemptQuestionId()).distinct().count()) { throw new BusinessException(ErrorCode.ABILITY_ANSWER_INVALID, "duplicate attemptQuestionId in answers"); } List aqs = attemptQuestionRepository .findByAttemptIdOrderByPositionAsc(attemptId); Map aqById = aqs.stream() .collect(Collectors.toMap(AbilityAttemptQuestion::getId, Function.identity())); // 先整体校验:attemptQuestionId 属于本答卷、选项存在于快照;任一无效 → 整体拒绝,不启动计时 for (SaveAbilityAnswersRequest.AnswerItem item : req.getAnswers()) { AbilityAttemptQuestion aq = aqById.get(item.getAttemptQuestionId()); if (aq == null || item.getSelectedOptionId() == null) { throw new BusinessException(ErrorCode.ABILITY_ANSWER_INVALID, "attemptQuestionId not in this attempt"); } if (!snapshotCodec.optionExists(aq.getQuestionSnapshotJson(), item.getSelectedOptionId())) { throw new BusinessException(ErrorCode.ABILITY_ANSWER_INVALID, "optionId does not exist"); } } LocalDateTime now = now(); boolean startTimer = attempt.getStartedAt() == null; // 批量加载本请求涉及题目的既有答案,避免对每题逐条查询(消除 N+1) List questionIds = req.getAnswers().stream() .map(SaveAbilityAnswersRequest.AnswerItem::getAttemptQuestionId).toList(); Map existingByAqId = answerRepository .findByAttemptIdAndAttemptQuestionIdIn(attemptId, questionIds).stream() .collect(Collectors.toMap(AbilityAnswer::getAttemptQuestionId, Function.identity())); List toSave = new ArrayList<>(questionIds.size()); for (SaveAbilityAnswersRequest.AnswerItem item : req.getAnswers()) { AbilityAttemptQuestion aq = aqById.get(item.getAttemptQuestionId()); Integer awarded = snapshotCodec.awardedScore(aq.getQuestionSnapshotJson(), item.getSelectedOptionId()); AbilityAnswer answer = existingByAqId.get(aq.getId()); if (answer == null) { answer = AbilityAnswer.builder().attemptId(attemptId).attemptQuestionId(aq.getId()).build(); } answer.setSelectedOptionId(item.getSelectedOptionId()); answer.setAwardedScore(awarded == null ? 0 : awarded); toSave.add(answer); } answerRepository.saveAll(toSave); answerRepository.flush(); int answered = (int) answerRepository.countByAttemptId(attemptId); attempt.setAnsweredCount(answered); if (startTimer && answered > 0) { attempt.setStartedAt(now); int limit = def == null || def.getTimeLimitMinutes() == null ? 30 : def.getTimeLimitMinutes(); attempt.setExpiresAt(now.plusMinutes(limit)); attempt.setStatus(AbilityAttempt.STATUS_IN_PROGRESS); } try { attemptRepository.saveAndFlush(attempt); } catch (OptimisticLockingFailureException e) { throw new BusinessException(ErrorCode.ABILITY_CONCURRENT_UPDATE); } return toAttemptResponse(attempt, def); } // ─────────────────────────── 提交答卷 ─────────────────────────── @Transactional public AbilityResultResponse submit(Long userId, Long attemptId, AuditContext ctx) { requireStudent(userId); AbilityAttempt attempt = loadOwned(userId, attemptId); if (AbilityAttempt.STATUS_COMPLETED.equals(attempt.getStatus())) { return idempotentResult(attempt); } if (expireIfTimedOut(attempt)) { throw new BusinessException(ErrorCode.ABILITY_ATTEMPT_INVALID_STATE); } if (!isMutable(attempt.getStatus())) { throw new BusinessException(ErrorCode.ABILITY_ATTEMPT_INVALID_STATE); } int answered = (int) answerRepository.countByAttemptId(attemptId); int total = totalOf(attempt); if (answered < total || !perDimensionAnswered(attemptId)) { throw new BusinessException(ErrorCode.ABILITY_INCOMPLETE); } int transitioned = attemptRepository.markCompleted(attemptId, now(), answered); if (transitioned == 0) { return idempotentResult(attempt); } AbilityDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null); AbilityResultComputer.ComputedResult computed = compute(attempt); AbilityResult result; try { result = resultRepository.save(resultService.persist(attempt, def, computed, now())); } catch (DataIntegrityViolationException e) { return idempotentResult(attempt); } auditService.log(userId, "ABILITY_ATTEMPT_SUBMITTED", "ABILITY_ATTEMPT", attemptId, "attemptId=" + attemptId + ", typeCode=" + (def == null ? null : def.getTypeCode()) + ", status=COMPLETED, answeredCount=" + answered + ", questionSetVersion=" + attempt.getQuestionSetVersion() + ", rulesVersion=" + (def == null ? null : def.getRulesVersion()), ctx); // R01-D4-1:能力测评提交成功(新有效结果)影响 ABILITY 维度 → 登记当前周期刷新。 scoringRefreshRequestService.requestRefresh(userId, now()); return resultService.toResponse(attempt, result, def, now()); } // ─────────────────────────── 我的历史 ─────────────────────────── @Transactional(readOnly = true) public Page listMine(Long userId, int page, int size) { requireStudent(userId); if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) { throw new BusinessException(ErrorCode.BAD_REQUEST); } LocalDateTime now = now(); Page attempts = attemptRepository .findByStudentUserIdOrderByCreatedAtDesc(userId, PageRequest.of(page, size)); List content = attempts.getContent(); Map resultByAttempt = resultRepository .findByAttemptIdIn(content.stream().map(AbilityAttempt::getId).toList()).stream() .collect(Collectors.toMap(AbilityResult::getAttemptId, Function.identity())); // 对当前页 definitionId 去重后批量加载,避免对每条历史逐个 findById(消除 N+1) List defIds = content.stream().map(AbilityAttempt::getDefinitionId).distinct().toList(); Map defById = defIds.isEmpty() ? Map.of() : definitionRepository.findAllById(defIds).stream() .collect(Collectors.toMap(AbilityDefinition::getId, Function.identity())); return attempts.map(a -> { AbilityResult r = resultByAttempt.get(a.getId()); AbilityDefinition d = defById.get(a.getDefinitionId()); String validity = null; if (r != null && r.getValidUntil() != null) { validity = now.isBefore(r.getValidUntil()) ? AbilityResultComputer.VALIDITY_VALID : AbilityResultComputer.VALIDITY_EXPIRED; } int total = d == null || d.getAttemptQuestionCount() == null ? 24 : d.getAttemptQuestionCount(); return AbilityHistoryItem.builder() .attemptId(a.getId()) .typeCode(d == null ? null : d.getTypeCode()) .status(a.getStatus()) .validityStatus(validity) .overallScore(r == null ? null : r.getOverallScore()) .answeredCount(a.getAnsweredCount()) .completionRate(completionRate(a.getAnsweredCount(), total)) .startedAt(a.getStartedAt()) .expiresAt(a.getExpiresAt()) .completedAt(a.getCompletedAt()) .validUntil(r == null ? null : r.getValidUntil()) .build(); }); } // ─────────────────────────── 内部工具 ─────────────────────────── private AbilityResultResponse idempotentResult(AbilityAttempt attempt) { AbilityResult result = resultRepository.findByAttemptId(attempt.getId()) .orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_ATTEMPT_INVALID_STATE)); AbilityDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null); return resultService.toResponse(attempt, result, def, now()); } private AbilityResultComputer.ComputedResult compute(AbilityAttempt attempt) { List items = new ArrayList<>(); List aqs = attemptQuestionRepository .findByAttemptIdOrderByPositionAsc(attempt.getId()); Map answerByAq = answerRepository.findByAttemptId(attempt.getId()).stream() .collect(Collectors.toMap(AbilityAnswer::getAttemptQuestionId, Function.identity())); for (AbilityAttemptQuestion aq : aqs) { AbilityAnswer ans = answerByAq.get(aq.getId()); if (ans == null) { continue; } JsonNode snap = snapshotCodec.parse(aq.getQuestionSnapshotJson()); items.add(new AbilityResultComputer.Answered( text(snap, AbilitySnapshotCodec.KEY_DIMENSION), text(snap, AbilitySnapshotCodec.KEY_COMPETENCY_TAG), text(snap, AbilitySnapshotCodec.KEY_STEM), text(snap, AbilitySnapshotCodec.KEY_EXPLANATION), ans.getAwardedScore(), aq.getId(), aq.getPosition(), ans.getSelectedOptionId())); } return resultComputer.compute(items, perDimOf(attempt)); } private int totalOf(AbilityAttempt attempt) { AbilityDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null); return def == null || def.getAttemptQuestionCount() == null ? 24 : def.getAttemptQuestionCount(); } private int perDimOf(AbilityAttempt attempt) { AbilityDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null); return def == null || def.getQuestionsPerDimension() == null ? 6 : def.getQuestionsPerDimension(); } private String text(JsonNode snap, String key) { JsonNode n = snap.path(key); return n.isMissingNode() || n.isNull() ? null : n.asText(); } private boolean perDimensionAnswered(Long attemptId) { // 一次查询该答题的题目,一次批量查询该答题的全部答案,避免对每题逐条查询(消除 N+1) List aqs = attemptQuestionRepository.findByAttemptIdOrderByPositionAsc(attemptId); if (aqs.isEmpty()) { return false; } Set answeredAqIds = answerRepository.findByAttemptId(attemptId).stream() .map(AbilityAnswer::getAttemptQuestionId).collect(Collectors.toSet()); Map counts = new LinkedHashMap<>(); for (AbilityAttemptQuestion aq : aqs) { if (answeredAqIds.contains(aq.getId())) { String dim = snapshotCodec.text(aq.getQuestionSnapshotJson(), AbilitySnapshotCodec.KEY_DIMENSION); counts.merge(dim, 1, Integer::sum); } } int required = 6; for (String d : DIMENSIONS) { if (counts.getOrDefault(d, 0) != required) { return false; } } return true; } private boolean expireIfTimedOut(AbilityAttempt attempt) { if (isMutable(attempt.getStatus()) && attempt.getExpiresAt() != null && !now().isBefore(attempt.getExpiresAt())) { attemptRepository.markExpiredAndReleaseActive(attempt.getId()); attempt.setStatus(AbilityAttempt.STATUS_EXPIRED); attempt.setActiveKey(null); return true; } return false; } private void releaseTimedOut(Long userId, Long definitionId, LocalDateTime now) { for (AbilityAttempt a : attemptRepository.findTimedOutActiveByUserAndDef(userId, definitionId, now)) { attemptRepository.markExpiredAndReleaseActive(a.getId()); } // 立即刷新旧状态以免 DELETE/REREAD 不一致:不做,仅在此事务内后续判断用 DB 最新 } private AbilityAttemptResponse toAttemptResponse(AbilityAttempt attempt) { return toAttemptResponse(attempt, definitionRepository.findById(attempt.getDefinitionId()).orElse(null)); } private AbilityAttemptResponse toAttemptResponse(AbilityAttempt attempt, AbilityDefinition def) { List aqs = attemptQuestionRepository .findByAttemptIdOrderByPositionAsc(attempt.getId()); Map selectedByAq = answerRepository.findByAttemptId(attempt.getId()).stream() .collect(Collectors.toMap(AbilityAnswer::getAttemptQuestionId, AbilityAnswer::getSelectedOptionId)); int total = totalOf(attempt); int answered = attempt.getAnsweredCount() == null ? (int) selectedByAq.size() : attempt.getAnsweredCount(); List questions = new ArrayList<>(aqs.size()); for (AbilityAttemptQuestion aq : aqs) { JsonNode snap = snapshotCodec.parse(aq.getQuestionSnapshotJson()); String itemType = text(snap, AbilitySnapshotCodec.KEY_ITEM_TYPE); String stem = text(snap, AbilitySnapshotCodec.KEY_STEM); List options = new ArrayList<>(); JsonNode opts = snap.path(AbilitySnapshotCodec.KEY_OPTIONS); if (opts.isArray()) { for (JsonNode o : opts) { options.add(AbilityAttemptResponse.AbilityOptionView.builder() .optionId(o.path("optionId").asText()).text(o.path("text").asText()).build()); } } questions.add(AbilityAttemptResponse.AbilityAttemptQuestionView.builder() .attemptQuestionId(aq.getId()) .position(aq.getPosition()) .itemType(itemType) .stem(stem) .options(options) .selectedOptionId(selectedByAq.get(aq.getId())) .build()); } Long remaining = null; if (attempt.getExpiresAt() != null) { long secs = Duration.between(now(), attempt.getExpiresAt()).getSeconds(); remaining = Math.max(0, secs); } return AbilityAttemptResponse.builder() .attemptId(attempt.getId()) .status(attempt.getStatus()) .answeredCount(answered) .totalQuestions(total) .completionRate(completionRate(answered, total)) .startedAt(attempt.getStartedAt()) .expiresAt(attempt.getExpiresAt()) .remainingSeconds(remaining) .questions(questions) .build(); } /** * 抽题:四维各若干道,且每维三个 competencyTag 各抽 2 道,共 def.attemptQuestionCount 道。 * 任一维度/标签可用题不足时直接拒绝创建(不静默减少)。 */ private List sample(List all, AbilityDefinition def) { int perDim = def.getQuestionsPerDimension() == null ? 6 : def.getQuestionsPerDimension(); int attemptCount = def.getAttemptQuestionCount() == null ? 24 : def.getAttemptQuestionCount(); int perTag = 2; // perDim = 3 tags × 2 long seed = new Random().nextLong(); Random random = new Random(seed); Map>> byDimTag = new LinkedHashMap<>(); for (AbilityQuestion q : all) { byDimTag.computeIfAbsent(q.getDimension(), k -> new LinkedHashMap<>()) .computeIfAbsent(q.getCompetencyTag(), k -> new ArrayList<>()).add(q); } int collected = 0; List picked = new ArrayList<>(); for (String dim : DIMENSIONS) { Map> tags = byDimTag.get(dim); if (tags == null || tags.size() < 3) { throw new BusinessException(ErrorCode.BAD_REQUEST, "insufficient competency tags in dimension " + dim); } List dimPicked = new ArrayList<>(); for (Map.Entry> e : tags.entrySet()) { List group = new ArrayList<>(e.getValue()); if (group.size() < perTag) { throw new BusinessException(ErrorCode.BAD_REQUEST, "insufficient active questions for tag " + e.getKey()); } Collections.shuffle(group, random); dimPicked.addAll(group.subList(0, perTag)); } if (dimPicked.size() != perDim) { throw new BusinessException(ErrorCode.BAD_REQUEST, "dimension " + dim + " sampled != " + perDim); } picked.addAll(dimPicked); collected += dimPicked.size(); } Collections.shuffle(picked, random); if (collected != attemptCount || picked.size() != attemptCount) { throw new BusinessException(ErrorCode.BAD_REQUEST, "sampled question count mismatch"); } return picked; } private boolean isMutable(String status) { return AbilityAttempt.STATUS_DRAFT.equals(status) || AbilityAttempt.STATUS_IN_PROGRESS.equals(status); } private AbilityAttempt loadOwned(Long userId, Long attemptId) { AbilityAttempt a = attemptRepository.findById(attemptId) .orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_ATTEMPT_NOT_FOUND)); if (!a.getStudentUserId().equals(userId)) { throw new BusinessException(ErrorCode.FORBIDDEN); } return a; } private void requireStudent(Long userId) { User u = userRepository.findById(userId) .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); boolean isStudent = u.getRoles() != null && u.getRoles().contains(User.Role.STUDENT); if (!isStudent) { throw new BusinessException(ErrorCode.FORBIDDEN); } } private AbilityAttempt findByIdem(Long userId, String idem) { if (idem == null) { return null; } return attemptRepository.findByStudentUserIdAndIdempotencyKey(userId, idem).orElse(null); } private String normalizeIdempotencyKey(String key) { return key == null || key.isBlank() ? null : key.trim(); } private String activeKeyOf(Long studentUserId, Long definitionId) { return studentUserId + ":" + definitionId + ":ACTIVE"; } private LocalDateTime now() { return LocalDateTime.now(clock); } private BigDecimal completionRate(int answered, int total) { if (total <= 0) { return BigDecimal.ZERO.setScale(2); } return BigDecimal.valueOf(answered) .multiply(BigDecimal.valueOf(100)) .divide(BigDecimal.valueOf(total), 2, RoundingMode.HALF_UP); } }