diff --git a/build.gradle b/build.gradle index 2cff7f4..c3e2a96 100644 --- a/build.gradle +++ b/build.gradle @@ -62,6 +62,9 @@ dependencies { // Env implementation 'io.github.cdimascio:dotenv-java:3.0.0' + + // Excel + implementation 'org.apache.poi:poi-ooxml:5.2.5' } tasks.named('test') { diff --git a/src/main/java/hackerthon/likelion13th/canfly/domain/mock/MockScore.java b/src/main/java/hackerthon/likelion13th/canfly/domain/mock/MockScore.java index 9f12875..4f8e511 100644 --- a/src/main/java/hackerthon/likelion13th/canfly/domain/mock/MockScore.java +++ b/src/main/java/hackerthon/likelion13th/canfly/domain/mock/MockScore.java @@ -39,7 +39,7 @@ public class MockScore extends BaseEntity { @Column(name = "cumulative", precision = 5, scale = 2) private BigDecimal cumulative; - /** 과목 카테고리 (예: 국어/수학) -> 국어 1 , 수학 2, 영어 3, 한국사 4, 사회 5, 과학 6 */ + /** 과목 카테고리 (예: 국어/수학) -> 국어 1 , 수학 2, 영어 3, 한국사 4, 탐구1 5, 탐구2 6, 제2외국어 7*/ @Column(name = "category", nullable = false) private Integer category; diff --git a/src/main/java/hackerthon/likelion13th/canfly/grades/controller/MockController.java b/src/main/java/hackerthon/likelion13th/canfly/grades/controller/MockController.java index 99d1228..8e95361 100644 --- a/src/main/java/hackerthon/likelion13th/canfly/grades/controller/MockController.java +++ b/src/main/java/hackerthon/likelion13th/canfly/grades/controller/MockController.java @@ -3,6 +3,8 @@ import hackerthon.likelion13th.canfly.domain.user.User; import hackerthon.likelion13th.canfly.global.api.ApiResponse; import hackerthon.likelion13th.canfly.global.api.SuccessCode; +import hackerthon.likelion13th.canfly.grades.dto.MockCreateRequest; +import hackerthon.likelion13th.canfly.grades.dto.MockCreateResponse; import hackerthon.likelion13th.canfly.grades.dto.MockRequestDto; import hackerthon.likelion13th.canfly.grades.dto.MockResponseDto; import hackerthon.likelion13th.canfly.grades.service.MockService; @@ -55,7 +57,7 @@ public ApiResponse createMock( public ApiResponse> getAllMocksOfUser(@AuthenticationPrincipal CustomUserDetails customUserDetails) { /* providerId 기반으로 User 찾기 */ - User user = userService.findUserByProviderId(customUserDetails.getProviderId()); + User user = userService.findUserByProviderId(customUserDetails.getUsername()); List allMocks = mockService.getAllMocksByUserId(user.getUid()); return ApiResponse.onSuccess(SuccessCode.MOCK_GET_ALL_SUCCESS, allMocks); @@ -117,4 +119,25 @@ public ApiResponse deleteMock(@PathVariable Long mockId) { return ApiResponse.onSuccess(SuccessCode.MOCK_DELETE_SUCCESS, true); } + // GPT + @PostMapping("/excel") + @Operation( + summary = "모의고사 등록(엑셀 평가)", + description = "엑셀 수식으로 각 과목의 백분위/등급/누적(%)를 계산한 뒤 저장합니다. " + + "규칙: 수학 3택1, (과탐/사탐) 합산 최대 2, 제2외국어 최대 1. " + + "표준점수 과목에서 계산 결과가 (백분위=0, 등급=0, 누적=0)이면 입력 오류로 처리됩니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "Mock_2011", description = "모의고사 등록이 완료되었습니다."), + }) + public ApiResponse createMockByExcel( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + @RequestBody MockCreateRequest req + ) { + User user = userService.findUserByProviderId(customUserDetails.getUsername()); + + MockCreateResponse res = mockService.createMockByExcel(user.getUid(), req); + + return ApiResponse.onSuccess(SuccessCode.MOCK_CREATE_SUCCESS, res); + } } diff --git a/src/main/java/hackerthon/likelion13th/canfly/grades/dto/MockCreateRequest.java b/src/main/java/hackerthon/likelion13th/canfly/grades/dto/MockCreateRequest.java new file mode 100644 index 0000000..3bd0adb --- /dev/null +++ b/src/main/java/hackerthon/likelion13th/canfly/grades/dto/MockCreateRequest.java @@ -0,0 +1,38 @@ +package hackerthon.likelion13th.canfly.grades.dto; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.*; + +import java.util.List; + +@Schema(description = "모의고사 생성 요청") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class MockCreateRequest { + + @Schema(description = "응시 연도", example = "2025", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull @Min(2000) @Max(2100) + private Integer examYear; + + @Schema(description = "응시 월(3,6,9,11)", example = "9", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull @Min(1) @Max(12) + private Integer examMonth; + + @Schema(description = "학년(고1~3)", example = "3", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull @Min(1) @Max(3) + private Integer examGrade; + + @Schema(description = "과목 입력 목록 (라벨은 엑셀 B열 텍스트와 동일해야 함)", requiredMode = Schema.RequiredMode.REQUIRED) + private List inputs; + + @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder + @Schema(description = "단일 과목 입력 (label=엑셀 B열 텍스트, value=표준점수 또는 등급)") + public static class ScoreInput { + @Schema(example = "국어", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank + private String label; + + @Schema(example = "129", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull + private Integer value; // 표준점수 or 등급(영어/한국사/제2외국어) + } +} \ No newline at end of file diff --git a/src/main/java/hackerthon/likelion13th/canfly/grades/dto/MockCreateResponse.java b/src/main/java/hackerthon/likelion13th/canfly/grades/dto/MockCreateResponse.java new file mode 100644 index 0000000..536f464 --- /dev/null +++ b/src/main/java/hackerthon/likelion13th/canfly/grades/dto/MockCreateResponse.java @@ -0,0 +1,53 @@ +package hackerthon.likelion13th.canfly.grades.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +import java.math.BigDecimal; +import java.util.List; + +@Schema(description = "모의고사 생성 응답") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class MockCreateResponse { + + @Schema(description = "생성된 Mock ID", example = "101") + private Long mockId; + + @Schema(description = "응시 연도", example = "2025") + private Integer examYear; + + @Schema(description = "응시 월", example = "9") + private Integer examMonth; + + @Schema(description = "학년", example = "3") + private Integer examGrade; + + @Schema(description = "과목별 계산 결과") + private List results; + + @Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder + @Schema(description = "과목별 계산 결과") + public static class SubjectCalculated { + + @Schema(description = "엑셀 B열 라벨", example = "국어") + private String label; + + @Schema(description = "입력값(표준점수 또는 등급)", example = "129") + private Integer input; + + @Schema(description = "백분위", example = "99") + private Integer percentile; + + @Schema(description = "등급", example = "1") + private Integer grade; + + @Schema(description = "누적 백분위(%)", example = "1.25") + private BigDecimal cumulative; + + @Schema(description = "카테고리 코드 (1:국어, 2:수학, 3:영어, 4:한국사, 5:탐구1, 6:탐구2, 7:제2외국어)", example = "1") + private Integer category; + + @Schema(description = "세부 과목명(엔티티 name에 저장)", example = "국어 / 물리학 Ⅰ / 사회·문화 등") + private String name; + } +} \ No newline at end of file diff --git a/src/main/java/hackerthon/likelion13th/canfly/grades/repository/MockRepository.java b/src/main/java/hackerthon/likelion13th/canfly/grades/repository/MockRepository.java index 8a79ab0..ba6f4d5 100644 --- a/src/main/java/hackerthon/likelion13th/canfly/grades/repository/MockRepository.java +++ b/src/main/java/hackerthon/likelion13th/canfly/grades/repository/MockRepository.java @@ -10,4 +10,9 @@ public interface MockRepository extends JpaRepository { List findByUser(User user); + + // 사용자(uid) + 연/월/학년으로 기존 모의고사 존재 여부 확인 + Optional findByUserUidAndExamYearAndExamMonthAndExamGrade( + String uid, Integer examYear, Integer examMonth, Integer examGrade + ); } diff --git a/src/main/java/hackerthon/likelion13th/canfly/grades/service/MockService.java b/src/main/java/hackerthon/likelion13th/canfly/grades/service/MockService.java index 6ac9ae0..65b8fe6 100644 --- a/src/main/java/hackerthon/likelion13th/canfly/grades/service/MockService.java +++ b/src/main/java/hackerthon/likelion13th/canfly/grades/service/MockService.java @@ -6,17 +6,26 @@ import hackerthon.likelion13th.canfly.domain.user.User; import hackerthon.likelion13th.canfly.global.api.ErrorCode; import hackerthon.likelion13th.canfly.global.exception.GeneralException; +import hackerthon.likelion13th.canfly.grades.dto.MockCreateRequest; +import hackerthon.likelion13th.canfly.grades.dto.MockCreateResponse; import hackerthon.likelion13th.canfly.grades.dto.MockRequestDto; import hackerthon.likelion13th.canfly.grades.dto.MockResponseDto; import hackerthon.likelion13th.canfly.grades.repository.MockRepository; import hackerthon.likelion13th.canfly.grades.repository.MockScoreRepository; import hackerthon.likelion13th.canfly.login.repository.UserRepository; +import hackerthon.likelion13th.canfly.score.ScoreExcelEngine; +import hackerthon.likelion13th.canfly.score.SubjectCategoryMapper; +import hackerthon.likelion13th.canfly.score.SubjectRegistry; +import hackerthon.likelion13th.canfly.score.SubjectSelectionValidator; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; @@ -28,6 +37,8 @@ public class MockService { private final MockScoreRepository mockScoreRepository; private final UserRepository userRepository; + private final SubjectSelectionValidator selectionValidator; + private final ScoreExcelEngine excelEngine; @Transactional public MockResponseDto createMock(String userId, MockRequestDto mockRequestDto) { @@ -151,4 +162,123 @@ private MockResponseDto.MockScoreResponseDto convertToDto(MockScore mockScore) { // MockResponseDto의 생성자를 호출하여 엔티티를 넘겨줍니다. return new MockResponseDto.MockScoreResponseDto(mockScore); } + + // 여기서부터 지피티 코드 -> 엑셀 추출 + 업서트 반영 + /** + * @param uid 현재 로그인 사용자 UID (또는 providerId 등, 레포지토리에 맞게 변경) + * @param req 모의고사 생성/갱신 요청 + * @return 생성 또는 갱신 결과(계산 결과 포함) + */ + @Transactional + public MockCreateResponse createMockByExcel(String uid, MockCreateRequest req) { + // 0) 사용자 조회 + User user = userRepository.findByUid(uid) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다. uid=" + uid)); + + // 1) 원본 입력을 엔진 형태로 변환 + 선택/조합/범위 검증 + var rawInputs = req.getInputs().stream() + .map(i -> new ScoreExcelEngine.SubjectInput(i.getLabel(), i.getValue())) + .collect(Collectors.toList()); + + var validated = selectionValidator.validateToEngineInputs(rawInputs); + var engineInputs = validated.engineInputs(); + + // 2) 엑셀 평가 (메모리에서만) + var results = excelEngine.evaluateAll(engineInputs); + + // 3) 과목 순서(탐구1/탐구2 코드 부여를 위해) - 라벨 → Subject + List pickedOrder = engineInputs.stream() + .map(in -> SubjectRegistry.byLabel(in.label())) + .toList(); + + // 4) 0/0/0 가드: 표준점수 과목에서 백분위=0 & 등급=0 & 누적=0 이면 입력 오류 + for (var in : engineInputs) { + SubjectRegistry.Subject subj = SubjectRegistry.byLabel(in.label()); + if (subj.getInputType() == SubjectRegistry.InputType.GRADE) continue; // 등급 입력 과목은 스킵 + + var r = results.get(in.label()); + if (r == null) throw new IllegalStateException("엑셀 평가 결과 없음: " + in.label()); + + int pct = r.percentile() == null ? 0 : r.percentile(); + int grd = r.grade() == null ? 0 : r.grade(); + boolean cumZero = (r.cumulative() == null) || r.cumulative().compareTo(BigDecimal.ZERO) == 0; + + if (pct == 0 && grd == 0 && cumZero) { + throw new IllegalArgumentException("입력 오류로 판단됩니다. 과목='" + in.label() + "', 입력값=" + in.value() + + " (계산 결과: 백분위=0, 등급=0, 누적=0)"); + } + } + + // 5) 기존 Mock 조회 (있으면 재사용) + Mock mock = mockRepository.findByUserUidAndExamYearAndExamMonthAndExamGrade( + user.getUid(), req.getExamYear(), req.getExamMonth(), req.getExamGrade() + ).orElseGet(() -> + Mock.builder() + .examYear(req.getExamYear()) + .examMonth(req.getExamMonth()) + .examGrade(req.getExamGrade()) + .user(user) + .build() + ); + + // 6) 자식 전량 교체: 기존 점수들 제거 후, 이번 계산 결과로 다시 채움 + // orphanRemoval = true 이므로 리스트에서 제거하면 DB에서도 삭제됨 + mock.getScoreLists().clear(); + + List responseItems = new ArrayList<>(); + for (var in : engineInputs) { + String label = in.label(); + Integer inputVal = toInt(in.value()); + var subj = SubjectRegistry.byLabel(label); + var r = results.get(label); + + Integer idxInSciSoc = SubjectCategoryMapper.sciSocIndex(subj, pickedOrder); + int categoryCode = SubjectCategoryMapper.toCategoryCode(subj, idxInSciSoc); + + Integer grade = (r.grade() != null) ? r.grade() + : (subj.getInputType() == SubjectRegistry.InputType.GRADE ? inputVal : null); + if (grade == null) throw new IllegalArgumentException("등급 계산값을 결정할 수 없습니다. 과목='" + label + "'"); + + MockScore ms = MockScore.builder() + .standardScore(subj.getInputType() == SubjectRegistry.InputType.SCORE ? inputVal : null) + .percentile(r.percentile()) + .grade(grade) + .cumulative(scale2(r.cumulative())) + .category(categoryCode) + .name(label) + .build(); + + mock.addMockScore(ms); + + responseItems.add( + MockCreateResponse.SubjectCalculated.builder() + .label(label).input(inputVal) + .percentile(r.percentile()).grade(grade).cumulative(scale2(r.cumulative())) + .category(categoryCode).name(label) + .build() + ); + } + + // 7) 저장 (신규면 insert, 기존이면 update) + Mock saved = mockRepository.save(mock); + + // 8) 응답 DTO 구성 + return MockCreateResponse.builder() + .mockId(saved.getId()) + .examYear(saved.getExamYear()) + .examMonth(saved.getExamMonth()) + .examGrade(saved.getExamGrade()) + .results(responseItems) + .build(); + } + + private Integer toInt(Object v) { + if (v == null) return null; + if (v instanceof Number n) return n.intValue(); + return Integer.valueOf(v.toString()); + } + + private BigDecimal scale2(BigDecimal v) { + return (v == null) ? null : v.setScale(2, java.math.RoundingMode.HALF_UP); + } } \ No newline at end of file diff --git a/src/main/java/hackerthon/likelion13th/canfly/score/ScoreExcelEngine.java b/src/main/java/hackerthon/likelion13th/canfly/score/ScoreExcelEngine.java new file mode 100644 index 0000000..8123957 --- /dev/null +++ b/src/main/java/hackerthon/likelion13th/canfly/score/ScoreExcelEngine.java @@ -0,0 +1,193 @@ +package hackerthon.likelion13th.canfly.score; + + +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.*; + +/** + * 엑셀 템플릿(시트명: 수능입력)의 B열에서 과목 라벨을 찾아 + * 같은 행의 C열(입력)을 채우고, D/E/F열(백분위/등급/누적%)을 수식 평가하여 읽어오는 엔진. + * + * - 파일은 절대 저장하지 않음 (메모리에서만 처리) + * - 영어/한국사/제2외국어는 "등급"을 C열에 넣어도 됨 (엔진은 점수/등급을 구분하지 않음) + */ +@Service +public class ScoreExcelEngine { + + private static final String TEMPLATE_PATH = "/excel/score_template.xlsx"; // resources 기준 + private static final String SHEET_NAME = "수능입력"; + + // 컬럼 인덱스(0-based) + private static final int COL_SUBJECT = 1; // B열: 과목 라벨 + private static final int COL_INPUT = 2; // C열: 표준점수 or 등급 입력 + private static final int COL_PCT = 3; // D열: 백분위 + private static final int COL_GRADE = 4; // E열: 등급 + private static final int COL_CUM = 5; // F열: 누적 백분위(%) + + private final byte[] templateBytes; + + public ScoreExcelEngine() throws IOException { + try (InputStream is = getClass().getResourceAsStream(TEMPLATE_PATH)) { + if (is == null) { + throw new IllegalStateException("Excel template not found: " + TEMPLATE_PATH); + } + this.templateBytes = is.readAllBytes(); + } + } + + /** + * 여러 과목 입력을 한 번에 평가. + * @param inputs 사용자 입력(과목라벨, 값)의 리스트 + * @return 과목라벨별 계산 결과(백분위/등급/누적%) + */ + public Map evaluateAll(List inputs) { + if (inputs == null || inputs.isEmpty()) return Collections.emptyMap(); + + try (Workbook wb = new XSSFWorkbook(new ByteArrayInputStream(templateBytes))) { + Sheet sheet = Optional.ofNullable(wb.getSheet(SHEET_NAME)) + .orElseThrow(() -> new IllegalStateException("시트를 찾을 수 없습니다: " + SHEET_NAME)); + + // 1) 시트에서 B열 라벨 → 행번호 맵핑을 생성 + Map labelToRow = buildLabelIndex(sheet); + + // 2) 입력값 주입(C열) + for (SubjectInput in : inputs) { + Integer rowIdx = labelToRow.get(normalize(in.label())); + if (rowIdx == null) { + throw new IllegalArgumentException("엑셀에서 과목 라벨을 찾지 못했습니다: " + in.label()); + } + Row row = getOrCreateRow(sheet, rowIdx); + Cell c = getOrCreateCell(row, COL_INPUT); + setNumericOrString(c, in.value()); + } + + // 3) 수식 평가 준비 + FormulaEvaluator evaluator = wb.getCreationHelper().createFormulaEvaluator(); + evaluator.clearAllCachedResultValues(); + + // 4) 결과 읽기(D/E/F열) + Map result = new LinkedHashMap<>(); + for (SubjectInput in : inputs) { + Integer rowIdx = labelToRow.get(normalize(in.label())); + Row row = sheet.getRow(rowIdx); + + SubjectResult r = new SubjectResult( + evalAsInteger(row.getCell(COL_PCT), evaluator), // 백분위 + evalAsInteger(row.getCell(COL_GRADE), evaluator), // 등급 + evalAsBigDecimal(row.getCell(COL_CUM), evaluator) // 누적% + ); + result.put(in.label(), r); + } + return result; + + } catch (IOException e) { + throw new IllegalStateException("Excel 평가 중 오류", e); + } + } + + // ---- 내부 유틸 ---- + + private static Map buildLabelIndex(Sheet sheet) { + Map map = new HashMap<>(); + int last = sheet.getLastRowNum(); + for (int i = 0; i <= last; i++) { + Row row = sheet.getRow(i); + if (row == null) continue; + Cell labelCell = row.getCell(COL_SUBJECT); + String label = readAsString(labelCell); + if (label == null || label.isBlank()) continue; + map.put(normalize(label), i); + } + return map; + } + + private static String normalize(String s) { + if (s == null) return null; + // 공백/전각스페이스 제거, 트림 + return s.replace('\u00A0', ' ').trim(); + } + + private static Row getOrCreateRow(Sheet sheet, int r) { + Row row = sheet.getRow(r); + return (row != null) ? row : sheet.createRow(r); + } + + private static Cell getOrCreateCell(Row row, int c) { + Cell cell = row.getCell(c); + return (cell != null) ? cell : row.createCell(c); + } + + private static void setNumericOrString(Cell cell, Object value) { + // // 입력값이 정수(표준점수/등급)라고 가정하지만, 혹시 문자열로 올 경우도 방어 + if (value == null) { + cell.setBlank(); + return; + } + if (value instanceof Number n) { + cell.setCellValue(n.doubleValue()); + } else { + cell.setCellValue(value.toString()); + } + } + + private static String readAsString(Cell cell) { + if (cell == null) return null; + return switch (cell.getCellType()) { + case STRING -> cell.getStringCellValue(); + case NUMERIC -> { + // B열이 숫자일 일은 거의 없지만 방어적으로 처리 + double d = cell.getNumericCellValue(); + // 소수점 없는 정수라면 깔끔하게 문자열로 변환 + if (d == Math.rint(d)) yield String.valueOf((long) d); + else yield String.valueOf(d); + } + case FORMULA -> cell.getCellFormula(); + default -> null; + }; + } + + private static Integer evalAsInteger(Cell cell, FormulaEvaluator evaluator) { + if (cell == null) return null; + CellValue cv = evaluator.evaluate(cell); + if (cv == null) return null; + return switch (cv.getCellType()) { + case NUMERIC -> (int) Math.round(cv.getNumberValue()); + case STRING -> { + try { yield Integer.parseInt(cv.getStringValue().trim()); } + catch (Exception e) { yield null; } + } + case BOOLEAN -> cv.getBooleanValue() ? 1 : 0; + default -> null; + }; + } + + private static BigDecimal evalAsBigDecimal(Cell cell, FormulaEvaluator evaluator) { + if (cell == null) return null; + CellValue cv = evaluator.evaluate(cell); + if (cv == null) return null; + return switch (cv.getCellType()) { + case NUMERIC -> BigDecimal.valueOf(cv.getNumberValue()).setScale(2, RoundingMode.HALF_UP); + case STRING -> { + try { yield new BigDecimal(cv.getStringValue().trim()).setScale(2, RoundingMode.HALF_UP); } + catch (Exception e) { yield null; } + } + default -> null; + }; + } + + // ==== 외부에서 쓰기 쉬운 입력/출력 타입(record) ==== + + /** 사용자가 보낸 과목 입력(라벨=엑셀 B열 텍스트, value=표준점수 또는 등급) */ + public record SubjectInput(String label, Object value) {} + + /** 엑셀 계산 결과(백분위/등급/누적%) */ + public record SubjectResult(Integer percentile, Integer grade, BigDecimal cumulative) {} +} \ No newline at end of file diff --git a/src/main/java/hackerthon/likelion13th/canfly/score/SubjectCategoryMapper.java b/src/main/java/hackerthon/likelion13th/canfly/score/SubjectCategoryMapper.java new file mode 100644 index 0000000..016bda6 --- /dev/null +++ b/src/main/java/hackerthon/likelion13th/canfly/score/SubjectCategoryMapper.java @@ -0,0 +1,44 @@ +package hackerthon.likelion13th.canfly.score; + +import java.util.List; + +import static hackerthon.likelion13th.canfly.score.SubjectRegistry.*; + +public final class SubjectCategoryMapper { + private SubjectCategoryMapper() {} + + /** + * 라벨(=Subject)과 SCIENCE/SOCIAL 선택 순서를 기반으로 엔티티 category 코드(1~7) 계산 + * @param subject 선택된 Subject + * @param indexInSciSoc SCIENCE/SOCIAL 묶음 내 선택 순서(0부터) — 첫 번째=5, 두 번째=6 + */ + public static int toCategoryCode(Subject subject, Integer indexInSciSoc) { + return switch (subject.getGroup()) { + case KOREAN -> 1; + case MATH -> 2; + case ENGLISH -> 3; + case HISTORY -> 4; + case SCIENCE, SOCIAL -> { + // 탐구는 1~2개만 가능(3단계 검증으로 보장). 순서대로 5, 6 부여. + if (indexInSciSoc == null) indexInSciSoc = 0; + yield (indexInSciSoc == 0) ? 5 : 6; + } + case SECOND_LANG -> 7; + }; + } + + /** + * SCIENCE/SOCIAL 안에서 몇 번째인지 반환 (없으면 null) — 5단계에서 저장 시 사용 + */ + public static Integer sciSocIndex(Subject subject, List pickedOrder) { + if (subject.getGroup() != Group.SCIENCE && subject.getGroup() != Group.SOCIAL) return null; + int idx = 0; + for (Subject s : pickedOrder) { + if (s.getGroup() == Group.SCIENCE || s.getGroup() == Group.SOCIAL) { + if (s == subject) return idx; + idx++; + } + } + return null; + } +} diff --git a/src/main/java/hackerthon/likelion13th/canfly/score/SubjectRegistry.java b/src/main/java/hackerthon/likelion13th/canfly/score/SubjectRegistry.java new file mode 100644 index 0000000..9fa7713 --- /dev/null +++ b/src/main/java/hackerthon/likelion13th/canfly/score/SubjectRegistry.java @@ -0,0 +1,107 @@ +package hackerthon.likelion13th.canfly.score; + +import lombok.Getter; + +import java.util.*; +import java.util.stream.Collectors; + +/** 엑셀 B열의 과목 라벨과 입력유형(표준점수/등급), 그룹 정보를 상수화 */ +public final class SubjectRegistry { + + private SubjectRegistry() {} + + public enum InputType { SCORE, GRADE } // C열에 들어갈 값의 타입 + public enum Group { KOREAN, MATH, ENGLISH, HISTORY, SCIENCE, SOCIAL, SECOND_LANG } + + @Getter + public enum Subject { + // 국어/수학 + KOR("국어", Group.KOREAN, InputType.SCORE), + MATH_MI("수학(미적)", Group.MATH, InputType.SCORE), + MATH_GI("수학(기하)", Group.MATH, InputType.SCORE), + MATH_ST("수학(확통)", Group.MATH, InputType.SCORE), + + // 영어/한국사 (등급 입력) + ENG("영어", Group.ENGLISH, InputType.GRADE), + HIST("한국사", Group.HISTORY, InputType.GRADE), + + // 과학탐구 (표준점수) + SCI_PHY1("물리학 Ⅰ", Group.SCIENCE, InputType.SCORE), + SCI_PHY2("물리학 Ⅱ", Group.SCIENCE, InputType.SCORE), + SCI_BIO1("생명과학 Ⅰ", Group.SCIENCE, InputType.SCORE), + SCI_BIO2("생명과학 Ⅱ", Group.SCIENCE, InputType.SCORE), + SCI_EAR1("지구과학 Ⅰ", Group.SCIENCE, InputType.SCORE), + SCI_EAR2("지구과학 Ⅱ", Group.SCIENCE, InputType.SCORE), + SCI_CHE1("화학 Ⅰ", Group.SCIENCE, InputType.SCORE), + SCI_CHE2("화학 Ⅱ", Group.SCIENCE, InputType.SCORE), + + // 사회탐구 (표준점수) + SOC_ECON("경제", Group.SOCIAL, InputType.SCORE), + SOC_EASIA("동아시아사", Group.SOCIAL, InputType.SCORE), + SOC_SOC("사회·문화", Group.SOCIAL, InputType.SCORE), // · (U+00B7) + SOC_ETH("생활과 윤리", Group.SOCIAL, InputType.SCORE), + SOC_WHIS("세계사", Group.SOCIAL, InputType.SCORE), + SOC_GEO("세계지리", Group.SOCIAL, InputType.SCORE), + SOC_PHIL("윤리와 사상", Group.SOCIAL, InputType.SCORE), + SOC_POL("정치와 법", Group.SOCIAL, InputType.SCORE), + SOC_KGEO("한국지리", Group.SOCIAL, InputType.SCORE), + + // 제2외국어 (등급 입력, 선택) + SEC_GER("독일어 Ⅰ", Group.SECOND_LANG, InputType.GRADE), + SEC_RUS("러시아어 Ⅰ", Group.SECOND_LANG, InputType.GRADE), + SEC_VIE("베트남어 Ⅰ", Group.SECOND_LANG, InputType.GRADE), + SEC_SPA("스페인어 Ⅰ", Group.SECOND_LANG, InputType.GRADE), + SEC_ARB("아랍어 Ⅰ", Group.SECOND_LANG, InputType.GRADE), + SEC_JPN("일본어 Ⅰ", Group.SECOND_LANG, InputType.GRADE), + SEC_CHN("중국어 Ⅰ", Group.SECOND_LANG, InputType.GRADE), + SEC_FRE("프랑스어 Ⅰ", Group.SECOND_LANG, InputType.GRADE), + SEC_HAN("한문 Ⅰ", Group.SECOND_LANG, InputType.GRADE); + + private final String label; // 엑셀 B열의 텍스트와 정확히 일치 + private final Group group; + private final InputType inputType; + + Subject(String label, Group group, InputType inputType) { + this.label = label; + this.group = group; + this.inputType = inputType; + } + + public String label() { return label; } + } + + /** label → Subject 빠른 조회용 */ + private static final Map BY_LABEL = Arrays.stream(Subject.values()) + .collect(Collectors.toMap(s -> normalize(s.label()), s -> s, (a,b)->a, LinkedHashMap::new)); + + public static Subject byLabel(String label) { + Subject s = BY_LABEL.get(normalize(label)); + if (s == null) throw new IllegalArgumentException("미등록 과목 라벨: " + label); + return s; + } + + public static boolean existsLabel(String label) { + return BY_LABEL.containsKey(normalize(label)); + } + + /** 수학 3트랙 집합 */ + public static final Set MATH_TRACKS = Set.of(Subject.MATH_MI, Subject.MATH_GI, Subject.MATH_ST); + + /** 입력 타입별 범위 기본값 (필요시 조정) */ + public static boolean isValidScore(Number n) { + if (n == null) return false; + int v = n.intValue(); + return (v >= 0 && v <= 200); // 표준점수 합리적 범위(유연) + } + public static boolean isValidGrade(Number n) { + if (n == null) return false; + int v = n.intValue(); + return (v >= 1 && v <= 9); // 등급 1~9 + } + + /** 공백/비가시문자 정규화 */ + public static String normalize(String s) { + if (s == null) return null; + return s.replace('\u00A0', ' ').trim(); + } +} \ No newline at end of file diff --git a/src/main/java/hackerthon/likelion13th/canfly/score/SubjectSelectionValidator.java b/src/main/java/hackerthon/likelion13th/canfly/score/SubjectSelectionValidator.java new file mode 100644 index 0000000..bc38814 --- /dev/null +++ b/src/main/java/hackerthon/likelion13th/canfly/score/SubjectSelectionValidator.java @@ -0,0 +1,105 @@ +package hackerthon.likelion13th.canfly.score; + +import hackerthon.likelion13th.canfly.score.ScoreExcelEngine.SubjectInput; +import lombok.Getter; +import org.springframework.stereotype.Component; + +import java.util.*; +import java.util.stream.Collectors; + +import static hackerthon.likelion13th.canfly.score.SubjectRegistry.*; + +@Component +public class SubjectSelectionValidator { + + /** 유저 입력(라벨, 값)을 검증/정규화해서 엔진에 넣을 수 있는 리스트로 변환 */ + public ValidatedSelection validateToEngineInputs(List rawInputs) { + if (rawInputs == null) rawInputs = List.of(); + + // 1) 라벨 표준화 및 중복 한 번만 사용 + Map picked = new LinkedHashMap<>(); + for (SubjectInput in : rawInputs) { + if (in == null || in.label() == null) continue; + Subject subj = SubjectRegistry.byLabel(in.label()); + picked.put(subj, in.value()); // 중복 라벨은 마지막 값으로 덮음 + } + + // 2) 값 타입 및 범위 체크 + for (Map.Entry e : picked.entrySet()) { + Subject s = e.getKey(); + Object v = e.getValue(); + if (!(v instanceof Number)) { + throw new IllegalArgumentException("과목 '" + s.label() + "' 값은 숫자여야 합니다. (입력: " + v + ")"); + } + Number n = (Number) v; + if (s.getInputType() == InputType.SCORE && !isValidScore(n)) { + throw new IllegalArgumentException("표준점수 범위 오류 '" + s.label() + "': " + n); + } + if (s.getInputType() == InputType.GRADE && !isValidGrade(n)) { + throw new IllegalArgumentException("등급 범위 오류 '" + s.label() + "': " + n); + } + } + + // 3) 조합 규칙 검증 + // 3-1) 수학 3택1 + long mathCount = picked.keySet().stream().filter(s -> s.getGroup() == Group.MATH).count(); + if (mathCount > 1) { + throw new IllegalArgumentException("수학은 (미적/기하/확통) 중 정확히 1개만 선택 가능합니다. (현재: " + mathCount + ")"); + } + + // 3-2) 과탐/사탐 합산 최대 2개 + List sciSoc = picked.keySet().stream() + .filter(s -> s.getGroup() == Group.SCIENCE || s.getGroup() == Group.SOCIAL) + .collect(Collectors.toList()); + if (sciSoc.size() > 2) { + throw new IllegalArgumentException("과탐/사탐 합산 최대 2과목까지 입력 가능합니다. (현재: " + sciSoc.size() + ")"); + } + + // 3-3) 제2외국어 최대 1개 + long secCount = picked.keySet().stream().filter(s -> s.getGroup() == Group.SECOND_LANG).count(); + if (secCount > 1) { + throw new IllegalArgumentException("제2외국어는 최대 1과목만 입력 가능합니다. (현재: " + secCount + ")"); + } + + // 4) 엔진 입력으로 변환 (입력한 과목만) + List engineInputs = picked.entrySet().stream() + .map(e -> new SubjectInput(e.getKey().label(), e.getValue())) + .collect(Collectors.toList()); + + // 보조 정보(선택/조합 요약)도 함께 리턴하면 이후 저장 단계에서 유용 + SelectionSummary summary = summarize(picked.keySet()); + + return new ValidatedSelection(engineInputs, summary); + } + + private SelectionSummary summarize(Collection picked) { + String math = picked.stream().filter(s -> s.getGroup() == Group.MATH).map(Subject::label).findFirst().orElse(null); + + List sciences = picked.stream().filter(s -> s.getGroup() == Group.SCIENCE).map(Subject::label).toList(); + List socials = picked.stream().filter(s -> s.getGroup() == Group.SOCIAL).map(Subject::label).toList(); + String secondLang = picked.stream().filter(s -> s.getGroup() == Group.SECOND_LANG).map(Subject::label).findFirst().orElse(null); + + return new SelectionSummary(math, sciences, socials, secondLang); + } + + // ---- 반환 타입 ---- + public record ValidatedSelection(List engineInputs, SelectionSummary summary) {} + + @Getter + public static class SelectionSummary { + private final String math; // 예: "수학(미적)" or null + private final List sciences; // 선택된 과탐 라벨(0~2개) + private final List socials; // 선택된 사탐 라벨(0~2개) + private final String secondLang; // 선택된 제2외국어 라벨 or null + + public SelectionSummary(String math, List sciences, List socials, String secondLang) { + this.math = math; + this.sciences = sciences != null ? List.copyOf(sciences) : List.of(); + this.socials = socials != null ? List.copyOf(socials) : List.of(); + this.secondLang = secondLang; + } + + /** 과탐/사탐 합산 개수 */ + public int sciSocCount() { return sciences.size() + socials.size(); } + } +} \ No newline at end of file diff --git a/src/main/resources/excel/score_template.xlsx b/src/main/resources/excel/score_template.xlsx new file mode 100644 index 0000000..6cc47a5 Binary files /dev/null and b/src/main/resources/excel/score_template.xlsx differ