Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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 @@ -23,6 +23,8 @@
import kr.allcll.backend.domain.graduation.certification.GraduationCertRuleRepository;
import kr.allcll.backend.domain.graduation.certification.GraduationCertRuleType;
import kr.allcll.backend.domain.graduation.credit.CategoryType;
import kr.allcll.backend.domain.graduation.credit.CourseEquivalence;
import kr.allcll.backend.domain.graduation.credit.CourseEquivalenceRepository;
import kr.allcll.backend.domain.graduation.credit.CourseReplacement;
import kr.allcll.backend.domain.graduation.credit.CourseReplacementRepository;
import kr.allcll.backend.domain.graduation.credit.CreditCriterion;
Expand All @@ -42,6 +44,7 @@
import kr.allcll.backend.support.sheet.validation.BalanceRequiredRulesSheetValidator;
import kr.allcll.backend.support.sheet.validation.ClassicCertCriteriaSheetValidator;
import kr.allcll.backend.support.sheet.validation.CodingCertCriteriaSheetValidator;
import kr.allcll.backend.support.sheet.validation.CourseEquivalencesSheetValidator;
import kr.allcll.backend.support.sheet.validation.CourseReplacementsSheetValidator;
import kr.allcll.backend.support.sheet.validation.CreditCriteriaSheetValidator;
import kr.allcll.backend.support.sheet.validation.DoubleCreditCriteriaSheetValidator;
Expand All @@ -64,6 +67,7 @@ public class AdminGraduationSyncService {
private final RequiredCourseRepository requiredCourseRepository;
private final CreditCriterionRepository creditCriterionRepository;
private final GraduationSheetProperties graduationSheetProperties;
private final CourseEquivalenceRepository courseEquivalenceRepository;
private final CourseReplacementRepository courseReplacementRepository;
private final GraduationCertRuleRepository graduationCertRuleRepository;
private final BalanceRequiredRuleRepository balanceRequiredRuleRepository;
Expand All @@ -83,6 +87,7 @@ public void syncGraduationRules() {
syncDoubleCreditCriteria();
syncRequiredCourses();
syncCourseReplacements();
syncCourseEquivalences();

syncBalanceRequiredRule();
syncBalanceRequiredCourseAreaMap();
Expand Down Expand Up @@ -179,6 +184,7 @@ private void syncRequiredCourses() {
graduationSheetTable.getString(row, "curi_no"),
graduationSheetTable.getString(row, "curi_nm"),
graduationSheetTable.getString(row, "alt_group"),
graduationSheetTable.getString(row, "group_code"),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject required-course rows missing group_code

This sync now persists group_code and the new findRequiredCourseInGroup logic depends on it, but required-courses validation still does not require that column/value; if the sheet is not updated (or cells are blank), rows are saved with null group codes and group-based matching always misses, causing equivalent/replacement academic-basic courses to be incorrectly treated as not required instead of failing fast during sync.

Useful? React with 👍 / 👎.

graduationSheetTable.getBoolean(row, "required"),
graduationSheetTable.getString(row, "note")
);
Expand Down Expand Up @@ -215,6 +221,25 @@ private void syncCourseReplacements() {
log.info("[졸업요건 데이터 동기화] 탭 이름={}, {}개 저장 완료", tabKey, courseReplacementList.size());
}

private void syncCourseEquivalences() {
String tabKey = CourseEquivalencesSheetValidator.TAB_KEY;
GraduationSheetTable graduationSheetTable = fetchAndValidate(tabKey);

List<CourseEquivalence> courseEquivalenceList = new ArrayList<>();
for (List<Object> row : graduationSheetTable.getDataRows()) {
CourseEquivalence courseEquivalence = new CourseEquivalence(
graduationSheetTable.getString(row, "group_code"),
graduationSheetTable.getString(row, "curi_no"),
graduationSheetTable.getString(row, "curi_nm")
);
courseEquivalenceList.add(courseEquivalence);
}

courseEquivalenceRepository.deleteAllInBatch();
courseEquivalenceRepository.saveAll(courseEquivalenceList);

log.info("[졸업요건 데이터 동기화] 탭 이름={}, {}개 저장 완료", tabKey, courseEquivalenceList.size());
}

private void syncBalanceRequiredRule() {
String tabKey = BalanceRequiredRulesSheetValidator.TAB_KEY;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@
public class AcademicBasicPolicy {

private final RequiredCourseResolver requiredCourseResolver;
private final CourseReplacementRepository courseReplacementRepository;
private final CourseEquivalenceRepository courseEquivalenceRepository;

public boolean isRecentMajorAcademicBasic(CompletedCourse course, CreditCriterion criterion) {
if (isNotAcademicBasic(course)) {
return true;
}
String courseName = course.getCuriNm();
String curiNm = course.getCuriNm();
String curiNo = course.getCuriNo();
Integer admissionYear = criterion.getAdmissionYear();
String departmentName = criterion.getDeptNm();
List<String> academicBasicRequiredCourseNames = requiredCourseResolver.findRequiredCourseNames(
Expand All @@ -25,44 +26,33 @@ public boolean isRecentMajorAcademicBasic(CompletedCourse course, CreditCriterio
CategoryType.ACADEMIC_BASIC
);

if (isExistAcademicBasicCourse(academicBasicRequiredCourseNames, courseName)) {
if (isExistAcademicBasicCourse(academicBasicRequiredCourseNames, curiNm)) {
return true;
}

return isHaveReplaceCourse(academicBasicRequiredCourseNames, admissionYear, courseName);
return isHaveReplaceOrEquivalenceCourse(admissionYear, departmentName, curiNo);
}

private boolean isExistAcademicBasicCourse(List<String> academicBasicRequiredCourseNames, String courseName) {
return academicBasicRequiredCourseNames.contains(courseName);
}

/*
대체된 최신 과목을 들었을 경우를 판별한다.
대체된 최신 과목이 없는 경우 false를 반환한다.
대체 과목의 예전 과목 명이, 학생의 이수 요건에 없으면 false를 반환한다.
*/
private boolean isHaveReplaceCourse(
List<String> academicBasicRequiredCourseNames,
Integer admissionYear,
String courseName
) {
List<CourseReplacement> recentCourse = courseReplacementRepository.findRecentCourse(admissionYear, courseName);
if (recentCourse.isEmpty()) {
return false;
}
for (CourseReplacement courseReplacement : recentCourse) {
if (academicBasicRequiredCourseNames.contains(courseReplacement.getLegacyCuriNm())) {
return true;
}
}
return false;
}

private boolean isNotAcademicBasic(CompletedCourse course) {
return !isAcademicBasic(course);
return !CategoryType.ACADEMIC_BASIC.equals(course.getCategoryType());
}

private boolean isAcademicBasic(CompletedCourse course) {
return CategoryType.ACADEMIC_BASIC.equals(course.getCategoryType());
private boolean isHaveReplaceOrEquivalenceCourse(
Integer admissionYear,
String departmentName,
String curiNo
) {
return courseEquivalenceRepository.findGroupCodeByCuriNo(curiNo)
.map(groupCode -> requiredCourseResolver.findRequiredCourseInGroup(
departmentName,
admissionYear,
CategoryType.ACADEMIC_BASIC,
groupCode
))
.orElse(false);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package kr.allcll.backend.domain.graduation.credit;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import kr.allcll.backend.support.entity.BaseEntity;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Table(name = "course_equivalences")
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class CourseEquivalence extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(name = "group_code", nullable = false)
private String groupCode; // 동일과목 그룹 번호

@Column(name = "curi_no", nullable = false)
private String curiNo; // 학수번호

@Column(name = "curi_nm", nullable = false, length = 255)
private String curiNm; // 과목명

public CourseEquivalence(String groupCode, String curiNo, String curiNm) {
this.groupCode = groupCode;
this.curiNo = curiNo;
this.curiNm = curiNm;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package kr.allcll.backend.domain.graduation.credit;

import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface CourseEquivalenceRepository extends JpaRepository<CourseEquivalence, Long> {

@Query("""
select e.groupCode from CourseEquivalence e
where e.curiNo = :curiNo
""")
Optional<String> findGroupCodeByCuriNo(String curiNo);
Comment on lines +19 to +22
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce unique curi_no when reading equivalence group

findGroupCodeByCuriNo assumes a single row, but course_equivalences is populated from a sheet that is only string-validated (no uniqueness check on curi_no), so duplicate rows for the same course code will make this query return multiple results and throw IncorrectResultSizeDataAccessException at runtime during academic-basic evaluation. This should either be made tolerant (distinct/first) or guarded by validation/constraints so one bad sheet row does not break graduation checks.

Useful? React with 👍 / 👎.

}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ public class RequiredCourse extends BaseEntity {
@Column(name = "alt_group")
private String altGroup; // 선택 과목 그룹 키

@Column(name = "group_code")
private String groupCode; // 동일 및 대체 과목 그룹 코드

@Column(name = "required", nullable = false)
private Boolean required; // 검사 대상 여부

Expand All @@ -63,6 +66,7 @@ public RequiredCourse(
String curiNo,
String curiNm,
String altGroup,
String groupCode,
Boolean required,
String note
) {
Expand All @@ -74,6 +78,7 @@ public RequiredCourse(
this.curiNo = curiNo;
this.curiNm = curiNm;
this.altGroup = altGroup;
this.groupCode = groupCode;
this.required = required;
this.note = note;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,18 @@ public interface RequiredCourseRepository extends JpaRepository<RequiredCourse,
and r.categoryType = :categoryType
""")
List<RequiredCourse> findRequiredCourses(List<String> deptNms, Integer admissionYear, CategoryType categoryType);

@Query("""
select r from RequiredCourse r
where r.deptNm in :deptNms
and r.admissionYear = :admissionYear
and r.categoryType = :categoryType
and r.groupCode = :groupCode
""")
List<RequiredCourse> findRequiredCoursesByGroupCode(
List<String> deptNms,
Integer admissionYear,
CategoryType categoryType,
String groupCode
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,27 @@ public class RequiredCourseResolver {
private static final String WILD_CARD_DEPT_NM = "ALL";
private final RequiredCourseRepository requiredCourseRepository;

public boolean findRequiredCourseInGroup(
String departmentName,
Integer admissionYear,
CategoryType categoryType,
String groupCode
) {
List<RequiredCourse> requiredCourseCandidatesWithWildCard =
requiredCourseRepository.findRequiredCoursesByGroupCode(
List.of(WILD_CARD_DEPT_NM, departmentName),
admissionYear,
categoryType,
groupCode
);
Comment on lines +27 to +32
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저희 요거 WILD_CARD인데 FALSE면 제외해줘야하지 않나요...???? wild card면 대상인 것으로 조회하는 것 같은데 맞을까요?!

Copy link
Copy Markdown
Member Author

@goldm0ng goldm0ng Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵! 맞습니다!

public boolean findRequiredCourseInGroup(
        String departmentName,
        Integer admissionYear,
        CategoryType categoryType,
        String groupCode
    ) {
        List<RequiredCourse> requiredCourseCandidatesWithWildCard =
            requiredCourseRepository.findRequiredCoursesByGroupCode(
                List.of(WILD_CARD_DEPT_NM, departmentName),
                admissionYear,
                categoryType,
                groupCode
            );

        List<RequiredCourse> requiredCoursesWithStatus
            = getDepartmentRequiredCourses(requiredCourseCandidatesWithWildCard, departmentName);

        return requiredCoursesWithStatus.stream()
            .anyMatch(RequiredCourse::getRequired);
    }

우선, 결론부터 말씀드리면 와일드카드 및 사용자 학과로 기준 데이터를 모두 조회한 뒤,
DB 적재 정책을 적용한 메서드인 getDepartmentRequiredCourses를 거쳐
최종적으로 return 부에서 false를 필터링 해주게 됩니다!

위 메서드에서 해당 쿼리가 실행되는데, 쿼리에서 제외하지 않은 이유는 RequiredCourses에 필요한 DB 적재 정책을 적용하기 위함입니다!
해당 DB 적재 정책은 이미 구현되어 있던 getDepartmentRequiredCourses 요놈을 재사용했습니다! (기존 로직에서 사용 중이었던 아이입니당)

예전에 RepositoryResolver의 계층 분리를 통해, 졸업 인증 기준 데이터 조회 시 안정성을 확보하자는 논의가 이루어진 바 있습니다! 졸업인증 기준 데이터는 사람이 직접 적재했다보니 비즈니스 로직에서 Repository 계층을 바로 사용하게 되면 적재 정책이나 예외 케이스가 누락될 위험이 존재합니다!
따라서, 와일드카드 학과 데이터와 실제 학과 데이터를 함께 조회한 뒤, getDepartmentRequiredCourses를 통해 기존 적재 정책을 반영한 최종 결과를 만든 후 판단하도록 했습니당

혹시 추가적으로 왜 와일드카드와 학과를 모두 조회하고 getDepartmentRequiredCourses메서드를 실행하는지 더 궁금하시다면 코멘트 남겨주세요! 예시 들어서 더 설명드리겠슴니다!


List<RequiredCourse> requiredCoursesWithStatus
= getDepartmentRequiredCourses(requiredCourseCandidatesWithWildCard, departmentName);

return requiredCoursesWithStatus.stream()
.anyMatch(RequiredCourse::getRequired);
}

public List<String> findRequiredCourseNames(
String departmentName,
Integer admissionYear,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package kr.allcll.backend.support.sheet.validation;

import java.util.List;
import kr.allcll.backend.support.sheet.GraduationSheetTable;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class CourseEquivalencesSheetValidator implements GraduationSheetValidator {

public static final String TAB_KEY = "course-equivalences";

private static final List<String> REQUIRED_HEADERS = List.of(
"group_code",
"curi_no",
"curi_nm"
);

private final GraduationSheetValidationSupport graduationSheetValidationSupport;

@Override
public String tabKey() {
return TAB_KEY;
}

@Override
public void validate(GraduationSheetTable sheetTable) {
graduationSheetValidationSupport.validateNotEmpty(TAB_KEY, sheetTable);
graduationSheetValidationSupport.validateRequiredHeaders(TAB_KEY, sheetTable, REQUIRED_HEADERS);

List<List<Object>> dataRows = sheetTable.getDataRows();
for (int rowIndex = 0; rowIndex < dataRows.size(); rowIndex++) {
List<Object> dataRow = dataRows.get(rowIndex);

graduationSheetValidationSupport.requireString(TAB_KEY, sheetTable, dataRow, rowIndex, "group_code");
graduationSheetValidationSupport.requireString(TAB_KEY, sheetTable, dataRow, rowIndex, "curi_no");
graduationSheetValidationSupport.requireString(TAB_KEY, sheetTable, dataRow, rowIndex, "curi_nm");
}
}
}
Loading
Loading