diff --git a/BUILD.bazel b/BUILD.bazel index 03d2acbb..c1e7d463 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -1,4 +1,4 @@ -load("//:kotlin.bzl", "setup_kotlin_compiler", "setup_spring_allopen_plugin") +load("//:kotlin.bzl", "setup_jpa_noarg_plugin", "setup_kotlin_compiler", "setup_spring_allopen_plugin") package(default_visibility = ["//visibility:public"]) @@ -6,6 +6,8 @@ setup_kotlin_compiler() setup_spring_allopen_plugin() +setup_jpa_noarg_plugin() + alias( name = "admin", actual = "//systems/admin/admin-bootstrap:main", diff --git a/kotlin.MODULE.bazel b/kotlin.MODULE.bazel index be80cb30..041d9c01 100644 --- a/kotlin.MODULE.bazel +++ b/kotlin.MODULE.bazel @@ -12,6 +12,9 @@ maven.artifact(artifact = "testcontainers", group = "org.testcontainers", versio maven.artifact(artifact = "kotlin-reflect", group = "org.jetbrains.kotlin", version = "2.1.0") maven.artifact(artifact = "kotlin-allopen-compiler-plugin", group = "org.jetbrains.kotlin", version = "2.1.0") maven.artifact(artifact = "jackson-module-kotlin", group = "com.fasterxml.jackson.module", version = "2.18.2") + +# Spring Boot 4는 Jackson 3(tools.jackson)을 쓴다. Kotlin 기본값 파라미터를 읽으려면 3.x 모듈이 필요하다. +maven.artifact(artifact = "jackson-module-kotlin", group = "tools.jackson.module", version = "3.0.3") maven.artifact(artifact = "jjwt-api", group = "io.jsonwebtoken", version = "0.13.0") maven.artifact(artifact = "jjwt-impl", group = "io.jsonwebtoken", version = "0.13.0") maven.artifact(artifact = "jjwt-jackson", group = "io.jsonwebtoken", version = "0.13.0") @@ -22,6 +25,13 @@ maven.artifact(artifact = "spring-boot-starter-data-redis", group = "org.springf maven.artifact(artifact = "spring-boot-starter", group = "org.springframework.boot", version = "4.0.1") maven.artifact(artifact = "mysql-connector-j", group = "com.mysql", version = "9.3.0") +# PDF +maven.artifact(artifact = "openhtmltopdf-core", group = "io.github.openhtmltopdf", version = "1.1.73") +maven.artifact(artifact = "openhtmltopdf-pdfbox", group = "io.github.openhtmltopdf", version = "1.1.73") + +# Object Storage +maven.artifact(artifact = "s3", group = "software.amazon.awssdk", version = "2.53.1") + # gRPC maven.artifact(artifact = "grpc-netty-shaded", group = "io.grpc", version = "1.70.0") maven.artifact(artifact = "grpc-protobuf", group = "io.grpc", version = "1.70.0") diff --git a/kotlin.bzl b/kotlin.bzl index 0df77007..0e5ac92d 100644 --- a/kotlin.bzl +++ b/kotlin.bzl @@ -13,3 +13,12 @@ def setup_spring_allopen_plugin(): options = {"preset": "spring"}, deps = ["@rules_kotlin//kotlin/compiler:allopen-compiler-plugin"], ) + +# JPA 엔티티는 인자 없는 생성자를 요구한다. Kotlin 클래스에는 없으므로 컴파일 시 만들어 준다. +def setup_jpa_noarg_plugin(): + kt_compiler_plugin( + name = "jpa_noarg", + id = "org.jetbrains.kotlin.noarg", + options = {"preset": "jpa"}, + deps = ["@rules_kotlin//kotlin/compiler:noarg-compiler-plugin"], + ) diff --git a/systems/admin/admin-adapter-in/deps.bzl b/systems/admin/admin-adapter-in/deps.bzl index 5cf03643..51895c85 100644 --- a/systems/admin/admin-adapter-in/deps.bzl +++ b/systems/admin/admin-adapter-in/deps.bzl @@ -1,4 +1,10 @@ -KOTLIN_DEPS = [] +KOTLIN_DEPS = [ + "@maven//:org_springframework_boot_spring_boot_starter_web", + "@maven//:org_springframework_boot_spring_boot_starter_validation", + "@maven//:com_fasterxml_jackson_core_jackson_annotations", + "//systems/admin/admin-application:main", + "//systems/admin/admin-domain:main", +] TEST_DEPS = [ "@maven//:junit_junit", diff --git a/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/AdminAuthorizationInterceptor.kt b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/AdminAuthorizationInterceptor.kt new file mode 100644 index 00000000..c6c76ceb --- /dev/null +++ b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/AdminAuthorizationInterceptor.kt @@ -0,0 +1,44 @@ +package hs.kr.entrydsm.admin.adapterin.web + +import hs.kr.entrydsm.admin.domain.enum.ErrorCode +import hs.kr.entrydsm.admin.domain.exception.AdminDomainException +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.stereotype.Component +import org.springframework.web.servlet.HandlerInterceptor + +const val USER_ID_HEADER = "X-User-Id" +const val USER_ROLE_HEADER = "X-User-Role" +private const val ADMIN_ROLE = "ADMIN" + +/** + * 관리자 권한을 확인합니다. + * + * 공통 규약 7항대로 인증은 Gateway 또는 Identity가 처리하고, 이 서비스는 그들이 넣어 준 + * 헤더만 신뢰합니다. 클라이언트가 직접 넣은 헤더가 그대로 들어오지 않도록 Gateway에서 + * 반드시 덮어써야 합니다. + * + * ponytail: Gateway가 없는 동안은 이 헤더가 유일한 관문이다. Gateway가 붙으면 + * 그쪽 JWT 검증으로 옮기고 여기서는 역할 확인만 남긴다. + */ +@Component +class AdminAuthorizationInterceptor : HandlerInterceptor { + + override fun preHandle( + request: HttpServletRequest, + response: HttpServletResponse, + handler: Any, + ): Boolean { + val userId = request.getHeader(USER_ID_HEADER) + val role = request.getHeader(USER_ROLE_HEADER) + + if (userId.isNullOrBlank() || role.isNullOrBlank()) { + throw AdminDomainException(ErrorCode.AUTH_UNAUTHORIZED) + } + if (role != ADMIN_ROLE) { + throw AdminDomainException(ErrorCode.ACCESS_DENIED) + } + + return true + } +} diff --git a/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/AdminEndpointPaths.kt b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/AdminEndpointPaths.kt new file mode 100644 index 00000000..5ca0c396 --- /dev/null +++ b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/AdminEndpointPaths.kt @@ -0,0 +1,28 @@ +package hs.kr.entrydsm.admin.adapterin.web + +/** + * 관리자 API 경로 상수입니다. + * + * Notion 명세의 경로를 그대로 쓰되, 명세에 있던 오타(슬래시 중복, `damin`)와 + * 수험표만 `v1`이던 버전 불일치는 바로잡았습니다. + */ +object AdminEndpointPaths { + const val BASE = "/api/v11/admin" + + const val APPLICANTS = "$BASE/applicants" + const val APPLICANT = "$APPLICANTS/{applicantId}" + const val APPLICANT_ARRIVAL = "$APPLICANT/arrival" + const val APPLICANT_STATUS = "$APPLICANT/status" + const val APPLICANT_ADMISSION_TICKET = "$APPLICANT/admission-ticket" + const val APPLICANT_APPLICATION_DOCUMENT = "$APPLICANT/application-document" + + const val EXAMINEE_NUMBER_ISSUE = "$BASE/examinee-numbers/issue" + const val SCORE_POLICY = "$BASE/score-policy" + const val FIRST_SCREENING_RESULTS = "$BASE/screenings/first/results" + const val FINAL_SCREENING_RESULTS = "$BASE/screenings/final/results" + const val STATISTICS = "$BASE/statistics" + const val EXPORTS = "$BASE/exports" + const val EXPORT = "$EXPORTS/{exportJobId}" + const val NOTICES = "$BASE/notices" + const val QUESTION_ANSWERS = "$BASE/questions/{questionId}/answers" +} diff --git a/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/ApplicantController.kt b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/ApplicantController.kt new file mode 100644 index 00000000..7d9dcfec --- /dev/null +++ b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/ApplicantController.kt @@ -0,0 +1,134 @@ +package hs.kr.entrydsm.admin.adapterin.web + +import hs.kr.entrydsm.admin.adapterin.web.dto.common.ApiResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.common.toDetailResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.common.toResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.common.toSummaryResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.request.UpdateApplicantStatusRequest +import hs.kr.entrydsm.admin.adapterin.web.dto.request.UpdateArrivalRequest +import hs.kr.entrydsm.admin.adapterin.web.dto.response.ApplicantDetailResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.response.ApplicantSummaryResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.response.DownloadResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.response.ExamineeNumberIssueResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.response.PageResponse +import hs.kr.entrydsm.admin.domain.command.UpdateApplicantStatusCommand +import hs.kr.entrydsm.admin.domain.command.UpdateArrivalCommand +import hs.kr.entrydsm.admin.domain.enum.AdmissionType +import hs.kr.entrydsm.admin.domain.enum.ApplicantStatus +import hs.kr.entrydsm.admin.domain.enum.GraduationStatus +import hs.kr.entrydsm.admin.domain.enum.Region +import hs.kr.entrydsm.admin.domain.model.ApplicantFilter +import hs.kr.entrydsm.admin.domain.model.PageRequest +import hs.kr.entrydsm.admin.domain.port.`in`.IssueAdmissionTicketUseCase +import hs.kr.entrydsm.admin.domain.port.`in`.IssueApplicationDocumentUseCase +import hs.kr.entrydsm.admin.domain.port.`in`.IssueExamineeNumberUseCase +import hs.kr.entrydsm.admin.domain.port.`in`.ReadApplicantUseCase +import hs.kr.entrydsm.admin.domain.port.`in`.UpdateApplicantUseCase +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +class ApplicantController( + private val readApplicantUseCase: ReadApplicantUseCase, + private val updateApplicantUseCase: UpdateApplicantUseCase, + private val issueExamineeNumberUseCase: IssueExamineeNumberUseCase, + private val issueAdmissionTicketUseCase: IssueAdmissionTicketUseCase, + private val issueApplicationDocumentUseCase: IssueApplicationDocumentUseCase, +) { + + @GetMapping(AdminEndpointPaths.APPLICANTS) + fun search( + @RequestParam(required = false) keyword: String?, + @RequestParam(required = false) regions: Set?, + @RequestParam(required = false) admissionTypes: Set?, + @RequestParam(required = false) graduationStatuses: Set?, + @RequestParam(required = false) isSubmitted: Boolean?, + @RequestParam(required = false) statuses: Set?, + @RequestParam(defaultValue = "1") page: Int, + @RequestParam(defaultValue = "10") size: Int, + ): ResponseEntity>> { + val result = readApplicantUseCase.search( + ApplicantFilter( + keyword = keyword, + regions = regions.orEmpty(), + admissionTypes = admissionTypes.orEmpty(), + graduationStatuses = graduationStatuses.orEmpty(), + isSubmitted = isSubmitted, + statuses = statuses.orEmpty(), + ), + PageRequest(page = page, size = size), + ) + + return ResponseEntity.ok( + ApiResponse(data = result.toResponse { it.toSummaryResponse() }), + ) + } + + @GetMapping(AdminEndpointPaths.APPLICANT) + fun findById( + @PathVariable applicantId: Long, + ): ResponseEntity> = + ResponseEntity.ok( + ApiResponse(data = readApplicantUseCase.findById(applicantId).toDetailResponse()), + ) + + @PatchMapping(AdminEndpointPaths.APPLICANT_ARRIVAL) + fun updateArrival( + @PathVariable applicantId: Long, + @Valid @RequestBody request: UpdateArrivalRequest, + ): ResponseEntity { + updateApplicantUseCase.updateArrival( + UpdateArrivalCommand(applicantId = applicantId, isSubmitted = request.isSubmitted!!), + ) + return ResponseEntity.noContent().build() + } + + @PatchMapping(AdminEndpointPaths.APPLICANT_STATUS) + fun updateStatus( + @PathVariable applicantId: Long, + @Valid @RequestBody request: UpdateApplicantStatusRequest, + ): ResponseEntity { + updateApplicantUseCase.updateStatus( + UpdateApplicantStatusCommand( + applicantId = applicantId, + status = request.status!!, + force = request.force, + reason = request.reason, + ), + ) + return ResponseEntity.noContent().build() + } + + @PostMapping(AdminEndpointPaths.EXAMINEE_NUMBER_ISSUE) + fun issueExamineeNumbers(): ResponseEntity> = + ResponseEntity.ok(ApiResponse(data = issueExamineeNumberUseCase.issueAll().toResponse())) + + @GetMapping(AdminEndpointPaths.APPLICANT_ADMISSION_TICKET) + fun issueAdmissionTicket( + @PathVariable applicantId: Long, + ): ResponseEntity> = + ResponseEntity.ok( + ApiResponse( + data = issueAdmissionTicketUseCase.issueAdmissionTicket(applicantId).toResponse(), + ), + ) + + @GetMapping(AdminEndpointPaths.APPLICANT_APPLICATION_DOCUMENT) + fun issueApplicationDocument( + @PathVariable applicantId: Long, + ): ResponseEntity> = + ResponseEntity.ok( + ApiResponse( + data = issueApplicationDocumentUseCase + .issueApplicationDocument(applicantId) + .toResponse(), + ), + ) +} diff --git a/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/ScreeningController.kt b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/ScreeningController.kt new file mode 100644 index 00000000..01a84354 --- /dev/null +++ b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/ScreeningController.kt @@ -0,0 +1,92 @@ +package hs.kr.entrydsm.admin.adapterin.web + +import hs.kr.entrydsm.admin.adapterin.web.dto.common.ApiResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.common.toResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.request.EvaluateScreeningRequest +import hs.kr.entrydsm.admin.adapterin.web.dto.request.UpdateScorePolicyRequest +import hs.kr.entrydsm.admin.adapterin.web.dto.response.ScorePolicyResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.response.ScreeningResultResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.response.StatisticsResponse +import hs.kr.entrydsm.admin.domain.command.EvaluateScreeningCommand +import hs.kr.entrydsm.admin.domain.command.UpdateScorePolicyCommand +import hs.kr.entrydsm.admin.domain.enum.StatisticsMetric +import hs.kr.entrydsm.admin.domain.model.ScoreWeights +import hs.kr.entrydsm.admin.domain.port.`in`.EvaluateFinalScreeningUseCase +import hs.kr.entrydsm.admin.domain.port.`in`.EvaluateFirstScreeningUseCase +import hs.kr.entrydsm.admin.domain.port.`in`.ReadScorePolicyUseCase +import hs.kr.entrydsm.admin.domain.port.`in`.ReadStatisticsUseCase +import hs.kr.entrydsm.admin.domain.port.`in`.UpdateScorePolicyUseCase +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +class ScreeningController( + private val readScorePolicyUseCase: ReadScorePolicyUseCase, + private val updateScorePolicyUseCase: UpdateScorePolicyUseCase, + private val evaluateFirstScreeningUseCase: EvaluateFirstScreeningUseCase, + private val evaluateFinalScreeningUseCase: EvaluateFinalScreeningUseCase, + private val readStatisticsUseCase: ReadStatisticsUseCase, +) { + + @GetMapping(AdminEndpointPaths.SCORE_POLICY) + fun findCurrentPolicy(): ResponseEntity> = + ResponseEntity.ok(ApiResponse(data = readScorePolicyUseCase.findCurrent().toResponse())) + + @PutMapping(AdminEndpointPaths.SCORE_POLICY) + fun updatePolicy( + @RequestHeader(USER_ID_HEADER) userId: String, + @Valid @RequestBody request: UpdateScorePolicyRequest, + ): ResponseEntity { + val weights = request.weights!! + updateScorePolicyUseCase.update( + UpdateScorePolicyCommand( + weights = ScoreWeights( + subject = weights.subject!!, + attendance = weights.attendance!!, + volunteer = weights.volunteer!!, + ), + roundingScale = request.roundingScale!!, + recalculate = request.recalculate, + updatedBy = userId, + ), + ) + return ResponseEntity.accepted().build() + } + + @PostMapping(AdminEndpointPaths.FIRST_SCREENING_RESULTS) + fun evaluateFirst( + @RequestBody(required = false) request: EvaluateScreeningRequest?, + ): ResponseEntity> = + ResponseEntity.ok( + ApiResponse( + data = evaluateFirstScreeningUseCase + .evaluateFirst(EvaluateScreeningCommand(dryRun = request?.dryRun ?: false)) + .toResponse(), + ), + ) + + @PostMapping(AdminEndpointPaths.FINAL_SCREENING_RESULTS) + fun evaluateFinal( + @RequestBody(required = false) request: EvaluateScreeningRequest?, + ): ResponseEntity> = + ResponseEntity.ok( + ApiResponse( + data = evaluateFinalScreeningUseCase + .evaluateFinal(EvaluateScreeningCommand(dryRun = request?.dryRun ?: false)) + .toResponse(), + ), + ) + + @GetMapping(AdminEndpointPaths.STATISTICS) + fun statistics( + @RequestParam metrics: Set, + ): ResponseEntity> = + ResponseEntity.ok(ApiResponse(data = readStatisticsUseCase.collect(metrics).toResponse())) +} diff --git a/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/SupportController.kt b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/SupportController.kt new file mode 100644 index 00000000..c52e4d2c --- /dev/null +++ b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/SupportController.kt @@ -0,0 +1,90 @@ +package hs.kr.entrydsm.admin.adapterin.web + +import hs.kr.entrydsm.admin.adapterin.web.dto.common.ApiResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.common.toCreateResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.common.toResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.request.AnswerQuestionRequest +import hs.kr.entrydsm.admin.adapterin.web.dto.request.CreateExportRequest +import hs.kr.entrydsm.admin.adapterin.web.dto.request.CreateNoticeRequest +import hs.kr.entrydsm.admin.adapterin.web.dto.response.CreateExportResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.response.ExportJobResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.response.NoticeResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.response.QuestionAnswerResponse +import hs.kr.entrydsm.admin.domain.command.AnswerQuestionCommand +import hs.kr.entrydsm.admin.domain.command.CreateExportCommand +import hs.kr.entrydsm.admin.domain.command.CreateNoticeCommand +import hs.kr.entrydsm.admin.domain.model.ApplicantFilter +import hs.kr.entrydsm.admin.domain.port.`in`.AnswerQuestionUseCase +import hs.kr.entrydsm.admin.domain.port.`in`.CreateExportUseCase +import hs.kr.entrydsm.admin.domain.port.`in`.CreateNoticeUseCase +import hs.kr.entrydsm.admin.domain.port.`in`.ReadExportUseCase +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RestController + +@RestController +class SupportController( + private val createExportUseCase: CreateExportUseCase, + private val readExportUseCase: ReadExportUseCase, + private val createNoticeUseCase: CreateNoticeUseCase, + private val answerQuestionUseCase: AnswerQuestionUseCase, +) { + + @PostMapping(AdminEndpointPaths.EXPORTS) + fun createExport( + @Valid @RequestBody request: CreateExportRequest, + ): ResponseEntity> { + val job = createExportUseCase.create( + CreateExportCommand( + type = request.type!!, + filter = ApplicantFilter( + admissionTypes = request.filter?.admissionTypes.orEmpty(), + statuses = request.filter?.statuses.orEmpty(), + ), + ), + ) + return ResponseEntity.accepted().body(ApiResponse(data = job.toCreateResponse())) + } + + @GetMapping(AdminEndpointPaths.EXPORT) + fun findExport( + @PathVariable exportJobId: String, + ): ResponseEntity> = + ResponseEntity.ok(ApiResponse(data = readExportUseCase.findById(exportJobId).toResponse())) + + @PostMapping(AdminEndpointPaths.NOTICES) + fun createNotice( + @Valid @RequestBody request: CreateNoticeRequest, + ): ResponseEntity> { + val notice = createNoticeUseCase.create( + CreateNoticeCommand( + title = request.title, + content = request.content, + isPinned = request.isPinned, + attachmentIds = request.attachmentIds, + ), + ) + return ResponseEntity.status(201).body(ApiResponse(data = notice.toResponse())) + } + + @PostMapping(AdminEndpointPaths.QUESTION_ANSWERS) + fun answerQuestion( + @PathVariable questionId: Long, + @RequestHeader(USER_ID_HEADER) userId: String, + @Valid @RequestBody request: AnswerQuestionRequest, + ): ResponseEntity> { + val answer = answerQuestionUseCase.answer( + AnswerQuestionCommand( + questionId = questionId, + content = request.content, + answeredBy = userId, + ), + ) + return ResponseEntity.status(201).body(ApiResponse(data = answer.toResponse())) + } +} diff --git a/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/dto/common/AdminResponseMapper.kt b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/dto/common/AdminResponseMapper.kt new file mode 100644 index 00000000..1a0e18e2 --- /dev/null +++ b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/dto/common/AdminResponseMapper.kt @@ -0,0 +1,109 @@ +package hs.kr.entrydsm.admin.adapterin.web.dto.common + +import hs.kr.entrydsm.admin.adapterin.web.dto.response.CreateExportResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.response.ExportJobResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.response.NoticeResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.response.QuestionAnswerResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.response.ScorePolicyResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.response.ScoreWeightsResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.response.ScreeningResultResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.response.StatisticsResponse +import hs.kr.entrydsm.admin.domain.enum.StatisticsMetric +import hs.kr.entrydsm.admin.domain.model.ApplicantStatistics +import hs.kr.entrydsm.admin.domain.model.ExportJob +import hs.kr.entrydsm.admin.domain.model.ExportJobView +import hs.kr.entrydsm.admin.domain.model.Notice +import hs.kr.entrydsm.admin.domain.model.QuestionAnswer +import hs.kr.entrydsm.admin.domain.model.ScorePolicy +import hs.kr.entrydsm.admin.domain.model.ScreeningResult + +fun ScorePolicy.toResponse(): ScorePolicyResponse = ScorePolicyResponse( + policyVersion = policyVersion, + weights = ScoreWeightsResponse( + subject = weights.subject, + attendance = weights.attendance, + volunteer = weights.volunteer, + ), + roundingScale = roundingScale, + effectiveFrom = effectiveFrom, + updatedBy = updatedBy, +) + +fun ScreeningResult.toResponse(): ScreeningResultResponse = ScreeningResultResponse( + dryRun = dryRun, + passCount = passCount, + failCount = failCount, + excludedCount = excludedCount, + processedAt = processedAt, +) + +/** + * 요청한 지표만 담아 명세의 `metrics` 맵 형태로 만듭니다. + */ +fun ApplicantStatistics.toResponse(): StatisticsResponse = StatisticsResponse( + generatedAt = generatedAt, + metrics = buildMap { + applicantCount?.let { + put( + StatisticsMetric.APPLICANT_COUNT.name, + mapOf( + "total" to it.total, + "byType" to it.byType.mapKeys { (type, _) -> type.name }, + ), + ) + } + competitionRate?.let { + put( + StatisticsMetric.COMPETITION_RATE.name, + it.mapKeys { (type, _) -> type.name }, + ) + } + regionDistribution?.let { + put( + StatisticsMetric.REGION_DISTRIBUTION.name, + it.mapKeys { (region, _) -> region.name }, + ) + } + typeDistribution?.let { + put( + StatisticsMetric.TYPE_DISTRIBUTION.name, + it.mapKeys { (type, _) -> type.name }, + ) + } + dailyTrend?.let { points -> + put( + StatisticsMetric.DAILY_TREND.name, + points.map { mapOf("date" to it.date.toString(), "count" to it.count) }, + ) + } + }, +) + +fun ExportJob.toCreateResponse(): CreateExportResponse = CreateExportResponse( + exportJobId = exportJobId, + status = status, +) + +fun ExportJobView.toResponse(): ExportJobResponse = ExportJobResponse( + exportJobId = job.exportJobId, + type = job.type, + status = job.status, + downloadUrl = download?.downloadUrl, + expiresAt = download?.expiresAt, + createdAt = job.createdAt, + completedAt = job.completedAt, +) + +fun Notice.toResponse(): NoticeResponse = NoticeResponse( + noticeId = id, + title = title, + isPinned = isPinned, + createdAt = createdAt, +) + +fun QuestionAnswer.toResponse(): QuestionAnswerResponse = QuestionAnswerResponse( + answerId = id, + questionId = questionId, + content = content, + answeredAt = answeredAt, +) diff --git a/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/dto/common/ApiResponse.kt b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/dto/common/ApiResponse.kt new file mode 100644 index 00000000..5c0de9f5 --- /dev/null +++ b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/dto/common/ApiResponse.kt @@ -0,0 +1,7 @@ +package hs.kr.entrydsm.admin.adapterin.web.dto.common + +data class ApiResponse( + val success: Boolean = true, + val data: T?, + val error: ErrorDetail? = null, +) diff --git a/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/dto/common/ErrorDetail.kt b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/dto/common/ErrorDetail.kt new file mode 100644 index 00000000..000bfe69 --- /dev/null +++ b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/dto/common/ErrorDetail.kt @@ -0,0 +1,17 @@ +package hs.kr.entrydsm.admin.adapterin.web.dto.common + +import hs.kr.entrydsm.admin.domain.enum.ErrorCode + +data class ErrorDetail( + val code: String, + val message: String, + val status: Int, +) { + companion object { + fun from(errorCode: ErrorCode) = ErrorDetail( + errorCode.name, + errorCode.message, + errorCode.status, + ) + } +} diff --git a/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/dto/common/ErrorResponse.kt b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/dto/common/ErrorResponse.kt new file mode 100644 index 00000000..92a97471 --- /dev/null +++ b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/dto/common/ErrorResponse.kt @@ -0,0 +1,9 @@ +package hs.kr.entrydsm.admin.adapterin.web.dto.common + +import java.time.Instant + +data class ErrorResponse( + val success: Boolean = false, + val error: ErrorDetail, + val timestamp: Instant = Instant.now(), +) diff --git a/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/dto/common/ResponseMapper.kt b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/dto/common/ResponseMapper.kt new file mode 100644 index 00000000..d04d2292 --- /dev/null +++ b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/dto/common/ResponseMapper.kt @@ -0,0 +1,70 @@ +package hs.kr.entrydsm.admin.adapterin.web.dto.common + +import hs.kr.entrydsm.admin.adapterin.web.dto.response.ApplicantDetailResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.response.ApplicantSummaryResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.response.DownloadResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.response.ExamineeNumberIssueResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.response.PageResponse +import hs.kr.entrydsm.admin.adapterin.web.dto.response.ScoreResponse +import hs.kr.entrydsm.admin.domain.model.Applicant +import hs.kr.entrydsm.admin.domain.model.ApplicantScore +import hs.kr.entrydsm.admin.domain.model.DownloadLink +import hs.kr.entrydsm.admin.domain.model.ExamineeNumberIssueResult +import hs.kr.entrydsm.admin.domain.model.Page + +fun Applicant.toSummaryResponse(): ApplicantSummaryResponse = ApplicantSummaryResponse( + applicantId = id, + receiptNumber = receiptNumber, + name = name, + region = region, + admissionType = admissionType, + graduationStatus = graduationStatus, + examineeNumber = examineeNumber, + isSubmitted = isSubmitted, + status = status, +) + +fun Applicant.toDetailResponse(): ApplicantDetailResponse = ApplicantDetailResponse( + applicantId = id, + receiptNumber = receiptNumber, + name = name, + birthDate = birthDate, + phoneNumber = phoneNumber, + region = region, + admissionType = admissionType, + graduationStatus = graduationStatus, + schoolName = schoolName, + examineeNumber = examineeNumber, + isSubmitted = isSubmitted, + status = status, + score = score?.toResponse(), + submittedAt = submittedAt, + updatedAt = updatedAt, +) + +fun ApplicantScore.toResponse(): ScoreResponse = ScoreResponse( + subjectScore = subjectScore, + attendanceScore = attendanceScore, + volunteerScore = volunteerScore, + totalScore = totalScore, +) + +fun Page.toResponse(transform: (T) -> R): PageResponse = PageResponse( + items = items.map(transform), + page = page, + size = size, + totalElements = totalElements, + totalPages = totalPages, +) + +fun ExamineeNumberIssueResult.toResponse(): ExamineeNumberIssueResponse = + ExamineeNumberIssueResponse( + issuedCount = issuedCount, + skippedCount = skippedCount, + totalTargets = totalTargets, + ) + +fun DownloadLink.toResponse(): DownloadResponse = DownloadResponse( + downloadUrl = downloadUrl, + expiresAt = expiresAt, +) diff --git a/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/dto/request/AdminRequests.kt b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/dto/request/AdminRequests.kt new file mode 100644 index 00000000..d41ae2cc --- /dev/null +++ b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/dto/request/AdminRequests.kt @@ -0,0 +1,64 @@ +package hs.kr.entrydsm.admin.adapterin.web.dto.request + +import com.fasterxml.jackson.annotation.JsonProperty +import hs.kr.entrydsm.admin.domain.enum.AdmissionType +import hs.kr.entrydsm.admin.domain.enum.ApplicantStatus +import hs.kr.entrydsm.admin.domain.enum.ExportType +import jakarta.validation.Valid +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size + +data class UpdateScorePolicyRequest( + @field:NotNull + @field:Valid + val weights: ScoreWeightsRequest?, + @field:NotNull + @field:Min(0) + @field:Max(6) + val roundingScale: Int?, + val recalculate: Boolean = false, +) + +data class ScoreWeightsRequest( + @field:NotNull + val subject: Double?, + @field:NotNull + val attendance: Double?, + @field:NotNull + val volunteer: Double?, +) + +data class EvaluateScreeningRequest( + val dryRun: Boolean = false, +) + +data class CreateExportRequest( + @field:NotNull + val type: ExportType?, + val filter: ExportFilterRequest? = null, +) + +data class ExportFilterRequest( + val admissionTypes: Set = emptySet(), + val statuses: Set = emptySet(), +) + +data class CreateNoticeRequest( + @field:NotBlank + @field:Size(max = 200) + val title: String, + @field:NotBlank + val content: String, + @param:JsonProperty("isPinned") + @get:JsonProperty("isPinned") + val isPinned: Boolean = false, + val attachmentIds: List = emptyList(), +) + +data class AnswerQuestionRequest( + @field:NotBlank + val content: String, +) diff --git a/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/dto/request/ApplicantRequests.kt b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/dto/request/ApplicantRequests.kt new file mode 100644 index 00000000..34f3011b --- /dev/null +++ b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/dto/request/ApplicantRequests.kt @@ -0,0 +1,21 @@ +package hs.kr.entrydsm.admin.adapterin.web.dto.request + +import com.fasterxml.jackson.annotation.JsonProperty +import hs.kr.entrydsm.admin.domain.enum.ApplicantStatus +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size + +data class UpdateArrivalRequest( + @field:NotNull + @param:JsonProperty("isSubmitted") + @get:JsonProperty("isSubmitted") + val isSubmitted: Boolean?, +) + +data class UpdateApplicantStatusRequest( + @field:NotNull + val status: ApplicantStatus?, + val force: Boolean = false, + @field:Size(max = 500) + val reason: String? = null, +) diff --git a/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/dto/response/AdminResponses.kt b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/dto/response/AdminResponses.kt new file mode 100644 index 00000000..81bd8733 --- /dev/null +++ b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/dto/response/AdminResponses.kt @@ -0,0 +1,63 @@ +package hs.kr.entrydsm.admin.adapterin.web.dto.response + +import com.fasterxml.jackson.annotation.JsonProperty +import hs.kr.entrydsm.admin.domain.enum.ExportStatus +import hs.kr.entrydsm.admin.domain.enum.ExportType +import java.time.Instant + +data class ScorePolicyResponse( + val policyVersion: Int, + val weights: ScoreWeightsResponse, + val roundingScale: Int, + val effectiveFrom: Instant, + val updatedBy: String, +) + +data class ScoreWeightsResponse( + val subject: Double, + val attendance: Double, + val volunteer: Double, +) + +data class ScreeningResultResponse( + val dryRun: Boolean, + val passCount: Int, + val failCount: Int, + val excludedCount: Int, + val processedAt: Instant, +) + +data class StatisticsResponse( + val generatedAt: Instant, + val metrics: Map, +) + +data class CreateExportResponse( + val exportJobId: String, + val status: ExportStatus, +) + +data class ExportJobResponse( + val exportJobId: String, + val type: ExportType, + val status: ExportStatus, + val downloadUrl: String?, + val expiresAt: Instant?, + val createdAt: Instant, + val completedAt: Instant?, +) + +data class NoticeResponse( + val noticeId: Long?, + val title: String, + @get:JsonProperty("isPinned") + val isPinned: Boolean, + val createdAt: Instant?, +) + +data class QuestionAnswerResponse( + val answerId: Long?, + val questionId: Long, + val content: String, + val answeredAt: Instant?, +) diff --git a/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/dto/response/ApplicantResponses.kt b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/dto/response/ApplicantResponses.kt new file mode 100644 index 00000000..418cb6e7 --- /dev/null +++ b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/dto/response/ApplicantResponses.kt @@ -0,0 +1,74 @@ +package hs.kr.entrydsm.admin.adapterin.web.dto.response + +import com.fasterxml.jackson.annotation.JsonProperty +import hs.kr.entrydsm.admin.domain.enum.AdmissionType +import hs.kr.entrydsm.admin.domain.enum.ApplicantStatus +import hs.kr.entrydsm.admin.domain.enum.GraduationStatus +import hs.kr.entrydsm.admin.domain.enum.Region +import java.time.Instant +import java.time.LocalDate + +/** + * `is` 접두사가 붙은 불리언은 Jackson이 기본적으로 접두사를 떼고 직렬화하므로 + * 명세의 필드명을 유지하려면 이름을 명시해야 합니다. + */ +data class ApplicantSummaryResponse( + val applicantId: Long?, + val receiptNumber: Int, + val name: String, + val region: Region, + val admissionType: AdmissionType, + val graduationStatus: GraduationStatus, + val examineeNumber: String?, + @get:JsonProperty("isSubmitted") + val isSubmitted: Boolean, + val status: ApplicantStatus, +) + +data class ApplicantDetailResponse( + val applicantId: Long?, + val receiptNumber: Int, + val name: String, + val birthDate: LocalDate, + val phoneNumber: String, + val region: Region, + val admissionType: AdmissionType, + val graduationStatus: GraduationStatus, + val schoolName: String, + val examineeNumber: String?, + @get:JsonProperty("isSubmitted") + val isSubmitted: Boolean, + val status: ApplicantStatus, + val score: ScoreResponse?, + val submittedAt: Instant?, + val updatedAt: Instant?, +) + +data class ScoreResponse( + val subjectScore: Double, + val attendanceScore: Double, + val volunteerScore: Double, + val totalScore: Double, +) + +/** + * 공통 규약의 목록 응답 형식입니다. + */ +data class PageResponse( + val items: List, + val page: Int, + val size: Int, + val totalElements: Long, + val totalPages: Int, +) + +data class ExamineeNumberIssueResponse( + val issuedCount: Int, + val skippedCount: Int, + val totalTargets: Int, +) + +data class DownloadResponse( + val downloadUrl: String, + val expiresAt: Instant, +) diff --git a/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/exception/GlobalExceptionHandler.kt b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/exception/GlobalExceptionHandler.kt new file mode 100644 index 00000000..3624ce39 --- /dev/null +++ b/systems/admin/admin-adapter-in/src/main/kotlin/hs/kr/entrydsm/admin/adapterin/web/exception/GlobalExceptionHandler.kt @@ -0,0 +1,67 @@ +package hs.kr.entrydsm.admin.adapterin.web.exception + +import hs.kr.entrydsm.admin.adapterin.web.dto.common.ErrorDetail +import hs.kr.entrydsm.admin.adapterin.web.dto.common.ErrorResponse +import hs.kr.entrydsm.admin.domain.enum.ErrorCode +import hs.kr.entrydsm.admin.domain.exception.AdminException +import jakarta.validation.ConstraintViolationException +import org.slf4j.LoggerFactory +import org.slf4j.MDC +import org.springframework.http.ResponseEntity +import org.springframework.http.converter.HttpMessageNotReadableException +import org.springframework.validation.BindException +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.MissingPathVariableException +import org.springframework.web.bind.MissingRequestHeaderException +import org.springframework.web.bind.MissingServletRequestParameterException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException + +private const val SERVER_ERROR_STATUS = 500 + +@RestControllerAdvice +class GlobalExceptionHandler { + private val logger = LoggerFactory.getLogger(javaClass) + + /** + * 클라이언트 잘못이 아닌 5xx는 원인을 남깁니다. 저장소 장애처럼 서버가 고쳐야 할 문제가 + * 코드만 남고 사라지면 추적할 수 없기 때문입니다. + */ + @ExceptionHandler(AdminException::class) + fun handleAdminException(exception: AdminException): ResponseEntity = + response(exception.errorCode).also { + if (exception.errorCode.status >= SERVER_ERROR_STATUS) { + logger.error("Admin failure [code={}]", exception.errorCode.name, exception) + } + } + + @ExceptionHandler( + HttpMessageNotReadableException::class, + BindException::class, + ConstraintViolationException::class, + MethodArgumentNotValidException::class, + MissingPathVariableException::class, + MissingRequestHeaderException::class, + MissingServletRequestParameterException::class, + MethodArgumentTypeMismatchException::class, + IllegalArgumentException::class, + ) + fun handleInvalidRequest(exception: Exception): ResponseEntity = + response(ErrorCode.INVALID_REQUEST_BODY) + + @ExceptionHandler(Exception::class) + fun handleUnhandledException(exception: Exception): ResponseEntity = + response(ErrorCode.INTERNAL_SERVER_ERROR).also { + logger.error( + "Unhandled exception [X-trace-Id={}]", + MDC.get("X-trace-Id") ?: "unknown", + exception, + ) + } + + private fun response(errorCode: ErrorCode): ResponseEntity = + ResponseEntity + .status(errorCode.status) + .body(ErrorResponse(error = ErrorDetail.from(errorCode))) +} diff --git a/systems/admin/admin-adapter-out/BUILD.bazel b/systems/admin/admin-adapter-out/BUILD.bazel index 14591409..f18ad003 100644 --- a/systems/admin/admin-adapter-out/BUILD.bazel +++ b/systems/admin/admin-adapter-out/BUILD.bazel @@ -8,6 +8,9 @@ kt_jvm_library( srcs = glob(["src/main/kotlin/**/*.kt"]), javac_opts = "//:javac_options", kotlinc_opts = "//:kotlinc_options", + # JPA 엔티티에 인자 없는 생성자를 만들어 준다. + plugins = ["//:jpa_noarg"], + resources = glob(["src/main/resources/**"]), deps = MODULE_DEPS, ) @@ -17,5 +20,14 @@ kt_jvm_test( javac_opts = "//:javac_options", kotlinc_opts = "//:kotlinc_options", test_class = "hs.kr.entrydsm.admin.adapterout.AdminAdapterOutModuleTest", - deps = MODULE_DEPS + TEST_DEPS, + deps = MODULE_DEPS + TEST_DEPS + [":main"], +) + +kt_jvm_test( + name = "admission_ticket_pdf_test", + srcs = glob(["src/test/kotlin/**/*.kt"]), + javac_opts = "//:javac_options", + kotlinc_opts = "//:kotlinc_options", + test_class = "hs.kr.entrydsm.admin.adapterout.document.AdmissionTicketPdfTest", + deps = MODULE_DEPS + TEST_DEPS + [":main"], ) diff --git a/systems/admin/admin-adapter-out/deps.bzl b/systems/admin/admin-adapter-out/deps.bzl index 5cf03643..f44a6a1c 100644 --- a/systems/admin/admin-adapter-out/deps.bzl +++ b/systems/admin/admin-adapter-out/deps.bzl @@ -1,4 +1,11 @@ -KOTLIN_DEPS = [] +KOTLIN_DEPS = [ + "@maven//:org_springframework_boot_spring_boot_starter_data_jpa", + "@maven//:com_mysql_mysql_connector_j", + "@maven//:io_github_openhtmltopdf_openhtmltopdf_core", + "@maven//:io_github_openhtmltopdf_openhtmltopdf_pdfbox", + "@maven//:software_amazon_awssdk_s3", + "//systems/admin/admin-domain:main", +] TEST_DEPS = [ "@maven//:junit_junit", diff --git a/systems/admin/admin-adapter-out/src/main/kotlin/hs/kr/entrydsm/admin/adapterout/document/OpenHtmlToPdfAdapter.kt b/systems/admin/admin-adapter-out/src/main/kotlin/hs/kr/entrydsm/admin/adapterout/document/OpenHtmlToPdfAdapter.kt new file mode 100644 index 00000000..e7b091bb --- /dev/null +++ b/systems/admin/admin-adapter-out/src/main/kotlin/hs/kr/entrydsm/admin/adapterout/document/OpenHtmlToPdfAdapter.kt @@ -0,0 +1,53 @@ +package hs.kr.entrydsm.admin.adapterout.document + +import com.openhtmltopdf.pdfboxout.PdfRendererBuilder +import hs.kr.entrydsm.admin.domain.enum.ErrorCode +import hs.kr.entrydsm.admin.domain.exception.AdminDomainException +import hs.kr.entrydsm.admin.domain.port.out.PdfRenderPort +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.InputStream +import java.nio.file.Files +import org.springframework.stereotype.Component + +private const val FONT_RESOURCE = "/fonts/NanumGothic-Regular.ttf" +private const val FONT_FAMILY = "AdmissionTicket" + +/** + * XHTML을 PDF로 변환합니다. + * + * openhtmltopdf는 시스템 폰트를 쓰지 않으므로 한글 폰트를 직접 등록해야 합니다. + * 폰트는 `File`로만 받기 때문에, 클래스패스 리소스를 기동 시 임시 파일로 한 번 풀어 둡니다. + */ +@Component +class OpenHtmlToPdfAdapter : PdfRenderPort { + + private val fontFile: File by lazy { extractFont() } + + override fun render(html: String): ByteArray = + runCatching { + ByteArrayOutputStream().use { output -> + PdfRendererBuilder() + .useFastMode() + .useFont(fontFile, FONT_FAMILY) + .withHtmlContent(html, null) + .toStream(output) + .run() + output.toByteArray() + } + }.getOrElse { cause -> + throw AdminDomainException(ErrorCode.ADMISSION_TICKET_GENERATION_FAILED, cause) + } + + private fun extractFont(): File { + val resource: InputStream = javaClass.getResourceAsStream(FONT_RESOURCE) + ?: throw AdminDomainException(ErrorCode.ADMISSION_TICKET_GENERATION_FAILED) + + return resource.use { stream -> + Files.createTempFile("admin-admission-ticket-", ".ttf").toFile().also { file -> + file.deleteOnExit() + file.outputStream().use(stream::copyTo) + } + } + } +} diff --git a/systems/admin/admin-adapter-out/src/main/kotlin/hs/kr/entrydsm/admin/adapterout/entity/ApplicantJpaEntity.kt b/systems/admin/admin-adapter-out/src/main/kotlin/hs/kr/entrydsm/admin/adapterout/entity/ApplicantJpaEntity.kt new file mode 100644 index 00000000..2a6cf4e7 --- /dev/null +++ b/systems/admin/admin-adapter-out/src/main/kotlin/hs/kr/entrydsm/admin/adapterout/entity/ApplicantJpaEntity.kt @@ -0,0 +1,136 @@ +package hs.kr.entrydsm.admin.adapterout.entity + +import hs.kr.entrydsm.admin.domain.enum.AdmissionType +import hs.kr.entrydsm.admin.domain.enum.ApplicantStatus +import hs.kr.entrydsm.admin.domain.enum.GraduationStatus +import hs.kr.entrydsm.admin.domain.enum.Region +import hs.kr.entrydsm.admin.domain.model.Applicant +import hs.kr.entrydsm.admin.domain.model.ApplicantScore +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.Instant +import java.time.LocalDate + +/** + * 지원자 원서 테이블입니다. + * + * ponytail: 원서 접수의 원본 데이터는 application 시스템이 갖는 것이 맞다. + * 그 시스템이 생기면 이 테이블은 조회 전용 투영으로 바꾸거나 gRPC 조회로 대체한다. + */ +@Entity +@Table(name = "applicant") +class ApplicantJpaEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + val id: Long? = null, + + @Column(name = "receipt_number", nullable = false, unique = true) + val receiptNumber: Int, + + @Column(name = "name", nullable = false, length = 50) + val name: String, + + @Column(name = "birth_date", nullable = false) + val birthDate: LocalDate, + + @Column(name = "phone_number", nullable = false, length = 20) + val phoneNumber: String, + + @Enumerated(EnumType.STRING) + @Column(name = "region", nullable = false, length = 20) + val region: Region, + + @Enumerated(EnumType.STRING) + @Column(name = "admission_type", nullable = false, length = 20) + val admissionType: AdmissionType, + + @Enumerated(EnumType.STRING) + @Column(name = "graduation_status", nullable = false, length = 20) + val graduationStatus: GraduationStatus, + + @Column(name = "school_name", nullable = false, length = 100) + val schoolName: String, + + @Column(name = "examinee_number", length = 20) + val examineeNumber: String? = null, + + @Column(name = "is_submitted", nullable = false) + val isSubmitted: Boolean = false, + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + val status: ApplicantStatus = ApplicantStatus.PENDING, + + @Column(name = "subject_score") + val subjectScore: Double? = null, + + @Column(name = "attendance_score") + val attendanceScore: Double? = null, + + @Column(name = "volunteer_score") + val volunteerScore: Double? = null, + + @Column(name = "total_score") + val totalScore: Double? = null, + + @Column(name = "submitted_at") + val submittedAt: Instant? = null, + + @Column(name = "updated_at") + val updatedAt: Instant? = null, +) { + fun toDomain(): Applicant = Applicant( + id = id, + receiptNumber = receiptNumber, + name = name, + birthDate = birthDate, + phoneNumber = phoneNumber, + region = region, + admissionType = admissionType, + graduationStatus = graduationStatus, + schoolName = schoolName, + examineeNumber = examineeNumber, + isSubmitted = isSubmitted, + status = status, + score = totalScore?.let { + ApplicantScore( + subjectScore = subjectScore ?: 0.0, + attendanceScore = attendanceScore ?: 0.0, + volunteerScore = volunteerScore ?: 0.0, + totalScore = it, + ) + }, + submittedAt = submittedAt, + updatedAt = updatedAt, + ) + + companion object { + fun from(applicant: Applicant): ApplicantJpaEntity = ApplicantJpaEntity( + id = applicant.id, + receiptNumber = applicant.receiptNumber, + name = applicant.name, + birthDate = applicant.birthDate, + phoneNumber = applicant.phoneNumber, + region = applicant.region, + admissionType = applicant.admissionType, + graduationStatus = applicant.graduationStatus, + schoolName = applicant.schoolName, + examineeNumber = applicant.examineeNumber, + isSubmitted = applicant.isSubmitted, + status = applicant.status, + subjectScore = applicant.score?.subjectScore, + attendanceScore = applicant.score?.attendanceScore, + volunteerScore = applicant.score?.volunteerScore, + totalScore = applicant.score?.totalScore, + submittedAt = applicant.submittedAt, + updatedAt = applicant.updatedAt, + ) + } +} diff --git a/systems/admin/admin-adapter-out/src/main/kotlin/hs/kr/entrydsm/admin/adapterout/entity/SupportJpaEntities.kt b/systems/admin/admin-adapter-out/src/main/kotlin/hs/kr/entrydsm/admin/adapterout/entity/SupportJpaEntities.kt new file mode 100644 index 00000000..72ed83b0 --- /dev/null +++ b/systems/admin/admin-adapter-out/src/main/kotlin/hs/kr/entrydsm/admin/adapterout/entity/SupportJpaEntities.kt @@ -0,0 +1,215 @@ +package hs.kr.entrydsm.admin.adapterout.entity + +import hs.kr.entrydsm.admin.domain.enum.ExportStatus +import hs.kr.entrydsm.admin.domain.enum.ExportType +import hs.kr.entrydsm.admin.domain.model.ExportJob +import hs.kr.entrydsm.admin.domain.model.Notice +import hs.kr.entrydsm.admin.domain.model.QuestionAnswer +import hs.kr.entrydsm.admin.domain.model.ScorePolicy +import hs.kr.entrydsm.admin.domain.model.ScoreWeights +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.time.Instant + +private const val ATTACHMENT_ID_DELIMITER = "," + +@Entity +@Table(name = "score_policy") +class ScorePolicyJpaEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + val id: Long? = null, + + @Column(name = "policy_version", nullable = false) + val policyVersion: Int, + + @Column(name = "subject_weight", nullable = false) + val subjectWeight: Double, + + @Column(name = "attendance_weight", nullable = false) + val attendanceWeight: Double, + + @Column(name = "volunteer_weight", nullable = false) + val volunteerWeight: Double, + + @Column(name = "rounding_scale", nullable = false) + val roundingScale: Int, + + @Column(name = "effective_from", nullable = false) + val effectiveFrom: Instant, + + @Column(name = "updated_by", nullable = false, length = 50) + val updatedBy: String, +) { + fun toDomain(): ScorePolicy = ScorePolicy( + id = id, + policyVersion = policyVersion, + weights = ScoreWeights(subjectWeight, attendanceWeight, volunteerWeight), + roundingScale = roundingScale, + effectiveFrom = effectiveFrom, + updatedBy = updatedBy, + ) + + companion object { + fun from(policy: ScorePolicy): ScorePolicyJpaEntity = ScorePolicyJpaEntity( + id = policy.id, + policyVersion = policy.policyVersion, + subjectWeight = policy.weights.subject, + attendanceWeight = policy.weights.attendance, + volunteerWeight = policy.weights.volunteer, + roundingScale = policy.roundingScale, + effectiveFrom = policy.effectiveFrom, + updatedBy = policy.updatedBy, + ) + } +} + +/** + * ponytail: 필터 조건은 저장하지 않는다. 작업 객체를 그대로 처리기에 넘기므로 지금은 필요 없다. + * 재시작 후 재처리가 필요해지면 그때 컬럼을 추가한다. + */ +@Entity +@Table(name = "export_job") +class ExportJobJpaEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + val id: Long? = null, + + @Column(name = "export_job_id", nullable = false, unique = true, length = 40) + val exportJobId: String, + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 30) + val type: ExportType, + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + val status: ExportStatus, + + @Column(name = "object_key", length = 255) + val objectKey: String? = null, + + @Column(name = "created_at", nullable = false) + val createdAt: Instant, + + @Column(name = "completed_at") + val completedAt: Instant? = null, +) { + fun toDomain(): ExportJob = ExportJob( + id = id, + exportJobId = exportJobId, + type = type, + status = status, + objectKey = objectKey, + createdAt = createdAt, + completedAt = completedAt, + ) + + companion object { + fun from(job: ExportJob): ExportJobJpaEntity = ExportJobJpaEntity( + id = job.id, + exportJobId = job.exportJobId, + type = job.type, + status = job.status, + objectKey = job.objectKey, + createdAt = job.createdAt, + completedAt = job.completedAt, + ) + } +} + +@Entity +@Table(name = "notice") +class NoticeJpaEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + val id: Long? = null, + + @Column(name = "title", nullable = false, length = 200) + val title: String, + + @Column(name = "content", nullable = false, columnDefinition = "TEXT") + val content: String, + + @Column(name = "is_pinned", nullable = false) + val isPinned: Boolean = false, + + @Column(name = "attachment_ids", length = 500) + val attachmentIds: String? = null, + + @Column(name = "created_at", nullable = false) + val createdAt: Instant, +) { + fun toDomain(): Notice = Notice( + id = id, + title = title, + content = content, + isPinned = isPinned, + attachmentIds = attachmentIds?.takeIf { it.isNotBlank() } + ?.split(ATTACHMENT_ID_DELIMITER) + ?: emptyList(), + createdAt = createdAt, + ) + + companion object { + fun from(notice: Notice, createdAt: Instant): NoticeJpaEntity = NoticeJpaEntity( + id = notice.id, + title = notice.title, + content = notice.content, + isPinned = notice.isPinned, + attachmentIds = notice.attachmentIds + .takeIf { it.isNotEmpty() } + ?.joinToString(ATTACHMENT_ID_DELIMITER), + createdAt = notice.createdAt ?: createdAt, + ) + } +} + +@Entity +@Table(name = "question_answer") +class QuestionAnswerJpaEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + val id: Long? = null, + + @Column(name = "question_id", nullable = false) + val questionId: Long, + + @Column(name = "content", nullable = false, columnDefinition = "TEXT") + val content: String, + + @Column(name = "answered_by", nullable = false, length = 50) + val answeredBy: String, + + @Column(name = "answered_at", nullable = false) + val answeredAt: Instant, +) { + fun toDomain(): QuestionAnswer = QuestionAnswer( + id = id, + questionId = questionId, + content = content, + answeredBy = answeredBy, + answeredAt = answeredAt, + ) + + companion object { + fun from(answer: QuestionAnswer, answeredAt: Instant): QuestionAnswerJpaEntity = + QuestionAnswerJpaEntity( + id = answer.id, + questionId = answer.questionId, + content = answer.content, + answeredBy = answer.answeredBy, + answeredAt = answer.answeredAt ?: answeredAt, + ) + } +} diff --git a/systems/admin/admin-adapter-out/src/main/kotlin/hs/kr/entrydsm/admin/adapterout/persistence/ApplicantPersistenceAdapter.kt b/systems/admin/admin-adapter-out/src/main/kotlin/hs/kr/entrydsm/admin/adapterout/persistence/ApplicantPersistenceAdapter.kt new file mode 100644 index 00000000..df4fec51 --- /dev/null +++ b/systems/admin/admin-adapter-out/src/main/kotlin/hs/kr/entrydsm/admin/adapterout/persistence/ApplicantPersistenceAdapter.kt @@ -0,0 +1,76 @@ +package hs.kr.entrydsm.admin.adapterout.persistence + +import hs.kr.entrydsm.admin.adapterout.entity.ApplicantJpaEntity +import hs.kr.entrydsm.admin.adapterout.repository.ApplicantJpaRepository +import hs.kr.entrydsm.admin.adapterout.repository.ApplicantSpecifications +import hs.kr.entrydsm.admin.domain.enum.AdmissionType +import hs.kr.entrydsm.admin.domain.enum.Region +import hs.kr.entrydsm.admin.domain.model.Applicant +import hs.kr.entrydsm.admin.domain.model.ApplicantFilter +import hs.kr.entrydsm.admin.domain.model.DailyApplicantCount +import hs.kr.entrydsm.admin.domain.model.Page +import hs.kr.entrydsm.admin.domain.model.PageRequest +import hs.kr.entrydsm.admin.domain.port.out.ApplicantRepository +import java.time.ZoneId +import org.springframework.data.domain.Sort +import org.springframework.stereotype.Component + +private val KOREA_ZONE = ZoneId.of("Asia/Seoul") + +@Component +class ApplicantPersistenceAdapter( + private val applicantJpaRepository: ApplicantJpaRepository, +) : ApplicantRepository { + + override fun search(filter: ApplicantFilter, pageRequest: PageRequest): Page { + val springPage = applicantJpaRepository.findAll( + ApplicantSpecifications.of(filter), + org.springframework.data.domain.PageRequest.of( + pageRequest.normalizedPage - 1, + pageRequest.normalizedSize, + Sort.by(Sort.Direction.ASC, "receiptNumber"), + ), + ) + + return Page( + items = springPage.content.map(ApplicantJpaEntity::toDomain), + page = pageRequest.normalizedPage, + size = pageRequest.normalizedSize, + totalElements = springPage.totalElements, + ) + } + + override fun findAll(filter: ApplicantFilter): List = + applicantJpaRepository + .findAll(ApplicantSpecifications.of(filter), Sort.by(Sort.Direction.ASC, "receiptNumber")) + .map(ApplicantJpaEntity::toDomain) + + override fun findById(applicantId: Long): Applicant? = + applicantJpaRepository.findById(applicantId).orElse(null)?.toDomain() + + override fun save(applicant: Applicant): Applicant = + applicantJpaRepository.save(ApplicantJpaEntity.from(applicant)).toDomain() + + override fun saveAll(applicants: List): List = + applicantJpaRepository + .saveAll(applicants.map(ApplicantJpaEntity::from)) + .map(ApplicantJpaEntity::toDomain) + + override fun countAll(): Long = applicantJpaRepository.count() + + // ponytail: 통계 집계를 메모리에서 돈다. 한 회차 지원자가 수천 명 규모라 충분하다. + // 만 단위로 커지면 GROUP BY 쿼리로 내린다. + override fun countByAdmissionType(): Map = + findAll().groupingBy { it.admissionType }.eachCount().mapValues { it.value.toLong() } + + override fun countByRegion(): Map = + findAll().groupingBy { it.region }.eachCount().mapValues { it.value.toLong() } + + override fun countBySubmittedDate(): List = + findAll() + .mapNotNull { it.submittedAt } + .groupingBy { it.atZone(KOREA_ZONE).toLocalDate() } + .eachCount() + .map { (date, count) -> DailyApplicantCount(date, count.toLong()) } + .sortedBy { it.date } +} diff --git a/systems/admin/admin-adapter-out/src/main/kotlin/hs/kr/entrydsm/admin/adapterout/persistence/SupportPersistenceAdapters.kt b/systems/admin/admin-adapter-out/src/main/kotlin/hs/kr/entrydsm/admin/adapterout/persistence/SupportPersistenceAdapters.kt new file mode 100644 index 00000000..5943fafb --- /dev/null +++ b/systems/admin/admin-adapter-out/src/main/kotlin/hs/kr/entrydsm/admin/adapterout/persistence/SupportPersistenceAdapters.kt @@ -0,0 +1,67 @@ +package hs.kr.entrydsm.admin.adapterout.persistence + +import hs.kr.entrydsm.admin.adapterout.entity.ExportJobJpaEntity +import hs.kr.entrydsm.admin.adapterout.entity.NoticeJpaEntity +import hs.kr.entrydsm.admin.adapterout.entity.QuestionAnswerJpaEntity +import hs.kr.entrydsm.admin.adapterout.entity.ScorePolicyJpaEntity +import hs.kr.entrydsm.admin.adapterout.repository.ExportJobJpaRepository +import hs.kr.entrydsm.admin.adapterout.repository.NoticeJpaRepository +import hs.kr.entrydsm.admin.adapterout.repository.QuestionAnswerJpaRepository +import hs.kr.entrydsm.admin.adapterout.repository.ScorePolicyJpaRepository +import hs.kr.entrydsm.admin.domain.model.ExportJob +import hs.kr.entrydsm.admin.domain.model.Notice +import hs.kr.entrydsm.admin.domain.model.QuestionAnswer +import hs.kr.entrydsm.admin.domain.model.ScorePolicy +import hs.kr.entrydsm.admin.domain.port.out.ExportJobRepository +import hs.kr.entrydsm.admin.domain.port.out.NoticeRepository +import hs.kr.entrydsm.admin.domain.port.out.QuestionAnswerRepository +import hs.kr.entrydsm.admin.domain.port.out.ScorePolicyRepository +import java.time.Clock +import java.time.Instant +import org.springframework.stereotype.Component + +@Component +class ScorePolicyPersistenceAdapter( + private val scorePolicyJpaRepository: ScorePolicyJpaRepository, +) : ScorePolicyRepository { + + override fun findCurrent(): ScorePolicy? = + scorePolicyJpaRepository.findTopByOrderByPolicyVersionDesc()?.toDomain() + + override fun save(scorePolicy: ScorePolicy): ScorePolicy = + scorePolicyJpaRepository.save(ScorePolicyJpaEntity.from(scorePolicy)).toDomain() +} + +@Component +class ExportJobPersistenceAdapter( + private val exportJobJpaRepository: ExportJobJpaRepository, +) : ExportJobRepository { + + override fun findByExportJobId(exportJobId: String): ExportJob? = + exportJobJpaRepository.findByExportJobId(exportJobId)?.toDomain() + + override fun save(exportJob: ExportJob): ExportJob = + exportJobJpaRepository.save(ExportJobJpaEntity.from(exportJob)).toDomain() +} + +@Component +class NoticePersistenceAdapter( + private val noticeJpaRepository: NoticeJpaRepository, + private val clock: Clock, +) : NoticeRepository { + + override fun save(notice: Notice): Notice = + noticeJpaRepository.save(NoticeJpaEntity.from(notice, Instant.now(clock))).toDomain() +} + +@Component +class QuestionAnswerPersistenceAdapter( + private val questionAnswerJpaRepository: QuestionAnswerJpaRepository, + private val clock: Clock, +) : QuestionAnswerRepository { + + override fun save(questionAnswer: QuestionAnswer): QuestionAnswer = + questionAnswerJpaRepository + .save(QuestionAnswerJpaEntity.from(questionAnswer, Instant.now(clock))) + .toDomain() +} diff --git a/systems/admin/admin-adapter-out/src/main/kotlin/hs/kr/entrydsm/admin/adapterout/repository/AdminJpaRepositories.kt b/systems/admin/admin-adapter-out/src/main/kotlin/hs/kr/entrydsm/admin/adapterout/repository/AdminJpaRepositories.kt new file mode 100644 index 00000000..571c49a1 --- /dev/null +++ b/systems/admin/admin-adapter-out/src/main/kotlin/hs/kr/entrydsm/admin/adapterout/repository/AdminJpaRepositories.kt @@ -0,0 +1,25 @@ +package hs.kr.entrydsm.admin.adapterout.repository + +import hs.kr.entrydsm.admin.adapterout.entity.ApplicantJpaEntity +import hs.kr.entrydsm.admin.adapterout.entity.ExportJobJpaEntity +import hs.kr.entrydsm.admin.adapterout.entity.NoticeJpaEntity +import hs.kr.entrydsm.admin.adapterout.entity.QuestionAnswerJpaEntity +import hs.kr.entrydsm.admin.adapterout.entity.ScorePolicyJpaEntity +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.JpaSpecificationExecutor + +interface ApplicantJpaRepository : + JpaRepository, + JpaSpecificationExecutor + +interface ScorePolicyJpaRepository : JpaRepository { + fun findTopByOrderByPolicyVersionDesc(): ScorePolicyJpaEntity? +} + +interface ExportJobJpaRepository : JpaRepository { + fun findByExportJobId(exportJobId: String): ExportJobJpaEntity? +} + +interface NoticeJpaRepository : JpaRepository + +interface QuestionAnswerJpaRepository : JpaRepository diff --git a/systems/admin/admin-adapter-out/src/main/kotlin/hs/kr/entrydsm/admin/adapterout/repository/ApplicantSpecifications.kt b/systems/admin/admin-adapter-out/src/main/kotlin/hs/kr/entrydsm/admin/adapterout/repository/ApplicantSpecifications.kt new file mode 100644 index 00000000..a331e961 --- /dev/null +++ b/systems/admin/admin-adapter-out/src/main/kotlin/hs/kr/entrydsm/admin/adapterout/repository/ApplicantSpecifications.kt @@ -0,0 +1,41 @@ +package hs.kr.entrydsm.admin.adapterout.repository + +import hs.kr.entrydsm.admin.adapterout.entity.ApplicantJpaEntity +import hs.kr.entrydsm.admin.domain.model.ApplicantFilter +import org.springframework.data.jpa.domain.Specification + +/** + * 지원자 목록 필터를 JPA Specification으로 옮깁니다. + * + * 비어 있는 조건은 아예 술어를 만들지 않아 전체 조회가 되게 합니다. + */ +object ApplicantSpecifications { + + fun of(filter: ApplicantFilter): Specification = + Specification { root, _, builder -> + val predicates = buildList { + filter.keyword?.takeIf { it.isNotBlank() }?.let { keyword -> + val pattern = "%${keyword.trim().lowercase()}%" + add( + builder.or( + builder.like(builder.lower(root.get("name")), pattern), + builder.like(builder.lower(root.get("examineeNumber")), pattern), + ), + ) + } + filter.regions.takeIf { it.isNotEmpty() } + ?.let { add(root.get("region").`in`(it)) } + filter.admissionTypes.takeIf { it.isNotEmpty() } + ?.let { add(root.get("admissionType").`in`(it)) } + filter.graduationStatuses.takeIf { it.isNotEmpty() } + ?.let { add(root.get("graduationStatus").`in`(it)) } + filter.statuses.takeIf { it.isNotEmpty() } + ?.let { add(root.get("status").`in`(it)) } + filter.isSubmitted?.let { + add(builder.equal(root.get("isSubmitted"), it)) + } + } + + if (predicates.isEmpty()) null else builder.and(*predicates.toTypedArray()) + } +} diff --git a/systems/admin/admin-adapter-out/src/main/kotlin/hs/kr/entrydsm/admin/adapterout/storage/S3StorageAdapter.kt b/systems/admin/admin-adapter-out/src/main/kotlin/hs/kr/entrydsm/admin/adapterout/storage/S3StorageAdapter.kt new file mode 100644 index 00000000..1383a360 --- /dev/null +++ b/systems/admin/admin-adapter-out/src/main/kotlin/hs/kr/entrydsm/admin/adapterout/storage/S3StorageAdapter.kt @@ -0,0 +1,63 @@ +package hs.kr.entrydsm.admin.adapterout.storage + +import hs.kr.entrydsm.admin.domain.enum.ErrorCode +import hs.kr.entrydsm.admin.domain.exception.AdminDomainException +import hs.kr.entrydsm.admin.domain.port.out.StoragePort +import java.time.Duration +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import software.amazon.awssdk.core.sync.RequestBody +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.model.HeadObjectRequest +import software.amazon.awssdk.services.s3.model.NoSuchKeyException +import software.amazon.awssdk.services.s3.model.PutObjectRequest +import software.amazon.awssdk.services.s3.presigner.S3Presigner +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest + +/** + * 산출물을 S3에 올리고 presigned URL을 발급합니다. + */ +@Component +class S3StorageAdapter( + private val s3Client: S3Client, + private val s3Presigner: S3Presigner, + @Value("\${admin.storage.bucket}") private val bucket: String, +) : StoragePort { + + override fun upload(objectKey: String, contentType: String, content: ByteArray) { + runCatching { + s3Client.putObject( + PutObjectRequest.builder() + .bucket(bucket) + .key(objectKey) + .contentType(contentType) + .build(), + RequestBody.fromBytes(content), + ) + }.getOrElse { cause -> + throw AdminDomainException(ErrorCode.STORAGE_UNAVAILABLE, cause) + } + } + + override fun issueDownloadUrl(objectKey: String, expiresInSeconds: Long): String = + runCatching { + s3Presigner.presignGetObject( + GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofSeconds(expiresInSeconds)) + .getObjectRequest { it.bucket(bucket).key(objectKey) } + .build(), + ).url().toExternalForm() + }.getOrElse { cause -> + throw AdminDomainException(ErrorCode.STORAGE_UNAVAILABLE, cause) + } + + override fun exists(objectKey: String): Boolean = + try { + s3Client.headObject( + HeadObjectRequest.builder().bucket(bucket).key(objectKey).build(), + ) + true + } catch (exception: NoSuchKeyException) { + false + } +} diff --git a/systems/admin/admin-adapter-out/src/main/resources/fonts/NanumGothic-Regular.ttf b/systems/admin/admin-adapter-out/src/main/resources/fonts/NanumGothic-Regular.ttf new file mode 100644 index 00000000..2aa2b209 Binary files /dev/null and b/systems/admin/admin-adapter-out/src/main/resources/fonts/NanumGothic-Regular.ttf differ diff --git a/systems/admin/admin-adapter-out/src/test/kotlin/hs/kr/entrydsm/admin/adapterout/document/AdmissionTicketPdfTest.kt b/systems/admin/admin-adapter-out/src/test/kotlin/hs/kr/entrydsm/admin/adapterout/document/AdmissionTicketPdfTest.kt new file mode 100644 index 00000000..4ba8a7f7 --- /dev/null +++ b/systems/admin/admin-adapter-out/src/test/kotlin/hs/kr/entrydsm/admin/adapterout/document/AdmissionTicketPdfTest.kt @@ -0,0 +1,37 @@ +package hs.kr.entrydsm.admin.adapterout.document + +import hs.kr.entrydsm.admin.domain.document.AdmissionTicketHtml +import hs.kr.entrydsm.admin.domain.enum.AdmissionType +import hs.kr.entrydsm.admin.domain.enum.Region +import hs.kr.entrydsm.admin.domain.model.AdmissionTicket +import java.io.File +import org.junit.Assert.assertTrue +import org.junit.Test + +class AdmissionTicketPdfTest { + + @Test + fun `수험표 HTML을 PDF로 변환한다`() { + val html = AdmissionTicketHtml.render( + AdmissionTicket( + admissionYear = 2027, + receiptNumber = 2, + examineeNumber = null, + name = "홍길동", + schoolName = "서울중학교", + region = Region.NATIONWIDE, + admissionType = AdmissionType.GENERAL, + ), + ) + + val pdf = OpenHtmlToPdfAdapter().render(html) + + assertTrue(pdf.size > 1024) + assertTrue(String(pdf.copyOfRange(0, 5)) == "%PDF-") + + // 눈으로 확인할 때 쓴다. bazel-testlogs/.../test.outputs/outputs.zip 에 담긴다. + System.getenv("TEST_UNDECLARED_OUTPUTS_DIR")?.let { directory -> + File(directory, "admission-ticket.pdf").writeBytes(pdf) + } + } +} diff --git a/systems/admin/admin-application/BUILD.bazel b/systems/admin/admin-application/BUILD.bazel index 75839e3f..6a671881 100644 --- a/systems/admin/admin-application/BUILD.bazel +++ b/systems/admin/admin-application/BUILD.bazel @@ -8,6 +8,8 @@ kt_jvm_library( srcs = glob(["src/main/kotlin/**/*.kt"]), javac_opts = "//:javac_options", kotlinc_opts = "//:kotlinc_options", + # @Transactional / @Async 프록시를 만들려면 클래스가 열려 있어야 한다. + plugins = ["//:spring_allopen"], deps = MODULE_DEPS, ) diff --git a/systems/admin/admin-application/deps.bzl b/systems/admin/admin-application/deps.bzl index 5cf03643..b4a8c532 100644 --- a/systems/admin/admin-application/deps.bzl +++ b/systems/admin/admin-application/deps.bzl @@ -1,4 +1,8 @@ -KOTLIN_DEPS = [] +KOTLIN_DEPS = [ + "@maven//:org_springframework_boot_spring_boot_starter", + "@maven//:org_springframework_spring_tx", + "//systems/admin/admin-domain:main", +] TEST_DEPS = [ "@maven//:junit_junit", diff --git a/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/AdmissionQuotaProperties.kt b/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/AdmissionQuotaProperties.kt new file mode 100644 index 00000000..7c676444 --- /dev/null +++ b/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/AdmissionQuotaProperties.kt @@ -0,0 +1,12 @@ +package hs.kr.entrydsm.admin.application + +import hs.kr.entrydsm.admin.domain.enum.AdmissionType +import org.springframework.boot.context.properties.ConfigurationProperties + +/** + * 전형별 모집 정원입니다. 경쟁률을 낼 때 분모로 씁니다. + */ +@ConfigurationProperties(prefix = "admin.quota") +data class AdmissionQuotaProperties( + val byType: Map = emptyMap(), +) diff --git a/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/ApplicantService.kt b/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/ApplicantService.kt new file mode 100644 index 00000000..33a1118c --- /dev/null +++ b/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/ApplicantService.kt @@ -0,0 +1,86 @@ +package hs.kr.entrydsm.admin.application + +import hs.kr.entrydsm.admin.domain.command.UpdateApplicantStatusCommand +import hs.kr.entrydsm.admin.domain.command.UpdateArrivalCommand +import hs.kr.entrydsm.admin.domain.enum.ErrorCode +import hs.kr.entrydsm.admin.domain.exception.AdminDomainException +import hs.kr.entrydsm.admin.domain.model.Applicant +import hs.kr.entrydsm.admin.domain.model.ApplicantFilter +import hs.kr.entrydsm.admin.domain.model.ExamineeNumberIssueResult +import hs.kr.entrydsm.admin.domain.model.Page +import hs.kr.entrydsm.admin.domain.model.PageRequest +import hs.kr.entrydsm.admin.domain.policy.ExamineeNumberPolicy +import hs.kr.entrydsm.admin.domain.port.`in`.IssueExamineeNumberUseCase +import hs.kr.entrydsm.admin.domain.port.`in`.ReadApplicantUseCase +import hs.kr.entrydsm.admin.domain.port.`in`.UpdateApplicantUseCase +import hs.kr.entrydsm.admin.domain.port.out.ApplicantRepository +import java.time.Clock +import java.time.Instant +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class ApplicantService( + private val applicantRepository: ApplicantRepository, + private val clock: Clock, +) : ReadApplicantUseCase, + UpdateApplicantUseCase, + IssueExamineeNumberUseCase { + + override fun search(filter: ApplicantFilter, pageRequest: PageRequest): Page = + applicantRepository.search(filter, pageRequest) + + override fun findById(applicantId: Long): Applicant = requireApplicant(applicantId) + + @Transactional + override fun updateArrival(command: UpdateArrivalCommand) { + val applicant = requireApplicant(command.applicantId) + applicantRepository.save( + applicant.copy( + isSubmitted = command.isSubmitted, + submittedAt = if (command.isSubmitted) { + applicant.submittedAt ?: Instant.now(clock) + } else { + null + }, + updatedAt = Instant.now(clock), + ), + ) + } + + @Transactional + override fun updateStatus(command: UpdateApplicantStatusCommand) { + val applicant = requireApplicant(command.applicantId) + + if (command.force) { + if (command.reason.isNullOrBlank()) { + throw AdminDomainException(ErrorCode.INVALID_REQUEST_BODY) + } + } else if (!applicant.status.canTransitionTo(command.status)) { + throw AdminDomainException(ErrorCode.INVALID_STATUS_TRANSITION) + } + + applicantRepository.save( + applicant.copy(status = command.status, updatedAt = Instant.now(clock)), + ) + } + + @Transactional + override fun issueAll(): ExamineeNumberIssueResult { + val issuance = ExamineeNumberPolicy.issue(applicantRepository.findAll()) + val now = Instant.now(clock) + + applicantRepository.saveAll(issuance.issued.map { it.copy(updatedAt = now) }) + + return ExamineeNumberIssueResult( + issuedCount = issuance.issued.size, + skippedCount = issuance.skippedCount, + totalTargets = issuance.totalTargets, + ) + } + + private fun requireApplicant(applicantId: Long): Applicant = + applicantRepository.findById(applicantId) + ?: throw AdminDomainException(ErrorCode.APPLICANT_NOT_FOUND) +} diff --git a/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/DocumentService.kt b/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/DocumentService.kt new file mode 100644 index 00000000..de4657a1 --- /dev/null +++ b/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/DocumentService.kt @@ -0,0 +1,73 @@ +package hs.kr.entrydsm.admin.application + +import hs.kr.entrydsm.admin.domain.document.AdmissionTicketHtml +import hs.kr.entrydsm.admin.domain.document.DocumentNaming +import hs.kr.entrydsm.admin.domain.enum.ErrorCode +import hs.kr.entrydsm.admin.domain.exception.AdminDomainException +import hs.kr.entrydsm.admin.domain.model.AdmissionTicket +import hs.kr.entrydsm.admin.domain.model.Applicant +import hs.kr.entrydsm.admin.domain.model.DownloadLink +import hs.kr.entrydsm.admin.domain.port.`in`.IssueAdmissionTicketUseCase +import hs.kr.entrydsm.admin.domain.port.`in`.IssueApplicationDocumentUseCase +import hs.kr.entrydsm.admin.domain.port.out.ApplicantRepository +import hs.kr.entrydsm.admin.domain.port.out.PdfRenderPort +import hs.kr.entrydsm.admin.domain.port.out.StoragePort +import java.time.Clock +import java.time.Instant +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +private const val PDF_CONTENT_TYPE = "application/pdf" + +/** + * 수험표와 원서 원본의 다운로드 링크를 발급합니다. + * + * 수험표는 요청 시점에 새로 만들어 올립니다. 이름 정정이나 수험 번호 발급이 반영된 + * 최신본을 항상 내려주기 위해서입니다. + */ +@Service +@Transactional(readOnly = true) +class DocumentService( + private val applicantRepository: ApplicantRepository, + private val pdfRenderPort: PdfRenderPort, + private val storagePort: StoragePort, + private val clock: Clock, + @Value("\${admin.admission-year}") private val admissionYear: Int, + @Value("\${admin.storage.download-url-expires-seconds:900}") + private val downloadUrlExpiresInSeconds: Long, +) : IssueAdmissionTicketUseCase, + IssueApplicationDocumentUseCase { + + override fun issueAdmissionTicket(applicantId: Long): DownloadLink { + val applicant = requireApplicant(applicantId) + val objectKey = DocumentNaming.admissionTicketObjectKey(applicant.receiptNumber) + + val pdf = pdfRenderPort.render( + AdmissionTicketHtml.render(AdmissionTicket.of(applicant, admissionYear)), + ) + storagePort.upload(objectKey, PDF_CONTENT_TYPE, pdf) + + return downloadLink(objectKey) + } + + override fun issueApplicationDocument(applicantId: Long): DownloadLink { + val applicant = requireApplicant(applicantId) + val objectKey = DocumentNaming.applicationDocumentObjectKey(applicant.receiptNumber) + + if (!storagePort.exists(objectKey)) { + throw AdminDomainException(ErrorCode.APPLICATION_DOCUMENT_NOT_FOUND) + } + + return downloadLink(objectKey) + } + + private fun downloadLink(objectKey: String): DownloadLink = DownloadLink( + downloadUrl = storagePort.issueDownloadUrl(objectKey, downloadUrlExpiresInSeconds), + expiresAt = Instant.now(clock).plusSeconds(downloadUrlExpiresInSeconds), + ) + + private fun requireApplicant(applicantId: Long): Applicant = + applicantRepository.findById(applicantId) + ?: throw AdminDomainException(ErrorCode.APPLICANT_NOT_FOUND) +} diff --git a/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/ExportJobCreatedEvent.kt b/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/ExportJobCreatedEvent.kt new file mode 100644 index 00000000..138b0543 --- /dev/null +++ b/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/ExportJobCreatedEvent.kt @@ -0,0 +1,8 @@ +package hs.kr.entrydsm.admin.application + +import hs.kr.entrydsm.admin.domain.model.ExportJob + +/** + * 내보내기 작업이 접수되었음을 알립니다. 처리기는 커밋 이후에 이 이벤트를 받습니다. + */ +data class ExportJobCreatedEvent(val job: ExportJob) diff --git a/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/ExportJobProcessor.kt b/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/ExportJobProcessor.kt new file mode 100644 index 00000000..17ce492a --- /dev/null +++ b/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/ExportJobProcessor.kt @@ -0,0 +1,126 @@ +package hs.kr.entrydsm.admin.application + +import hs.kr.entrydsm.admin.domain.document.AdmissionTicketHtml +import hs.kr.entrydsm.admin.domain.document.DocumentNaming +import hs.kr.entrydsm.admin.domain.enum.ExportType +import hs.kr.entrydsm.admin.domain.model.AdmissionTicket +import hs.kr.entrydsm.admin.domain.model.Applicant +import hs.kr.entrydsm.admin.domain.model.ExportJob +import hs.kr.entrydsm.admin.domain.port.out.ApplicantRepository +import hs.kr.entrydsm.admin.domain.port.out.ExportJobRepository +import hs.kr.entrydsm.admin.domain.port.out.PdfRenderPort +import hs.kr.entrydsm.admin.domain.port.out.StoragePort +import java.io.ByteArrayOutputStream +import java.time.Clock +import java.time.Instant +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener + +private const val ZIP_CONTENT_TYPE = "application/zip" +private const val CSV_CONTENT_TYPE = "text/csv" + +/** + * 내보내기 산출물을 실제로 만들어 저장소에 올립니다. + * + * `@Async`는 프록시를 통해서만 동작하므로 작업 생성 서비스와 별도 빈으로 둡니다. + * 같은 클래스 안에서 호출하면 비동기로 돌지 않습니다. + * + * ponytail: 인메모리 executor라 서버가 죽으면 진행 중 작업이 유실된다. 재시도가 + * 필요해지면 DB 큐나 배치 스케줄러로 승격한다. + */ +@Component +class ExportJobProcessor( + private val exportJobRepository: ExportJobRepository, + private val applicantRepository: ApplicantRepository, + private val pdfRenderPort: PdfRenderPort, + private val storagePort: StoragePort, + private val clock: Clock, + @Value("\${admin.admission-year}") private val admissionYear: Int, +) { + private val logger = LoggerFactory.getLogger(javaClass) + + /** + * 작업 생성 트랜잭션이 커밋된 뒤에 실행합니다. 커밋 전에 다른 스레드가 같은 행을 건드리면 + * 아직 보이지 않는 행을 갱신하려다 실패합니다. + */ + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + fun onExportJobCreated(event: ExportJobCreatedEvent) { + process(event.job) + } + + private fun process(job: ExportJob) { + exportJobRepository.save(job.started()) + + runCatching { + val applicants = applicantRepository.findAll(job.filter) + when (job.type) { + ExportType.ADMISSION_TICKET -> bundleAdmissionTickets(job, applicants) + ExportType.APPLICANT_LIST -> writeApplicantList(job, applicants) + } + }.onSuccess { objectKey -> + exportJobRepository.save(job.completed(objectKey, Instant.now(clock))) + }.onFailure { cause -> + logger.error("Export job failed [exportJobId={}]", job.exportJobId, cause) + exportJobRepository.save(job.failed(Instant.now(clock))) + } + } + + private fun bundleAdmissionTickets(job: ExportJob, applicants: List): String { + val objectKey = DocumentNaming.admissionTicketBundleObjectKey(job.exportJobId) + + val archive = ByteArrayOutputStream().also { output -> + ZipOutputStream(output).use { zip -> + applicants.forEach { applicant -> + val pdf = pdfRenderPort.render( + AdmissionTicketHtml.render(AdmissionTicket.of(applicant, admissionYear)), + ) + zip.putNextEntry(ZipEntry("admission_ticket_${applicant.receiptNumber}.pdf")) + zip.write(pdf) + zip.closeEntry() + } + } + }.toByteArray() + + storagePort.upload(objectKey, ZIP_CONTENT_TYPE, archive) + return objectKey + } + + /** + * ponytail: 지원자 목록을 CSV로 낸다. 엑셀 서식이 필요해지면 그때 POI를 붙인다. + */ + private fun writeApplicantList(job: ExportJob, applicants: List): String { + val objectKey = DocumentNaming.applicantListObjectKey(job.exportJobId) + + val csv = buildString { + appendLine("접수번호,수험번호,성명,지역,전형,학력,원서도착,상태,총점") + applicants.forEach { applicant -> + appendLine( + listOf( + applicant.receiptNumber, + applicant.examineeNumber ?: "", + applicant.name, + applicant.region.label, + applicant.admissionType.label, + applicant.graduationStatus.label, + applicant.isSubmitted, + applicant.status, + applicant.score?.totalScore ?: "", + ).joinToString(",") { it.toString().replace(",", " ") }, + ) + } + } + + storagePort.upload(objectKey, CSV_CONTENT_TYPE, csv.toByteArray(Charsets.UTF_8)) + return objectKey + } +} diff --git a/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/ExportService.kt b/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/ExportService.kt new file mode 100644 index 00000000..9fa47636 --- /dev/null +++ b/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/ExportService.kt @@ -0,0 +1,70 @@ +package hs.kr.entrydsm.admin.application + +import hs.kr.entrydsm.admin.domain.command.CreateExportCommand +import hs.kr.entrydsm.admin.domain.enum.ErrorCode +import hs.kr.entrydsm.admin.domain.enum.ExportStatus +import hs.kr.entrydsm.admin.domain.exception.AdminDomainException +import hs.kr.entrydsm.admin.domain.model.DownloadLink +import hs.kr.entrydsm.admin.domain.model.ExportJob +import hs.kr.entrydsm.admin.domain.model.ExportJobView +import hs.kr.entrydsm.admin.domain.port.`in`.CreateExportUseCase +import hs.kr.entrydsm.admin.domain.port.`in`.ReadExportUseCase +import hs.kr.entrydsm.admin.domain.port.out.ExportJobRepository +import hs.kr.entrydsm.admin.domain.port.out.StoragePort +import java.time.Clock +import java.time.Instant +import java.util.UUID +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +private const val EXPORT_JOB_ID_PREFIX = "exp_" + +/** + * 내보내기 작업을 접수하고 상태를 조회합니다. 실제 생성은 [ExportJobProcessor]가 맡습니다. + */ +@Service +@Transactional(readOnly = true) +class ExportService( + private val exportJobRepository: ExportJobRepository, + private val applicationEventPublisher: ApplicationEventPublisher, + private val storagePort: StoragePort, + private val clock: Clock, + @Value("\${admin.storage.download-url-expires-seconds:900}") + private val downloadUrlExpiresInSeconds: Long, +) : CreateExportUseCase, + ReadExportUseCase { + + @Transactional + override fun create(command: CreateExportCommand): ExportJob { + val job = exportJobRepository.save( + ExportJob( + exportJobId = EXPORT_JOB_ID_PREFIX + UUID.randomUUID().toString().replace("-", ""), + type = command.type, + status = ExportStatus.PENDING, + filter = command.filter, + createdAt = Instant.now(clock), + ), + ) + + applicationEventPublisher.publishEvent(ExportJobCreatedEvent(job)) + return job + } + + override fun findById(exportJobId: String): ExportJobView { + val job = exportJobRepository.findByExportJobId(exportJobId) + ?: throw AdminDomainException(ErrorCode.EXPORT_JOB_NOT_FOUND) + + val download = job.objectKey + ?.takeIf { job.status.isDownloadable() } + ?.let { + DownloadLink( + downloadUrl = storagePort.issueDownloadUrl(it, downloadUrlExpiresInSeconds), + expiresAt = Instant.now(clock).plusSeconds(downloadUrlExpiresInSeconds), + ) + } + + return ExportJobView(job = job, download = download) + } +} diff --git a/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/ScorePolicyService.kt b/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/ScorePolicyService.kt new file mode 100644 index 00000000..371cc77f --- /dev/null +++ b/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/ScorePolicyService.kt @@ -0,0 +1,82 @@ +package hs.kr.entrydsm.admin.application + +import hs.kr.entrydsm.admin.domain.command.UpdateScorePolicyCommand +import hs.kr.entrydsm.admin.domain.enum.ErrorCode +import hs.kr.entrydsm.admin.domain.exception.AdminDomainException +import hs.kr.entrydsm.admin.domain.model.Applicant +import hs.kr.entrydsm.admin.domain.model.ApplicantScore +import hs.kr.entrydsm.admin.domain.model.ScorePolicy +import hs.kr.entrydsm.admin.domain.port.`in`.ReadScorePolicyUseCase +import hs.kr.entrydsm.admin.domain.port.`in`.UpdateScorePolicyUseCase +import hs.kr.entrydsm.admin.domain.port.out.ApplicantRepository +import hs.kr.entrydsm.admin.domain.port.out.ScorePolicyRepository +import java.math.BigDecimal +import java.math.RoundingMode +import java.time.Clock +import java.time.Instant +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class ScorePolicyService( + private val scorePolicyRepository: ScorePolicyRepository, + private val applicantRepository: ApplicantRepository, + private val clock: Clock, +) : ReadScorePolicyUseCase, + UpdateScorePolicyUseCase { + + override fun findCurrent(): ScorePolicy = + scorePolicyRepository.findCurrent() + ?: throw AdminDomainException(ErrorCode.SCORE_POLICY_NOT_FOUND) + + @Transactional + override fun update(command: UpdateScorePolicyCommand) { + val nextVersion = (scorePolicyRepository.findCurrent()?.policyVersion ?: 0) + 1 + val policy = scorePolicyRepository.save( + ScorePolicy( + policyVersion = nextVersion, + weights = command.weights, + roundingScale = command.roundingScale, + effectiveFrom = Instant.now(clock), + updatedBy = command.updatedBy, + ), + ) + + if (command.recalculate) { + recalculateAll(policy) + } + } + + /** + * 새 정책의 가중치로 모든 지원자의 총점을 다시 계산합니다. + * + * ponytail: 동기로 전부 다시 계산한다. 지원자 수가 만 단위가 되면 배치로 뺀다. + */ + private fun recalculateAll(policy: ScorePolicy) { + val recalculated = applicantRepository.findAll() + .filter { it.score != null } + .map { it.recalculated(policy) } + + applicantRepository.saveAll(recalculated) + } + + private fun Applicant.recalculated(policy: ScorePolicy): Applicant { + val current = score!! + val total = current.subjectScore * policy.weights.subject + + current.attendanceScore * policy.weights.attendance + + current.volunteerScore * policy.weights.volunteer + + return copy( + score = ApplicantScore( + subjectScore = current.subjectScore, + attendanceScore = current.attendanceScore, + volunteerScore = current.volunteerScore, + totalScore = BigDecimal(total) + .setScale(policy.roundingScale, RoundingMode.HALF_UP) + .toDouble(), + ), + updatedAt = Instant.now(clock), + ) + } +} diff --git a/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/ScreeningService.kt b/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/ScreeningService.kt new file mode 100644 index 00000000..75b05225 --- /dev/null +++ b/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/ScreeningService.kt @@ -0,0 +1,52 @@ +package hs.kr.entrydsm.admin.application + +import hs.kr.entrydsm.admin.domain.command.EvaluateScreeningCommand +import hs.kr.entrydsm.admin.domain.model.ScreeningResult +import hs.kr.entrydsm.admin.domain.policy.ScreeningPolicy +import hs.kr.entrydsm.admin.domain.policy.ScreeningStage +import hs.kr.entrydsm.admin.domain.port.`in`.EvaluateFinalScreeningUseCase +import hs.kr.entrydsm.admin.domain.port.`in`.EvaluateFirstScreeningUseCase +import hs.kr.entrydsm.admin.domain.port.out.ApplicantRepository +import java.time.Clock +import java.time.Instant +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class ScreeningService( + private val applicantRepository: ApplicantRepository, + private val clock: Clock, + @Value("\${admin.screening.first-quota}") private val firstQuota: Int, + @Value("\${admin.screening.final-quota}") private val finalQuota: Int, +) : EvaluateFirstScreeningUseCase, + EvaluateFinalScreeningUseCase { + + @Transactional + override fun evaluateFirst(command: EvaluateScreeningCommand): ScreeningResult = + evaluate(ScreeningStage.FIRST, firstQuota, command.dryRun) + + @Transactional + override fun evaluateFinal(command: EvaluateScreeningCommand): ScreeningResult = + evaluate(ScreeningStage.FINAL, finalQuota, command.dryRun) + + private fun evaluate(stage: ScreeningStage, quota: Int, dryRun: Boolean): ScreeningResult { + val outcome = ScreeningPolicy.evaluate(applicantRepository.findAll(), stage, quota) + val now = Instant.now(clock) + + if (!dryRun) { + applicantRepository.saveAll( + (outcome.passed + outcome.failed).map { it.copy(updatedAt = now) }, + ) + } + + return ScreeningResult( + dryRun = dryRun, + passCount = outcome.passed.size, + failCount = outcome.failed.size, + excludedCount = outcome.excluded.size, + processedAt = now, + ) + } +} diff --git a/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/StatisticsService.kt b/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/StatisticsService.kt new file mode 100644 index 00000000..b06ac0b6 --- /dev/null +++ b/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/StatisticsService.kt @@ -0,0 +1,65 @@ +package hs.kr.entrydsm.admin.application + +import hs.kr.entrydsm.admin.domain.enum.AdmissionType +import hs.kr.entrydsm.admin.domain.enum.StatisticsMetric +import hs.kr.entrydsm.admin.domain.model.ApplicantCount +import hs.kr.entrydsm.admin.domain.model.ApplicantStatistics +import hs.kr.entrydsm.admin.domain.port.`in`.ReadStatisticsUseCase +import hs.kr.entrydsm.admin.domain.port.out.ApplicantRepository +import java.math.BigDecimal +import java.math.RoundingMode +import java.time.Clock +import java.time.Instant +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +private const val COMPETITION_RATE_SCALE = 2 + +@Service +@Transactional(readOnly = true) +class StatisticsService( + private val applicantRepository: ApplicantRepository, + private val quotaProperties: AdmissionQuotaProperties, + private val clock: Clock, +) : ReadStatisticsUseCase { + + override fun collect(metrics: Set): ApplicantStatistics { + val countByType by lazy { applicantRepository.countByAdmissionType() } + + return ApplicantStatistics( + generatedAt = Instant.now(clock), + applicantCount = metrics.ifRequested(StatisticsMetric.APPLICANT_COUNT) { + ApplicantCount(total = applicantRepository.countAll(), byType = countByType) + }, + competitionRate = metrics.ifRequested(StatisticsMetric.COMPETITION_RATE) { + competitionRate(countByType) + }, + regionDistribution = metrics.ifRequested(StatisticsMetric.REGION_DISTRIBUTION) { + applicantRepository.countByRegion() + }, + typeDistribution = metrics.ifRequested(StatisticsMetric.TYPE_DISTRIBUTION) { + countByType + }, + dailyTrend = metrics.ifRequested(StatisticsMetric.DAILY_TREND) { + applicantRepository.countBySubmittedDate() + }, + ) + } + + /** + * 전형별 지원자 수를 모집 정원으로 나눕니다. 정원이 설정되지 않은 전형은 건너뜁니다. + */ + private fun competitionRate(countByType: Map): Map = + quotaProperties.byType + .filterValues { it > 0 } + .mapValues { (type, quota) -> + BigDecimal((countByType[type] ?: 0L).toDouble() / quota) + .setScale(COMPETITION_RATE_SCALE, RoundingMode.HALF_UP) + .toDouble() + } + + private fun Set.ifRequested( + metric: StatisticsMetric, + block: () -> T, + ): T? = if (metric in this) block() else null +} diff --git a/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/SupportService.kt b/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/SupportService.kt new file mode 100644 index 00000000..279b7903 --- /dev/null +++ b/systems/admin/admin-application/src/main/kotlin/hs/kr/entrydsm/admin/application/SupportService.kt @@ -0,0 +1,42 @@ +package hs.kr.entrydsm.admin.application + +import hs.kr.entrydsm.admin.domain.command.AnswerQuestionCommand +import hs.kr.entrydsm.admin.domain.command.CreateNoticeCommand +import hs.kr.entrydsm.admin.domain.model.Notice +import hs.kr.entrydsm.admin.domain.model.QuestionAnswer +import hs.kr.entrydsm.admin.domain.port.`in`.AnswerQuestionUseCase +import hs.kr.entrydsm.admin.domain.port.`in`.CreateNoticeUseCase +import hs.kr.entrydsm.admin.domain.port.out.NoticeRepository +import hs.kr.entrydsm.admin.domain.port.out.QuestionAnswerRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class SupportService( + private val noticeRepository: NoticeRepository, + private val questionAnswerRepository: QuestionAnswerRepository, +) : CreateNoticeUseCase, + AnswerQuestionUseCase { + + @Transactional + override fun create(command: CreateNoticeCommand): Notice = + noticeRepository.save( + Notice( + title = command.title, + content = command.content, + isPinned = command.isPinned, + attachmentIds = command.attachmentIds, + ), + ) + + @Transactional + override fun answer(command: AnswerQuestionCommand): QuestionAnswer = + questionAnswerRepository.save( + QuestionAnswer( + questionId = command.questionId, + content = command.content, + answeredBy = command.answeredBy, + ), + ) +} diff --git a/systems/admin/admin-bootstrap/deps.bzl b/systems/admin/admin-bootstrap/deps.bzl index bfdb5aef..4f44529a 100644 --- a/systems/admin/admin-bootstrap/deps.bzl +++ b/systems/admin/admin-bootstrap/deps.bzl @@ -1,11 +1,16 @@ SPRING_DEPS = [ "@maven//:org_springframework_boot_spring_boot_starter_web", + "@maven//:org_springframework_boot_spring_boot_starter_validation", + "@maven//:org_springframework_boot_spring_boot_starter_data_jpa", "@maven//:org_springframework_boot_spring_boot_starter_actuator", + "@maven//:com_mysql_mysql_connector_j", ] KOTLIN_DEPS = [ "@maven//:org_jetbrains_kotlin_kotlin_reflect", "@maven//:com_fasterxml_jackson_module_jackson_module_kotlin", + "@maven//:tools_jackson_module_jackson_module_kotlin", + "@maven//:software_amazon_awssdk_s3", ] TEST_DEPS = [ diff --git a/systems/admin/admin-bootstrap/src/main/kotlin/hs/kr/entrydsm/ExampleApplication.kt b/systems/admin/admin-bootstrap/src/main/kotlin/hs/kr/entrydsm/AdminBootstrapApplication.kt similarity index 100% rename from systems/admin/admin-bootstrap/src/main/kotlin/hs/kr/entrydsm/ExampleApplication.kt rename to systems/admin/admin-bootstrap/src/main/kotlin/hs/kr/entrydsm/AdminBootstrapApplication.kt diff --git a/systems/admin/admin-bootstrap/src/main/kotlin/hs/kr/entrydsm/admin/config/AdminConfig.kt b/systems/admin/admin-bootstrap/src/main/kotlin/hs/kr/entrydsm/admin/config/AdminConfig.kt new file mode 100644 index 00000000..9ce90d9b --- /dev/null +++ b/systems/admin/admin-bootstrap/src/main/kotlin/hs/kr/entrydsm/admin/config/AdminConfig.kt @@ -0,0 +1,41 @@ +package hs.kr.entrydsm.admin.config + +import hs.kr.entrydsm.admin.adapterin.web.AdminAuthorizationInterceptor +import hs.kr.entrydsm.admin.adapterin.web.AdminEndpointPaths +import hs.kr.entrydsm.admin.application.AdmissionQuotaProperties +import java.time.Clock +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.annotation.EnableAsync +import org.springframework.web.servlet.config.annotation.InterceptorRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.presigner.S3Presigner + +/** + * S3 클라이언트는 AWS 표준 설정 체계(`AWS_REGION`, `AWS_ENDPOINT_URL_S3`, 자격 증명 체인)를 + * 그대로 씁니다. 별도 프로퍼티로 감싸지 않아야 로컬 MinIO와 운영 배포가 같은 방식으로 동작합니다. + */ +@Configuration(proxyBeanMethods = false) +@EnableAsync +@EnableConfigurationProperties(AdmissionQuotaProperties::class) +class AdminConfig( + private val adminAuthorizationInterceptor: AdminAuthorizationInterceptor, +) : WebMvcConfigurer { + + @Bean + fun clock(): Clock = Clock.systemUTC() + + @Bean + fun s3Client(): S3Client = S3Client.create() + + @Bean + fun s3Presigner(): S3Presigner = S3Presigner.create() + + override fun addInterceptors(registry: InterceptorRegistry) { + registry + .addInterceptor(adminAuthorizationInterceptor) + .addPathPatterns("${AdminEndpointPaths.BASE}/**") + } +} diff --git a/systems/admin/admin-bootstrap/src/main/resources/application.yaml b/systems/admin/admin-bootstrap/src/main/resources/application.yaml index 939589b9..4d60589a 100644 --- a/systems/admin/admin-bootstrap/src/main/resources/application.yaml +++ b/systems/admin/admin-bootstrap/src/main/resources/application.yaml @@ -5,8 +5,34 @@ spring: default: local main: lazy-initialization: true + datasource: + url: jdbc:mysql://localhost:3306/admin_db?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true + username: ${DB_USERNAME:root} + password: ${DB_PASSWORD:password} + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: validate + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect server: shutdown: graceful +admin: + admission-year: ${ADMISSION_YEAR:2027} + screening: + first-quota: ${FIRST_SCREENING_QUOTA:80} + final-quota: ${FINAL_SCREENING_QUOTA:72} + quota: + by-type: + GENERAL: ${GENERAL_QUOTA:20} + MEISTER: ${MEISTER_QUOTA:10} + SOCIAL: ${SOCIAL_QUOTA:10} + storage: + # 리전, 엔드포인트, 자격 증명은 AWS 표준 환경변수(AWS_REGION, AWS_ENDPOINT_URL_S3 등)를 따른다. + bucket: ${STORAGE_BUCKET:entrydsm-admin} + download-url-expires-seconds: ${DOWNLOAD_URL_EXPIRES_SECONDS:900} management: endpoints: web: diff --git a/systems/admin/admin-bootstrap/src/main/resources/schema.sql b/systems/admin/admin-bootstrap/src/main/resources/schema.sql new file mode 100644 index 00000000..20d57051 --- /dev/null +++ b/systems/admin/admin-bootstrap/src/main/resources/schema.sql @@ -0,0 +1,73 @@ +-- admin_db 스키마. +-- ddl-auto 가 validate 이고 마이그레이션 도구가 없으므로 배포 전에 직접 적용한다. +-- configuration 시스템과 같은 방식이다. + +CREATE TABLE IF NOT EXISTS applicant ( + id BIGINT NOT NULL AUTO_INCREMENT, + receipt_number INT NOT NULL, + name VARCHAR(50) NOT NULL, + birth_date DATE NOT NULL, + phone_number VARCHAR(20) NOT NULL, + region VARCHAR(20) NOT NULL, + admission_type VARCHAR(20) NOT NULL, + graduation_status VARCHAR(20) NOT NULL, + school_name VARCHAR(100) NOT NULL, + examinee_number VARCHAR(20) NULL, + is_submitted BIT(1) NOT NULL, + status VARCHAR(20) NOT NULL, + subject_score DOUBLE NULL, + attendance_score DOUBLE NULL, + volunteer_score DOUBLE NULL, + total_score DOUBLE NULL, + submitted_at DATETIME(6) NULL, + updated_at DATETIME(6) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_applicant_receipt_number (receipt_number), + KEY idx_applicant_status (status), + KEY idx_applicant_total_score (total_score) +); + +CREATE TABLE IF NOT EXISTS score_policy ( + id BIGINT NOT NULL AUTO_INCREMENT, + policy_version INT NOT NULL, + subject_weight DOUBLE NOT NULL, + attendance_weight DOUBLE NOT NULL, + volunteer_weight DOUBLE NOT NULL, + rounding_scale INT NOT NULL, + effective_from DATETIME(6) NOT NULL, + updated_by VARCHAR(50) NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_score_policy_version (policy_version) +); + +CREATE TABLE IF NOT EXISTS export_job ( + id BIGINT NOT NULL AUTO_INCREMENT, + export_job_id VARCHAR(40) NOT NULL, + type VARCHAR(30) NOT NULL, + status VARCHAR(20) NOT NULL, + object_key VARCHAR(255) NULL, + created_at DATETIME(6) NOT NULL, + completed_at DATETIME(6) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_export_job_id (export_job_id) +); + +CREATE TABLE IF NOT EXISTS notice ( + id BIGINT NOT NULL AUTO_INCREMENT, + title VARCHAR(200) NOT NULL, + content TEXT NOT NULL, + is_pinned BIT(1) NOT NULL, + attachment_ids VARCHAR(500) NULL, + created_at DATETIME(6) NOT NULL, + PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS question_answer ( + id BIGINT NOT NULL AUTO_INCREMENT, + question_id BIGINT NOT NULL, + content TEXT NOT NULL, + answered_by VARCHAR(50) NOT NULL, + answered_at DATETIME(6) NOT NULL, + PRIMARY KEY (id), + KEY idx_question_answer_question_id (question_id) +); diff --git a/systems/admin/admin-domain/BUILD.bazel b/systems/admin/admin-domain/BUILD.bazel index 8efb94cd..4885df77 100644 --- a/systems/admin/admin-domain/BUILD.bazel +++ b/systems/admin/admin-domain/BUILD.bazel @@ -17,5 +17,32 @@ kt_jvm_test( javac_opts = "//:javac_options", kotlinc_opts = "//:kotlinc_options", test_class = "hs.kr.entrydsm.admin.domain.AdminDomainModuleTest", - deps = MODULE_DEPS + TEST_DEPS, + deps = MODULE_DEPS + TEST_DEPS + [":main"], +) + +kt_jvm_test( + name = "admission_ticket_html_test", + srcs = glob(["src/test/kotlin/**/*.kt"]), + javac_opts = "//:javac_options", + kotlinc_opts = "//:kotlinc_options", + test_class = "hs.kr.entrydsm.admin.domain.AdmissionTicketHtmlTest", + deps = MODULE_DEPS + TEST_DEPS + [":main"], +) + +kt_jvm_test( + name = "score_policy_test", + srcs = glob(["src/test/kotlin/**/*.kt"]), + javac_opts = "//:javac_options", + kotlinc_opts = "//:kotlinc_options", + test_class = "hs.kr.entrydsm.admin.domain.ScorePolicyTest", + deps = MODULE_DEPS + TEST_DEPS + [":main"], +) + +kt_jvm_test( + name = "admin_policy_test", + srcs = glob(["src/test/kotlin/**/*.kt"]), + javac_opts = "//:javac_options", + kotlinc_opts = "//:kotlinc_options", + test_class = "hs.kr.entrydsm.admin.domain.AdminPolicyTest", + deps = MODULE_DEPS + TEST_DEPS + [":main"], ) diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/command/AdminCommands.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/command/AdminCommands.kt new file mode 100644 index 00000000..27cc0269 --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/command/AdminCommands.kt @@ -0,0 +1,75 @@ +package hs.kr.entrydsm.admin.domain.command + +import hs.kr.entrydsm.admin.domain.enum.ApplicantStatus +import hs.kr.entrydsm.admin.domain.enum.ExportType +import hs.kr.entrydsm.admin.domain.model.ApplicantFilter +import hs.kr.entrydsm.admin.domain.model.ScoreWeights + +/** + * 원서 도착 여부를 정정합니다. + */ +data class UpdateArrivalCommand( + val applicantId: Long, + val isSubmitted: Boolean, +) + +/** + * 지원자 상태를 개별 정정합니다. + * + * @property force 정상 전이 흐름을 벗어나 강제로 바꿀지 여부 + * @property reason 정정 사유. 강제 변경일 때는 반드시 있어야 한다 + */ +data class UpdateApplicantStatusCommand( + val applicantId: Long, + val status: ApplicantStatus, + val force: Boolean = false, + val reason: String? = null, +) + +/** + * 성적 산출 정책을 교체합니다. + * + * @property recalculate 정책 반영 후 기존 지원자 점수를 다시 계산할지 여부 + */ +data class UpdateScorePolicyCommand( + val weights: ScoreWeights, + val roundingScale: Int, + val recalculate: Boolean = false, + val updatedBy: String, +) + +/** + * 합격자를 일괄 산출합니다. + * + * @property dryRun true이면 상태를 저장하지 않고 산출 결과만 돌려준다 + */ +data class EvaluateScreeningCommand( + val dryRun: Boolean = false, +) + +/** + * 내보내기 작업을 생성합니다. + */ +data class CreateExportCommand( + val type: ExportType, + val filter: ApplicantFilter = ApplicantFilter(), +) + +/** + * 공지사항을 등록합니다. + */ +data class CreateNoticeCommand( + val title: String, + val content: String, + val isPinned: Boolean = false, + val attachmentIds: List = emptyList(), +) + +/** + * 지원자 질문에 답변합니다. + */ +data class AnswerQuestionCommand( + val questionId: Long, + val content: String, + val answeredBy: String, +) diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/document/AdmissionTicketHtml.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/document/AdmissionTicketHtml.kt new file mode 100644 index 00000000..6eb3c106 --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/document/AdmissionTicketHtml.kt @@ -0,0 +1,75 @@ +package hs.kr.entrydsm.admin.domain.document + +import hs.kr.entrydsm.admin.domain.model.AdmissionTicket + +private const val SCHOOL_NAME = "대덕소프트웨어마이스터고등학교" +private const val PRINCIPAL_LINE = "대덕소프트웨어마이스터고등학교장" +private const val UNISSUED_EXAMINEE_NUMBER = "미발급" + +/** + * 수험표 레이아웃을 XHTML 문자열로 만듭니다. + * + * PDF 변환기(openhtmltopdf)는 well-formed XHTML만 받으므로 태그를 모두 닫아야 합니다. + * 렌더링 규칙을 도메인에 두어 PDF 라이브러리 없이도 레이아웃을 검증할 수 있게 했습니다. + */ +object AdmissionTicketHtml { + + fun render(ticket: AdmissionTicket): String { + val rows = listOf( + "수험번호" to (ticket.examineeNumber ?: UNISSUED_EXAMINEE_NUMBER), + "성명" to ticket.name, + "출신 중학교" to ticket.schoolName, + "지역" to ticket.region.label, + "전형 유형" to ticket.admissionType.label, + "접수 번호" to ticket.receiptNumber.toString(), + ) + + return """ + + + + 수험표 + + + + + + + + + + ${rows.first().let { (label, value) -> cells(label, value) }} + + ${rows.drop(1).joinToString("\n") { (label, value) -> "${cells(label, value)}" }} + + + +
${escape("${ticket.admissionYear}학년도 $SCHOOL_NAME 입학전형 수험표")}
${photoCell(ticket.photoDataUri)}
$PRINCIPAL_LINE
+ + + """.trimIndent() + } + + private fun cells(label: String, value: String): String = + """${escape(label)}${escape(value)}""" + + private fun photoCell(photoDataUri: String?): String = + photoDataUri?.let { """사진""" } ?: "사진" + + private fun escape(value: String): String = value + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'") +} diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/document/DocumentNaming.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/document/DocumentNaming.kt new file mode 100644 index 00000000..721c9e76 --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/document/DocumentNaming.kt @@ -0,0 +1,24 @@ +package hs.kr.entrydsm.admin.domain.document + +/** + * 관리자가 발급하는 문서의 저장소 객체 키 규칙입니다. + * + * configuration 시스템의 `FileNaming`/`FileCategory`와 같은 규칙을 따릅니다. + * + * ponytail: 두 시스템이 같은 규칙을 각자 들고 있다. 세 번째 시스템이 같은 걸 필요로 하면 + * packages/ 공용 모듈로 올린다. + */ +object DocumentNaming { + + fun admissionTicketObjectKey(receiptNumber: Int): String = + "admission-ticket/admission_ticket_$receiptNumber.pdf" + + fun applicationDocumentObjectKey(receiptNumber: Int): String = + "application/application_$receiptNumber.pdf" + + fun applicantListObjectKey(exportJobId: String): String = + "applicant-list/applicants_$exportJobId.csv" + + fun admissionTicketBundleObjectKey(exportJobId: String): String = + "admission-ticket/admission_tickets_$exportJobId.zip" +} diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/enum/AdmissionType.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/enum/AdmissionType.kt new file mode 100644 index 00000000..b40f79d8 --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/enum/AdmissionType.kt @@ -0,0 +1,12 @@ +package hs.kr.entrydsm.admin.domain.enum + +/** + * 지원자가 선택한 전형 유형입니다. + * + * @property label 수험표 등 대외 문서에 출력하는 한글 표기 + */ +enum class AdmissionType(val label: String) { + GENERAL("일반전형"), + MEISTER("마이스터전형"), + SOCIAL("사회통합전형"), +} diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/enum/ApplicantStatus.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/enum/ApplicantStatus.kt new file mode 100644 index 00000000..080225ef --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/enum/ApplicantStatus.kt @@ -0,0 +1,27 @@ +package hs.kr.entrydsm.admin.domain.enum + +/** + * 지원자의 전형 진행 상태입니다. + * + * 정상 흐름은 `PENDING` -> 1차 결과 -> 최종 결과 순으로만 진행합니다. + * 관리자가 정정을 위해 이 흐름을 벗어나야 하는 경우에는 강제 변경을 사용합니다. + */ +enum class ApplicantStatus { + PENDING, + FIRST_PASS, + FIRST_FAIL, + FINAL_PASS, + FINAL_FAIL, + ; + + /** + * 정상 흐름에서 [next] 상태로 넘어갈 수 있는지 판단합니다. + */ + fun canTransitionTo(next: ApplicantStatus): Boolean = next in allowedNextStatuses() + + private fun allowedNextStatuses(): Set = when (this) { + PENDING -> setOf(FIRST_PASS, FIRST_FAIL) + FIRST_PASS -> setOf(FINAL_PASS, FINAL_FAIL) + FIRST_FAIL, FINAL_PASS, FINAL_FAIL -> emptySet() + } +} diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/enum/ErrorCode.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/enum/ErrorCode.kt new file mode 100644 index 00000000..d4e5d21e --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/enum/ErrorCode.kt @@ -0,0 +1,28 @@ +package hs.kr.entrydsm.admin.domain.enum + +/** + * Admin API에서 클라이언트에 노출하는 오류 코드입니다. + * + * @property status 오류에 대응하는 HTTP 상태 코드 + * @property message 클라이언트에 전달할 기본 오류 메시지 + */ +enum class ErrorCode( + val status: Int, + val message: String, +) { + INVALID_REQUEST_BODY(400, "요청 본문이 올바르지 않습니다."), + INVALID_SCORE_POLICY(400, "성적 정책 가중치의 합은 1이어야 합니다."), + INVALID_STATISTICS_METRIC(400, "지원하지 않는 통계 지표입니다."), + AUTH_UNAUTHORIZED(401, "인증이 필요합니다."), + ACCESS_DENIED(403, "관리자 권한이 없습니다."), + APPLICANT_NOT_FOUND(404, "지원자를 찾을 수 없습니다."), + SCORE_POLICY_NOT_FOUND(404, "등록된 성적 정책이 없습니다."), + EXPORT_JOB_NOT_FOUND(404, "Export 작업을 찾을 수 없습니다."), + APPLICATION_DOCUMENT_NOT_FOUND(404, "제출된 원서 원본이 없습니다."), + INVALID_STATUS_TRANSITION(409, "현재 상태에서는 변경할 수 없는 상태입니다."), + EXAMINEE_NUMBER_NOT_ISSUED(409, "수험 번호가 발급되지 않은 지원자입니다."), + EXPORT_NOT_COMPLETED(409, "아직 완료되지 않은 Export 작업입니다."), + ADMISSION_TICKET_GENERATION_FAILED(500, "수험표 생성에 실패했습니다."), + STORAGE_UNAVAILABLE(500, "파일 저장소를 사용할 수 없습니다."), + INTERNAL_SERVER_ERROR(500, "서버 내부 오류가 발생했습니다."), +} diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/enum/ExportStatus.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/enum/ExportStatus.kt new file mode 100644 index 00000000..4127bb82 --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/enum/ExportStatus.kt @@ -0,0 +1,14 @@ +package hs.kr.entrydsm.admin.domain.enum + +/** + * 내보내기 작업의 진행 상태입니다. + */ +enum class ExportStatus { + PENDING, + PROCESSING, + COMPLETED, + FAILED, + ; + + fun isDownloadable(): Boolean = this == COMPLETED +} diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/enum/ExportType.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/enum/ExportType.kt new file mode 100644 index 00000000..780110e7 --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/enum/ExportType.kt @@ -0,0 +1,9 @@ +package hs.kr.entrydsm.admin.domain.enum + +/** + * 내보내기로 만들 수 있는 산출물 종류입니다. + */ +enum class ExportType { + ADMISSION_TICKET, + APPLICANT_LIST, +} diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/enum/GraduationStatus.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/enum/GraduationStatus.kt new file mode 100644 index 00000000..d53b99e6 --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/enum/GraduationStatus.kt @@ -0,0 +1,12 @@ +package hs.kr.entrydsm.admin.domain.enum + +/** + * 지원자의 학력 구분입니다. + * + * @property label 대외 문서에 출력하는 한글 표기 + */ +enum class GraduationStatus(val label: String) { + EXPECTED("졸업예정"), + GRADUATED("졸업"), + GED("검정고시"), +} diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/enum/Region.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/enum/Region.kt new file mode 100644 index 00000000..ef73180a --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/enum/Region.kt @@ -0,0 +1,11 @@ +package hs.kr.entrydsm.admin.domain.enum + +/** + * 지원자가 선택한 모집 지역입니다. + * + * @property label 수험표 등 대외 문서에 출력하는 한글 표기 + */ +enum class Region(val label: String) { + DAEJEON("대전"), + NATIONWIDE("전국"), +} diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/enum/StatisticsMetric.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/enum/StatisticsMetric.kt new file mode 100644 index 00000000..b982f49d --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/enum/StatisticsMetric.kt @@ -0,0 +1,12 @@ +package hs.kr.entrydsm.admin.domain.enum + +/** + * 지원 현황 통계에서 조회할 수 있는 지표입니다. + */ +enum class StatisticsMetric { + APPLICANT_COUNT, + COMPETITION_RATE, + REGION_DISTRIBUTION, + TYPE_DISTRIBUTION, + DAILY_TREND, +} diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/exception/AdminDomainException.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/exception/AdminDomainException.kt new file mode 100644 index 00000000..13e99274 --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/exception/AdminDomainException.kt @@ -0,0 +1,8 @@ +package hs.kr.entrydsm.admin.domain.exception + +import hs.kr.entrydsm.admin.domain.enum.ErrorCode + +class AdminDomainException( + errorCode: ErrorCode, + cause: Throwable? = null, +) : AdminException(errorCode, cause) diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/exception/AdminException.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/exception/AdminException.kt new file mode 100644 index 00000000..5d1c4f58 --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/exception/AdminException.kt @@ -0,0 +1,11 @@ +package hs.kr.entrydsm.admin.domain.exception + +import hs.kr.entrydsm.admin.domain.enum.ErrorCode + +/** + * Admin 도메인에서 의도적으로 발생시키는 예외의 기본 타입입니다. + */ +abstract class AdminException( + val errorCode: ErrorCode, + cause: Throwable? = null, +) : RuntimeException(errorCode.message, cause) diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/AdminResults.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/AdminResults.kt new file mode 100644 index 00000000..52ee3b5d --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/AdminResults.kt @@ -0,0 +1,62 @@ +package hs.kr.entrydsm.admin.domain.model + +import hs.kr.entrydsm.admin.domain.enum.AdmissionType +import hs.kr.entrydsm.admin.domain.enum.Region +import java.time.Instant +import java.time.LocalDate + +/** + * 수험 번호 일괄 발급 결과입니다. + */ +data class ExamineeNumberIssueResult( + val issuedCount: Int, + val skippedCount: Int, + val totalTargets: Int, +) + +/** + * 합격자 일괄 산출 결과입니다. + */ +data class ScreeningResult( + val dryRun: Boolean, + val passCount: Int, + val failCount: Int, + val excludedCount: Int, + val processedAt: Instant, +) + +/** + * 서명된 다운로드 링크입니다. + */ +data class DownloadLink( + val downloadUrl: String, + val expiresAt: Instant, +) + +/** + * 날짜별 접수 건수입니다. + */ +data class DailyApplicantCount( + val date: LocalDate, + val count: Long, +) + +/** + * 지원 현황 통계입니다. 요청하지 않은 지표는 null로 둡니다. + */ +data class ApplicantStatistics( + val generatedAt: Instant, + val applicantCount: ApplicantCount? = null, + val competitionRate: Map? = null, + val regionDistribution: Map? = null, + val typeDistribution: Map? = null, + val dailyTrend: List? = null, +) + +/** + * 전체 지원자 수와 전형별 내역입니다. + */ +data class ApplicantCount( + val total: Long, + val byType: Map, +) diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/AdmissionTicket.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/AdmissionTicket.kt new file mode 100644 index 00000000..d41e5624 --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/AdmissionTicket.kt @@ -0,0 +1,41 @@ +package hs.kr.entrydsm.admin.domain.model + +import hs.kr.entrydsm.admin.domain.enum.AdmissionType +import hs.kr.entrydsm.admin.domain.enum.Region + +/** + * 수험표에 인쇄되는 값만 추린 모델입니다. + * + * @property admissionYear 입학 학년도. 지원자 정보가 아니라 전형 설정에서 주입한다 + * @property photoDataUri 증명사진. 없으면 사진 칸을 빈 칸으로 인쇄한다 + */ +data class AdmissionTicket( + val admissionYear: Int, + val receiptNumber: Int, + val examineeNumber: String?, + val name: String, + val schoolName: String, + val region: Region, + val admissionType: AdmissionType, + val photoDataUri: String? = null, +) { + companion object { + /** + * 지원자로부터 수험표를 만듭니다. 수험 번호는 아직 없을 수 있습니다. + */ + fun of( + applicant: Applicant, + admissionYear: Int, + photoDataUri: String? = null, + ): AdmissionTicket = AdmissionTicket( + admissionYear = admissionYear, + receiptNumber = applicant.receiptNumber, + examineeNumber = applicant.examineeNumber, + name = applicant.name, + schoolName = applicant.schoolName, + region = applicant.region, + admissionType = applicant.admissionType, + photoDataUri = photoDataUri, + ) + } +} diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/Applicant.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/Applicant.kt new file mode 100644 index 00000000..08aa9109 --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/Applicant.kt @@ -0,0 +1,33 @@ +package hs.kr.entrydsm.admin.domain.model + +import hs.kr.entrydsm.admin.domain.enum.AdmissionType +import hs.kr.entrydsm.admin.domain.enum.ApplicantStatus +import hs.kr.entrydsm.admin.domain.enum.GraduationStatus +import hs.kr.entrydsm.admin.domain.enum.Region +import java.time.Instant +import java.time.LocalDate + +/** + * 관리자가 조회하고 관리하는 지원자(원서) 정보입니다. + * + * @property receiptNumber 접수 순서대로 부여되는 접수 번호 + * @property examineeNumber 수험 번호. 일괄 발급 전에는 null + * @property isSubmitted 원서 원본(우편) 도착 여부 + */ +data class Applicant( + val id: Long? = null, + val receiptNumber: Int, + val name: String, + val birthDate: LocalDate, + val phoneNumber: String, + val region: Region, + val admissionType: AdmissionType, + val graduationStatus: GraduationStatus, + val schoolName: String, + val examineeNumber: String? = null, + val isSubmitted: Boolean = false, + val status: ApplicantStatus = ApplicantStatus.PENDING, + val score: ApplicantScore? = null, + val submittedAt: Instant? = null, + val updatedAt: Instant? = null, +) diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/ApplicantFilter.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/ApplicantFilter.kt new file mode 100644 index 00000000..46dd5b34 --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/ApplicantFilter.kt @@ -0,0 +1,22 @@ +package hs.kr.entrydsm.admin.domain.model + +import hs.kr.entrydsm.admin.domain.enum.AdmissionType +import hs.kr.entrydsm.admin.domain.enum.ApplicantStatus +import hs.kr.entrydsm.admin.domain.enum.GraduationStatus +import hs.kr.entrydsm.admin.domain.enum.Region + +/** + * 지원자 목록 조회와 내보내기에서 함께 쓰는 필터 조건입니다. + * + * 비어 있는 컬렉션과 null은 모두 "해당 조건으로 거르지 않음"을 뜻합니다. + * + * @property keyword 이름 또는 수험 번호 부분 일치 검색어 + */ +data class ApplicantFilter( + val keyword: String? = null, + val regions: Set = emptySet(), + val admissionTypes: Set = emptySet(), + val graduationStatuses: Set = emptySet(), + val isSubmitted: Boolean? = null, + val statuses: Set = emptySet(), +) diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/ApplicantScore.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/ApplicantScore.kt new file mode 100644 index 00000000..d894ac20 --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/ApplicantScore.kt @@ -0,0 +1,13 @@ +package hs.kr.entrydsm.admin.domain.model + +/** + * 지원자의 성적 산출 결과입니다. + * + * 각 항목 점수는 성적 정책의 가중치가 이미 반영된 값입니다. + */ +data class ApplicantScore( + val subjectScore: Double, + val attendanceScore: Double, + val volunteerScore: Double, + val totalScore: Double, +) diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/ExportJob.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/ExportJob.kt new file mode 100644 index 00000000..e1507b7a --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/ExportJob.kt @@ -0,0 +1,30 @@ +package hs.kr.entrydsm.admin.domain.model + +import hs.kr.entrydsm.admin.domain.enum.ExportStatus +import hs.kr.entrydsm.admin.domain.enum.ExportType +import java.time.Instant + +/** + * 비동기로 처리하는 내보내기 작업입니다. + * + * @property exportJobId 외부에 노출하는 작업 식별자 + * @property objectKey 완료된 산출물의 저장소 객체 키. 완료 전에는 null + */ +data class ExportJob( + val id: Long? = null, + val exportJobId: String, + val type: ExportType, + val status: ExportStatus, + val filter: ApplicantFilter = ApplicantFilter(), + val objectKey: String? = null, + val createdAt: Instant, + val completedAt: Instant? = null, +) { + fun started(): ExportJob = copy(status = ExportStatus.PROCESSING) + + fun completed(objectKey: String, completedAt: Instant): ExportJob = + copy(status = ExportStatus.COMPLETED, objectKey = objectKey, completedAt = completedAt) + + fun failed(completedAt: Instant): ExportJob = + copy(status = ExportStatus.FAILED, completedAt = completedAt) +} diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/ExportJobView.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/ExportJobView.kt new file mode 100644 index 00000000..c8323f49 --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/ExportJobView.kt @@ -0,0 +1,11 @@ +package hs.kr.entrydsm.admin.domain.model + +/** + * 내보내기 작업 조회 결과입니다. + * + * @property download 완료된 작업에만 채워지는 서명된 다운로드 링크 + */ +data class ExportJobView( + val job: ExportJob, + val download: DownloadLink? = null, +) diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/Notice.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/Notice.kt new file mode 100644 index 00000000..21b6e7ca --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/Notice.kt @@ -0,0 +1,18 @@ +package hs.kr.entrydsm.admin.domain.model + +import java.time.Instant + +/** + * 관리자가 등록하는 공지사항입니다. + * + * @property isPinned 상단 고정 여부 + * @property attachmentIds 파일관리 시스템에 업로드된 첨부 문서 식별자 목록 + */ +data class Notice( + val id: Long? = null, + val title: String, + val content: String, + val isPinned: Boolean = false, + val attachmentIds: List = emptyList(), + val createdAt: Instant? = null, +) diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/Page.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/Page.kt new file mode 100644 index 00000000..981c9398 --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/Page.kt @@ -0,0 +1,33 @@ +package hs.kr.entrydsm.admin.domain.model + +private const val DEFAULT_PAGE = 1 +private const val DEFAULT_SIZE = 10 +private const val MAX_SIZE = 100 + +/** + * 목록 조회 요청의 페이지 조건입니다. 공통 규약대로 `page`는 1부터 시작합니다. + * + * 범위를 벗어난 값은 예외 대신 허용 범위로 맞춥니다. 관리자 화면의 페이지 이동은 + * 실패시키는 것보다 가장 가까운 유효 페이지를 보여주는 편이 쓸모 있기 때문입니다. + */ +data class PageRequest( + val page: Int = DEFAULT_PAGE, + val size: Int = DEFAULT_SIZE, +) { + val normalizedPage: Int = page.coerceAtLeast(DEFAULT_PAGE) + val normalizedSize: Int = size.coerceIn(1, MAX_SIZE) + val offset: Int = (normalizedPage - 1) * normalizedSize +} + +/** + * 목록 조회 결과입니다. + */ +data class Page( + val items: List, + val page: Int, + val size: Int, + val totalElements: Long, +) { + val totalPages: Int = + if (size <= 0) 0 else ((totalElements + size - 1) / size).toInt() +} diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/QuestionAnswer.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/QuestionAnswer.kt new file mode 100644 index 00000000..00b90a6e --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/QuestionAnswer.kt @@ -0,0 +1,14 @@ +package hs.kr.entrydsm.admin.domain.model + +import java.time.Instant + +/** + * 지원자가 남긴 질문에 대한 관리자 답변입니다. + */ +data class QuestionAnswer( + val id: Long? = null, + val questionId: Long, + val content: String, + val answeredBy: String, + val answeredAt: Instant? = null, +) diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/ScorePolicy.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/ScorePolicy.kt new file mode 100644 index 00000000..a8ba39cd --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/ScorePolicy.kt @@ -0,0 +1,45 @@ +package hs.kr.entrydsm.admin.domain.model + +import hs.kr.entrydsm.admin.domain.enum.ErrorCode +import hs.kr.entrydsm.admin.domain.exception.AdminDomainException +import java.time.Instant + +private const val WEIGHT_SUM_TOLERANCE = 1e-6 +private val ROUNDING_SCALE_RANGE = 0..6 + +/** + * 성적 산출에 쓰는 항목별 가중치입니다. 세 항목의 합은 항상 1이어야 합니다. + */ +data class ScoreWeights( + val subject: Double, + val attendance: Double, + val volunteer: Double, +) { + init { + val sum = subject + attendance + volunteer + if (Math.abs(sum - 1.0) > WEIGHT_SUM_TOLERANCE) { + throw AdminDomainException(ErrorCode.INVALID_SCORE_POLICY) + } + } +} + +/** + * 현재 적용 중인 성적 산출 정책입니다. + * + * @property policyVersion 정책을 바꿀 때마다 1씩 오르는 버전 + * @property roundingScale 총점 반올림 소수 자릿수 + */ +data class ScorePolicy( + val id: Long? = null, + val policyVersion: Int, + val weights: ScoreWeights, + val roundingScale: Int, + val effectiveFrom: Instant, + val updatedBy: String, +) { + init { + if (roundingScale !in ROUNDING_SCALE_RANGE) { + throw AdminDomainException(ErrorCode.INVALID_SCORE_POLICY) + } + } +} diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/policy/ExamineeNumberPolicy.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/policy/ExamineeNumberPolicy.kt new file mode 100644 index 00000000..ef077081 --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/policy/ExamineeNumberPolicy.kt @@ -0,0 +1,53 @@ +package hs.kr.entrydsm.admin.domain.policy + +import hs.kr.entrydsm.admin.domain.model.Applicant + +private const val FIRST_EXAMINEE_NUMBER = 100001 + +/** + * 수험 번호 일괄 발급 규칙입니다. + * + * 원서 원본이 도착한 지원자에게만, 접수 번호 오름차순으로 빈 번호 없이 순차 발급합니다. + * 이미 번호를 받은 지원자는 재발급하지 않습니다. + */ +object ExamineeNumberPolicy { + + /** + * 발급 대상 전체를 훑어 새로 번호를 받아야 하는 지원자에게 번호를 채워 반환합니다. + * + * @param applicants 회차에 속한 지원자 전체 + */ + fun issue(applicants: List): ExamineeNumberIssuance { + val targets = applicants.filter { it.isSubmitted } + val (alreadyIssued, pending) = targets.partition { it.examineeNumber != null } + + var nextNumber = alreadyIssued + .mapNotNull { it.examineeNumber?.toIntOrNull() } + .maxOrNull() + ?.plus(1) + ?: FIRST_EXAMINEE_NUMBER + + val issued = pending + .sortedBy { it.receiptNumber } + .map { it.copy(examineeNumber = (nextNumber++).toString()) } + + return ExamineeNumberIssuance( + issued = issued, + skippedCount = alreadyIssued.size, + totalTargets = targets.size, + ) + } +} + +/** + * 수험 번호 일괄 발급 결과입니다. + * + * @property issued 이번 발급으로 번호가 채워진 지원자 목록 + * @property skippedCount 이미 번호가 있어 건너뛴 지원자 수 + * @property totalTargets 발급 대상(원서 도착) 지원자 총 수 + */ +data class ExamineeNumberIssuance( + val issued: List, + val skippedCount: Int, + val totalTargets: Int, +) diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/policy/ScreeningPolicy.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/policy/ScreeningPolicy.kt new file mode 100644 index 00000000..3dbeb5c7 --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/policy/ScreeningPolicy.kt @@ -0,0 +1,53 @@ +package hs.kr.entrydsm.admin.domain.policy + +import hs.kr.entrydsm.admin.domain.model.Applicant + +/** + * 합격자 일괄 산출 규칙입니다. + * + * 단계별 대상 상태의 지원자만 평가하며, 원서 미도착·수험 번호 미발급·성적 미산출 지원자는 + * 평가에서 제외한다. 합격자는 총점 내림차순으로 정원까지 채우고, 동점이면 접수 번호가 + * 빠른 지원자를 우선한다. + */ +object ScreeningPolicy { + + /** + * @param applicants 회차에 속한 지원자 전체 + * @param stage 산출 단계 + * @param quota 해당 단계의 합격 정원 + */ + fun evaluate( + applicants: List, + stage: ScreeningStage, + quota: Int, + ): ScreeningOutcome { + val candidates = applicants.filter { it.status == stage.from } + val (evaluable, excluded) = candidates.partition(::isEvaluable) + + val ranked = evaluable.sortedWith( + compareByDescending { it.score!!.totalScore }.thenBy { it.receiptNumber }, + ) + + return ScreeningOutcome( + passed = ranked.take(quota).map { it.copy(status = stage.pass) }, + failed = ranked.drop(quota).map { it.copy(status = stage.fail) }, + excluded = excluded, + ) + } + + private fun isEvaluable(applicant: Applicant): Boolean = + applicant.isSubmitted && applicant.examineeNumber != null && applicant.score != null +} + +/** + * 합격자 일괄 산출 결과입니다. + * + * @property passed 합격 상태가 반영된 지원자 목록 + * @property failed 불합격 상태가 반영된 지원자 목록 + * @property excluded 평가 조건을 갖추지 못해 상태를 바꾸지 않은 지원자 목록 + */ +data class ScreeningOutcome( + val passed: List, + val failed: List, + val excluded: List, +) diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/policy/ScreeningStage.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/policy/ScreeningStage.kt new file mode 100644 index 00000000..5e9411cc --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/policy/ScreeningStage.kt @@ -0,0 +1,19 @@ +package hs.kr.entrydsm.admin.domain.policy + +import hs.kr.entrydsm.admin.domain.enum.ApplicantStatus + +/** + * 합격자 일괄 산출 단계입니다. + * + * @property from 산출 대상이 되는 이전 상태 + * @property pass 합격 시 부여할 상태 + * @property fail 불합격 시 부여할 상태 + */ +enum class ScreeningStage( + val from: ApplicantStatus, + val pass: ApplicantStatus, + val fail: ApplicantStatus, +) { + FIRST(ApplicantStatus.PENDING, ApplicantStatus.FIRST_PASS, ApplicantStatus.FIRST_FAIL), + FINAL(ApplicantStatus.FIRST_PASS, ApplicantStatus.FINAL_PASS, ApplicantStatus.FINAL_FAIL), +} diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/port/in/ApplicantUseCases.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/port/in/ApplicantUseCases.kt new file mode 100644 index 00000000..0091991c --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/port/in/ApplicantUseCases.kt @@ -0,0 +1,34 @@ +package hs.kr.entrydsm.admin.domain.port.`in` + +import hs.kr.entrydsm.admin.domain.command.UpdateApplicantStatusCommand +import hs.kr.entrydsm.admin.domain.command.UpdateArrivalCommand +import hs.kr.entrydsm.admin.domain.model.Applicant +import hs.kr.entrydsm.admin.domain.model.ApplicantFilter +import hs.kr.entrydsm.admin.domain.model.DownloadLink +import hs.kr.entrydsm.admin.domain.model.ExamineeNumberIssueResult +import hs.kr.entrydsm.admin.domain.model.Page +import hs.kr.entrydsm.admin.domain.model.PageRequest + +interface ReadApplicantUseCase { + fun search(filter: ApplicantFilter, pageRequest: PageRequest): Page + + fun findById(applicantId: Long): Applicant +} + +interface UpdateApplicantUseCase { + fun updateArrival(command: UpdateArrivalCommand) + + fun updateStatus(command: UpdateApplicantStatusCommand) +} + +interface IssueExamineeNumberUseCase { + fun issueAll(): ExamineeNumberIssueResult +} + +interface IssueAdmissionTicketUseCase { + fun issueAdmissionTicket(applicantId: Long): DownloadLink +} + +interface IssueApplicationDocumentUseCase { + fun issueApplicationDocument(applicantId: Long): DownloadLink +} diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/port/in/ScreeningUseCases.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/port/in/ScreeningUseCases.kt new file mode 100644 index 00000000..40b4392b --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/port/in/ScreeningUseCases.kt @@ -0,0 +1,28 @@ +package hs.kr.entrydsm.admin.domain.port.`in` + +import hs.kr.entrydsm.admin.domain.command.EvaluateScreeningCommand +import hs.kr.entrydsm.admin.domain.command.UpdateScorePolicyCommand +import hs.kr.entrydsm.admin.domain.enum.StatisticsMetric +import hs.kr.entrydsm.admin.domain.model.ApplicantStatistics +import hs.kr.entrydsm.admin.domain.model.ScorePolicy +import hs.kr.entrydsm.admin.domain.model.ScreeningResult + +interface ReadScorePolicyUseCase { + fun findCurrent(): ScorePolicy +} + +interface UpdateScorePolicyUseCase { + fun update(command: UpdateScorePolicyCommand) +} + +interface EvaluateFirstScreeningUseCase { + fun evaluateFirst(command: EvaluateScreeningCommand): ScreeningResult +} + +interface EvaluateFinalScreeningUseCase { + fun evaluateFinal(command: EvaluateScreeningCommand): ScreeningResult +} + +interface ReadStatisticsUseCase { + fun collect(metrics: Set): ApplicantStatistics +} diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/port/in/SupportUseCases.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/port/in/SupportUseCases.kt new file mode 100644 index 00000000..170e2eb3 --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/port/in/SupportUseCases.kt @@ -0,0 +1,28 @@ +package hs.kr.entrydsm.admin.domain.port.`in` + +import hs.kr.entrydsm.admin.domain.command.AnswerQuestionCommand +import hs.kr.entrydsm.admin.domain.command.CreateExportCommand +import hs.kr.entrydsm.admin.domain.command.CreateNoticeCommand +import hs.kr.entrydsm.admin.domain.model.ExportJob +import hs.kr.entrydsm.admin.domain.model.ExportJobView +import hs.kr.entrydsm.admin.domain.model.Notice +import hs.kr.entrydsm.admin.domain.model.QuestionAnswer + +interface CreateExportUseCase { + fun create(command: CreateExportCommand): ExportJob +} + +interface ReadExportUseCase { + /** + * 작업 상태를 조회합니다. 완료된 작업이면 서명된 다운로드 링크를 함께 채웁니다. + */ + fun findById(exportJobId: String): ExportJobView +} + +interface CreateNoticeUseCase { + fun create(command: CreateNoticeCommand): Notice +} + +interface AnswerQuestionUseCase { + fun answer(command: AnswerQuestionCommand): QuestionAnswer +} diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/port/out/AdminRepositories.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/port/out/AdminRepositories.kt new file mode 100644 index 00000000..108d4228 --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/port/out/AdminRepositories.kt @@ -0,0 +1,53 @@ +package hs.kr.entrydsm.admin.domain.port.out + +import hs.kr.entrydsm.admin.domain.enum.AdmissionType +import hs.kr.entrydsm.admin.domain.enum.Region +import hs.kr.entrydsm.admin.domain.model.Applicant +import hs.kr.entrydsm.admin.domain.model.ApplicantFilter +import hs.kr.entrydsm.admin.domain.model.DailyApplicantCount +import hs.kr.entrydsm.admin.domain.model.ExportJob +import hs.kr.entrydsm.admin.domain.model.Notice +import hs.kr.entrydsm.admin.domain.model.Page +import hs.kr.entrydsm.admin.domain.model.PageRequest +import hs.kr.entrydsm.admin.domain.model.QuestionAnswer +import hs.kr.entrydsm.admin.domain.model.ScorePolicy + +interface ApplicantRepository { + fun search(filter: ApplicantFilter, pageRequest: PageRequest): Page + + fun findAll(filter: ApplicantFilter = ApplicantFilter()): List + + fun findById(applicantId: Long): Applicant? + + fun save(applicant: Applicant): Applicant + + fun saveAll(applicants: List): List + + fun countAll(): Long + + fun countByAdmissionType(): Map + + fun countByRegion(): Map + + fun countBySubmittedDate(): List +} + +interface ScorePolicyRepository { + fun findCurrent(): ScorePolicy? + + fun save(scorePolicy: ScorePolicy): ScorePolicy +} + +interface ExportJobRepository { + fun findByExportJobId(exportJobId: String): ExportJob? + + fun save(exportJob: ExportJob): ExportJob +} + +interface NoticeRepository { + fun save(notice: Notice): Notice +} + +interface QuestionAnswerRepository { + fun save(questionAnswer: QuestionAnswer): QuestionAnswer +} diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/port/out/PdfRenderPort.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/port/out/PdfRenderPort.kt new file mode 100644 index 00000000..5804acde --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/port/out/PdfRenderPort.kt @@ -0,0 +1,10 @@ +package hs.kr.entrydsm.admin.domain.port.out + +/** + * XHTML 문서를 PDF 바이트로 변환합니다. + * + * 레이아웃은 도메인의 `AdmissionTicketHtml`이 만들고, 이 포트는 변환만 책임집니다. + */ +interface PdfRenderPort { + fun render(html: String): ByteArray +} diff --git a/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/port/out/StoragePort.kt b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/port/out/StoragePort.kt new file mode 100644 index 00000000..7e96e41d --- /dev/null +++ b/systems/admin/admin-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/port/out/StoragePort.kt @@ -0,0 +1,12 @@ +package hs.kr.entrydsm.admin.domain.port.out + +/** + * 산출물 파일을 보관하고 서명된 다운로드 링크를 발급하는 저장소입니다. + */ +interface StoragePort { + fun upload(objectKey: String, contentType: String, content: ByteArray) + + fun issueDownloadUrl(objectKey: String, expiresInSeconds: Long): String + + fun exists(objectKey: String): Boolean +} diff --git a/systems/admin/admin-domain/src/test/kotlin/hs/kr/entrydsm/admin/domain/AdminPolicyTest.kt b/systems/admin/admin-domain/src/test/kotlin/hs/kr/entrydsm/admin/domain/AdminPolicyTest.kt new file mode 100644 index 00000000..9b1afae4 --- /dev/null +++ b/systems/admin/admin-domain/src/test/kotlin/hs/kr/entrydsm/admin/domain/AdminPolicyTest.kt @@ -0,0 +1,146 @@ +package hs.kr.entrydsm.admin.domain + +import hs.kr.entrydsm.admin.domain.enum.AdmissionType +import hs.kr.entrydsm.admin.domain.enum.ApplicantStatus +import hs.kr.entrydsm.admin.domain.enum.GraduationStatus +import hs.kr.entrydsm.admin.domain.enum.Region +import hs.kr.entrydsm.admin.domain.model.Applicant +import hs.kr.entrydsm.admin.domain.model.ApplicantScore +import hs.kr.entrydsm.admin.domain.policy.ExamineeNumberPolicy +import hs.kr.entrydsm.admin.domain.policy.ScreeningPolicy +import hs.kr.entrydsm.admin.domain.policy.ScreeningStage +import java.time.LocalDate +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AdminPolicyTest { + + private fun applicant( + receiptNumber: Int, + isSubmitted: Boolean = true, + examineeNumber: String? = null, + totalScore: Double? = null, + status: ApplicantStatus = ApplicantStatus.PENDING, + ) = Applicant( + id = receiptNumber.toLong(), + receiptNumber = receiptNumber, + name = "지원자$receiptNumber", + birthDate = LocalDate.of(2010, 3, 15), + phoneNumber = "010-0000-0000", + region = Region.DAEJEON, + admissionType = AdmissionType.MEISTER, + graduationStatus = GraduationStatus.EXPECTED, + schoolName = "대전중학교", + examineeNumber = examineeNumber, + isSubmitted = isSubmitted, + status = status, + score = totalScore?.let { ApplicantScore(0.0, 0.0, 0.0, it) }, + ) + + @Test + fun `원서가 도착한 지원자에게만 접수 번호 순으로 수험 번호를 발급한다`() { + val result = ExamineeNumberPolicy.issue( + listOf( + applicant(receiptNumber = 3), + applicant(receiptNumber = 1), + applicant(receiptNumber = 2, isSubmitted = false), + ), + ) + + assertEquals(listOf("100001", "100002"), result.issued.map { it.examineeNumber }) + assertEquals(listOf(1, 3), result.issued.map { it.receiptNumber }) + assertEquals(2, result.totalTargets) + } + + @Test + fun `이미 수험 번호가 있는 지원자는 건너뛰고 다음 번호부터 이어 발급한다`() { + val result = ExamineeNumberPolicy.issue( + listOf( + applicant(receiptNumber = 1, examineeNumber = "100001"), + applicant(receiptNumber = 2), + ), + ) + + assertEquals(1, result.skippedCount) + assertEquals(listOf("100002"), result.issued.map { it.examineeNumber }) + } + + @Test + fun `1차 산출은 총점 순으로 정원까지 합격시키고 나머지는 불합격 처리한다`() { + val outcome = ScreeningPolicy.evaluate( + listOf( + applicant(receiptNumber = 1, examineeNumber = "100001", totalScore = 80.0), + applicant(receiptNumber = 2, examineeNumber = "100002", totalScore = 95.0), + applicant(receiptNumber = 3, examineeNumber = "100003", totalScore = 90.0), + ), + stage = ScreeningStage.FIRST, + quota = 2, + ) + + assertEquals(listOf(2, 3), outcome.passed.map { it.receiptNumber }) + assertEquals(listOf(1), outcome.failed.map { it.receiptNumber }) + assertTrue(outcome.passed.all { it.status == ApplicantStatus.FIRST_PASS }) + assertTrue(outcome.failed.all { it.status == ApplicantStatus.FIRST_FAIL }) + } + + @Test + fun `동점이면 접수 번호가 빠른 지원자를 우선 합격시킨다`() { + val outcome = ScreeningPolicy.evaluate( + listOf( + applicant(receiptNumber = 5, examineeNumber = "100005", totalScore = 90.0), + applicant(receiptNumber = 4, examineeNumber = "100004", totalScore = 90.0), + ), + stage = ScreeningStage.FIRST, + quota = 1, + ) + + assertEquals(listOf(4), outcome.passed.map { it.receiptNumber }) + } + + @Test + fun `원서 미도착이나 수험 번호 미발급 지원자는 산출에서 제외한다`() { + val outcome = ScreeningPolicy.evaluate( + listOf( + applicant(receiptNumber = 1, isSubmitted = false, totalScore = 99.0), + applicant(receiptNumber = 2, examineeNumber = null, totalScore = 99.0), + applicant(receiptNumber = 3, examineeNumber = "100003", totalScore = null), + applicant(receiptNumber = 4, examineeNumber = "100004", totalScore = 70.0), + ), + stage = ScreeningStage.FIRST, + quota = 10, + ) + + assertEquals(listOf(1, 2, 3), outcome.excluded.map { it.receiptNumber }) + assertEquals(listOf(4), outcome.passed.map { it.receiptNumber }) + } + + @Test + fun `최종 산출은 1차 합격자만 대상으로 한다`() { + val outcome = ScreeningPolicy.evaluate( + listOf( + applicant(receiptNumber = 1, examineeNumber = "100001", totalScore = 99.0), + applicant( + receiptNumber = 2, + examineeNumber = "100002", + totalScore = 70.0, + status = ApplicantStatus.FIRST_PASS, + ), + ), + stage = ScreeningStage.FINAL, + quota = 10, + ) + + assertEquals(listOf(2), outcome.passed.map { it.receiptNumber }) + assertTrue(outcome.passed.all { it.status == ApplicantStatus.FINAL_PASS }) + } + + @Test + fun `정상 흐름을 벗어나는 상태 전이는 거부한다`() { + assertTrue(ApplicantStatus.PENDING.canTransitionTo(ApplicantStatus.FIRST_PASS)) + assertTrue(ApplicantStatus.FIRST_PASS.canTransitionTo(ApplicantStatus.FINAL_PASS)) + assertFalse(ApplicantStatus.PENDING.canTransitionTo(ApplicantStatus.FINAL_PASS)) + assertFalse(ApplicantStatus.FIRST_FAIL.canTransitionTo(ApplicantStatus.FINAL_PASS)) + } +} diff --git a/systems/admin/admin-domain/src/test/kotlin/hs/kr/entrydsm/admin/domain/AdmissionTicketHtmlTest.kt b/systems/admin/admin-domain/src/test/kotlin/hs/kr/entrydsm/admin/domain/AdmissionTicketHtmlTest.kt new file mode 100644 index 00000000..747e404f --- /dev/null +++ b/systems/admin/admin-domain/src/test/kotlin/hs/kr/entrydsm/admin/domain/AdmissionTicketHtmlTest.kt @@ -0,0 +1,62 @@ +package hs.kr.entrydsm.admin.domain + +import hs.kr.entrydsm.admin.domain.document.AdmissionTicketHtml +import hs.kr.entrydsm.admin.domain.enum.AdmissionType +import hs.kr.entrydsm.admin.domain.enum.Region +import hs.kr.entrydsm.admin.domain.model.AdmissionTicket +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AdmissionTicketHtmlTest { + + private fun ticket( + examineeNumber: String? = null, + name: String = "홍길동", + ) = AdmissionTicket( + admissionYear = 2027, + receiptNumber = 2, + examineeNumber = examineeNumber, + name = name, + schoolName = "서울중학교", + region = Region.NATIONWIDE, + admissionType = AdmissionType.GENERAL, + ) + + @Test + fun `수험표에 학년도와 학교장 서명을 인쇄한다`() { + val html = AdmissionTicketHtml.render(ticket()) + + assertTrue(html.contains("2027학년도 대덕소프트웨어마이스터고등학교 입학전형 수험표")) + assertTrue(html.contains("대덕소프트웨어마이스터고등학교장")) + } + + @Test + fun `수험 번호가 없으면 미발급으로 인쇄한다`() { + assertTrue(AdmissionTicketHtml.render(ticket()).contains("미발급")) + } + + @Test + fun `수험 번호가 있으면 번호를 그대로 인쇄한다`() { + val html = AdmissionTicketHtml.render(ticket(examineeNumber = "100001")) + + assertTrue(html.contains("100001")) + assertFalse(html.contains("미발급")) + } + + @Test + fun `지역과 전형 유형을 한글 표기로 인쇄한다`() { + val html = AdmissionTicketHtml.render(ticket()) + + assertTrue(html.contains("전국")) + assertTrue(html.contains("일반전형")) + } + + @Test + fun `이름에 들어온 태그를 이스케이프해 마크업을 깨지 않는다`() { + val html = AdmissionTicketHtml.render(ticket(name = "")) + + assertFalse(html.contains("