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.CandidateScoringCalculator;
import com.mydreamjob.careerprogress.dto.CareerProgressResponse;
import com.mydreamjob.careerprogress.dto.CareerProgressResponse.CareerProgressItem;
import com.mydreamjob.careerprogress.dto.CareerProgressResponse.CompetitionSummary;
import com.mydreamjob.careerprogress.dto.CareerProgressResponse.InterviewSummary;
import com.mydreamjob.careerprogress.dto.CareerProgressResponse.JobSummary;
import com.mydreamjob.careerprogress.dto.CareerProgressResponse.OfferSummary;
import com.mydreamjob.careerprogress.dto.CareerProgressResponse.PaginatedApplications;
import com.mydreamjob.careerprogress.dto.CareerProgressResponse.ProgressTodoSummary;
import com.mydreamjob.careerprogress.dto.CareerProgressResponse.ScoreSummary;
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 lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 统一求职进度聚合服务(V34)。
*
*
只读学生端接口,汇总投递分页 + 岗位 + 下一场面试 + 最近已提交评分 + 可展示 Offer +
* 同岗位竞争力排名 + 待办/通知计数。纯只读:@Transactional(readOnly=true),
* 不写业务表/审计表/通知表/缓存。所有附属数据批量读取,DB 查询次数固定(与分页
* items 数量无关),避免 N+1。身份仅从 JWT/SecurityContext 获取。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CareerProgressService {
private static final Set ALLOWED_STATUSES = Set.of(
"SUBMITTED", "VIEWED", "INTERVIEW", "HIRED", "REJECTED", "WITHDRAWN");
private static final Set NEXT_INTERVIEW_STATUSES = Set.of("SCHEDULED", "CONFIRMED");
private static final String REPORT_DECISION_TYPE = "REPORT_DECISION";
private final com.mydreamjob.security.UserRoleGuard userRoleGuard;
private final ApplicationRepository applicationRepository;
private final InterviewRepository interviewRepository;
private final InterviewScoreRepository interviewScoreRepository;
private final OfferRepository offerRepository;
private final NotificationRepository notificationRepository;
private final JobRepository jobRepository;
private final CompanyRepository companyRepository;
private final UserRepository userRepository;
private final CompetitionRankingCalculator rankingCalculator;
private final CandidateScoringCalculator candidateScoringCalculator;
@Transactional(readOnly = true)
public CareerProgressResponse getCareerProgress(Long studentUserId, int page, int size, String status) {
userRoleGuard.requireStudent(studentUserId);
validateParams(page, size, status);
LocalDateTime now = LocalDateTime.now();
String asOf = yearMonth(LocalDate.now());
// 1. 投递分页(按 createdAt DESC;status 可选筛选)
PageRequest pr = PageRequest.of(page, size);
Page appPage = (status == null || status.isBlank())
? applicationRepository.findByStudentUserIdOrderByCreatedAtDesc(studentUserId, pr)
: applicationRepository.findByStudentUserIdAndStatusOrderByCreatedAtDesc(studentUserId, status, pr);
List pageApps = appPage.getContent();
// 2. 岗位与企业(批量,与 items 数量无关)
List jobIds = pageApps.stream().map(Application::getJobId).distinct().toList();
Map jobs = jobIds.isEmpty() ? Map.of()
: jobRepository.findAllById(jobIds).stream()
.collect(Collectors.toMap(Job::getId, Function.identity()));
List companyIds = jobs.values().stream().map(Job::getCompanyId).distinct().toList();
Map companies = companyIds.isEmpty() ? Map.of()
: companyRepository.findAllById(companyIds).stream()
.collect(Collectors.toMap(Company::getId, Function.identity()));
// 3. 面试:学生全部 SCHEDULED/CONFIRMED(todo 计数 + nextInterview 共用一个批次)
List pendingInterviews = interviewRepository
.findByStudentUserIdAndStatusInOrderByStartAtAsc(studentUserId, NEXT_INTERVIEW_STATUSES);
// 4. 已提交评分(本页投递,批量)
List appIds = pageApps.stream().map(Application::getId).toList();
List scores = appIds.isEmpty() ? List.of()
: interviewScoreRepository.findByStudentUserIdAndApplicationIdInAndStatus(
studentUserId, appIds, InterviewScore.STATUS_SUBMITTED);
// 5. 非 DRAFT Offer(todo 有效 Offer 计数 + 每条投递 Offer 展示共用一个批次)
List offers = offerRepository.findByCandidateIdAndStatusNotOrderByCreatedAtDesc(studentUserId, "DRAFT");
// 6. 竞争力:本页岗位的 cohort 投递与候选人快照各批量一次,再按岗位内存排序
List cohortApps = jobIds.isEmpty() ? List.of()
: applicationRepository.findByJobIdInAndStatusNotInOrderByCreatedAtAsc(
jobIds, CompetitionRankingCalculator.EXCLUDED_STATUSES);
List cohortCandidateIds = cohortApps.stream()
.map(Application::getStudentUserId).distinct().toList();
Map> latestByStudent =
rankingCalculator.loadLatestSnapshots(cohortCandidateIds, asOf);
Map> cohortByJob = cohortApps.stream()
.collect(Collectors.groupingBy(Application::getJobId));
// 7. 通知计数(只读 count)
long unreadTotal = notificationRepository.countUnreadByUserId(studentUserId);
long unreadReportDecision = notificationRepository.countUnreadByUserIdAndType(studentUserId, REPORT_DECISION_TYPE);
// 组装 todo
ProgressTodoSummary todo = buildTodo(pendingInterviews, offers, unreadTotal, unreadReportDecision, now);
// 组装 items
Map nextInterviewByApp = buildNextInterviewMap(pendingInterviews);
Map latestScoreByApp = buildLatestScoreMap(scores);
Map offerByApp = buildOfferMap(offers, now);
List items = pageApps.stream().map(app -> {
Job job = jobs.get(app.getJobId());
return CareerProgressItem.builder()
.applicationId(app.getId())
.applicationStatus(app.getStatus())
.appliedAt(app.getCreatedAt())
.updatedAt(app.getUpdatedAt())
.job(job == null ? null : buildJobSummary(job, companies))
.nextInterview(buildInterviewSummary(nextInterviewByApp.get(app.getId())))
.latestSubmittedInterviewScore(buildScoreSummary(latestScoreByApp.get(app.getId())))
.offer(buildOfferSummary(offerByApp.get(app.getId())))
.competition(buildCompetition(app, cohortByJob, latestByStudent, studentUserId))
.build();
}).collect(Collectors.toList());
PaginatedApplications applications = PaginatedApplications.builder()
.items(items)
.total(appPage.getTotalElements())
.page(page)
.size(size)
.totalPages(appPage.getTotalPages())
.build();
return CareerProgressResponse.builder()
.todo(todo)
.applications(applications)
.generatedAt(Instant.now())
.build();
}
// ── todo ────────────────────────────────────────────────
private ProgressTodoSummary buildTodo(List interviews, List offers,
long unreadTotal, long unreadReportDecision, LocalDateTime now) {
long pendingInterview = interviews.stream()
.filter(i -> "SCHEDULED".equals(i.getStatus())).count();
long upcomingConfirmed = interviews.stream()
.filter(i -> "CONFIRMED".equals(i.getStatus())
&& i.getStartAt() != null && i.getStartAt().isAfter(now)).count();
long pendingOffer = offers.stream()
.filter(o -> ("SENT".equals(o.getStatus()) || "NEGOTIATING".equals(o.getStatus()))
&& o.getExpireAt() != null && o.getExpireAt().isAfter(now)).count();
return ProgressTodoSummary.builder()
.pendingInterviewResponseCount(pendingInterview)
.upcomingConfirmedInterviewCount(upcomingConfirmed)
.pendingOfferResponseCount(pendingOffer)
.unreadNotificationCount(unreadTotal)
.unreadReportDecisionCount(unreadReportDecision)
.build();
}
// ── 批量结果归组(内存,无额外查询)────────────────────────
private Map buildNextInterviewMap(List interviews) {
Map map = new LinkedHashMap<>();
// 已按 startAt ASC,putIfAbsent 保留每个 applicationId 最早一场
for (Interview iv : interviews) {
map.putIfAbsent(iv.getApplicationId(), iv);
}
return map;
}
private Map buildLatestScoreMap(List scores) {
Map map = new HashMap<>();
for (InterviewScore s : scores) {
InterviewScore existing = map.get(s.getApplicationId());
if (existing == null || isAfter(s.getSubmittedAt(), existing.getSubmittedAt())) {
map.put(s.getApplicationId(), s);
}
}
return map;
}
private boolean isAfter(LocalDateTime a, LocalDateTime b) {
if (a == null) return false;
if (b == null) return true;
return a.isAfter(b);
}
private Map buildOfferMap(List offers, LocalDateTime now) {
Map> byApp = offers.stream().collect(Collectors.groupingBy(Offer::getApplicationId));
Map result = new HashMap<>();
for (Map.Entry> e : byApp.entrySet()) {
List list = e.getValue();
Offer effective = list.stream()
.filter(o -> ("SENT".equals(o.getStatus()) || "NEGOTIATING".equals(o.getStatus()))
&& o.getExpireAt() != null && o.getExpireAt().isAfter(now))
.min(Comparator.comparing(Offer::getExpireAt))
.orElse(null);
if (effective != null) {
result.put(e.getKey(), effective);
} else if (!list.isEmpty()) {
// offers 已按 createdAt DESC,第一个即该投递最新的非 DRAFT Offer
result.put(e.getKey(), list.get(0));
}
}
return result;
}
// ── 子对象映射 ─────────────────────────────────────────
private JobSummary buildJobSummary(Job job, Map companies) {
Company c = companies.get(job.getCompanyId());
return JobSummary.builder()
.jobId(job.getId())
.title(job.getTitle())
.companyName(c != null ? c.getName() : null)
.city(job.getCity())
.employmentType(job.getEmploymentType())
.jobStatus(job.getStatus())
.build();
}
private InterviewSummary buildInterviewSummary(Interview iv) {
if (iv == null) return null;
return InterviewSummary.builder()
.interviewId(iv.getId())
.roundNo(iv.getRoundNo())
.title(iv.getTitle())
.interviewType(iv.getInterviewType())
.startAt(iv.getStartAt())
.endAt(iv.getEndAt())
.status(iv.getStatus())
.build();
}
private ScoreSummary buildScoreSummary(InterviewScore s) {
if (s == null) return null;
return ScoreSummary.builder()
.interviewId(s.getInterviewId())
.overallScore(s.getOverallScore())
.recommendation(s.getRecommendation())
.submittedAt(s.getSubmittedAt())
.build();
}
private OfferSummary buildOfferSummary(Offer o) {
if (o == null) return null;
return OfferSummary.builder()
.offerId(o.getId())
.status(o.getStatus())
.title(o.getTitle())
.expireAt(o.getExpireAt())
.onboardingDate(o.getOnboardingDate())
.build();
}
private CompetitionSummary buildCompetition(Application app,
Map> cohortByJob,
Map> latestByStudent,
Long meId) {
String st = app.getStatus();
if ("WITHDRAWN".equals(st) || "REJECTED".equals(st)) {
return CompetitionSummary.builder().eligible(false).build();
}
List cohort = cohortByJob.getOrDefault(app.getJobId(), List.of());
CompetitionRankingCalculator.CohortRanking cr =
rankingCalculator.rankCohort(cohort, latestByStudent, meId);
return CompetitionSummary.builder()
.eligible(true)
.rank(cr.rank)
.cohortSize(cr.cohortSize)
.overallScore(cr.overallScore)
.percentile(cr.percentile)
.status(cr.status)
.mySnapshotPeriod(cr.mySnapshotPeriod)
.rulesVersion(candidateScoringCalculator.currentRulesVersion())
.build();
}
// ── 权限 / 参数 / 工具 ─────────────────────────────────
private void validateParams(int page, int size, String status) {
if (page < 0 || size < 1 || size > 50) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
if (status != null && !status.isBlank() && !ALLOWED_STATUSES.contains(status)) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
}
private String yearMonth(LocalDate d) {
return String.format("%04d-%02d", d.getYear(), d.getMonthValue());
}
}