diff --git a/src/main/java/com/hansung/tracktory/domain/catalog/curriculum/repository/SubjectRepository.java b/src/main/java/com/hansung/tracktory/domain/catalog/curriculum/repository/SubjectRepository.java index 06c3267..fa4f74e 100644 --- a/src/main/java/com/hansung/tracktory/domain/catalog/curriculum/repository/SubjectRepository.java +++ b/src/main/java/com/hansung/tracktory/domain/catalog/curriculum/repository/SubjectRepository.java @@ -1,6 +1,8 @@ package com.hansung.tracktory.domain.catalog.curriculum.repository; import com.hansung.tracktory.domain.catalog.curriculum.entity.Subject; +import java.util.Collection; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -10,4 +12,6 @@ public interface SubjectRepository extends JpaRepository { Optional findByName(String name); boolean existsByCode(String code); + + List findByCodeIn(Collection codes); } diff --git a/src/main/java/com/hansung/tracktory/domain/catalog/curriculum/repository/TrackSubjectRepository.java b/src/main/java/com/hansung/tracktory/domain/catalog/curriculum/repository/TrackSubjectRepository.java index 7aef44a..e174f0f 100644 --- a/src/main/java/com/hansung/tracktory/domain/catalog/curriculum/repository/TrackSubjectRepository.java +++ b/src/main/java/com/hansung/tracktory/domain/catalog/curriculum/repository/TrackSubjectRepository.java @@ -14,4 +14,6 @@ public interface TrackSubjectRepository extends JpaRepository findBySubjectIn(Collection subjects); List findByTrackInAndType(Collection tracks, SubjectType type); + + List findByTrackIn(Collection tracks); } diff --git a/src/main/java/com/hansung/tracktory/domain/recommendation/ai/AiRecommendResponse.java b/src/main/java/com/hansung/tracktory/domain/recommendation/ai/AiRecommendResponse.java index 1ff2e0e..2613e14 100644 --- a/src/main/java/com/hansung/tracktory/domain/recommendation/ai/AiRecommendResponse.java +++ b/src/main/java/com/hansung/tracktory/domain/recommendation/ai/AiRecommendResponse.java @@ -16,6 +16,7 @@ public record AiRecommendResponse( List primaryCombos, List secondaryCombos, Roadmap roadmap, + CoverageAnalysis coverageAnalysis, Explanation explanation) { /** 추천 직무 단건. match_score 는 [0,1] 적합도. */ @@ -73,6 +74,50 @@ public record SemesterPlan( public record Roadmap( List stages, List semesters, String derivedFromComboKey) {} + /** 분야(추천 직무)별 현재/예상 역량 충족도. ratio 는 [0,1], count 는 표기 정합 후 토큰 수. */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + @JsonIgnoreProperties(ignoreUnknown = true) + public record JobCoverage( + String jobId, + String jobName, + int requiredCount, + int currentCovered, + int expectedCovered, + double currentRatio, + double expectedRatio, + List missingTokens) {} + + /** 잔여(추천) 과목 한 건이 충족도에 더하는 독립 한계 기여. contribution_ratio 는 그 과목 단독 이수 시 증가분. */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + @JsonIgnoreProperties(ignoreUnknown = true) + public record CourseCoverageContribution( + String courseId, String courseName, List addedTokens, double contributionRatio) {} + + /** 추천 기반 다음 액션 — 충족도를 가장 많이 올리는 과목 제안. */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + @JsonIgnoreProperties(ignoreUnknown = true) + public record NextActionSuggestion( + String courseId, String courseName, double contributionRatio, String message) {} + + /** + * 추천 직무 요구 역량 대비 현재 → 예상 충족도 분석. next_actions_covered 는 다음 액션 shortlist 까지 이수 시 덮는 토큰의 합집합 수로, + * {@code current_covered <= next_actions_covered <= expected_covered} 를 만족한다. + */ + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + @JsonIgnoreProperties(ignoreUnknown = true) + public record CoverageAnalysis( + int requiredCount, + int currentCovered, + int expectedCovered, + double currentRatio, + double expectedRatio, + int nextActionsCovered, + double nextActionsRatio, + List jobs, + List courseContributions, + List nextActions, + List gapTokens) {} + /** LLM 설명의 영역별 단락 — topic: jobs/tracks/roadmap. */ @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/src/main/java/com/hansung/tracktory/domain/recommendation/controller/RecommendationController.java b/src/main/java/com/hansung/tracktory/domain/recommendation/controller/RecommendationController.java index 6055bbe..1aa78e7 100644 --- a/src/main/java/com/hansung/tracktory/domain/recommendation/controller/RecommendationController.java +++ b/src/main/java/com/hansung/tracktory/domain/recommendation/controller/RecommendationController.java @@ -1,19 +1,22 @@ package com.hansung.tracktory.domain.recommendation.controller; +import com.hansung.tracktory.domain.recommendation.dto.AnalysisReportResponse; import com.hansung.tracktory.domain.recommendation.dto.RecommendationResponse; +import com.hansung.tracktory.domain.recommendation.service.RecommendationReportService; import com.hansung.tracktory.domain.recommendation.service.RecommendationService; import com.hansung.tracktory.domain.user.service.UserPrincipal; import com.hansung.tracktory.global.response.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; /** - * 추천 생성 API — 인증된 사용자의 온보딩 데이터로 직무·트랙·로드맵 추천을 한 번에 생성한다. + * 추천 생성·조회 API — 인증된 사용자의 온보딩 데이터로 직무·트랙·로드맵 추천을 생성하고, 활성 추천 기준 상세 분석 리포트를 조회한다. * *

온보딩 미완료 사용자는 {@code ONBOARDING_NOT_FOUND}(404) 로 거절된다. {@code forceRefresh=true} 면 기존 활성 추천을 * 무시하고 새로 생성한다. @@ -24,6 +27,7 @@ public class RecommendationController { private final RecommendationService recommendationService; + private final RecommendationReportService recommendationReportService; @PostMapping public ResponseEntity> generate( @@ -33,4 +37,19 @@ public ResponseEntity> generate( recommendationService.generate(principal.getUserId(), forceRefresh); return ResponseEntity.ok(ApiResponse.ok(response)); } + + /** + * 상세 분석 리포트 조회 — 활성 추천을 기준으로 기준 직무·역량 충족도·분야별 분석·잔여 과목·다음 액션·집계 수치를 반환한다. + * + *

{@code anchorJobCode} 로 충족도 기준 직무를 지정할 수 있다. 생략하면 매칭 1순위 직무가 기준이 되고, 추천 직무 목록에 없는 코드는 {@code + * INVALID_ANCHOR_JOB}(400) 로 거절된다. 활성 추천이 없으면 {@code RECOMMENDATION_NOT_FOUND}(404) 로 거절된다. + */ + @GetMapping("/report") + public ResponseEntity> getReport( + @AuthenticationPrincipal UserPrincipal principal, + @RequestParam(name = "anchorJobCode", required = false) String anchorJobCode) { + AnalysisReportResponse response = + recommendationReportService.getReport(principal.getUserId(), anchorJobCode); + return ResponseEntity.ok(ApiResponse.ok(response)); + } } diff --git a/src/main/java/com/hansung/tracktory/domain/recommendation/dto/AnalysisReportResponse.java b/src/main/java/com/hansung/tracktory/domain/recommendation/dto/AnalysisReportResponse.java new file mode 100644 index 0000000..a4c42fe --- /dev/null +++ b/src/main/java/com/hansung/tracktory/domain/recommendation/dto/AnalysisReportResponse.java @@ -0,0 +1,71 @@ +package com.hansung.tracktory.domain.recommendation.dto; + +import java.util.List; + +/** + * 상세 분석 리포트 응답 — 활성 추천을 기준으로 기준 직무·역량 충족도·분야별 분석·잔여 과목·다음 액션·집계 수치를 한 묶음으로 전달한다. + * + *

역량 충족도(현재/예상)와 분야별 분석은 추천 생성 시점에 AI 가 산출해 보존한 스냅샷에서 나오고, 잔여 과목·이수 수·취득 학점은 카탈로그와 이수 이력으로 조회 + * 시점에 계산한다. 모두 같은 활성 추천을 기준으로 하므로 홈 추천 결과와 정합한다. + * + *

{@code anchorJob} 은 충족도(현재/다음 N개/전체)가 어느 직무를 기준으로 산출됐는지 알려주는 단일 기준 직무로, 화면의 "○○ 직무 기준" 라벨을 + * 채운다. 기본값은 매칭 1순위 직무이며, 조회 시 다른 추천 직무를 기준으로 지정하면 충족도가 그 직무 기준으로 재산출된다. + */ +public record AnalysisReportResponse( + Long recommendationId, + AnchorJobView anchorJob, + CoverageView coverage, + AggregateView aggregate, + List remainingCourses, + List nextActions) { + + /** 충족도 산출 기준 직무 — "○○ 직무 기준" 라벨용. 추천 직무가 없으면 null. */ + public record AnchorJobView(String code, String name) {} + + /** + * 역량 충족도 — 현재 → 다음 N개 이수 시 → 전체(천장) 3단 + 분야별 분석. 카운트는 {@code current ≤ nextActions ≤ expected} 를 + * 만족하고, 백분율은 {@code covered / required} 의 반올림이다(분모 0 이면 0). {@code nextActionsCovered} 는 다음 액션 + * 과목까지 이수 시 도달하는 합집합 충족 토큰 수로, 과목별 기여(+%)의 단순 합과 다르다. + */ + public record CoverageView( + int requiredCount, + int currentCovered, + int nextActionsCovered, + int expectedCovered, + int currentPercent, + int nextActionsPercent, + int expectedPercent, + List gapTokens, + List fields) {} + + /** 분야(추천 직무)별 현재/예상 충족도. */ + public record FieldCoverageView( + String jobCode, + String jobName, + int requiredCount, + int currentCovered, + int expectedCovered, + int currentPercent, + int expectedPercent, + List missingTokens) {} + + /** 이수 과목 수·취득 학점 집계. */ + public record AggregateView(int completedCourseCount, double earnedCredits) {} + + /** + * 잔여(미이수) 과목 한 건. {@code type} 으로 전공필수/전공선택/전공기초를 구분해 필수 잔여만 추릴 수 있다. {@code tracks} 는 이 과목이 기여하는 + * 추천 트랙명 목록(한 과목이 여러 트랙에 걸칠 수 있음), {@code contributionPercent} 는 이 과목 단독 이수 시 역량 충족도 증가분(없으면 + * null). + */ + public record RemainingCourseView( + String code, + String name, + double credit, + String type, + String stage, + List tracks, + Integer contributionPercent) {} + + /** 추천 기반 다음 액션 — 충족도를 가장 많이 올리는 과목 제안. */ + public record NextActionView(String code, String name, int contributionPercent, String message) {} +} diff --git a/src/main/java/com/hansung/tracktory/domain/recommendation/entity/Recommendation.java b/src/main/java/com/hansung/tracktory/domain/recommendation/entity/Recommendation.java index 107f14d..13fe394 100644 --- a/src/main/java/com/hansung/tracktory/domain/recommendation/entity/Recommendation.java +++ b/src/main/java/com/hansung/tracktory/domain/recommendation/entity/Recommendation.java @@ -67,6 +67,13 @@ public class Recommendation extends BaseEntity { fetch = FetchType.LAZY) private Roadmap roadmap; + @OneToOne( + mappedBy = "recommendation", + cascade = CascadeType.ALL, + orphanRemoval = true, + fetch = FetchType.LAZY) + private RecommendationCoverage coverage; + @Builder public Recommendation( User user, @@ -101,6 +108,12 @@ public void attachRoadmap(Roadmap newRoadmap) { newRoadmap.assignTo(this); } + /** 역량 충족도 분석 1:1 자식을 연결하고 양방향 연관을 맞춘다. */ + public void attachCoverage(RecommendationCoverage newCoverage) { + this.coverage = newCoverage; + newCoverage.assignTo(this); + } + /** 새 추천으로 대체되었음을 표시한다 (메인 노출 제외, 이력으로만 조회). */ public void markSuperseded() { this.status = RecommendationStatus.SUPERSEDED; diff --git a/src/main/java/com/hansung/tracktory/domain/recommendation/entity/RecommendationCourseContribution.java b/src/main/java/com/hansung/tracktory/domain/recommendation/entity/RecommendationCourseContribution.java new file mode 100644 index 0000000..a10f60e --- /dev/null +++ b/src/main/java/com/hansung/tracktory/domain/recommendation/entity/RecommendationCourseContribution.java @@ -0,0 +1,55 @@ +package com.hansung.tracktory.domain.recommendation.entity; + +import com.hansung.tracktory.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 잔여(추천) 과목 한 건의 충족도 기여 — coverage 분석의 자식. {@code contributionPercent} 는 그 과목을 단독으로 이수했을 때의 독립 한계 + * 증가분(0~100). 과목 간 토큰이 겹칠 수 있어 기여도 합은 누적 도달과 다르므로 "이 과목 가치(+X%)" 배지로만 쓴다. + */ +@Entity +@Table(name = "recommendation_course_contribution") +@Getter +@NoArgsConstructor +public class RecommendationCourseContribution extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "coverage_id", nullable = false) + private RecommendationCoverage coverage; + + @Column(name = "course_code", nullable = false) + private String courseCode; + + @Column(name = "course_name", nullable = false) + private String courseName; + + @Column(name = "contribution_percent", nullable = false) + private int contributionPercent; + + @Builder + public RecommendationCourseContribution( + String courseCode, String courseName, int contributionPercent) { + this.courseCode = courseCode; + this.courseName = courseName; + this.contributionPercent = contributionPercent; + } + + void assignTo(RecommendationCoverage owner) { + this.coverage = owner; + } +} diff --git a/src/main/java/com/hansung/tracktory/domain/recommendation/entity/RecommendationCoverage.java b/src/main/java/com/hansung/tracktory/domain/recommendation/entity/RecommendationCoverage.java new file mode 100644 index 0000000..b805f8b --- /dev/null +++ b/src/main/java/com/hansung/tracktory/domain/recommendation/entity/RecommendationCoverage.java @@ -0,0 +1,103 @@ +package com.hansung.tracktory.domain.recommendation.entity; + +import com.hansung.tracktory.global.entity.BaseEntity; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import java.util.ArrayList; +import java.util.List; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 추천 직무 요구 역량 대비 현재 → 예상 충족도 분석 — recommendation 과 1:1. AI 추천 응답이 추천 생성 시점에 산출한 스냅샷을 그대로 보존해, 상세 분석 + * 리포트가 홈 추천과 같은 기준으로 조회되도록 한다. + * + *

비율은 저장하지 않고 카운트({@code coveredCount / requiredCount})로 조회 시점에 파생한다. 세 카운트가 {@code + * currentCovered ≤ nextActionsCovered ≤ expectedCovered} 를 만족하므로 화면의 3단(현재 → 다음 N개 → 전체) 표기 불변이 + * 보장된다. {@code nextActionsCovered} 는 다음 액션 shortlist 까지 이수 시 덮는 토큰의 합집합 수로, AI 가 계산해 내려준 값을 그대로 + * 보존한다(중복 토큰 이중 계산 없음). + */ +@Entity +@Table(name = "recommendation_coverage") +@Getter +@NoArgsConstructor +public class RecommendationCoverage extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "recommendation_id", nullable = false, unique = true) + private Recommendation recommendation; + + @Column(name = "required_count", nullable = false) + private int requiredCount; + + @Column(name = "current_covered", nullable = false) + private int currentCovered; + + @Column(name = "next_actions_covered", nullable = false) + private int nextActionsCovered; + + @Column(name = "expected_covered", nullable = false) + private int expectedCovered; + + @Column(name = "gap_tokens", columnDefinition = "text") + private String gapTokens; + + @OneToMany(mappedBy = "coverage", cascade = CascadeType.ALL, orphanRemoval = true) + private List jobCoverages = new ArrayList<>(); + + @OneToMany(mappedBy = "coverage", cascade = CascadeType.ALL, orphanRemoval = true) + private List courseContributions = new ArrayList<>(); + + @OneToMany(mappedBy = "coverage", cascade = CascadeType.ALL, orphanRemoval = true) + private List nextActions = new ArrayList<>(); + + @Builder + public RecommendationCoverage( + int requiredCount, + int currentCovered, + int nextActionsCovered, + int expectedCovered, + String gapTokens) { + this.requiredCount = requiredCount; + this.currentCovered = currentCovered; + this.nextActionsCovered = nextActionsCovered; + this.expectedCovered = expectedCovered; + this.gapTokens = gapTokens; + } + + void assignTo(Recommendation owner) { + this.recommendation = owner; + } + + /** 분야(직무)별 충족도 자식을 추가하고 양방향 연관을 맞춘다. */ + public void addJobCoverage(RecommendationJobCoverage jobCoverage) { + jobCoverages.add(jobCoverage); + jobCoverage.assignTo(this); + } + + /** 과목별 기여 자식을 추가하고 양방향 연관을 맞춘다. */ + public void addCourseContribution(RecommendationCourseContribution contribution) { + courseContributions.add(contribution); + contribution.assignTo(this); + } + + /** 다음 액션 자식을 추가하고 양방향 연관을 맞춘다. */ + public void addNextAction(RecommendationNextAction nextAction) { + nextActions.add(nextAction); + nextAction.assignTo(this); + } +} diff --git a/src/main/java/com/hansung/tracktory/domain/recommendation/entity/RecommendationJobCoverage.java b/src/main/java/com/hansung/tracktory/domain/recommendation/entity/RecommendationJobCoverage.java new file mode 100644 index 0000000..b8c5153 --- /dev/null +++ b/src/main/java/com/hansung/tracktory/domain/recommendation/entity/RecommendationJobCoverage.java @@ -0,0 +1,72 @@ +package com.hansung.tracktory.domain.recommendation.entity; + +import com.hansung.tracktory.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 분야(추천 직무)별 역량 충족도 — coverage 분석의 자식. 비율은 저장하지 않고 {@code coveredCount / requiredCount} 로 조회 시점에 + * 파생한다(현재 ≤ 예상 불변을 카운트가 보장). {@code missingTokens} 는 예상 이수 후에도 못 덮는 토큰의 표시용 문자열(개행 결합). + */ +@Entity +@Table(name = "recommendation_job_coverage") +@Getter +@NoArgsConstructor +public class RecommendationJobCoverage extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "coverage_id", nullable = false) + private RecommendationCoverage coverage; + + @Column(name = "job_code", nullable = false) + private String jobCode; + + @Column(name = "job_name", nullable = false) + private String jobName; + + @Column(name = "required_count", nullable = false) + private int requiredCount; + + @Column(name = "current_covered", nullable = false) + private int currentCovered; + + @Column(name = "expected_covered", nullable = false) + private int expectedCovered; + + @Column(name = "missing_tokens", columnDefinition = "text") + private String missingTokens; + + @Builder + public RecommendationJobCoverage( + String jobCode, + String jobName, + int requiredCount, + int currentCovered, + int expectedCovered, + String missingTokens) { + this.jobCode = jobCode; + this.jobName = jobName; + this.requiredCount = requiredCount; + this.currentCovered = currentCovered; + this.expectedCovered = expectedCovered; + this.missingTokens = missingTokens; + } + + void assignTo(RecommendationCoverage owner) { + this.coverage = owner; + } +} diff --git a/src/main/java/com/hansung/tracktory/domain/recommendation/entity/RecommendationNextAction.java b/src/main/java/com/hansung/tracktory/domain/recommendation/entity/RecommendationNextAction.java new file mode 100644 index 0000000..a2c0e7e --- /dev/null +++ b/src/main/java/com/hansung/tracktory/domain/recommendation/entity/RecommendationNextAction.java @@ -0,0 +1,67 @@ +package com.hansung.tracktory.domain.recommendation.entity; + +import com.hansung.tracktory.global.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 추천 기반 다음 액션 한 건 — coverage 분석의 자식. 잔여 과목 중 충족도를 가장 크게 끌어올리는 과목 제안. {@code orderIndex} 로 AI 가 매긴 + * 기여도 순서를 보존한다. {@code contributionPercent} 는 이 과목 단독 이수 시 증가분(0~100, 독립값). + */ +@Entity +@Table(name = "recommendation_next_action") +@Getter +@NoArgsConstructor +public class RecommendationNextAction extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "coverage_id", nullable = false) + private RecommendationCoverage coverage; + + @Column(name = "order_index", nullable = false) + private int orderIndex; + + @Column(name = "course_code", nullable = false) + private String courseCode; + + @Column(name = "course_name", nullable = false) + private String courseName; + + @Column(name = "contribution_percent", nullable = false) + private int contributionPercent; + + @Column(columnDefinition = "text") + private String message; + + @Builder + public RecommendationNextAction( + int orderIndex, + String courseCode, + String courseName, + int contributionPercent, + String message) { + this.orderIndex = orderIndex; + this.courseCode = courseCode; + this.courseName = courseName; + this.contributionPercent = contributionPercent; + this.message = message; + } + + void assignTo(RecommendationCoverage owner) { + this.coverage = owner; + } +} diff --git a/src/main/java/com/hansung/tracktory/domain/recommendation/service/AnalysisReportAssembler.java b/src/main/java/com/hansung/tracktory/domain/recommendation/service/AnalysisReportAssembler.java new file mode 100644 index 0000000..e9ab770 --- /dev/null +++ b/src/main/java/com/hansung/tracktory/domain/recommendation/service/AnalysisReportAssembler.java @@ -0,0 +1,291 @@ +package com.hansung.tracktory.domain.recommendation.service; + +import com.hansung.tracktory.domain.catalog.curriculum.entity.Subject; +import com.hansung.tracktory.domain.catalog.curriculum.entity.SubjectStage; +import com.hansung.tracktory.domain.catalog.curriculum.entity.SubjectType; +import com.hansung.tracktory.domain.catalog.curriculum.entity.TrackSubject; +import com.hansung.tracktory.domain.catalog.curriculum.repository.SubjectRepository; +import com.hansung.tracktory.domain.catalog.curriculum.repository.TrackSubjectRepository; +import com.hansung.tracktory.domain.catalog.organization.entity.Track; +import com.hansung.tracktory.domain.recommendation.dto.AnalysisReportResponse; +import com.hansung.tracktory.domain.recommendation.dto.AnalysisReportResponse.AggregateView; +import com.hansung.tracktory.domain.recommendation.dto.AnalysisReportResponse.AnchorJobView; +import com.hansung.tracktory.domain.recommendation.dto.AnalysisReportResponse.CoverageView; +import com.hansung.tracktory.domain.recommendation.dto.AnalysisReportResponse.FieldCoverageView; +import com.hansung.tracktory.domain.recommendation.dto.AnalysisReportResponse.NextActionView; +import com.hansung.tracktory.domain.recommendation.dto.AnalysisReportResponse.RemainingCourseView; +import com.hansung.tracktory.domain.recommendation.entity.Recommendation; +import com.hansung.tracktory.domain.recommendation.entity.RecommendationCourseContribution; +import com.hansung.tracktory.domain.recommendation.entity.RecommendationCoverage; +import com.hansung.tracktory.domain.recommendation.entity.RecommendationJobCoverage; +import com.hansung.tracktory.domain.recommendation.entity.RecommendationNextAction; +import com.hansung.tracktory.domain.recommendation.entity.RecommendedJob; +import com.hansung.tracktory.domain.recommendation.entity.RecommendedTrack; +import com.hansung.tracktory.domain.recommendation.onboarding.OnboardingProfileSnapshot; +import com.hansung.tracktory.domain.recommendation.onboarding.OnboardingProfileSnapshot.CompletedCourse; +import com.hansung.tracktory.global.exception.BusinessException; +import com.hansung.tracktory.global.exception.ErrorCode; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 영속된 추천 aggregate(역량 충족도 스냅샷 포함)와 온보딩 스냅샷을 합쳐 상세 분석 리포트 응답으로 조립한다. + * + *

