[BE-63] 지원 추천도 계산 도메인 — 4축·판단 불가 정규화·59점 상한 (FR-96)

작업 내용 (설계 의도)

근거 TDD: 20260808-지원관리-확장-tdd.md — “방안 9 지원 추천도”, “실패 경로”, “Open Questions #1”

변경 사항

단계 2에서 규칙 밀도가 가장 높은 티켓입니다. PRD가 확정한 적용 순서 5단계를 절차형 계산이 아니라 타입으로 드러냅니다.

  1. AxisAssessment가 판단 불가를 1급으로 표현합니다 — judgeable: Boolean. judged()/notJudgeable() 두 팩토리로만 생성되고, 판단 불가인데 fulfillmentRate에 접근하면 예외입니다(호출부가 judgeable을 먼저 물어야 합니다). “판단 불가”와 “0점”이 구분되지 않으면 정규화 규칙 자체를 표현할 수 없습니다.
  2. RecommendationScore.of()가 5단계 전체를 캡슐화합니다 — ①가중치 합계 100 검증 ②판단 불가 축 제외 ③남은 축 비중을 합계 100으로 정규화 ④가중 합산 ⑤필수 기술 미충족 시 59점 상한. 필수 기술 축(45)이 판단 불가여도 예외 없이 정규화합니다(PRD B-16~18 — docs/PRD.md §215가 경력·직무 축에만 정규화를 규정해 45점 축 처리가 비어 있던 갭).
  3. “필수 기술 미충족”의 정의AxisAssessment.hasUnmetRequiredItem(), 즉 필수 기술 항목 중 NONE(기술명 자체 부재)이 1건 이상입니다. PARTIAL(60%, 오래된 기술)은 상한 사유로 보지 않습니다. 이 판정은 Open Question 1로 사용자 확인 대상이며, 상수 하나(CAP_TRIGGER)로 분리해 바뀌면 1곳만 고치게 합니다.
  4. JD 없는 공고는 평가 제외(검수 미결 #5 확정) — REQUIRED_SKILL·PREFERRED_SKILL·CAREER_ROLE 3축이 모두 판단 불가면 점수를 내지 않고 NOT_EVALUABLE(JD_UNAVAILABLE) 행을 저장합니다. “결과 없음”이 아니라 사유를 가진 결과로 남겨야 Operations 집계(재평가 실패 사유)와 화면 설명이 가능합니다.
  5. 결과는 대표 공고 id에 귀속합니다 — 평가 입력(JD)이 공고의 것이라 근거 추적이 명확합니다. 중복 그룹 귀속은 대표 교체 시 근거가 흐려져 미채택입니다.
  6. 59점 상한 해제는 결과 행과 분리합니다 — job_posting_recommendation_cap_overrides(공고당 1행). 결과 행은 재평가 때 덮어써지므로 같은 행에 두면 해제 플래그가 날아갑니다(FR-97 “재평가 시에도 유지”).
  7. 동시성job_posting_id 유니크 + 대상별 REQUIRES_NEW 격리 + DataIntegrityViolationException 흡수. 기존 EvaluateJobPostingsUseCase.kt:109-118 패턴과 동일합니다.
  8. job_posting_recommendation_axes·..._axis_items@OneToMany(cascade, orphanRemoval)로 한 단위 저장합니다.

의존

  • BE-58 (프로필·축 가중치·기준 버전)
  • BE-59 (공고 요구사항 추출)

다이어그램

처리 흐름

sequenceDiagram
    participant U as EvaluateInterestedPostingsUseCase
    participant D as RecommendationDomainService
    participant E as JobRequirementExtractor
    participant A as AxisAssessment
    participant S as RecommendationScore
    participant R as JobPostingRecommendationRepository
    U->>D: evaluateOne(target, plan)
    D->>E: extract(descriptionBody, tags)
    alt 3축 모두 판단 불가
        D->>R: save(NOT_EVALUABLE, JD_UNAVAILABLE)
    else 평가 가능
        D->>A: 축별 judged / notJudgeable 생성
        D->>S: of(assessments, weights, capReleased)
        S->>S: 합계 검증 → 판단불가 제외 → 정규화 → 합산 → 59 상한
        S-->>D: totalScore / grade / capApplied
        D->>R: save(결과 + 축 + 항목 cascade)
    end
    D-->>U: RecommendationItemOutcome

클래스 의존

flowchart LR
    subgraph Domain["domain/recommendation"]
        DS[RecommendationDomainService]
        Axis[RecommendationAxis]
        Assess[AxisAssessment]
        Item[AxisItemAssessment]
        Score[RecommendationScore]
        Grade[RecommendationGrade]
        Extractor[JobRequirementExtractor]
        Repo[JobPostingRecommendationRepository]
        Cap[CapOverrideRepository]
    end
    DS --> Extractor
    DS --> Assess
    DS --> Score
    DS --> Repo
    DS --> Cap
    Assess --> Axis
    Assess --> Item
    Score --> Grade

테스트 케이스

  • 4축이 모두 판단 가능하면 45/20/20/15 비중으로 가중 합산된다
  • 필수 기술 축이 판단 불가면 나머지 3축이 20:20:15 → 36:36:28로 정규화된다 (B-16~18)
  • 근무 조건 축만 판단 불가면 나머지 3축이 45:20:20 → 53:24:24로 정규화된다
  • 필수·우대·경력 3축이 모두 판단 불가면 NOT_EVALUABLE(JD_UNAVAILABLE)로 저장되고 점수가 null이다
  • 필수 기술 항목 중 NONE이 1건 있으면 총점이 59를 넘지 않는다
  • 필수 기술이 전부 PARTIAL이면 상한이 적용되지 않는다
  • 상한이 걸린 상태에서 해제하면 원 점수가 복원되고 capReleased=true, 원 상한 사유가 유지된다
  • 상한 해제 후 재평가해도 해제가 유지된다 (FR-97)
  • 상한 해제를 취소하면 다시 59점 상한이 적용된다
  • 총점 80이면 STRONGLY_RECOMMENDED, 79면 RECOMMENDED다 (경계값)
  • 총점 65면 RECOMMENDED, 64면 REVIEW다 (경계값)
  • 총점 45면 REVIEW, 44면 NOT_RECOMMENDED다 (경계값)
  • 가중치 합계가 100이 아니면 InvalidRecommendationWeightException이다
  • 판단 불가 축의 fulfillmentRate에 접근하면 예외다 (1급 표현 검증)
  • 같은 기준 버전으로 이미 평가된 공고는 force=false면 skip된다 (멱등)
  • 축·항목이 cascade로 한 단위 저장되고 재평가 시 orphanRemoval로 교체된다
  • 동시 최초 삽입으로 유니크 위반이 나면 SAVE_CONFLICT_SKIPPED로 흡수되고 배치가 계속된다