From 8d48e7a9192cb9a0fa3d53ea25ad0b197f7b832c Mon Sep 17 00:00:00 2001 From: kusuri12 Date: Tue, 20 Oct 2026 19:17:23 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(application):=20=EC=9B=90=EC=84=9C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EC=97=90=20=EC=9A=B0=ED=8E=B8=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=A0=84=EB=8B=AC=20#399?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/ApplicantJpaRepository.kt | 3 ++- .../repository/ApplicantPersistenceAdapter.kt | 2 ++ .../repository/ApplicantSummaryQueryTest.kt | 3 +++ .../persistence/LocalApplicantDataAdapter.kt | 9 ++++++--- .../LocalApplicantDataAdapterTest.kt | 10 ++++++++++ .../kr/entrydsm/admin/domain/model/Applicant.kt | 1 + .../port/in/result/ApplicantResult.kt | 1 + .../service/ApplicationCommandService.kt | 2 ++ .../service/ApplicationCommandServiceTest.kt | 17 +++++++++++++++++ 9 files changed, 44 insertions(+), 4 deletions(-) diff --git a/systems/application/application-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/adapterout/repository/ApplicantJpaRepository.kt b/systems/application/application-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/adapterout/repository/ApplicantJpaRepository.kt index d8a94f7c..80c222c5 100644 --- a/systems/application/application-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/adapterout/repository/ApplicantJpaRepository.kt +++ b/systems/application/application-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/adapterout/repository/ApplicantJpaRepository.kt @@ -39,7 +39,7 @@ interface ApplicantJpaRepository : JpaRepository { a.birthdate as birthdate, a.phoneNumber as phoneNumber, a.graduationType as graduationType, a.totalScore as totalScore, a.status as status, a.submittedAt as submittedAt, a.gender as gender, a.addressBase as address, - a.statusVersion as statusVersion + a.statusVersion as statusVersion, a.zipCode as zipCode from ApplicantJpaEntity a left join a.middleSchoolInfo m where a.status in :statuses @@ -67,4 +67,5 @@ interface ApplicantSummaryRow { val submittedAt: LocalDateTime? val gender: Gender? val address: String? + val zipCode: String? } diff --git a/systems/application/application-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/adapterout/repository/ApplicantPersistenceAdapter.kt b/systems/application/application-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/adapterout/repository/ApplicantPersistenceAdapter.kt index 2c6f04dc..7621452b 100644 --- a/systems/application/application-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/adapterout/repository/ApplicantPersistenceAdapter.kt +++ b/systems/application/application-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/adapterout/repository/ApplicantPersistenceAdapter.kt @@ -53,10 +53,12 @@ class ApplicantPersistenceAdapter( override fun findAllByAccountIdIn(accountIds: List): List = applicantJpaRepository.findAllByAccountIdIn(accountIds).map { it.toDomain(includePassResults = false) } + // 일괄 발급의 주소 대체 좌표 조회에 필요한 우편번호도 전달한다. @Transactional(readOnly = true) override fun findSummariesByStatusIn(statuses: Set): List = applicantJpaRepository.findSummariesByStatusIn(statuses).map { ApplicantResult( + zipCode = it.zipCode, statusVersion = it.statusVersion, applicantId = it.id, accountId = it.accountId, diff --git a/systems/application/application-adapter-out/src/test/kotlin/hs/kr/entrydsm/application/adapterout/repository/ApplicantSummaryQueryTest.kt b/systems/application/application-adapter-out/src/test/kotlin/hs/kr/entrydsm/application/adapterout/repository/ApplicantSummaryQueryTest.kt index dde37896..1246aa74 100644 --- a/systems/application/application-adapter-out/src/test/kotlin/hs/kr/entrydsm/application/adapterout/repository/ApplicantSummaryQueryTest.kt +++ b/systems/application/application-adapter-out/src/test/kotlin/hs/kr/entrydsm/application/adapterout/repository/ApplicantSummaryQueryTest.kt @@ -79,6 +79,7 @@ class ApplicantSummaryQueryTest { submittedAt = SUBMITTED_AT gender = Gender.FEMALE addressBase = "충청남도 천안시" + zipCode = "01234" middleSchoolInfo = MiddleSchoolInfo( schoolCode = "7031234", schoolName = "대덕중학교", @@ -105,6 +106,7 @@ class ApplicantSummaryQueryTest { assertEquals(SUBMITTED_AT, summary.submittedAt) assertEquals(Gender.FEMALE, summary.gender) assertEquals("충청남도 천안시", summary.address) + assertEquals("01234", summary.zipCode) } /** 제출 검증이 중학교를 요구하지 않으므로 낸 원서에도 없을 수 있다. 목록이 그 지원자를 빠뜨리면 안 된다. */ @@ -116,6 +118,7 @@ class ApplicantSummaryQueryTest { assertEquals(101L, summary.accountId) assertNull(summary.schoolName) + assertNull(summary.zipCode) assertNull(summary.totalScore) assertNull(applicantJpaRepository.findByAccountId(101L)?.toDomain()?.totalScore) } diff --git a/systems/application/application-administration-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/administration/adapterout/persistence/LocalApplicantDataAdapter.kt b/systems/application/application-administration-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/administration/adapterout/persistence/LocalApplicantDataAdapter.kt index 95d33fa6..cd1bec2f 100644 --- a/systems/application/application-administration-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/administration/adapterout/persistence/LocalApplicantDataAdapter.kt +++ b/systems/application/application-administration-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/administration/adapterout/persistence/LocalApplicantDataAdapter.kt @@ -65,21 +65,24 @@ class LocalApplicantDataAdapter( ) } + // 원서 우편번호는 외부 응답 확장 없이 내부 모델에 직접 싣는다. override fun findAll(filter: ApplicantFilter): List { val screenings = screeningJpaRepository.findAll().associateBy { it.applicantId } return source { applicationPort.listApplicants() } - .map { it.toGrpcApplicant().toApplicant(screenings[it.applicantId]).copy(applicationVersion = it.statusVersion) } + .map { it.toGrpcApplicant().toApplicant(screenings[it.applicantId]).copy(applicationVersion = it.statusVersion, zipCode = it.zipCode) } .filter { it.matches(filter) }.sortedBy { it.id } } + // 단건 조회에도 원서의 우편번호를 유지한다. override fun findById(applicantId: Long): Applicant? = applicantId.takeIf { it > 0 }?.let { source(it) { applicationPort.findApplicant(it) }?.let { original -> original.toGrpcApplicant().toApplicant(screeningJpaRepository.findById(it).orElse(null)) - .copy(applicationVersion = original.statusVersion) + .copy(applicationVersion = original.statusVersion, zipCode = original.zipCode) } } + // 상세 조회에서도 동일한 원서 우편번호를 사용한다. override fun findDetailById(applicantId: Long): ApplicantDetail? { if (applicantId <= 0) return null val applicant = source(applicantId) { applicationPort.findApplicant(applicantId) } ?: return null @@ -89,7 +92,7 @@ class LocalApplicantDataAdapter( return ApplicantDetail( semesterScores = sourceForm.score?.semesterScores?.mapKeys { it.key.name } ?: emptyMap(), version = form.statusVersion, - applicant = response.toApplicant(screeningJpaRepository.findById(applicantId).orElse(null)).copy(applicationVersion = form.statusVersion), + applicant = response.toApplicant(screeningJpaRepository.findById(applicantId).orElse(null)).copy(applicationVersion = form.statusVersion, zipCode = applicant.zipCode), photoFileId = form.photoFileId.takeIf { form.hasPhotoFileId() }, introduction = form.introduction.takeIf { form.hasIntroduction() }, studyPlan = form.studyPlan.takeIf { form.hasStudyPlan() }, diff --git a/systems/application/application-administration-adapter-out/src/test/kotlin/hs/kr/entrydsm/application/administration/adapterout/persistence/LocalApplicantDataAdapterTest.kt b/systems/application/application-administration-adapter-out/src/test/kotlin/hs/kr/entrydsm/application/administration/adapterout/persistence/LocalApplicantDataAdapterTest.kt index 3a5e8dd1..4fbb9b2f 100644 --- a/systems/application/application-administration-adapter-out/src/test/kotlin/hs/kr/entrydsm/application/administration/adapterout/persistence/LocalApplicantDataAdapterTest.kt +++ b/systems/application/application-administration-adapter-out/src/test/kotlin/hs/kr/entrydsm/application/administration/adapterout/persistence/LocalApplicantDataAdapterTest.kt @@ -19,6 +19,16 @@ import org.junit.Assert.* import org.junit.Test class LocalApplicantDataAdapterTest { + @Test + fun `목록 단건 상세 조회는 원서 우편번호의 선행 0과 null을 보존한다`() { + for (zipCode in listOf("01234", null)) { + val adapter = adapter(applicant().copy(zipCode = zipCode)) { form() } + assertEquals(zipCode, adapter.findAll(ApplicantFilter()).single().zipCode) + assertEquals(zipCode, adapter.findById(5)?.zipCode) + assertEquals(zipCode, adapter.findDetailById(5)?.applicant?.zipCode) + } + } + @Test fun absentApplicantReturnsNullWithoutReadingForm() { val adapter = adapter(null) { error("없는 지원자의 성적을 조회하면 안 된다") } diff --git a/systems/application/application-administration-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/Applicant.kt b/systems/application/application-administration-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/Applicant.kt index eda84369..30a97910 100644 --- a/systems/application/application-administration-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/Applicant.kt +++ b/systems/application/application-administration-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/model/Applicant.kt @@ -43,6 +43,7 @@ data class Applicant( val address: String? = null, /** 원본을 읽은 버전. 전형 저장 전에 잠근 원본과 비교해 오래된 산출 결과를 거부한다. */ val applicationVersion: Long? = null, + val zipCode: String? = null, ) { /** 서류에 찍는 접수 번호. 접수 순서인 [id] 를 네 자리로 채운다. 9999 번을 넘으면 자릿수가 늘어난다. */ val receiptNumber: String get() = id.toString().padStart(4, '0') diff --git a/systems/application/application-application/src/main/kotlin/hs/kr/entrydsm/application/application/port/in/result/ApplicantResult.kt b/systems/application/application-application/src/main/kotlin/hs/kr/entrydsm/application/application/port/in/result/ApplicantResult.kt index 69627a79..88717920 100644 --- a/systems/application/application-application/src/main/kotlin/hs/kr/entrydsm/application/application/port/in/result/ApplicantResult.kt +++ b/systems/application/application-application/src/main/kotlin/hs/kr/entrydsm/application/application/port/in/result/ApplicantResult.kt @@ -31,4 +31,5 @@ data class ApplicantResult( val gender: Gender? = null, val address: String? = null, val statusVersion: Long = 0, + val zipCode: String? = null, ) diff --git a/systems/application/application-application/src/main/kotlin/hs/kr/entrydsm/application/application/service/ApplicationCommandService.kt b/systems/application/application-application/src/main/kotlin/hs/kr/entrydsm/application/application/service/ApplicationCommandService.kt index 344b576b..f2517071 100644 --- a/systems/application/application-application/src/main/kotlin/hs/kr/entrydsm/application/application/service/ApplicationCommandService.kt +++ b/systems/application/application-application/src/main/kotlin/hs/kr/entrydsm/application/application/service/ApplicationCommandService.kt @@ -210,7 +210,9 @@ class ApplicationCommandService( return applicantRepository.save(this) } + // 우편번호를 내부 조회 결과로 전달해 주소 검색 실패 시 좌표를 찾는다. private fun Applicant.toApplicantResult(): ApplicantResult = ApplicantResult( + zipCode = zipCode, statusVersion = statusVersion, applicantId = id, accountId = accountId, diff --git a/systems/application/application-application/src/test/kotlin/hs/kr/entrydsm/application/application/service/ApplicationCommandServiceTest.kt b/systems/application/application-application/src/test/kotlin/hs/kr/entrydsm/application/application/service/ApplicationCommandServiceTest.kt index a9cabd66..111461eb 100644 --- a/systems/application/application-application/src/test/kotlin/hs/kr/entrydsm/application/application/service/ApplicationCommandServiceTest.kt +++ b/systems/application/application-application/src/test/kotlin/hs/kr/entrydsm/application/application/service/ApplicationCommandServiceTest.kt @@ -619,6 +619,23 @@ class ApplicationCommandServiceTest { assertNotNull(repository.savedApplicant?.totalScoreUpdatedAt) } + @Test + fun `단건과 목록 결과에 원서 우편번호를 보존한다`() { + for (zipCode in listOf("01234", null)) { + val applicant = submittableGedApplicant().copy(zipCode = zipCode) + val summary = ApplicantResult( + applicantId = 1L, accountId = 10L, name = null, schoolName = null, + region = null, admissionType = AdmissionType.REGULAR, photoFileId = null, + birthdate = null, phoneNumber = null, graduationType = GraduationType.GED, + totalScore = null, status = ApplicantStatus.SUBMITTED, submittedAt = null, + zipCode = zipCode, + ) + val service = ApplicationCommandService(FakeApplicantRepository(applicant, listOf(summary)), OPEN, ACCEPT_PHONE) + assertEquals(zipCode, service.findApplicant(1L)?.zipCode) + assertEquals(zipCode, service.listApplicants().single().zipCode) + } + } + @Test fun scoreQueriesRecalculateMissingScoresOnlyOnce() { val queries: List<(ApplicationCommandService) -> Double?> = listOf( From 16eb26c4b4ee2c6f8ab66149247470977348b184 Mon Sep 17 00:00:00 2001 From: kusuri12 Date: Tue, 20 Oct 2026 19:17:43 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(application):=20=EC=A3=BC=EC=86=8C=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EC=9A=B0?= =?UTF-8?q?=ED=8E=B8=EB=B2=88=ED=98=B8=20=EC=A2=8C=ED=91=9C=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=ED=97=98=EB=B2=88=ED=98=B8=20=EB=B0=9C=EA=B8=89=20#39?= =?UTF-8?q?9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../distance/KakaoDistanceAdapter.kt | 22 +++++-- .../entity/PostalCodeLocationJpaEntity.kt | 19 ++++++ .../PostalCodeLocationJpaRepository.kt | 6 ++ .../distance/KakaoDistanceAdapterTest.kt | 60 ++++++++++++++++++- .../application/ApplicantService.kt | 3 +- .../application/AdminApplicationModuleTest.kt | 37 ++++++++++++ .../domain/port/out/AdminRepositories.kt | 3 + 7 files changed, 142 insertions(+), 8 deletions(-) create mode 100644 systems/application/application-administration-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/administration/adapterout/entity/PostalCodeLocationJpaEntity.kt create mode 100644 systems/application/application-administration-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/administration/adapterout/repository/PostalCodeLocationJpaRepository.kt diff --git a/systems/application/application-administration-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/administration/adapterout/distance/KakaoDistanceAdapter.kt b/systems/application/application-administration-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/administration/adapterout/distance/KakaoDistanceAdapter.kt index 95b570f3..e5b72274 100644 --- a/systems/application/application-administration-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/administration/adapterout/distance/KakaoDistanceAdapter.kt +++ b/systems/application/application-administration-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/administration/adapterout/distance/KakaoDistanceAdapter.kt @@ -3,6 +3,7 @@ package hs.kr.entrydsm.application.administration.adapterout.distance import hs.kr.entrydsm.admin.domain.enum.ErrorCode import hs.kr.entrydsm.admin.domain.exception.AdminDomainException import hs.kr.entrydsm.admin.domain.port.out.DistancePort +import hs.kr.entrydsm.application.administration.adapterout.repository.PostalCodeLocationJpaRepository import java.io.IOException import java.net.URI import java.net.URLEncoder @@ -27,16 +28,20 @@ class KakaoDistanceAdapter( @Value("\${admin.maps.directions-url}") private val directionsUrl: String, @Value("\${admin.maps.school-address}") private val schoolAddress: String, @Value("\${admin.maps.timeout-ms}") private val timeoutMs: Long, + private val postalCodeLocations: PostalCodeLocationJpaRepository? = null, ) : DistancePort { private val logger = LoggerFactory.getLogger(javaClass) private val client = HttpClient.newBuilder().connectTimeout(Duration.ofMillis(timeoutMs)).build() private val schoolCoordinates by lazy { geocode(schoolAddress, "SCHOOL_GEOCODING") } - // 외부 응답과 예외 메시지 대신 단계와 안전한 원인만 남긴다. - override fun distanceFromSchool(address: String): Long { + // 우편번호 없는 기존 호출도 같은 경로 계산을 사용한다. + override fun distanceFromSchool(address: String): Long = distanceFromSchool(address, null) + + // 검색 결과가 비었을 때만 원서 우편번호의 대표 좌표로 경로를 계산한다. + override fun distanceFromSchool(address: String, zipCode: String?): Long { var stage = "APPLICANT_GEOCODING" return try { - val origin = geocode(address, stage) + val origin = parseCoordinates(send("$geocodingUrl?query=${encode(address)}", stage), stage, zipCode) stage = "SCHOOL_GEOCODING" val destination = schoolCoordinates stage = "DIRECTIONS" @@ -77,9 +82,16 @@ class KakaoDistanceAdapter( fun parseCoordinates(body: String): Coordinates = parseCoordinates(body, "APPLICANT_GEOCODING") // 검색 결과 없음과 좌표 형식 오류를 구분한다. - private fun parseCoordinates(body: String, stage: String): Coordinates { + private fun parseCoordinates(body: String, stage: String, zipCode: String? = null): Coordinates { val documents = objectMapper.readTree(body).path("documents") - if (documents.isArray && documents.isEmpty) unavailable(stage, "EMPTY_SEARCH") + if (documents.isArray && documents.isEmpty) { + if (stage == "APPLICANT_GEOCODING" && zipCode != null) { + postalCodeLocations?.findById(zipCode)?.orElse(null)?.let { + return Coordinates(it.longitude.toDouble(), it.latitude.toDouble()) + } + } + unavailable(stage, "EMPTY_SEARCH") + } val document = documents.path(0) val x = document.path("x").asText().toDoubleOrNull() ?: unavailable(stage, "COORDINATE_FORMAT") val y = document.path("y").asText().toDoubleOrNull() ?: unavailable(stage, "COORDINATE_FORMAT") diff --git a/systems/application/application-administration-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/administration/adapterout/entity/PostalCodeLocationJpaEntity.kt b/systems/application/application-administration-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/administration/adapterout/entity/PostalCodeLocationJpaEntity.kt new file mode 100644 index 00000000..21bfa486 --- /dev/null +++ b/systems/application/application-administration-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/administration/adapterout/entity/PostalCodeLocationJpaEntity.kt @@ -0,0 +1,19 @@ +package hs.kr.entrydsm.application.administration.adapterout.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import java.math.BigDecimal + +@Entity +@Table(name = "postal_code_locations") +class PostalCodeLocationJpaEntity( + @Id + @Column(name = "zip_code", length = 5, columnDefinition = "char(5)") + val zipCode: String = "", + @Column(name = "latitude", nullable = false, precision = 17, scale = 14) + val latitude: BigDecimal = BigDecimal.ZERO, + @Column(name = "longitude", nullable = false, precision = 18, scale = 14) + val longitude: BigDecimal = BigDecimal.ZERO, +) diff --git a/systems/application/application-administration-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/administration/adapterout/repository/PostalCodeLocationJpaRepository.kt b/systems/application/application-administration-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/administration/adapterout/repository/PostalCodeLocationJpaRepository.kt new file mode 100644 index 00000000..c5dcd2ae --- /dev/null +++ b/systems/application/application-administration-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/administration/adapterout/repository/PostalCodeLocationJpaRepository.kt @@ -0,0 +1,6 @@ +package hs.kr.entrydsm.application.administration.adapterout.repository + +import hs.kr.entrydsm.application.administration.adapterout.entity.PostalCodeLocationJpaEntity +import org.springframework.data.jpa.repository.JpaRepository + +interface PostalCodeLocationJpaRepository : JpaRepository diff --git a/systems/application/application-administration-adapter-out/src/test/kotlin/hs/kr/entrydsm/application/administration/adapterout/distance/KakaoDistanceAdapterTest.kt b/systems/application/application-administration-adapter-out/src/test/kotlin/hs/kr/entrydsm/application/administration/adapterout/distance/KakaoDistanceAdapterTest.kt index 5f8e1762..648e69ab 100644 --- a/systems/application/application-administration-adapter-out/src/test/kotlin/hs/kr/entrydsm/application/administration/adapterout/distance/KakaoDistanceAdapterTest.kt +++ b/systems/application/application-administration-adapter-out/src/test/kotlin/hs/kr/entrydsm/application/administration/adapterout/distance/KakaoDistanceAdapterTest.kt @@ -1,6 +1,11 @@ package hs.kr.entrydsm.application.administration.adapterout.distance import hs.kr.entrydsm.admin.domain.exception.AdminDomainException +import hs.kr.entrydsm.application.administration.adapterout.repository.PostalCodeLocationJpaRepository +import hs.kr.entrydsm.application.administration.adapterout.entity.PostalCodeLocationJpaEntity +import java.lang.reflect.Proxy +import java.math.BigDecimal +import java.util.Optional import org.junit.Assert.* import org.junit.Test import tools.jackson.databind.ObjectMapper @@ -14,6 +19,55 @@ import java.util.concurrent.atomic.AtomicInteger import org.slf4j.LoggerFactory class KakaoDistanceAdapterTest { + @Test + fun `검색 결과가 없으면 원서 우편번호 좌표를 카카오 경로 출발지로 사용한다`() { + val queries = mutableListOf() + val lookups = mutableListOf() + withServer(failedRequest = 0, body = """{"documents":[]}""", queries = queries) { base, requests -> + val adapter = adapter(base, postalCodes = postalRepository(lookups)) + assertEquals(1234L, adapter.distanceFromSchool(ADDRESS, "01234")) + assertEquals(listOf("01234"), lookups) + assertEquals(3, requests.get()) + assertTrue(queries.last(), queries.last().contains("origin=128.25,35.75&destination=127.123456,36.654321")) + } + } + + @Test + fun `검색 성공은 우편번호 저장소를 조회하지 않는다`() = withServer { base, _ -> + val lookups = mutableListOf() + assertEquals(1234L, adapter(base, postalCodes = postalRepository(lookups)).distanceFromSchool(ADDRESS, "01234")) + assertTrue(lookups.isEmpty()) + } + + @Test + fun `미등록 또는 누락 우편번호는 기존 오류를 유지한다`() { + for (zipCode in listOf("99999", null, "")) { + withServer(failedRequest = 0, body = """{"documents":[]}""") { base, requests -> + assertUnavailable { adapter(base, postalCodes = postalRepository(mutableListOf())).distanceFromSchool(ADDRESS, zipCode) } + assertEquals(1, requests.get()) + } + } + } + + @Test + fun `카카오 HTTP JSON 좌표 오류는 등록 우편번호가 있어도 대체하지 않는다`() { + for ((status, body) in listOf(503 to "장애", 200 to "broken-json", 200 to """{"documents":[{"x":"bad","y":"35.75"}]}""")) { + withServer(failedRequest = 0, status = status, body = body) { base, _ -> + val lookups = mutableListOf() + assertUnavailable { adapter(base, postalCodes = postalRepository(lookups)).distanceFromSchool(ADDRESS, "01234") } + assertTrue(lookups.isEmpty()) + } + } + } + + private fun postalRepository(lookups: MutableList): PostalCodeLocationJpaRepository = + Proxy.newProxyInstance(javaClass.classLoader, arrayOf(PostalCodeLocationJpaRepository::class.java)) { _, method, args -> + assertEquals("findById", method.name) + val zipCode = args[0] as String + lookups += zipCode + Optional.ofNullable(if (zipCode == "01234") PostalCodeLocationJpaEntity(zipCode, BigDecimal("35.75"), BigDecimal("128.25")) else null) + } as PostalCodeLocationJpaRepository + @Test fun kakaoDistanceReadsCoordinatesAndMeterValue() { val adapter = KakaoDistanceAdapter(ObjectMapper(), "key", "https://local.test", "https://directions.test", "학교", 1000) @@ -129,8 +183,8 @@ class KakaoDistanceAdapterTest { } } - private fun adapter(base: String, timeoutMs: Long = 3000) = - KakaoDistanceAdapter(ObjectMapper(), KEY, "$base/geocode", "$base/directions", SCHOOL, timeoutMs) + private fun adapter(base: String, timeoutMs: Long = 3000, postalCodes: PostalCodeLocationJpaRepository? = null) = + KakaoDistanceAdapter(ObjectMapper(), KEY, "$base/geocode", "$base/directions", SCHOOL, timeoutMs, postalCodes) private fun assertUnavailable(action: () -> Unit) { val failure = runCatching(action).exceptionOrNull() @@ -171,12 +225,14 @@ class KakaoDistanceAdapterTest { status: Int = 200, body: String = "", delayMs: Long = 0, + queries: MutableList = mutableListOf(), action: (String, AtomicInteger) -> Unit, ) { val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) val requests = AtomicInteger() server.createContext("/") { exchange -> val index = requests.getAndIncrement() + queries += exchange.requestURI.rawQuery.orEmpty() try { if (delayMs > 0) Thread.sleep(delayMs) val payload = if (index == failedRequest) body else if (exchange.requestURI.path == "/directions") { diff --git a/systems/application/application-administration-application/src/main/kotlin/hs/kr/entrydsm/application/administration/application/ApplicantService.kt b/systems/application/application-administration-application/src/main/kotlin/hs/kr/entrydsm/application/administration/application/ApplicantService.kt index a86c8dd5..aee167ca 100644 --- a/systems/application/application-administration-application/src/main/kotlin/hs/kr/entrydsm/application/administration/application/ApplicantService.kt +++ b/systems/application/application-administration-application/src/main/kotlin/hs/kr/entrydsm/application/administration/application/ApplicantService.kt @@ -119,6 +119,7 @@ class ApplicantService( ) } + // 원서 우편번호를 거리 계산에 전달하고 기존 발급 정책을 적용한다. @Transactional override fun issueAll(): ExamineeNumberIssueResult { val applicants = applicantRepository.findAll() @@ -129,7 +130,7 @@ class ApplicantService( .filter { it.isArrived && it.examineeNumber == null && it.admissionType != null && it.region != null } .mapNotNull { applicant -> applicant.address?.takeIf(String::isNotBlank) - ?.let { applicant.id to distancePort.distanceFromSchool(it) } + ?.let { applicant.id to distancePort.distanceFromSchool(it, applicant.zipCode) } } .toMap() val issuance = ExamineeNumberPolicy.issue(applicants, distances) diff --git a/systems/application/application-administration-application/src/test/kotlin/hs/kr/entrydsm/application/administration/application/AdminApplicationModuleTest.kt b/systems/application/application-administration-application/src/test/kotlin/hs/kr/entrydsm/application/administration/application/AdminApplicationModuleTest.kt index 4527e665..a49da6d4 100644 --- a/systems/application/application-administration-application/src/test/kotlin/hs/kr/entrydsm/application/administration/application/AdminApplicationModuleTest.kt +++ b/systems/application/application-administration-application/src/test/kotlin/hs/kr/entrydsm/application/administration/application/AdminApplicationModuleTest.kt @@ -520,6 +520,43 @@ class AdminApplicationModuleTest { val content get() = contentProvider() } + @Test + fun `일괄 발급은 거리 오름차순과 동률 접수순을 유지하고 기존 번호 다음부터 발급한다`() { + val existing = Applicant(9L, admissionType = AdmissionType.MEISTER, region = Region.DAEJEON, + address = "기발급", isArrived = true, examineeNumber = "11007") + val repository = FakeApplicantRepository(listOf( + Applicant(3L, admissionType = AdmissionType.MEISTER, region = Region.DAEJEON, address = "동률3", zipCode = "01234", isArrived = true), + existing, + Applicant(2L, admissionType = AdmissionType.MEISTER, region = Region.DAEJEON, address = "동률2", zipCode = "34111", isArrived = true), + Applicant(1L, admissionType = AdmissionType.MEISTER, region = Region.DAEJEON, address = "가까움", isArrived = true), + )) + val requested = mutableListOf>() + val service = ApplicantService( + applicantRepository = repository, + applicantArrivalPort = ApplicantArrivalPort { _, _ -> }, + distancePort = object : DistancePort { + override fun distanceFromSchool(address: String): Long = error("우편번호가 포함된 호출이어야 한다") + override fun distanceFromSchool(address: String, zipCode: String?): Long { + requested += address to zipCode + return when (address) { + "가까움" -> 0L + "동률2", "동률3" -> 1234L + else -> error("기발급 지원자는 거리 조회 대상이 아니다") + } + } + }, + clock = Clock.fixed(Instant.EPOCH, ZoneOffset.UTC), + ) + + val result = service.issueAll() + + assertEquals(3, result.issuedCount) + assertEquals(mapOf(1L to "11008", 2L to "11009", 3L to "11010", 9L to "11007"), + repository.saved.associate { it.id to it.examineeNumber }) + assertEquals(existing, repository.saved.single { it.id == 9L }) + assertEquals(listOf("동률3" to "01234", "동률2" to "34111", "가까움" to null), requested) + } + @Test fun doesNotSaveAnyNumberWhenOneDistanceLookupFailsAndSkipsAlreadyIssuedApplicant() { val applicants = listOf( diff --git a/systems/application/application-administration-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/port/out/AdminRepositories.kt b/systems/application/application-administration-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/port/out/AdminRepositories.kt index da4f50f4..499de360 100644 --- a/systems/application/application-administration-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/port/out/AdminRepositories.kt +++ b/systems/application/application-administration-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/port/out/AdminRepositories.kt @@ -61,6 +61,9 @@ fun interface ApplicantDeletionPort { fun interface DistancePort { fun distanceFromSchool(address: String): Long + + // 기존 단일 주소 구현과의 호환성을 유지한다. + fun distanceFromSchool(address: String, zipCode: String?): Long = distanceFromSchool(address) } interface ScorePolicyRepository { From ff584830c50a7759b2ee69119a5e963e9d083313 Mon Sep 17 00:00:00 2001 From: kusuri12 Date: Tue, 20 Oct 2026 19:18:03 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(application):=20DRAFT=20=EC=9B=90?= =?UTF-8?q?=EC=84=9C=20=EC=9E=85=EB=A0=A5=EA=B0=92=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EC=B6=94=EA=B0=80=20#399?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapterin/web/ApplicationController.kt | 7 ++ .../grpc/ApplicationGrpcServiceTest.kt | 1 + .../web/ApplicationControllerTest.kt | 85 +++++++++++++++++++ .../exception/ApplicationErrorCode.kt | 3 +- .../application/port/in/ApplicationPort.kt | 3 + .../port/in/result/DraftApplicationResult.kt | 60 +++++++++++++ .../service/ApplicationCommandService.kt | 10 +++ .../service/ApplicationCommandServiceTest.kt | 73 ++++++++++++++++ 8 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 systems/application/application-application/src/main/kotlin/hs/kr/entrydsm/application/application/port/in/result/DraftApplicationResult.kt diff --git a/systems/application/application-adapter-in/src/main/kotlin/hs/kr/entrydsm/application/adapterin/web/ApplicationController.kt b/systems/application/application-adapter-in/src/main/kotlin/hs/kr/entrydsm/application/adapterin/web/ApplicationController.kt index d8f42f97..f86a6e63 100644 --- a/systems/application/application-adapter-in/src/main/kotlin/hs/kr/entrydsm/application/adapterin/web/ApplicationController.kt +++ b/systems/application/application-adapter-in/src/main/kotlin/hs/kr/entrydsm/application/adapterin/web/ApplicationController.kt @@ -11,6 +11,7 @@ import hs.kr.entrydsm.application.adapterin.web.dto.request.UpdateTypeRequest import hs.kr.entrydsm.application.adapterin.web.dto.response.CreateApplicantResponse import hs.kr.entrydsm.application.adapterin.web.dto.response.LandingResponse import hs.kr.entrydsm.application.application.port.`in`.ApplicationPort +import hs.kr.entrydsm.application.application.port.`in`.result.DraftApplicationResult import hs.kr.entrydsm.application.application.port.`in`.command.CreateApplicantCommand import hs.kr.entrydsm.application.application.port.`in`.command.SubmitApplicationCommand import hs.kr.entrydsm.application.application.port.`in`.command.UpdateFamilyCommand @@ -41,6 +42,12 @@ import org.springframework.web.bind.annotation.RestController class ApplicationController( private val applicationPort: ApplicationPort, ) { + // 신뢰된 사용자 헤더의 본인 계정으로만 작성 중 원서를 조회한다. + @GetMapping + fun getDraftApplication( + @RequestHeader(USER_ID_HEADER) accountId: Long, + ): ApiResponse = ApiResponse(data = applicationPort.findDraftApplication(accountId)) + @GetMapping("/landing") fun getLanding( @RequestHeader(USER_ID_HEADER) accountId: Long, diff --git a/systems/application/application-adapter-in/src/test/kotlin/hs/kr/entrydsm/application/adapterin/grpc/ApplicationGrpcServiceTest.kt b/systems/application/application-adapter-in/src/test/kotlin/hs/kr/entrydsm/application/adapterin/grpc/ApplicationGrpcServiceTest.kt index 2aec3e17..3b2c697c 100644 --- a/systems/application/application-adapter-in/src/test/kotlin/hs/kr/entrydsm/application/adapterin/grpc/ApplicationGrpcServiceTest.kt +++ b/systems/application/application-adapter-in/src/test/kotlin/hs/kr/entrydsm/application/adapterin/grpc/ApplicationGrpcServiceTest.kt @@ -401,6 +401,7 @@ class ApplicationGrpcServiceTest { } private class FakeApplicationPort : ApplicationPort { + override fun findDraftApplication(accountId: Long): hs.kr.entrydsm.application.application.port.`in`.result.DraftApplicationResult = error("not used") var snapshot: ApplicationSnapshotResult? = null var examineeNumberUpdate: Pair? = null diff --git a/systems/application/application-adapter-in/src/test/kotlin/hs/kr/entrydsm/application/adapterin/web/ApplicationControllerTest.kt b/systems/application/application-adapter-in/src/test/kotlin/hs/kr/entrydsm/application/adapterin/web/ApplicationControllerTest.kt index d0aa038d..9b55dc25 100644 --- a/systems/application/application-adapter-in/src/test/kotlin/hs/kr/entrydsm/application/adapterin/web/ApplicationControllerTest.kt +++ b/systems/application/application-adapter-in/src/test/kotlin/hs/kr/entrydsm/application/adapterin/web/ApplicationControllerTest.kt @@ -30,8 +30,91 @@ import org.junit.Assert.assertThrows import org.springframework.mock.web.MockHttpServletRequest import org.springframework.mock.web.MockHttpServletResponse import hs.kr.entrydsm.application.application.exception.ApplicationAccessDeniedException +import hs.kr.entrydsm.application.application.service.ApplicationCommandService +import hs.kr.entrydsm.application.application.port.out.ApplicantRepository +import hs.kr.entrydsm.application.application.port.out.ApplicationPeriodReader +import hs.kr.entrydsm.application.domain.model.* +import hs.kr.entrydsm.application.domain.enum.* +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.junit.Assert.assertTrue class ApplicationControllerTest { + @Test + fun `미입력 작성 원서는 필수 구조와 null 및 저장 기본값을 JSON에 보존한다`() { + val fixture = DraftFixture(Applicant(id = 1, accountId = 10)) + val response = fixture.mvc.perform(get(DRAFT_URL).header("X-USER-ID", "10").header("X-User-Role", "STUDENT")).andReturn().response + assertEquals(200, response.status) + assertEquals(fixture.mapper.readTree("""{"success":true,"data":{"type":{"admissionType":null,"region":null,"graduationType":null,"graduationDate":null},"personal":{"photoFileId":null,"name":null,"phoneNumber":null,"gender":null,"birthdate":null,"specialAdmissionType":"NONE"},"family":{"guardianName":null,"guardianPhoneNumber":null,"guardianGender":null,"guardianRelation":null,"address":{"zipCode":null,"addressBase":null,"addressDetail":null}},"middleSchool":null,"evaluation":{"subjectGrades":[],"gedScores":null,"academicRecord":null,"certificates":null},"introduction":null,"studyPlan":null},"error":null}"""), fixture.mapper.readTree(response.contentAsString)) + assertEquals(listOf(10L), fixture.lookups) + } + + @Test + fun `저장 입력과 성적은 날짜 형식과 학기 순서를 지켜 반환하고 내부 정보는 제외한다`() { + val grades = SubjectGrades(SubjectGrade.A, SubjectGrade.B, SubjectGrade.C, SubjectGrade.D, SubjectGrade.E, SubjectGrade.X, SubjectGrade.A) + val applicant = Applicant(id = 99, accountId = 10, photoFileId = "photo_1", name = "지원자", phoneNumber = "01012345678", + gender = Gender.FEMALE, birthdate = java.time.LocalDate.of(2010, 3, 4), admissionType = AdmissionType.REGULAR, + region = Region.DAEJEON, graduationType = GraduationType.PROSPECTIVE, graduationDate = java.time.YearMonth.of(2027, 2), + guardianName = "보호자", guardianPhoneNumber = "01087654321", guardianGender = Gender.MALE, guardianRelation = "부", + zipCode = "01234", addressBase = "기본 주소", addressDetail = "상세 주소", introduction = "자기소개", studyPlan = "학업계획", + middleSchoolInfo = MiddleSchoolInfo("code", "중학교", "30101", "-", "담임", "반환금지 학교주소"), + academicRecord = AcademicRecord(absentCount = 1, lateCount = 2, earlyLeaveCount = 3, classAbsenceCount = 4, volunteerTime = 5, + isDsmAlgorithmAwarded = true, subjectGrades = linkedMapOf(SchoolSemester.THIRD_GRADE_FIRST_SEMESTER to grades, + SchoolSemester.SECOND_GRADE_FIRST_SEMESTER to grades), gedScores = GedScores(100, 90, 80, 70, 60)), + totalScore = 123.45, examineeNumber = "11001", passStatus = PassResultStatus.PASS) + val before = applicant.copy() + val fixture = DraftFixture(applicant) + val response = fixture.mvc.perform(get(DRAFT_URL).header("X-USER-ID", "10").header("X-User-Role", "STUDENT") + .param("accountId", "20").param("applicantId", "99")).andReturn().response + assertEquals(200, response.status) + val expected = """{"success":true,"data":{"type":{"admissionType":"REGULAR","region":"DAEJEON","graduationType":"PROSPECTIVE","graduationDate":"2027-02"},"personal":{"photoFileId":"photo_1","name":"지원자","phoneNumber":"01012345678","gender":"FEMALE","birthdate":"2010-03-04","specialAdmissionType":"NONE"},"family":{"guardianName":"보호자","guardianPhoneNumber":"01087654321","guardianGender":"MALE","guardianRelation":"부","address":{"zipCode":"01234","addressBase":"기본 주소","addressDetail":"상세 주소"}},"middleSchool":{"schoolCode":"code","schoolName":"중학교","studentNumber":"30101","schoolPhone":"-","teacherName":"담임"},"evaluation":{"subjectGrades":[{"schoolSemester":"2-1","subjects":{"koreanGrade":"A","mathGrade":"B","englishGrade":"C","scienceGrade":"D","societyGrade":"E","technologyGrade":"X","historyGrade":"A"}},{"schoolSemester":"3-1","subjects":{"koreanGrade":"A","mathGrade":"B","englishGrade":"C","scienceGrade":"D","societyGrade":"E","technologyGrade":"X","historyGrade":"A"}}],"gedScores":{"koreanScore":100,"mathScore":90,"englishScore":80,"scienceScore":70,"societyScore":60,"technologyScore":null},"academicRecord":{"absentCount":1,"lateCount":2,"earlyLeaveCount":3,"classAbsenceCount":4,"volunteerTime":5},"certificates":{"isDsmAlgorithmAwarded":true,"isProgrammingCertified":false}},"introduction":"자기소개","studyPlan":"학업계획"},"error":null}""" + assertEquals(fixture.mapper.readTree(expected), fixture.mapper.readTree(response.contentAsString)) + assertEquals(before, applicant) + assertEquals(listOf(10L), fixture.lookups) + } + + @Test + fun `원서 없음과 모든 비작성 상태는 정해진 HTTP 오류로 응답한다`() { + for (status in listOf(null) + ApplicantStatus.entries.filter { it != ApplicantStatus.DRAFT }) { + val fixture = DraftFixture(status?.let { Applicant(id = 1, accountId = 10, status = it) }) + val response = fixture.mvc.perform(get(DRAFT_URL).header("X-USER-ID", "10").header("X-User-Role", "STUDENT")).andReturn().response + assertEquals("$status: ${response.contentAsString}", if (status == null) 404 else 400, response.status) + val root = fixture.mapper.readTree(response.contentAsString) + assertEquals(false, root.path("success").asBoolean()) + org.junit.Assert.assertFalse(root.has("data")) + assertEquals(if (status == null) "APPLICANT_NOT_FOUND" else "APPLICATION_NOT_READABLE", root.path("error").path("code").asString()) + if (status != null) assertEquals("작성 중인 원서만 조회할 수 있습니다.", root.path("error").path("message").asString()) + } + } + + @Test + fun `조회는 미인증과 학생 외 권한을 차단하고 요청 계정으로 타인 원서를 조회하지 않는다`() { + val fixture = DraftFixture(Applicant(id = 1, accountId = 20)) + assertEquals(401, fixture.mvc.perform(get(DRAFT_URL)).andReturn().response.status) + for (role in listOf("ADMIN", "TEACHER", "")) { + assertEquals(403, fixture.mvc.perform(get(DRAFT_URL).header("X-USER-ID", "10").header("X-User-Role", role)).andReturn().response.status) + } + assertTrue(fixture.lookups.isEmpty()) + assertEquals(404, fixture.mvc.perform(get(DRAFT_URL).header("X-USER-ID", "10").header("X-User-Role", "STUDENT") + .param("accountId", "20").param("applicantId", "1")).andReturn().response.status) + assertEquals(listOf(10L), fixture.lookups) + } + + private class DraftFixture(applicant: Applicant?) { + val lookups = mutableListOf() + val mapper = tools.jackson.module.kotlin.jacksonMapperBuilder().build() + private val repository = java.lang.reflect.Proxy.newProxyInstance(javaClass.classLoader, arrayOf(ApplicantRepository::class.java)) { _, method, args -> + check(method.name == "findByAccountId") { "조회 중 예상하지 않은 저장소 호출: ${method.name}" } + val accountId = args[0] as Long + lookups += accountId + applicant?.takeIf { it.accountId == accountId } + } as ApplicantRepository + val mvc = MockMvcBuilders.standaloneSetup(ApplicationController(ApplicationCommandService(repository, + ApplicationPeriodReader { error("조회 중 기간 확인 금지") }, hs.kr.entrydsm.application.application.port.out.AccountPhoneValidator { _, _ -> error("전화번호 검증 금지") }))) + .setMessageConverters(org.springframework.http.converter.json.JacksonJsonHttpMessageConverter(mapper)) + .addInterceptors(ApplicationAuthorizationInterceptor()) + .setControllerAdvice(GlobalExceptionHandler()).build() + } + @Test fun middleSchoolPassesLiteralDashForBothContactFields() { var command: UpdateMiddleSchoolCommand? = null @@ -221,6 +304,7 @@ class ApplicationControllerTest { } private class FakeApplicationPort : ApplicationPort { + override fun findDraftApplication(accountId: Long): hs.kr.entrydsm.application.application.port.`in`.result.DraftApplicationResult = error("not used") var createApplicantCommand: CreateApplicantCommand? = null override fun listApplicants(): List = emptyList() @@ -256,6 +340,7 @@ class ApplicationControllerTest { } private companion object { + const val DRAFT_URL = "/api/application/v11/applicants" val applicationStartAt: LocalDateTime = LocalDateTime.parse("2026-10-19T09:00:00") val applicationEndAt: LocalDateTime = LocalDateTime.parse("2026-10-23T17:00:00") val resultAnnouncedAt: LocalDateTime = LocalDateTime.parse("2026-10-30T10:00:00") diff --git a/systems/application/application-application/src/main/kotlin/hs/kr/entrydsm/application/application/exception/ApplicationErrorCode.kt b/systems/application/application-application/src/main/kotlin/hs/kr/entrydsm/application/application/exception/ApplicationErrorCode.kt index c79fa7f4..b154f0b3 100644 --- a/systems/application/application-application/src/main/kotlin/hs/kr/entrydsm/application/application/exception/ApplicationErrorCode.kt +++ b/systems/application/application-application/src/main/kotlin/hs/kr/entrydsm/application/application/exception/ApplicationErrorCode.kt @@ -149,7 +149,8 @@ enum class ApplicationErrorCode( APPLICATION_MIDDLE_SCHOOL_NOT_ALLOWED("middleSchool", "NOT_ALLOWED", "검정고시 지원자는 출신학교 정보를 입력할 수 없습니다."), APPLICATION_SUBJECTS_NOT_ALLOWED("subjects", "NOT_ALLOWED", "검정고시 지원자는 학교 성적을 입력할 수 없습니다."), APPLICATION_GED_SCORES_NOT_ALLOWED("gedScores", "NOT_ALLOWED", "검정고시 점수는 검정고시 지원자만 입력할 수 있습니다."), - APPLICATION_NOT_EDITABLE("status", "NOT_ALLOWED", "작성 중인 원서만 수정할 수 있습니다."); + APPLICATION_NOT_EDITABLE("status", "NOT_ALLOWED", "작성 중인 원서만 수정할 수 있습니다."), + APPLICATION_NOT_READABLE("status", "NOT_ALLOWED", "작성 중인 원서만 조회할 수 있습니다."); fun requireValid(condition: Boolean) { if (!condition) throw ApplicationValidationException(this) diff --git a/systems/application/application-application/src/main/kotlin/hs/kr/entrydsm/application/application/port/in/ApplicationPort.kt b/systems/application/application-application/src/main/kotlin/hs/kr/entrydsm/application/application/port/in/ApplicationPort.kt index bcf5448a..5c88026b 100644 --- a/systems/application/application-application/src/main/kotlin/hs/kr/entrydsm/application/application/port/in/ApplicationPort.kt +++ b/systems/application/application-application/src/main/kotlin/hs/kr/entrydsm/application/application/port/in/ApplicationPort.kt @@ -14,8 +14,11 @@ import hs.kr.entrydsm.application.application.port.`in`.result.ApplicationFormRe import hs.kr.entrydsm.application.application.port.`in`.result.ApplicationSnapshotResult import hs.kr.entrydsm.application.application.port.`in`.result.CreateApplicantResult import hs.kr.entrydsm.application.application.port.`in`.result.LandingResult +import hs.kr.entrydsm.application.application.port.`in`.result.DraftApplicationResult interface ApplicationPort { + // 인증된 본인 계정의 작성 중 원서만 조회한다. + fun findDraftApplication(accountId: Long): DraftApplicationResult fun createApplicant(command: CreateApplicantCommand): CreateApplicantResult fun updateType(command: UpdateTypeCommand) fun updatePersonal(command: UpdatePersonalCommand) diff --git a/systems/application/application-application/src/main/kotlin/hs/kr/entrydsm/application/application/port/in/result/DraftApplicationResult.kt b/systems/application/application-application/src/main/kotlin/hs/kr/entrydsm/application/application/port/in/result/DraftApplicationResult.kt new file mode 100644 index 00000000..2eb40344 --- /dev/null +++ b/systems/application/application-application/src/main/kotlin/hs/kr/entrydsm/application/application/port/in/result/DraftApplicationResult.kt @@ -0,0 +1,60 @@ +package hs.kr.entrydsm.application.application.port.`in`.result + +import hs.kr.entrydsm.application.domain.enum.AdmissionType +import hs.kr.entrydsm.application.domain.enum.Gender +import hs.kr.entrydsm.application.domain.enum.GraduationType +import hs.kr.entrydsm.application.domain.enum.Region +import hs.kr.entrydsm.application.domain.enum.SchoolSemester +import hs.kr.entrydsm.application.domain.enum.SpecialAdmissionType +import hs.kr.entrydsm.application.domain.model.Applicant +import hs.kr.entrydsm.application.domain.model.GedScores +import hs.kr.entrydsm.application.domain.model.SubjectGrades + +data class DraftApplicationResult( + val type: Type, + val personal: Personal, + val family: Family, + val middleSchool: MiddleSchool?, + val evaluation: Evaluation, + val introduction: String?, + val studyPlan: String?, +) { + data class Type(val admissionType: AdmissionType?, val region: Region?, val graduationType: GraduationType?, val graduationDate: String?) + data class Personal(val photoFileId: String?, val name: String?, val phoneNumber: String?, val gender: Gender?, val birthdate: String?, val specialAdmissionType: SpecialAdmissionType) + data class Family(val guardianName: String?, val guardianPhoneNumber: String?, val guardianGender: Gender?, val guardianRelation: String?, val address: Address) + data class Address(val zipCode: String?, val addressBase: String?, val addressDetail: String?) + data class MiddleSchool(val schoolCode: String, val schoolName: String, val studentNumber: String, val schoolPhone: String, val teacherName: String) + data class Evaluation(val subjectGrades: List, val gedScores: GedScores?, val academicRecord: AcademicRecord?, val certificates: Certificates?) + data class SemesterGrades(val schoolSemester: String, val subjects: SubjectGrades) + data class AcademicRecord(val absentCount: Int, val lateCount: Int, val earlyLeaveCount: Int, val classAbsenceCount: Int, val volunteerTime: Int) + data class Certificates(val isDsmAlgorithmAwarded: Boolean, val isProgrammingCertified: Boolean) +} + +// 저장된 입력만 복사하며 계산·보정·원본 변경은 수행하지 않는다. +fun Applicant.toDraftApplicationResult(): DraftApplicationResult { + val record = academicRecord + return DraftApplicationResult( + type = DraftApplicationResult.Type(admissionType, region, graduationType, graduationDate?.toString()), + personal = DraftApplicationResult.Personal(photoFileId, name, phoneNumber, gender, birthdate?.toString(), specialAdmissionType), + family = DraftApplicationResult.Family(guardianName, guardianPhoneNumber, guardianGender, guardianRelation, + DraftApplicationResult.Address(zipCode, addressBase, addressDetail)), + middleSchool = middleSchoolInfo?.let { + DraftApplicationResult.MiddleSchool(it.schoolCode, it.schoolName, it.studentNumber, it.schoolPhone, it.teacherName) + }, + evaluation = DraftApplicationResult.Evaluation( + subjectGrades = listOf( + SchoolSemester.SECOND_GRADE_FIRST_SEMESTER to "2-1", + SchoolSemester.SECOND_GRADE_SECOND_SEMESTER to "2-2", + SchoolSemester.THIRD_GRADE_FIRST_SEMESTER to "3-1", + SchoolSemester.THIRD_GRADE_SECOND_SEMESTER to "3-2", + ).mapNotNull { (semester, label) -> + record?.subjectGrades?.get(semester)?.let { DraftApplicationResult.SemesterGrades(label, it) } + }, + gedScores = record?.gedScores?.copy(), + academicRecord = record?.let { DraftApplicationResult.AcademicRecord(it.absentCount, it.lateCount, it.earlyLeaveCount, it.classAbsenceCount, it.volunteerTime) }, + certificates = record?.let { DraftApplicationResult.Certificates(it.isDsmAlgorithmAwarded, it.isProgrammingCertified) }, + ), + introduction = introduction, + studyPlan = studyPlan, + ) +} diff --git a/systems/application/application-application/src/main/kotlin/hs/kr/entrydsm/application/application/service/ApplicationCommandService.kt b/systems/application/application-application/src/main/kotlin/hs/kr/entrydsm/application/application/service/ApplicationCommandService.kt index f2517071..7539f56c 100644 --- a/systems/application/application-application/src/main/kotlin/hs/kr/entrydsm/application/application/service/ApplicationCommandService.kt +++ b/systems/application/application-application/src/main/kotlin/hs/kr/entrydsm/application/application/service/ApplicationCommandService.kt @@ -21,6 +21,8 @@ import hs.kr.entrydsm.application.application.port.`in`.result.ApplicationFormRe import hs.kr.entrydsm.application.application.port.`in`.result.ApplicationSnapshotResult import hs.kr.entrydsm.application.application.port.`in`.result.CreateApplicantResult import hs.kr.entrydsm.application.application.port.`in`.result.LandingResult +import hs.kr.entrydsm.application.application.port.`in`.result.DraftApplicationResult +import hs.kr.entrydsm.application.application.port.`in`.result.toDraftApplicationResult import hs.kr.entrydsm.application.application.port.out.ApplicantRepository import hs.kr.entrydsm.application.application.port.out.AccountPhoneValidator import hs.kr.entrydsm.application.application.port.out.ApplicantStatusChanged @@ -54,6 +56,14 @@ class ApplicationCommandService( private val firstScreeningPublicationPolicy: FirstScreeningPublicationPolicy = FirstScreeningPublicationPolicy { false }, private val applicantStatusEventOutbox: ApplicantStatusEventOutbox = ApplicantStatusEventOutbox {}, ) : ApplicationPort { + // 기간 제한과 성적 재계산 없이 저장된 작성 중 원서를 읽는다. + @Transactional(readOnly = true) + override fun findDraftApplication(accountId: Long): DraftApplicationResult { + val applicant = applicantRepository.findByAccountId(accountId) ?: throw ApplicantNotFoundException(accountId) + APPLICATION_NOT_READABLE.requireValid(applicant.status == ApplicantStatus.DRAFT) + return applicant.toDraftApplicationResult() + } + private val scoreCalculator = ScoreCalculator() private val editor = ApplicationFormEditor(accountPhoneValidator) diff --git a/systems/application/application-application/src/test/kotlin/hs/kr/entrydsm/application/application/service/ApplicationCommandServiceTest.kt b/systems/application/application-application/src/test/kotlin/hs/kr/entrydsm/application/application/service/ApplicationCommandServiceTest.kt index 111461eb..8c853cd6 100644 --- a/systems/application/application-application/src/test/kotlin/hs/kr/entrydsm/application/application/service/ApplicationCommandServiceTest.kt +++ b/systems/application/application-application/src/test/kotlin/hs/kr/entrydsm/application/application/service/ApplicationCommandServiceTest.kt @@ -39,6 +39,79 @@ import org.junit.Assert.assertTrue import org.junit.Test class ApplicationCommandServiceTest { + @Test + fun `작성 원서 조회는 기간 밖에서도 저장값만 반환하고 성적이나 원본을 변경하지 않는다`() { + val applicant = submittableGedApplicant().copy(status = ApplicantStatus.DRAFT, totalScore = null, totalScoreUpdatedAt = null, + zipCode = "01234", introduction = "저장 자기소개", studyPlan = "저장 학업계획") + val before = applicant.copy() + val repository = FakeApplicantRepository(applicant) + val service = ApplicationCommandService(repository, ApplicationPeriodReader { error("조회는 기간을 읽으면 안 된다") }, ACCEPT_PHONE, + applicantStatusEventOutbox = hs.kr.entrydsm.application.application.port.out.ApplicantStatusEventOutbox { error("조회 이벤트 금지") }) + + val result = service.findDraftApplication(10L) + + assertEquals("01234", result.family.address.zipCode) + assertEquals(applicant.academicRecord?.gedScores, result.evaluation.gedScores) + assertEquals("저장 자기소개", result.introduction) + assertEquals("저장 학업계획", result.studyPlan) + assertEquals(before, applicant) + assertNull(applicant.totalScore) + assertNull(applicant.totalScoreUpdatedAt) + assertEquals(0, repository.saveCount) + assertNull(repository.deletedId) + assertEquals(1, repository.singleReadCount) + } + + @Test + fun `원서가 없거나 다른 계정만 있으면 생성 없이 찾을 수 없음이다`() { + for (applicant in listOf(null, Applicant(id = 1, accountId = 20))) { + val repository = FakeApplicantRepository(applicant) + val service = ApplicationCommandService(repository, CLOSED, ACCEPT_PHONE) + assertThrows(hs.kr.entrydsm.application.application.exception.ApplicantNotFoundException::class.java) { + service.findDraftApplication(10L) + } + assertEquals(0, repository.saveCount) + assertNull(repository.deletedId) + } + } + + @Test + fun `DRAFT 외 모든 상태는 조회를 거부하고 저장 상태를 보존한다`() { + for (status in ApplicantStatus.entries.filter { it != ApplicantStatus.DRAFT }) { + val applicant = Applicant(id = 1, accountId = 10, status = status) + val before = applicant.copy() + val repository = FakeApplicantRepository(applicant) + val failure = runCatching { ApplicationCommandService(repository, CLOSED, ACCEPT_PHONE).findDraftApplication(10L) }.exceptionOrNull() + assertNotNull("$status 조회를 허용하면 안 된다", failure) + assertEquals("작성 중인 원서만 조회할 수 있습니다.", failure?.message) + assertEquals(before, applicant) + assertEquals(0, repository.saveCount) + } + } + + @Test + fun `학적 레코드의 유무와 저장된 학기 성적을 구별한다`() { + val applicant = Applicant(id = 1, accountId = 10) + val service = ApplicationCommandService(FakeApplicantRepository(applicant), CLOSED, ACCEPT_PHONE) + val empty = service.findDraftApplication(10L) + assertNull(empty.middleSchool) + assertNull(empty.evaluation.academicRecord) + assertNull(empty.evaluation.certificates) + assertNull(empty.evaluation.gedScores) + assertTrue(empty.evaluation.subjectGrades.isEmpty()) + applicant.academicRecord = AcademicRecord(subjectGrades = linkedMapOf( + SchoolSemester.THIRD_GRADE_SECOND_SEMESTER to all(SubjectGrade.D), + SchoolSemester.SECOND_GRADE_FIRST_SEMESTER to all(SubjectGrade.A), + SchoolSemester.THIRD_GRADE_FIRST_SEMESTER to all(SubjectGrade.C), + SchoolSemester.SECOND_GRADE_SECOND_SEMESTER to all(SubjectGrade.B))) + val stored = service.findDraftApplication(10L) + assertEquals(listOf("2-1", "2-2", "3-1", "3-2"), stored.evaluation.subjectGrades.map { it.schoolSemester }) + assertEquals(listOf(SubjectGrade.A, SubjectGrade.B, SubjectGrade.C, SubjectGrade.D), stored.evaluation.subjectGrades.map { it.subjects.koreanGrade }) + assertEquals(0, stored.evaluation.academicRecord?.volunteerTime) + assertEquals(false, stored.evaluation.certificates?.isDsmAlgorithmAwarded) + assertEquals(false, stored.evaluation.certificates?.isProgrammingCertified) + } + @Test fun `발표 전 내부 합격과 불합격 결과는 지원자 조회에서 대기로 표시한다`() { for (status in listOf(hs.kr.entrydsm.application.domain.enum.PassResultStatus.PASS,