Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import com.boaz.backend.domain.recruitment.dto.response.RecruitmentIdResponse;
import com.boaz.backend.domain.recruitment.dto.response.RecruitmentResponse;
import com.boaz.backend.domain.recruitment.dto.response.SubscriptionResponse;
import com.boaz.backend.domain.recruitment.entity.DecisionFilter;
import com.boaz.backend.domain.recruitment.service.RecruitmentService;
import com.boaz.backend.global.common.ApiResponse;

Expand Down Expand Up @@ -64,11 +65,13 @@ public ResponseEntity<ApiResponse<Void>> deleteRecruitment(
return ResponseEntity.ok(ApiResponse.ok(null));
}

@Operation(summary = "지원서 CSV 파일 생성")
@Operation(summary = "지원서 CSV 파일 생성",
description = "term 공고의 지원서를 부문별 CSV로 생성합니다. decision으로 합격(PASS)/불합격(FAIL)/전체(ALL) 추출 범위를 지정합니다.")
@PostMapping("/applications/download")
public ResponseEntity<ApiResponse<Void>> downloadApplications(
@RequestParam Integer term) {
recruitmentService.downloadApplications(term);
@RequestParam Integer term,
@RequestParam(required = false, defaultValue = "ALL") DecisionFilter decision) {
recruitmentService.downloadApplications(term, decision);
return ResponseEntity.ok(ApiResponse.ok(null));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.boaz.backend.domain.recruitment.entity;

import io.swagger.v3.oas.annotations.media.Schema;

// 지원서 CSV 추출 시 final_decision 기준 추출 범위 필터 (서류 평가 결과 전달용)
public enum DecisionFilter {
@Schema(description = "합격자만")
PASS,
@Schema(description = "불합격자만")
FAIL,
@Schema(description = "전체 (필터 없음, 기본값)")
ALL;

// 필터에 대응하는 EvaluationDecision 반환. ALL이면 null(필터 미적용)
public EvaluationDecision toEvaluationDecisionOrNull() {
return this == ALL ? null : EvaluationDecision.valueOf(name());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import org.springframework.data.repository.query.Param;

import com.boaz.backend.domain.recruitment.entity.Applicant;
import com.boaz.backend.domain.recruitment.entity.EvaluationDecision;
import com.boaz.backend.global.common.enums.Track;

public interface ApplicantRepository extends JpaRepository<Applicant, Long> {
Expand All @@ -21,16 +22,19 @@ public interface ApplicantRepository extends JpaRepository<Applicant, Long> {
// (recruitment_id, user_id) 조합으로 지원서 조회
Optional<Applicant> findByRecruitmentIdAndUserId(Long recruitmentId, Long userId);

// recruitment_id + track + status 기반 조회 (CSV 다운로드용)
// recruitment_id + track + status + (선택) final_decision 기반 조회 (CSV 다운로드용)
// - JOIN FETCH a.user: user_id 접근 시 N+1 방지
// - ORDER BY a.submittedAt ASC: 제출 순 정렬 (DRAFT 생성 시점과 분리)
// - decision == null: 전체(필터 미적용), 값이 있으면 해당 final_decision 만 추출
@Query("SELECT a FROM Applicant a JOIN FETCH a.user " +
"WHERE a.recruitment.id = :recruitmentId AND a.track = :track AND a.status = :status " +
"AND (:decision IS NULL OR a.finalDecision = :decision) " +
"ORDER BY a.submittedAt ASC")
List<Applicant> findSubmittedByRecruitmentIdAndTrackOrderBySubmittedAt(
List<Applicant> findSubmittedByRecruitmentIdAndTrackAndDecision(
@Param("recruitmentId") Long recruitmentId,
@Param("track") Track track,
@Param("status") Applicant.ApplicantStatus status
@Param("status") Applicant.ApplicantStatus status,
@Param("decision") EvaluationDecision decision
);

// userId + status 기반 조회 (합격자 승격용, recruitment JOIN FETCH로 term 접근)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public class CsvService {
private final ObjectMapper objectMapper;

private static final List<String> BASE_HEADERS = List.of(
"user_id", "지원 부문", "성명", "이메일 주소", "전화번호", "대학교", "본전공",
"user_id", "지원 부문", "성명", "이메일 주소", "전화번호", "최종 평가", "대학교", "본전공",
"복수/부전공", "마지막 재학 학기", "병역 이수 여부", "생년월일",
"졸업 예정 시점", "대학원 진학 여부", "지원서 제출 일시"
);
Expand Down Expand Up @@ -64,6 +64,7 @@ public byte[] generateCsv(
row.add(applicant.getName());
row.add(applicant.getEmail());
row.add(applicant.getPhone() != null ? applicant.getPhone() : "");
row.add(toKoreanDecision(applicant.getFinalDecision()));
row.add(applicant.getUniversity() != null ? applicant.getUniversity() : "");
row.add(applicant.getMajor() != null ? applicant.getMajor() : "");
row.add(formatMinorDoubleMajor(applicant.getMinorDoubleMajor()));
Expand All @@ -85,6 +86,17 @@ public byte[] generateCsv(
return baos.toByteArray();
}

// 최종 평가(final_decision) ENUM → 결과 전달용 한글 표기
private String toKoreanDecision(EvaluationDecision decision) {
if (decision == null) return "";
return switch (decision) {
case PASS -> "합격";
case FAIL -> "불합격";
case HOLD -> "보류";
case PENDING -> "미정";
};
}

private String formatMinorDoubleMajor(String minorDoubleMajorJson) {
if (minorDoubleMajorJson == null) return "";
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import com.boaz.backend.domain.admin.entity.Admin;
import com.boaz.backend.domain.admin.repository.AdminRepository;
import com.boaz.backend.domain.recruitment.entity.ApplicantEval;
import com.boaz.backend.domain.recruitment.entity.DecisionFilter;
import com.boaz.backend.domain.recruitment.entity.EvaluationDecision;
import com.boaz.backend.domain.recruitment.repository.ApplicantEvalRepository;
import com.boaz.backend.domain.recruitment.dto.request.EvaluationSaveRequest;
Expand Down Expand Up @@ -598,23 +599,26 @@ public SubscriptionResponse subscribe(SubscriptionRequest request) {
// Admin 전용 메서드
// ========================

// 지원서 파일 다운로드
public void downloadApplications(Integer term) {
// 지원서 파일 다운로드 (decision: 합격/불합격/전체 추출 필터)
public void downloadApplications(Integer term, DecisionFilter decision) {

// 공고 존재 여부 확인
Recruitment recruitment = recruitmentRepository.findByTerm(term)
.orElseThrow(() -> new CustomException(ErrorCode.RECRUITMENT_NOT_FOUND));

// 필터에 대응하는 final_decision (ALL이면 null = 필터 미적용)
EvaluationDecision decisionFilter = decision.toEvaluationDecisionOrNull();

String timestamp = LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"));

// 부문별 CSV 생성 및 S3 업로드
for (Track track : List.of(Track.VISUALIZATION, Track.ANALYSIS, Track.ENGINEERING)) {

// 해당 부문 제출된 지원자 조회 (submittedAt 오름차순 + user JOIN FETCH)
// 해당 부문 + 필터 조건 제출 지원자 조회 (submittedAt 오름차순 + user JOIN FETCH)
List<Applicant> applicants = applicantRepository
.findSubmittedByRecruitmentIdAndTrackOrderBySubmittedAt(
recruitment.getId(), track, Applicant.ApplicantStatus.SUBMITTED);
.findSubmittedByRecruitmentIdAndTrackAndDecision(
recruitment.getId(), track, Applicant.ApplicantStatus.SUBMITTED, decisionFilter);

// 공통 + 해당 부문 질문 조회
List<ApplicationQuestion> questions = applicationQuestionRepository
Expand All @@ -641,9 +645,9 @@ public void downloadApplications(Integer term) {
throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR);
}

// S3 업로드
String key = String.format("%d/applicants_%s_%s.csv",
term, track.name(), timestamp);
// S3 업로드 (키에 추출 필터 표기 → 합격/불합격/전체 파일 구분)
String key = String.format("%d/applicants_%s_%s_%s.csv",
term, track.name(), decision.name(), timestamp);
s3Service.uploadCsv(recruitmentBucket, key, csv);
}
}
Expand Down
40 changes: 40 additions & 0 deletions src/main/resources/data.sql
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,25 @@ VALUES (3, 'kakao', 'dummy_kakao_003', '박시각', 'OUTSIDER', NOW(), NOW());
INSERT INTO users (id, provider, provider_id, nickname, member_type, created_at, updated_at)
VALUES (4, 'kakao', 'dummy_kakao_004', '스웨거테스터', 'OUTSIDER', NOW(), NOW());

-- CSV 추출 필터(decision=PASS/FAIL/ALL) 테스트용 user (id=5~10)
INSERT INTO users (id, provider, provider_id, nickname, member_type, created_at, updated_at)
VALUES (5, 'kakao', 'dummy_kakao_005', '합격엔지', 'OUTSIDER', NOW(), NOW());

INSERT INTO users (id, provider, provider_id, nickname, member_type, created_at, updated_at)
VALUES (6, 'kakao', 'dummy_kakao_006', '불합격엔지', 'OUTSIDER', NOW(), NOW());

INSERT INTO users (id, provider, provider_id, nickname, member_type, created_at, updated_at)
VALUES (7, 'kakao', 'dummy_kakao_007', '합격분석', 'OUTSIDER', NOW(), NOW());

INSERT INTO users (id, provider, provider_id, nickname, member_type, created_at, updated_at)
VALUES (8, 'kakao', 'dummy_kakao_008', '불합격분석', 'OUTSIDER', NOW(), NOW());

INSERT INTO users (id, provider, provider_id, nickname, member_type, created_at, updated_at)
VALUES (9, 'kakao', 'dummy_kakao_009', '합격시각', 'OUTSIDER', NOW(), NOW());

INSERT INTO users (id, provider, provider_id, nickname, member_type, created_at, updated_at)
VALUES (10, 'kakao', 'dummy_kakao_010', '불합격시각', 'OUTSIDER', NOW(), NOW());

-- recruitment 임시 데이터
-- id=1: 26기
INSERT INTO recruitment (id, term, start_date, end_date, schedule, brochure_url, created_at, updated_at)
Expand Down Expand Up @@ -198,6 +217,27 @@ VALUES (3, 1, 2, 'SUBMITTED', 'ANALYSIS', '이분석', 'ana@example.com', '01022
INSERT INTO applicants (id, recruitment_id, user_id, status, track, name, email, phone, university, major, minor_double_major, last_semester, military_status, birth_date, graduation_date, grad_school_plan, final_decision, submitted_at, created_at, updated_at)
VALUES (4, 1, 3, 'SUBMITTED', 'VISUALIZATION', '박시각', 'vis@example.com', '01033333333', '한국대학교', '시각디자인', null, 7, 'NOT_COMPLETED', '2002-01-01', '2026-08', false, 'PENDING', '2026-03-14 22:21:58', '2026-03-14 22:21:58', '2026-03-14 22:21:58');

-- CSV 추출 필터 테스트용 지원자 (recruitment_id=1, term 26)
-- 각 부문별로 final_decision=PASS 1명 + FAIL 1명을 두어, decision=PASS/FAIL/ALL 추출 결과가 명확히 구분되도록 함
-- (기존 id=1 ENG=PENDING, id=3 ANA=PASS, id=4 VIS=PENDING 는 그대로 유지)
INSERT INTO applicants (id, recruitment_id, user_id, status, track, name, email, phone, university, major, minor_double_major, last_semester, military_status, birth_date, graduation_date, grad_school_plan, final_decision, submitted_at, created_at, updated_at)
VALUES (5, 1, 5, 'SUBMITTED', 'ENGINEERING', '합격엔지', 'eng_pass@example.com', '01051110001', '한국대학교', '컴퓨터공학', null, 8, 'COMPLETED_OR_EXEMPT', '2001-05-10', '2026-02', false, 'PASS', '2026-03-15 09:00:00', '2026-03-15 09:00:00', '2026-03-15 09:00:00');

INSERT INTO applicants (id, recruitment_id, user_id, status, track, name, email, phone, university, major, minor_double_major, last_semester, military_status, birth_date, graduation_date, grad_school_plan, final_decision, submitted_at, created_at, updated_at)
VALUES (6, 1, 6, 'SUBMITTED', 'ENGINEERING', '불합격엔지', 'eng_fail@example.com', '01051110002', '한국대학교', '소프트웨어', null, 6, 'NOT_COMPLETED', '2002-07-22', '2026-08', false, 'FAIL', '2026-03-15 09:10:00', '2026-03-15 09:10:00', '2026-03-15 09:10:00');

INSERT INTO applicants (id, recruitment_id, user_id, status, track, name, email, phone, university, major, minor_double_major, last_semester, military_status, birth_date, graduation_date, grad_school_plan, final_decision, submitted_at, created_at, updated_at)
VALUES (7, 1, 7, 'SUBMITTED', 'ANALYSIS', '합격분석', 'ana_pass@example.com', '01052220001', '한국대학교', '통계학', '["수학"]', 7, 'COMPLETED_OR_EXEMPT', '2001-11-03', '2026-02', true, 'PASS', '2026-03-15 09:20:00', '2026-03-15 09:20:00', '2026-03-15 09:20:00');

INSERT INTO applicants (id, recruitment_id, user_id, status, track, name, email, phone, university, major, minor_double_major, last_semester, military_status, birth_date, graduation_date, grad_school_plan, final_decision, submitted_at, created_at, updated_at)
VALUES (8, 1, 8, 'SUBMITTED', 'ANALYSIS', '불합격분석', 'ana_fail@example.com', '01052220002', '한국대학교', '경제학', null, 5, 'NOT_COMPLETED', '2003-02-14', '2027-02', false, 'FAIL', '2026-03-15 09:30:00', '2026-03-15 09:30:00', '2026-03-15 09:30:00');

INSERT INTO applicants (id, recruitment_id, user_id, status, track, name, email, phone, university, major, minor_double_major, last_semester, military_status, birth_date, graduation_date, grad_school_plan, final_decision, submitted_at, created_at, updated_at)
VALUES (9, 1, 9, 'SUBMITTED', 'VISUALIZATION', '합격시각', 'vis_pass@example.com', '01053330001', '한국대학교', '시각디자인', null, 8, 'COMPLETED_OR_EXEMPT', '2001-08-30', '2026-02', false, 'PASS', '2026-03-15 09:40:00', '2026-03-15 09:40:00', '2026-03-15 09:40:00');

INSERT INTO applicants (id, recruitment_id, user_id, status, track, name, email, phone, university, major, minor_double_major, last_semester, military_status, birth_date, graduation_date, grad_school_plan, final_decision, submitted_at, created_at, updated_at)
VALUES (10, 1, 10, 'SUBMITTED', 'VISUALIZATION', '불합격시각', 'vis_fail@example.com', '01053330002', '한국대학교', '산업디자인', null, 6, 'NOT_COMPLETED', '2002-04-18', '2026-08', false, 'FAIL', '2026-03-15 09:50:00', '2026-03-15 09:50:00', '2026-03-15 09:50:00');

-- applicant_answer 임시 데이터 (id=1, ENGINEERING)
INSERT INTO applicant_answer (applicant_id, question_id, answer_text, answer_json, created_at, updated_at)
VALUES (1, 1, 'COMMON1답변', null, '2026-03-14 22:19:27', '2026-03-14 22:19:27');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.boaz.backend.domain.recruitment.dto.response.RecruitmentResponse;
import com.boaz.backend.domain.recruitment.dto.response.SubscriptionResponse;
import com.boaz.backend.domain.recruitment.entity.ApplicationQuestion;
import com.boaz.backend.domain.recruitment.entity.DecisionFilter;
import com.boaz.backend.domain.recruitment.entity.ApplicationQuestion.Category;
import com.boaz.backend.domain.recruitment.entity.ApplicationQuestion.Type;
import com.boaz.backend.domain.recruitment.entity.Recruitment;
Expand Down Expand Up @@ -81,15 +82,41 @@ private UsernamePasswordAuthenticationToken userAuth() {
class DownloadApplications {

@Test
@DisplayName("정상 요청 → 200")
@DisplayName("정상 요청(decision 미지정) → 200, 기본값 ALL 전달")
void success() throws Exception {
doNothing().when(recruitmentService).downloadApplications(27);
doNothing().when(recruitmentService).downloadApplications(eq(27), any());

mockMvc.perform(post("/api/v1/admin/recruitment/applications/download")
.with(authentication(adminAuth()))
.param("term", "27"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value(200));

verify(recruitmentService).downloadApplications(27, DecisionFilter.ALL);
}

@Test
@DisplayName("decision=PASS 지정 → 200, PASS 필터 전달")
void successWithPassFilter() throws Exception {
doNothing().when(recruitmentService).downloadApplications(eq(27), any());

mockMvc.perform(post("/api/v1/admin/recruitment/applications/download")
.with(authentication(adminAuth()))
.param("term", "27")
.param("decision", "PASS"))
.andExpect(status().isOk());

verify(recruitmentService).downloadApplications(27, DecisionFilter.PASS);
}

@Test
@DisplayName("decision 값이 ENUM 범위 밖 → 400")
void invalidDecision() throws Exception {
mockMvc.perform(post("/api/v1/admin/recruitment/applications/download")
.with(authentication(adminAuth()))
.param("term", "27")
.param("decision", "INVALID"))
.andExpect(status().isBadRequest());
}

@Test
Expand All @@ -115,7 +142,7 @@ void forbidden() throws Exception {
@DisplayName("EX-001 존재하지 않는 term → 404 RECRUITMENT_NOT_FOUND")
void notFound() throws Exception {
doThrow(new CustomException(ErrorCode.RECRUITMENT_NOT_FOUND))
.when(recruitmentService).downloadApplications(999);
.when(recruitmentService).downloadApplications(eq(999), any());

mockMvc.perform(post("/api/v1/admin/recruitment/applications/download")
.with(authentication(adminAuth()))
Expand All @@ -128,7 +155,7 @@ void notFound() throws Exception {
@DisplayName("EX-002 S3 업로드 실패 → 500 S3_UPLOAD_FAILED")
void s3Failed() throws Exception {
doThrow(new CustomException(ErrorCode.S3_UPLOAD_FAILED))
.when(recruitmentService).downloadApplications(27);
.when(recruitmentService).downloadApplications(eq(27), any());

mockMvc.perform(post("/api/v1/admin/recruitment/applications/download")
.with(authentication(adminAuth()))
Expand Down
Loading
Loading