충족도·분야별 분석·다음 액션은 추천 생성 시점에 AI 가 산출해 보존한 값을 그대로 노출하고(비율만 조회 시점에 카운트로 파생), 잔여 과목·이수 수·취득 학점은 + * 카탈로그와 이수 이력으로 계산한다. 잔여 과목은 주 추천 트랙의 졸업요건 과목 중 미이수분을 과목 단위로 묶어, 한 과목이 여러 트랙에 걸치면 기여 트랙을 함께 노출한다. + * 모든 lazy 연관 접근은 호출 측 트랜잭션 안에서 이뤄진다. + */ +@Component +@RequiredArgsConstructor +public class AnalysisReportAssembler { + + private final SubjectRepository subjectRepository; + private final TrackSubjectRepository trackSubjectRepository; + + AnalysisReportResponse assemble( + Recommendation recommendation, OnboardingProfileSnapshot profile) { + return assemble(recommendation, profile, null); + } + + /** + * 기준 직무({@code anchorJobCode})를 받아 그 직무 기준으로 충족도를 조립한다. null/공백이면 매칭 1순위 직무가 기준이 된다. 추천 직무 목록에 없는 + * 코드는 {@code INVALID_ANCHOR_JOB} 로 거절한다. 기준 직무를 바꾼 경우 다음 액션·기여 배지는 매칭 1순위 기준 산출물이라 비운다. + */ + AnalysisReportResponse assemble( + Recommendation recommendation, OnboardingProfileSnapshot profile, String anchorJobCode) { + RecommendationCoverage coverage = recommendation.getCoverage(); + ResolvedAnchor anchor = resolveAnchor(recommendation, anchorJobCode); + boolean defaultAnchor = anchor == null || anchor.isDefault(); + return new AnalysisReportResponse( + recommendation.getId(), + anchor == null ? null : new AnchorJobView(anchor.code(), anchor.name()), + coverage(coverage, anchor), + aggregate(profile), + remainingCourses( + recommendation, profile, defaultAnchor ? contributionIndex(coverage) : Map.of()), + defaultAnchor ? nextActions(coverage) : List.of()); + } + + // 충족도 산출 기준 직무를 정한다. 기본값은 match_score(=score) 1순위 추천 직무. anchorJobCode 가 주어지면 그 직무를 기준으로 하되, + // 추천 직무 목록에 없으면 거절한다. 추천 직무가 없으면 null (커버리지 자체가 비는 케이스). + private ResolvedAnchor resolveAnchor(Recommendation recommendation, String anchorJobCode) { + RecommendedJob defaultJob = + recommendation.getRecommendedJobs().stream() + .max(Comparator.comparingInt(RecommendedJob::getScore)) + .orElse(null); + boolean requested = anchorJobCode != null && !anchorJobCode.isBlank(); + if (defaultJob == null) { + if (requested) { + throw new BusinessException(ErrorCode.INVALID_ANCHOR_JOB); + } + return null; + } + if (!requested) { + return ResolvedAnchor.of(defaultJob, true); + } + RecommendedJob selected = + recommendation.getRecommendedJobs().stream() + .filter(job -> anchorJobCode.equals(job.getJob().getCode())) + .findFirst() + .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_ANCHOR_JOB)); + boolean isDefault = selected.getJob().getCode().equals(defaultJob.getJob().getCode()); + return ResolvedAnchor.of(selected, isDefault); + } + + private CoverageView coverage(RecommendationCoverage coverage, ResolvedAnchor anchor) { + if (coverage == null) { + return new CoverageView(0, 0, 0, 0, 0, 0, 0, List.of(), List.of()); + } + List fields = + coverage.getJobCoverages().stream() + .map(this::fieldCoverage) + .sorted(Comparator.comparingInt(FieldCoverageView::currentPercent).reversed()) + .toList(); + if (anchor == null || anchor.isDefault()) { + // 기본 anchor(매칭 1순위) 기준 — AI 가 합집합으로 산출한 다음 N개 충족(중간 단)까지 그대로 노출. + return new CoverageView( + coverage.getRequiredCount(), + coverage.getCurrentCovered(), + coverage.getNextActionsCovered(), + coverage.getExpectedCovered(), + percent(coverage.getCurrentCovered(), coverage.getRequiredCount()), + percent(coverage.getNextActionsCovered(), coverage.getRequiredCount()), + percent(coverage.getExpectedCovered(), coverage.getRequiredCount()), + splitTokens(coverage.getGapTokens()), + fields); + } + // 기준 직무를 바꾼 경우 — 그 직무의 분야별 충족도(현재→전체)로 헤드라인을 재anchor 한다. 다음 N개(중간 단)는 + // 매칭 1순위 기준 합집합이라 그 직무에 유효하지 않으므로 현재값으로 둬 current ≤ nextActions ≤ expected 불변만 지킨다. + RecommendationJobCoverage job = + coverage.getJobCoverages().stream() + .filter(candidate -> anchor.code().equals(candidate.getJobCode())) + .findFirst() + .orElse(null); + if (job == null) { + return new CoverageView(0, 0, 0, 0, 0, 0, 0, List.of(), fields); + } + int currentPercent = percent(job.getCurrentCovered(), job.getRequiredCount()); + return new CoverageView( + job.getRequiredCount(), + job.getCurrentCovered(), + job.getCurrentCovered(), + job.getExpectedCovered(), + currentPercent, + currentPercent, + percent(job.getExpectedCovered(), job.getRequiredCount()), + splitTokens(job.getMissingTokens()), + fields); + } + + private FieldCoverageView fieldCoverage(RecommendationJobCoverage job) { + return new FieldCoverageView( + job.getJobCode(), + job.getJobName(), + job.getRequiredCount(), + job.getCurrentCovered(), + job.getExpectedCovered(), + percent(job.getCurrentCovered(), job.getRequiredCount()), + percent(job.getExpectedCovered(), job.getRequiredCount()), + splitTokens(job.getMissingTokens())); + } + + private AggregateView aggregate(OnboardingProfileSnapshot profile) { + List codes = + profile.completedCourses().stream().map(CompletedCourse::subjectCode).distinct().toList(); + BigDecimal earned = BigDecimal.ZERO; + if (!codes.isEmpty()) { + for (Subject subject : subjectRepository.findByCodeIn(codes)) { + earned = earned.add(subject.getCredit()); + } + } + return new AggregateView(codes.size(), earned.doubleValue()); + } + + private List remainingCourses( + Recommendation recommendation, + OnboardingProfileSnapshot profile, + Map contributions) { + List primaryTracks = + recommendation.getRecommendedTracks().stream() + .filter(RecommendedTrack::isPrimary) + .map(RecommendedTrack::getTrack) + .toList(); + if (primaryTracks.isEmpty()) { + return List.of(); + } + Set completed = + profile.completedCourses().stream() + .map(CompletedCourse::subjectCode) + .collect(Collectors.toSet()); + + Map grouped = new LinkedHashMap<>(); + for (TrackSubject link : trackSubjectRepository.findByTrackIn(primaryTracks)) { + Subject subject = link.getSubject(); + if (completed.contains(subject.getCode())) { + continue; + } + grouped.computeIfAbsent(subject.getCode(), k -> new RemainingAccumulator(subject)).add(link); + } + return grouped.values().stream() + .map(acc -> acc.toView(contributions.get(acc.subject.getCode()))) + .toList(); + } + + private List nextActions(RecommendationCoverage coverage) { + if (coverage == null) { + return List.of(); + } + return coverage.getNextActions().stream() + .sorted(Comparator.comparingInt(RecommendationNextAction::getOrderIndex)) + .map( + action -> + new NextActionView( + action.getCourseCode(), + action.getCourseName(), + action.getContributionPercent(), + action.getMessage())) + .toList(); + } + + private Map contributionIndex(RecommendationCoverage coverage) { + if (coverage == null) { + return Map.of(); + } + Map index = new LinkedHashMap<>(); + for (RecommendationCourseContribution contribution : coverage.getCourseContributions()) { + index.putIfAbsent(contribution.getCourseCode(), contribution.getContributionPercent()); + } + return index; + } + + private static int percent(int covered, int required) { + if (required <= 0) { + return 0; + } + return (int) Math.round(covered * 100.0 / required); + } + + private static List splitTokens(String joined) { + if (joined == null || joined.isBlank()) { + return List.of(); + } + return List.of(joined.split("\n")); + } + + /** 충족도 산출 기준 직무 — 코드·이름과 매칭 1순위(기본) 여부. */ + private record ResolvedAnchor(String code, String name, boolean isDefault) { + private static ResolvedAnchor of(RecommendedJob job, boolean isDefault) { + return new ResolvedAnchor(job.getJob().getCode(), job.getJob().getName(), isDefault); + } + } + + /** 같은 과목이 여러 주 추천 트랙에 걸칠 때 기여 트랙·졸업요건 유형·학습 단계를 한 과목으로 묶는 누적기. */ + private static final class RemainingAccumulator { + private final Subject subject; + private final List tracks = new ArrayList<>(); + private SubjectType type; + private SubjectStage stage; + + private RemainingAccumulator(Subject subject) { + this.subject = subject; + } + + private void add(TrackSubject link) { + tracks.add(link.getTrack().getName()); + if (type == null || typeRank(link.getType()) > typeRank(type)) { + type = link.getType(); + } + if (stage == null || link.getStage().ordinal() < stage.ordinal()) { + stage = link.getStage(); + } + } + + private RemainingCourseView toView(Integer contributionPercent) { + return new RemainingCourseView( + subject.getCode(), + subject.getName(), + subject.getCredit().doubleValue(), + type == null ? null : type.name(), + stage == null ? null : stage.name(), + List.copyOf(tracks), + contributionPercent); + } + + // 필수 잔여만 추릴 수 있도록 졸업요건 유형 우선순위를 둔다 (전공필수 > 전공선택 > 전공기초). + private static int typeRank(SubjectType type) { + return switch (type) { + case REQUIRED -> 2; + case ELECTIVE -> 1; + case BASIC -> 0; + }; + } + } +} diff --git a/src/main/java/com/hansung/tracktory/domain/recommendation/service/RecommendationReportService.java b/src/main/java/com/hansung/tracktory/domain/recommendation/service/RecommendationReportService.java new file mode 100644 index 0000000..5755650 --- /dev/null +++ b/src/main/java/com/hansung/tracktory/domain/recommendation/service/RecommendationReportService.java @@ -0,0 +1,45 @@ +package com.hansung.tracktory.domain.recommendation.service; + +import com.hansung.tracktory.domain.recommendation.dto.AnalysisReportResponse; +import com.hansung.tracktory.domain.recommendation.entity.Recommendation; +import com.hansung.tracktory.domain.recommendation.entity.RecommendationStatus; +import com.hansung.tracktory.domain.recommendation.onboarding.OnboardingProfileReader; +import com.hansung.tracktory.domain.recommendation.onboarding.OnboardingProfileSnapshot; +import com.hansung.tracktory.domain.recommendation.repository.RecommendationRepository; +import com.hansung.tracktory.global.exception.BusinessException; +import com.hansung.tracktory.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 상세 분석 리포트 조회 — 사용자의 활성 추천을 기준으로 읽기 전용 리포트를 조립한다. + * + *

AI 재호출이나 추천 재계산 없이 홈 추천과 같은 활성 추천 aggregate 를 읽어 조립하므로 홈 결과와 정합한다. 활성 추천이 없으면(이수 과목 변경으로 직전 + * 추천이 무효화된 경우 포함) {@code RECOMMENDATION_NOT_FOUND}(404) 로 거절해 새 추천 생성을 유도한다. + */ +@Service +@RequiredArgsConstructor +public class RecommendationReportService { + + private final OnboardingProfileReader onboardingProfileReader; + private final RecommendationRepository recommendationRepository; + private final AnalysisReportAssembler analysisReportAssembler; + + /** + * 활성 추천을 기준으로 리포트를 조립한다. {@code anchorJobCode} 가 주어지면 그 직무를 충족도 기준으로 하고, 없으면 매칭 1순위 직무가 기준이 된다(추천 + * 직무 목록에 없는 코드는 assembler 에서 {@code INVALID_ANCHOR_JOB} 로 거절). + */ + @Transactional(readOnly = true) + public AnalysisReportResponse getReport(Long userId, String anchorJobCode) { + OnboardingProfileSnapshot profile = + onboardingProfileReader + .read(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.ONBOARDING_NOT_FOUND)); + Recommendation active = + recommendationRepository + .findFirstByUser_IdAndStatusOrderByCreatedAtDesc(userId, RecommendationStatus.ACTIVE) + .orElseThrow(() -> new BusinessException(ErrorCode.RECOMMENDATION_NOT_FOUND)); + return analysisReportAssembler.assemble(active, profile, anchorJobCode); + } +} diff --git a/src/main/java/com/hansung/tracktory/domain/recommendation/service/RecommendationService.java b/src/main/java/com/hansung/tracktory/domain/recommendation/service/RecommendationService.java index ed3170e..e90e412 100644 --- a/src/main/java/com/hansung/tracktory/domain/recommendation/service/RecommendationService.java +++ b/src/main/java/com/hansung/tracktory/domain/recommendation/service/RecommendationService.java @@ -8,13 +8,20 @@ import com.hansung.tracktory.domain.recommendation.ai.AiRecommendClient; import com.hansung.tracktory.domain.recommendation.ai.AiRecommendRequest; import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse; +import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.CourseCoverageContribution; import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.Explanation; +import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.JobCoverage; +import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.NextActionSuggestion; import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.RankedCombo; import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.RoadmapCourse; import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.RoadmapStage; import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.SemesterPlan; import com.hansung.tracktory.domain.recommendation.dto.RecommendationResponse; import com.hansung.tracktory.domain.recommendation.entity.Recommendation; +import com.hansung.tracktory.domain.recommendation.entity.RecommendationCourseContribution; +import com.hansung.tracktory.domain.recommendation.entity.RecommendationCoverage; +import com.hansung.tracktory.domain.recommendation.entity.RecommendationJobCoverage; +import com.hansung.tracktory.domain.recommendation.entity.RecommendationNextAction; import com.hansung.tracktory.domain.recommendation.entity.RecommendationStatus; import com.hansung.tracktory.domain.recommendation.entity.RecommendationTriggerSource; import com.hansung.tracktory.domain.recommendation.entity.RecommendedJob; @@ -126,9 +133,77 @@ private Recommendation buildRecommendation(User user, AiRecommendResponse ai) { addJobs(recommendation, ai); addTracks(recommendation, ai, topCombo); addRoadmap(recommendation, ai); + addCoverage(recommendation, ai); return recommendation; } + // AI 가 추천 생성 시점에 산출한 역량 충족도 스냅샷을 그대로 보존한다. 비율은 저장하지 않고 카운트만 보존하며, + // AI 가 합집합으로 계산한 next_actions_covered 도 재계산 없이 그대로 옮겨 화면 3단 표기(현재 ≤ 다음 N개 ≤ 전체) 불변을 지킨다. + private void addCoverage(Recommendation recommendation, AiRecommendResponse ai) { + AiRecommendResponse.CoverageAnalysis analysis = ai.coverageAnalysis(); + if (analysis == null) { + return; + } + RecommendationCoverage coverage = + RecommendationCoverage.builder() + .requiredCount(analysis.requiredCount()) + .currentCovered(analysis.currentCovered()) + .nextActionsCovered(analysis.nextActionsCovered()) + .expectedCovered(analysis.expectedCovered()) + .gapTokens(joinTokens(analysis.gapTokens())) + .build(); + recommendation.attachCoverage(coverage); + + for (JobCoverage job : nullSafe(analysis.jobs())) { + if (job == null || job.jobId() == null) { + continue; + } + coverage.addJobCoverage( + RecommendationJobCoverage.builder() + .jobCode(job.jobId()) + .jobName(job.jobName()) + .requiredCount(job.requiredCount()) + .currentCovered(job.currentCovered()) + .expectedCovered(job.expectedCovered()) + .missingTokens(joinTokens(job.missingTokens())) + .build()); + } + + for (CourseCoverageContribution contribution : nullSafe(analysis.courseContributions())) { + if (contribution == null || contribution.courseId() == null) { + continue; + } + coverage.addCourseContribution( + RecommendationCourseContribution.builder() + .courseCode(contribution.courseId()) + .courseName(contribution.courseName()) + .contributionPercent(percent(contribution.contributionRatio())) + .build()); + } + + int order = 0; + for (NextActionSuggestion action : nullSafe(analysis.nextActions())) { + if (action == null || action.courseId() == null) { + continue; + } + coverage.addNextAction( + RecommendationNextAction.builder() + .orderIndex(order++) + .courseCode(action.courseId()) + .courseName(action.courseName()) + .contributionPercent(percent(action.contributionRatio())) + .message(action.message()) + .build()); + } + } + + private static String joinTokens(List tokens) { + if (tokens == null || tokens.isEmpty()) { + return null; + } + return String.join("\n", tokens); + } + private void addJobs(Recommendation recommendation, AiRecommendResponse ai) { // AI 서버가 세분화 직무를 카탈로그 코드로 fold 하면 서로 다른 직무가 같은 코드로 겹칠 수 있다. // (recommendation_id, job_id) 유니크 제약을 지키도록 코드 기준으로 중복을 제거한다(match_score 내림차순 가정 → 첫 건 채택). diff --git a/src/main/java/com/hansung/tracktory/global/exception/ErrorCode.java b/src/main/java/com/hansung/tracktory/global/exception/ErrorCode.java index b740b46..3019e0a 100644 --- a/src/main/java/com/hansung/tracktory/global/exception/ErrorCode.java +++ b/src/main/java/com/hansung/tracktory/global/exception/ErrorCode.java @@ -16,6 +16,8 @@ public enum ErrorCode { ONBOARDING_ALREADY_COMPLETED(HttpStatus.CONFLICT, "이미 온보딩이 완료된 사용자입니다."), SUBJECT_ALREADY_COMPLETED(HttpStatus.CONFLICT, "이미 이수 처리된 과목입니다."), ONBOARDING_NOT_FOUND(HttpStatus.NOT_FOUND, "온보딩 정보를 찾을 수 없습니다. 먼저 온보딩을 완료해주세요."), + RECOMMENDATION_NOT_FOUND(HttpStatus.NOT_FOUND, "활성 추천 결과가 없습니다. 먼저 추천을 생성해주세요."), + INVALID_ANCHOR_JOB(HttpStatus.BAD_REQUEST, "기준 직무가 추천 직무 목록에 없습니다."), AI_RELAY_ERROR(HttpStatus.BAD_GATEWAY, "추천 생성 중 AI 서버 오류가 발생했습니다."); private final HttpStatus httpStatus; diff --git a/src/test/java/com/hansung/tracktory/domain/recommendation/service/AnalysisReportAssemblerTest.java b/src/test/java/com/hansung/tracktory/domain/recommendation/service/AnalysisReportAssemblerTest.java new file mode 100644 index 0000000..f465826 --- /dev/null +++ b/src/test/java/com/hansung/tracktory/domain/recommendation/service/AnalysisReportAssemblerTest.java @@ -0,0 +1,307 @@ +package com.hansung.tracktory.domain.recommendation.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.BDDMockito.given; + +import com.hansung.tracktory.domain.catalog.career.entity.Job; +import com.hansung.tracktory.domain.catalog.curriculum.entity.Subject; +import com.hansung.tracktory.domain.catalog.curriculum.entity.SubjectSemester; +import com.hansung.tracktory.domain.catalog.curriculum.entity.SubjectStage; +import com.hansung.tracktory.domain.catalog.curriculum.entity.SubjectType; +import com.hansung.tracktory.domain.catalog.curriculum.entity.TrackSubject; +import com.hansung.tracktory.domain.catalog.curriculum.repository.SubjectRepository; +import com.hansung.tracktory.domain.catalog.curriculum.repository.TrackSubjectRepository; +import com.hansung.tracktory.domain.catalog.organization.entity.Track; +import com.hansung.tracktory.domain.recommendation.dto.AnalysisReportResponse; +import com.hansung.tracktory.domain.recommendation.dto.AnalysisReportResponse.RemainingCourseView; +import com.hansung.tracktory.domain.recommendation.entity.Recommendation; +import com.hansung.tracktory.domain.recommendation.entity.RecommendationCourseContribution; +import com.hansung.tracktory.domain.recommendation.entity.RecommendationCoverage; +import com.hansung.tracktory.domain.recommendation.entity.RecommendationJobCoverage; +import com.hansung.tracktory.domain.recommendation.entity.RecommendationNextAction; +import com.hansung.tracktory.domain.recommendation.entity.RecommendationStatus; +import com.hansung.tracktory.domain.recommendation.entity.RecommendationTriggerSource; +import com.hansung.tracktory.domain.recommendation.entity.RecommendedJob; +import com.hansung.tracktory.domain.recommendation.entity.RecommendedTrack; +import com.hansung.tracktory.domain.recommendation.onboarding.OnboardingProfileSnapshot; +import com.hansung.tracktory.domain.recommendation.onboarding.OnboardingProfileSnapshot.CompletedCourse; +import com.hansung.tracktory.global.exception.BusinessException; +import com.hansung.tracktory.global.exception.ErrorCode; +import java.math.BigDecimal; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AnalysisReportAssemblerTest { + + @InjectMocks private AnalysisReportAssembler assembler; + + @Mock private SubjectRepository subjectRepository; + @Mock private TrackSubjectRepository trackSubjectRepository; + + @Test + void assemble_derivesThreeTierPercentsAndFieldCoverageFromSnapshot() { + Recommendation recommendation = recommendationWithCoverage(); + OnboardingProfileSnapshot profile = profileWithCompleted(); + given(subjectRepository.findByCodeIn(anyCollection())).willReturn(List.of()); + + AnalysisReportResponse report = assembler.assemble(recommendation, profile); + + // 카운트는 그대로 보존, 백분율은 covered/required 반올림 — current ≤ nextActions ≤ expected 불변. + assertThat(report.coverage().currentPercent()).isEqualTo(40); + assertThat(report.coverage().nextActionsPercent()).isEqualTo(60); + assertThat(report.coverage().expectedPercent()).isEqualTo(80); + assertThat(report.coverage().gapTokens()).containsExactly("Kafka", "Redis"); + assertThat(report.coverage().fields()) + .singleElement() + .satisfies( + f -> { + assertThat(f.jobCode()).isEqualTo("be_dev"); + assertThat(f.currentPercent()).isEqualTo(40); + assertThat(f.expectedPercent()).isEqualTo(80); + assertThat(f.missingTokens()).containsExactly("Kafka", "Redis"); + }); + assertThat(report.nextActions()) + .singleElement() + .satisfies( + a -> { + assertThat(a.code()).isEqualTo("db"); + assertThat(a.contributionPercent()).isEqualTo(20); + }); + } + + @Test + void assemble_remainingCoursesExcludeCompletedAndDistinguishRequiredFromElective() { + Track track = Track.builder().code("BIGDATA").name("빅데이터트랙").build(); + Recommendation recommendation = recommendationWithPrimaryTrack(track); + OnboardingProfileSnapshot profile = profileWithCompleted(); // 이수: 자료구조(ds) + + Subject ds = subject("ds", "자료구조", "3.0"); + Subject db = subject("db", "데이터베이스", "3.0"); + Subject ai = subject("ai", "인공지능", "3.0"); + given(trackSubjectRepository.findByTrackIn(anyCollection())) + .willReturn( + List.of( + trackSubject(track, ds, SubjectType.REQUIRED, SubjectStage.FOUNDATION), + trackSubject(track, db, SubjectType.REQUIRED, SubjectStage.CORE), + trackSubject(track, ai, SubjectType.ELECTIVE, SubjectStage.APPLIED))); + given(subjectRepository.findByCodeIn(anyCollection())).willReturn(List.of()); + + AnalysisReportResponse report = assembler.assemble(recommendation, profile); + + // 이수한 ds 는 제외, 미이수 db(필수)·ai(선택)만 남고 type 으로 구분된다. + assertThat(report.remainingCourses()) + .extracting(RemainingCourseView::code) + .containsExactly("db", "ai"); + assertThat(report.remainingCourses()) + .filteredOn(c -> c.type().equals("REQUIRED")) + .extracting(RemainingCourseView::code) + .containsExactly("db"); + } + + @Test + void assemble_aggregatesCompletedCountAndEarnedCredits() { + Recommendation recommendation = recommendationWithPrimaryTrack(null); + OnboardingProfileSnapshot profile = profileWithCompleted(); // 이수: ds 1건 + given(subjectRepository.findByCodeIn(anyCollection())) + .willReturn(List.of(subject("ds", "자료구조", "3.5"))); + + AnalysisReportResponse report = assembler.assemble(recommendation, profile); + + assertThat(report.aggregate().completedCourseCount()).isEqualTo(1); + assertThat(report.aggregate().earnedCredits()).isEqualTo(3.5); + } + + @Test + void assemble_defaultAnchorIsHighestScoreJobAndLabelsCoverage() { + Recommendation recommendation = recommendationWithJobsAndCoverage(); + OnboardingProfileSnapshot profile = profileWithCompleted(); + given(subjectRepository.findByCodeIn(anyCollection())).willReturn(List.of()); + + AnalysisReportResponse report = assembler.assemble(recommendation, profile); + + // 기준 직무 미지정 → 매칭 1순위(score 90) be_dev 가 기준, 헤드라인은 AI 보존 top-level 3단(40/60/80). + assertThat(report.anchorJob().code()).isEqualTo("be_dev"); + assertThat(report.anchorJob().name()).isEqualTo("백엔드 개발자"); + assertThat(report.coverage().currentPercent()).isEqualTo(40); + assertThat(report.coverage().nextActionsPercent()).isEqualTo(60); + assertThat(report.coverage().expectedPercent()).isEqualTo(80); + assertThat(report.nextActions()).hasSize(1); + } + + @Test + void assemble_switchedAnchorReAnchorsCoverageToSelectedJob() { + Recommendation recommendation = recommendationWithJobsAndCoverage(); + OnboardingProfileSnapshot profile = profileWithCompleted(); + given(subjectRepository.findByCodeIn(anyCollection())).willReturn(List.of()); + + AnalysisReportResponse report = assembler.assemble(recommendation, profile, "fe_dev"); + + // 기준 직무를 fe_dev 로 바꾸면 그 직무 per-job 충족도(3/5→4/5)로 재anchor, 중간 단은 현재값과 같게 둬 불변을 지킨다. + assertThat(report.anchorJob().code()).isEqualTo("fe_dev"); + assertThat(report.coverage().currentPercent()).isEqualTo(60); + assertThat(report.coverage().nextActionsPercent()).isEqualTo(60); + assertThat(report.coverage().expectedPercent()).isEqualTo(80); + assertThat(report.coverage().gapTokens()).containsExactly("Vue"); + // 다음 액션·기여 배지는 매칭 1순위 기준 산출물이라 비기본 anchor 에서는 비운다. + assertThat(report.nextActions()).isEmpty(); + // 분야별 분석은 anchor 와 무관하게 추천 직무 전체를 노출한다. + assertThat(report.coverage().fields()).hasSize(2); + } + + @Test + void assemble_unknownAnchorJobCode_throwsInvalidAnchorJob() { + Recommendation recommendation = recommendationWithJobsAndCoverage(); + OnboardingProfileSnapshot profile = profileWithCompleted(); + + assertThatThrownBy(() -> assembler.assemble(recommendation, profile, "nope")) + .isInstanceOf(BusinessException.class) + .satisfies( + e -> + assertThat(((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.INVALID_ANCHOR_JOB)); + } + + private Recommendation recommendationWithJobsAndCoverage() { + Recommendation recommendation = baseRecommendation(); + recommendation.addRecommendedJob( + RecommendedJob.builder().score(90).job(job("be_dev", "백엔드 개발자")).build()); + recommendation.addRecommendedJob( + RecommendedJob.builder().score(70).job(job("fe_dev", "프론트엔드 개발자")).build()); + RecommendationCoverage coverage = + RecommendationCoverage.builder() + .requiredCount(10) + .currentCovered(4) + .nextActionsCovered(6) + .expectedCovered(8) + .gapTokens("Kafka\nRedis") + .build(); + recommendation.attachCoverage(coverage); + coverage.addJobCoverage( + RecommendationJobCoverage.builder() + .jobCode("be_dev") + .jobName("백엔드 개발자") + .requiredCount(10) + .currentCovered(4) + .expectedCovered(8) + .missingTokens("Kafka\nRedis") + .build()); + coverage.addJobCoverage( + RecommendationJobCoverage.builder() + .jobCode("fe_dev") + .jobName("프론트엔드 개발자") + .requiredCount(5) + .currentCovered(3) + .expectedCovered(4) + .missingTokens("Vue") + .build()); + coverage.addNextAction( + RecommendationNextAction.builder() + .orderIndex(0) + .courseCode("db") + .courseName("데이터베이스") + .contributionPercent(20) + .message("데이터베이스를 들으면 충족도가 오릅니다") + .build()); + return recommendation; + } + + private Job job(String code, String name) { + return Job.builder().code(code).name(name).description("설명").build(); + } + + private Recommendation recommendationWithCoverage() { + Recommendation recommendation = baseRecommendation(); + RecommendationCoverage coverage = + RecommendationCoverage.builder() + .requiredCount(10) + .currentCovered(4) + .nextActionsCovered(6) + .expectedCovered(8) + .gapTokens("Kafka\nRedis") + .build(); + recommendation.attachCoverage(coverage); + coverage.addJobCoverage( + RecommendationJobCoverage.builder() + .jobCode("be_dev") + .jobName("백엔드 개발자") + .requiredCount(10) + .currentCovered(4) + .expectedCovered(8) + .missingTokens("Kafka\nRedis") + .build()); + coverage.addNextAction( + RecommendationNextAction.builder() + .orderIndex(0) + .courseCode("db") + .courseName("데이터베이스") + .contributionPercent(20) + .message("데이터베이스를 들으면 충족도가 오릅니다") + .build()); + coverage.addCourseContribution( + RecommendationCourseContribution.builder() + .courseCode("db") + .courseName("데이터베이스") + .contributionPercent(20) + .build()); + return recommendation; + } + + private Recommendation recommendationWithPrimaryTrack(Track track) { + Recommendation recommendation = baseRecommendation(); + if (track != null) { + recommendation.addRecommendedTrack( + RecommendedTrack.builder() + .score(90) + .primary(true) + .crossCombination(false) + .track(track) + .build()); + } + return recommendation; + } + + private Recommendation baseRecommendation() { + return Recommendation.builder() + .status(RecommendationStatus.ACTIVE) + .triggerSource(RecommendationTriggerSource.MANUAL) + .build(); + } + + private OnboardingProfileSnapshot profileWithCompleted() { + return new OnboardingProfileSnapshot( + 1L, + 2024, + "IT공과대학", + "컴퓨터공학부", + 4, + List.of(), + List.of("IT/인터넷"), + List.of("백엔드"), + List.of(), + List.of(), + List.of(), + List.of(new CompletedCourse("ds", 2, 1))); + } + + private Subject subject(String code, String name, String credit) { + return Subject.builder() + .code(code) + .name(name) + .description("설명") + .credit(new BigDecimal(credit)) + .semester(SubjectSemester.FIRST) + .build(); + } + + private TrackSubject trackSubject( + Track track, Subject subject, SubjectType type, SubjectStage stage) { + return TrackSubject.builder().track(track).subject(subject).type(type).stage(stage).build(); + } +} diff --git a/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationReportServiceTest.java b/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationReportServiceTest.java new file mode 100644 index 0000000..bf44564 --- /dev/null +++ b/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationReportServiceTest.java @@ -0,0 +1,122 @@ +package com.hansung.tracktory.domain.recommendation.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import com.hansung.tracktory.domain.recommendation.dto.AnalysisReportResponse; +import com.hansung.tracktory.domain.recommendation.entity.Recommendation; +import com.hansung.tracktory.domain.recommendation.entity.RecommendationStatus; +import com.hansung.tracktory.domain.recommendation.onboarding.OnboardingProfileReader; +import com.hansung.tracktory.domain.recommendation.onboarding.OnboardingProfileSnapshot; +import com.hansung.tracktory.domain.recommendation.repository.RecommendationRepository; +import com.hansung.tracktory.global.exception.BusinessException; +import com.hansung.tracktory.global.exception.ErrorCode; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class RecommendationReportServiceTest { + + private static final long USER_ID = 1L; + + @InjectMocks private RecommendationReportService reportService; + + @Mock private OnboardingProfileReader onboardingProfileReader; + @Mock private RecommendationRepository recommendationRepository; + @Mock private AnalysisReportAssembler analysisReportAssembler; + + @Test + void getReport_onboardingMissing_throwsOnboardingNotFound() { + given(onboardingProfileReader.read(USER_ID)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> reportService.getReport(USER_ID, null)) + .isInstanceOf(BusinessException.class) + .satisfies( + e -> + assertThat(((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.ONBOARDING_NOT_FOUND)); + verify(analysisReportAssembler, never()).assemble(any(), any(), any()); + } + + @Test + void getReport_noActiveRecommendation_throwsRecommendationNotFound() { + given(onboardingProfileReader.read(USER_ID)).willReturn(Optional.of(sampleProfile())); + given( + recommendationRepository.findFirstByUser_IdAndStatusOrderByCreatedAtDesc( + USER_ID, RecommendationStatus.ACTIVE)) + .willReturn(Optional.empty()); + + assertThatThrownBy(() -> reportService.getReport(USER_ID, null)) + .isInstanceOf(BusinessException.class) + .satisfies( + e -> + assertThat(((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.RECOMMENDATION_NOT_FOUND)); + verify(analysisReportAssembler, never()).assemble(any(), any(), any()); + } + + @Test + void getReport_activeRecommendation_assemblesFromSameAggregate() { + OnboardingProfileSnapshot profile = sampleProfile(); + Recommendation active = Recommendation.builder().status(RecommendationStatus.ACTIVE).build(); + AnalysisReportResponse expected = + new AnalysisReportResponse(7L, null, null, null, List.of(), List.of()); + given(onboardingProfileReader.read(USER_ID)).willReturn(Optional.of(profile)); + given( + recommendationRepository.findFirstByUser_IdAndStatusOrderByCreatedAtDesc( + USER_ID, RecommendationStatus.ACTIVE)) + .willReturn(Optional.of(active)); + given(analysisReportAssembler.assemble(active, profile, null)).willReturn(expected); + + AnalysisReportResponse result = reportService.getReport(USER_ID, null); + + assertThat(result).isSameAs(expected); + verify(analysisReportAssembler).assemble(eq(active), eq(profile), isNull()); + } + + @Test + void getReport_anchorJobCode_passedThroughToAssembler() { + OnboardingProfileSnapshot profile = sampleProfile(); + Recommendation active = Recommendation.builder().status(RecommendationStatus.ACTIVE).build(); + AnalysisReportResponse expected = + new AnalysisReportResponse(7L, null, null, null, List.of(), List.of()); + given(onboardingProfileReader.read(USER_ID)).willReturn(Optional.of(profile)); + given( + recommendationRepository.findFirstByUser_IdAndStatusOrderByCreatedAtDesc( + USER_ID, RecommendationStatus.ACTIVE)) + .willReturn(Optional.of(active)); + given(analysisReportAssembler.assemble(active, profile, "be_dev")).willReturn(expected); + + AnalysisReportResponse result = reportService.getReport(USER_ID, "be_dev"); + + assertThat(result).isSameAs(expected); + verify(analysisReportAssembler).assemble(eq(active), eq(profile), eq("be_dev")); + } + + private OnboardingProfileSnapshot sampleProfile() { + return new OnboardingProfileSnapshot( + USER_ID, + 2024, + "IT공과대학", + "컴퓨터공학부", + 4, + List.of(), + List.of("IT/인터넷"), + List.of("백엔드"), + List.of(), + List.of(), + List.of(), + List.of()); + } +} diff --git a/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationServiceTest.java b/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationServiceTest.java index 9b98bbb..34d8735 100644 --- a/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationServiceTest.java +++ b/src/test/java/com/hansung/tracktory/domain/recommendation/service/RecommendationServiceTest.java @@ -17,9 +17,13 @@ import com.hansung.tracktory.domain.catalog.organization.repository.TrackRepository; import com.hansung.tracktory.domain.recommendation.ai.AiRecommendClient; import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse; +import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.CourseCoverageContribution; +import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.CoverageAnalysis; import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.Explanation; import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.ExplanationSection; import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.JobCandidate; +import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.JobCoverage; +import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.NextActionSuggestion; import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.RankedCombo; import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.Roadmap; import com.hansung.tracktory.domain.recommendation.ai.AiRecommendResponse.RoadmapCourse; @@ -180,6 +184,32 @@ void generate_freshWhenNoActive_callsAiSupersedesSavesAndMaps() { assertThat(semester.getItems()).hasSize(1); assertThat(semester.getItems().get(0).getSubject().getCode()).isEqualTo("W080001"); assertThat(semester.getItems().get(0).getScore()).isEqualTo(60); + + // 역량 충족도 스냅샷: 카운트는 그대로 보존(비율 재계산 X), next_actions_covered 합집합 값도 그대로 옮긴다. + assertThat(saved.getCoverage()).isNotNull(); + assertThat(saved.getCoverage().getRequiredCount()).isEqualTo(10); + assertThat(saved.getCoverage().getCurrentCovered()).isEqualTo(4); + assertThat(saved.getCoverage().getNextActionsCovered()).isEqualTo(6); + assertThat(saved.getCoverage().getExpectedCovered()).isEqualTo(8); + assertThat(saved.getCoverage().getGapTokens()).isEqualTo("Kafka\nRedis"); + assertThat(saved.getCoverage().getJobCoverages()) + .singleElement() + .satisfies( + jc -> { + assertThat(jc.getJobCode()).isEqualTo("be_dev"); + assertThat(jc.getCurrentCovered()).isEqualTo(4); + assertThat(jc.getMissingTokens()).isEqualTo("Kafka\nRedis"); + }); + assertThat(saved.getCoverage().getNextActions()) + .singleElement() + .satisfies( + na -> { + assertThat(na.getCourseCode()).isEqualTo("db"); + assertThat(na.getContributionPercent()).isEqualTo(20); + }); + assertThat(saved.getCoverage().getCourseContributions()) + .singleElement() + .satisfies(cc -> assertThat(cc.getCourseCode()).isEqualTo("os")); } @Test @@ -224,6 +254,7 @@ void generate_jobsFoldingToSameCatalogCode_dedupedToSingleRecommendedJob() { List.of(), List.of(), null, + null, null); given(onboardingProfileReader.read(USER_ID)).willReturn(Optional.of(profile)); @@ -262,7 +293,7 @@ void generate_secondaryComboWithNullSlotType_notMarkedCrossCombination() { RankedCombo secondaryNull = new RankedCombo(new TrackCombo(mobile, null, "MOBILE"), 0.5, null, 2); AiRecommendResponse ai = - new AiRecommendResponse(List.of(), List.of(), List.of(secondaryNull), null, null); + new AiRecommendResponse(List.of(), List.of(), List.of(secondaryNull), null, null, null); given(onboardingProfileReader.read(USER_ID)).willReturn(Optional.of(profile)); given( @@ -305,7 +336,7 @@ void generate_secondaryComboWithMiddleDotVariant_mappedViaNormalizedCode() { RankedCombo secondary = new RankedCombo(new TrackCombo(vr, null, aiCode), 0.6, "cross_college", 2); AiRecommendResponse ai = - new AiRecommendResponse(List.of(), List.of(), List.of(secondary), null, null); + new AiRecommendResponse(List.of(), List.of(), List.of(secondary), null, null, null); given(onboardingProfileReader.read(USER_ID)).willReturn(Optional.of(profile)); given( @@ -396,11 +427,28 @@ private static AiRecommendResponse sampleAiResponse() { List.of(), List.of()); + CoverageAnalysis coverage = + new CoverageAnalysis( + 10, + 4, + 8, + 0.4, + 0.8, + 6, + 0.6, + List.of( + new JobCoverage( + "be_dev", "백엔드 개발자", 10, 4, 8, 0.4, 0.8, List.of("Kafka", "Redis"))), + List.of(new CourseCoverageContribution("os", "운영체제", List.of("OS"), 0.1)), + List.of(new NextActionSuggestion("db", "데이터베이스", 0.2, "데이터베이스를 들으면 충족도가 오릅니다")), + List.of("Kafka", "Redis")); + return new AiRecommendResponse( List.of(new JobCandidate("be_dev", "백엔드 개발자", List.of(), List.of(), 0.9, 0.8, false)), List.of(primary), List.of(secondaryCross, secondaryMmr), roadmap, + coverage, explanation); } }