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 217a8e96..95b570f3 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 @@ -62,6 +62,8 @@ class KakaoDistanceAdapter( fun parseDistance(body: String): Long { val route = objectMapper.readTree(body).path("routes").path(0) val resultCode = route.path("result_code").asInt(-1) + // 출발지와 도착지가 가까워 경로가 없는 경우 정상 거리 0m로 처리한다. + if (resultCode == 104) return 0L if (resultCode != 0) { unavailable("DIRECTIONS", "ROUTE_RESULT", resultCode = resultCode) } diff --git a/systems/application/application-administration-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/administration/adapterout/document/LocalExportDocumentAdapter.kt b/systems/application/application-administration-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/administration/adapterout/document/LocalExportDocumentAdapter.kt index 0f637437..c233de7b 100644 --- a/systems/application/application-administration-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/administration/adapterout/document/LocalExportDocumentAdapter.kt +++ b/systems/application/application-administration-adapter-out/src/main/kotlin/hs/kr/entrydsm/application/administration/adapterout/document/LocalExportDocumentAdapter.kt @@ -5,9 +5,14 @@ import hs.kr.entrydsm.admin.domain.exception.AdminDomainException import hs.kr.entrydsm.admin.domain.port.out.AdmissionTicketPort import hs.kr.entrydsm.admin.domain.port.out.ApplicationEssayPdfs import hs.kr.entrydsm.admin.domain.port.out.ApplicationEssayPort +import hs.kr.entrydsm.admin.domain.port.out.EssayPdfMergePort import hs.kr.entrydsm.application.administration.domain.document.exception.ApplicantNotFoundException import hs.kr.entrydsm.application.administration.domain.document.port.`in`.ApplicantFileUseCase import hs.kr.entrydsm.application.application.exception.EvaluationValidationException +import java.io.OutputStream +import org.apache.pdfbox.Loader +import org.apache.pdfbox.multipdf.PDFMergerUtility +import org.apache.pdfbox.pdmodel.PDDocument import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty import org.springframework.stereotype.Component @@ -31,6 +36,23 @@ class LocalApplicationEssayAdapter(private val documents: ApplicantFileUseCase) } } +@Component +@ConditionalOnProperty(name = ["application.administration.enabled"], havingValue = "true") +class PdfBoxEssayMergeAdapter : EssayPdfMergePort { + // 원본을 닫아도 글꼴과 리소스가 유지되도록 두 문서 전체를 복사한다. + override fun writeTo(documents: List, output: OutputStream) { + PDDocument().use { merged -> + val merger = PDFMergerUtility() + documents.forEach { pdfs -> + listOfNotNull(pdfs.introduction, pdfs.studyPlan).forEach { pdf -> + Loader.loadPDF(pdf).use { source -> merger.appendDocument(merged, source) } + } + } + merged.save(output) + } + } +} + private fun renderFailure(error: Exception, fallback: ErrorCode, targets: List): AdminDomainException { val causes = generateSequence(error) { it.cause }.toList() val code = when { 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 2b8501bc..5f8e1762 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 @@ -75,11 +75,22 @@ class KakaoDistanceAdapterTest { @Test fun routeFailureRecordsOnlyNumericCode() = withServer( failedRequest = 2, - body = """{"routes":[{"result_code":104,"result_msg":"$PRIVATE_BODY"}]}""", + body = """{"routes":[{"result_code":105,"result_msg":"$PRIVATE_BODY"}]}""", ) { base, _ -> capture { logs -> assertUnavailable { adapter(base).distanceFromSchool(ADDRESS) } - assertDiagnostic(logs, "DIRECTIONS", "ROUTE_RESULT", "resultCode=104") + assertDiagnostic(logs, "DIRECTIONS", "ROUTE_RESULT", "resultCode=105") + } + } + + @Test + fun sameOriginAndDestinationReturnsZeroWithoutDistanceSummary() = withServer( + failedRequest = 2, + body = """{"routes":[{"result_code":104,"result_msg":"$PRIVATE_BODY"}]}""", + ) { base, _ -> + capture { logs -> + assertEquals(0L, adapter(base).distanceFromSchool(ADDRESS)) + assertTrue(logs.list.isEmpty()) } } diff --git a/systems/application/application-administration-adapter-out/src/test/kotlin/hs/kr/entrydsm/application/administration/adapterout/document/ApplicationFormPdfTest.kt b/systems/application/application-administration-adapter-out/src/test/kotlin/hs/kr/entrydsm/application/administration/adapterout/document/ApplicationFormPdfTest.kt index 7b8e0004..9adac78b 100644 --- a/systems/application/application-administration-adapter-out/src/test/kotlin/hs/kr/entrydsm/application/administration/adapterout/document/ApplicationFormPdfTest.kt +++ b/systems/application/application-administration-adapter-out/src/test/kotlin/hs/kr/entrydsm/application/administration/adapterout/document/ApplicationFormPdfTest.kt @@ -35,6 +35,78 @@ import kotlin.math.roundToInt */ class ApplicationFormPdfTest { + @Test + fun `번호 없는 합격자 자기소개서와 학업계획서를 실제 PDF로 모두 내려받는다`() { + val form = form().copy(examineeNumber = null) + val applicantPort = object : hs.kr.entrydsm.application.administration.domain.document.port.out.ApplicantPort { + override fun findById(applicantId: Long) = Applicant(form.userId, form.name, null, null, null, null) + override fun findApplicationForm(accountId: Long) = form + override fun isFinalPassed(accountId: Long) = false + } + val service = hs.kr.entrydsm.application.administration.application.FileDocumentService( + unused(hs.kr.entrydsm.configuration.domain.document.port.out.StoragePort::class.java), + unused(hs.kr.entrydsm.configuration.domain.document.port.out.FileDocumentRepository::class.java), + 300, applicantPort, + unused(hs.kr.entrydsm.application.administration.domain.document.port.out.PdfRenderPort::class.java), + adapter, + unused(hs.kr.entrydsm.application.administration.domain.document.port.out.AdmissionTicketSheetPort::class.java), + 2027, + ) + val repository = java.lang.reflect.Proxy.newProxyInstance(javaClass.classLoader, + arrayOf(hs.kr.entrydsm.admin.domain.port.out.ApplicantRepository::class.java)) { _, method, args -> + assertEquals("findAll", method.name) + assertEquals(setOf(hs.kr.entrydsm.admin.domain.enum.ApplicantStatus.FIRST_PASS), + (args[0] as hs.kr.entrydsm.admin.domain.model.ApplicantFilter).statuses) + listOf(hs.kr.entrydsm.admin.domain.model.Applicant(id = form.applicantId, + status = hs.kr.entrydsm.admin.domain.enum.ApplicantStatus.FIRST_PASS)) + } as hs.kr.entrydsm.admin.domain.port.out.ApplicantRepository + val output = ByteArrayOutputStream() + assertEquals(1, hs.kr.entrydsm.application.administration.application.EssayPdfService( + repository, LocalApplicationEssayAdapter(service), PdfBoxEssayMergeAdapter()).writeTo(output)) + val bytes = output.toByteArray() + System.getenv("TEST_UNDECLARED_OUTPUTS_DIR")?.let { File(it, "unissued-essays.pdf").writeBytes(bytes) } + Loader.loadPDF(bytes).use { document -> + assertEquals(2, document.numberOfPages) + val introduction = pageText(bytes, 1) + val plan = pageText(bytes, 2) + assertTrue(introduction, introduction.contains("저는 어려서부터")) + assertFalse(introduction.contains("입학 후에는")) + assertTrue(plan, plan.contains("입학 후에는")) + assertFalse(plan.contains("저는 어려서부터")) + assertFalse(PDFTextStripper().getText(document).contains("미발급")) + } + } + + @Test + fun `PDF 병합은 두 문서와 단독 학업계획서를 포함하고 빈 목록도 처리한다`() { + fun pdf(pages: Int) = PDDocument().use { document -> + repeat(pages) { document.addPage(PDPage()) } + ByteArrayOutputStream().also { document.save(it) }.toByteArray() + } + val merger = PdfBoxEssayMergeAdapter() + val output = ByteArrayOutputStream() + merger.writeTo(listOf( + hs.kr.entrydsm.admin.domain.port.out.ApplicationEssayPdfs(pdf(1), pdf(2)), + hs.kr.entrydsm.admin.domain.port.out.ApplicationEssayPdfs(null, pdf(4)), + hs.kr.entrydsm.admin.domain.port.out.ApplicationEssayPdfs(null, null), + ), output) + Loader.loadPDF(output.toByteArray()).use { assertEquals(7, it.numberOfPages) } + val empty = ByteArrayOutputStream() + merger.writeTo(emptyList(), empty) + Loader.loadPDF(empty.toByteArray()).use { assertEquals(0, it.numberOfPages) } + org.junit.Assert.assertThrows(Exception::class.java) { + merger.writeTo(listOf(hs.kr.entrydsm.admin.domain.port.out.ApplicationEssayPdfs("broken pdf".toByteArray(), null)), + ByteArrayOutputStream()) + } + org.junit.Assert.assertThrows(IllegalStateException::class.java) { + merger.writeTo(listOf(hs.kr.entrydsm.admin.domain.port.out.ApplicationEssayPdfs(pdf(1), null)), + object : java.io.OutputStream() { override fun write(value: Int) { error("storage failed") } }) + } + } + + private fun unused(type: Class): T = type.cast(java.lang.reflect.Proxy.newProxyInstance( + javaClass.classLoader, arrayOf(type)) { _, method, _ -> error("Unexpected ${method.name}") }) + /** 요강의 인터넷접수 후 출력 서식 수 — 입학원서·개인정보 동의·자기소개서·추천서·금연 동의·흡연검사 동의 */ private val FORM_COUNT = 6 diff --git a/systems/application/application-administration-application/deps.bzl b/systems/application/application-administration-application/deps.bzl index 72200532..8cededd6 100644 --- a/systems/application/application-administration-application/deps.bzl +++ b/systems/application/application-administration-application/deps.bzl @@ -4,7 +4,6 @@ KOTLIN_DEPS = [ "//systems/configuration/configuration-domain:main", "@maven//:org_springframework_boot_spring_boot_starter", "@maven//:org_springframework_spring_tx", - "@maven//:org_apache_pdfbox_pdfbox", "//systems/admin/admin-domain:main", ] diff --git a/systems/application/application-administration-application/src/main/kotlin/hs/kr/entrydsm/application/administration/application/EssayZipService.kt b/systems/application/application-administration-application/src/main/kotlin/hs/kr/entrydsm/application/administration/application/EssayZipService.kt index b5de29db..30d35f0f 100644 --- a/systems/application/application-administration-application/src/main/kotlin/hs/kr/entrydsm/application/administration/application/EssayZipService.kt +++ b/systems/application/application-administration-application/src/main/kotlin/hs/kr/entrydsm/application/administration/application/EssayZipService.kt @@ -1,13 +1,13 @@ package hs.kr.entrydsm.application.administration.application import hs.kr.entrydsm.admin.domain.enum.ApplicantStatus +import hs.kr.entrydsm.admin.domain.model.Applicant import hs.kr.entrydsm.admin.domain.model.ApplicantFilter import hs.kr.entrydsm.admin.domain.port.`in`.DownloadEssaysUseCase import hs.kr.entrydsm.admin.domain.port.out.ApplicantRepository import hs.kr.entrydsm.admin.domain.port.out.ApplicationEssayPort +import hs.kr.entrydsm.admin.domain.port.out.EssayPdfMergePort import java.io.OutputStream -import org.apache.pdfbox.Loader -import org.apache.pdfbox.pdmodel.PDDocument import org.springframework.stereotype.Service @org.springframework.boot.autoconfigure.condition.ConditionalOnProperty(name = ["application.administration.enabled"], havingValue = "true") @@ -15,23 +15,15 @@ import org.springframework.stereotype.Service class EssayPdfService( private val applicantRepository: ApplicantRepository, private val applicationEssayPort: ApplicationEssayPort, + private val essayPdfMergePort: EssayPdfMergePort, ) : DownloadEssaysUseCase { + // 실제 1차 합격자의 두 서류를 수험번호 발급 여부와 무관하게 합친다. override fun writeTo(output: OutputStream): Int { val targets = applicantRepository.findAll(ApplicantFilter(statuses = setOf(ApplicantStatus.FIRST_PASS))) - .map { applicant -> - applicant.id to checkNotNull(applicant.examineeNumber) { - "1차 합격자의 수험 번호가 없습니다: ${applicant.id}" - } - } + .sortedWith(compareBy { it.examineeNumber.isNullOrBlank() }.thenBy { it.id }) + .map { applicant -> applicant.id to applicant.examineeNumber?.takeIf(String::isNotBlank).orEmpty() } val documents = applicationEssayPort.renderBatch(targets) - PDDocument().use { merged -> - documents.forEach { pdfs -> - (pdfs.introduction ?: pdfs.studyPlan)?.let { pdf -> - Loader.loadPDF(pdf).use { source -> source.pages.forEach(merged::importPage) } - } - } - merged.save(output) - } + essayPdfMergePort.writeTo(documents, output) return targets.size } } diff --git a/systems/application/application-administration-application/src/main/kotlin/hs/kr/entrydsm/application/administration/application/ExportJobProcessor.kt b/systems/application/application-administration-application/src/main/kotlin/hs/kr/entrydsm/application/administration/application/ExportJobProcessor.kt index 8168211f..35d2e771 100644 --- a/systems/application/application-administration-application/src/main/kotlin/hs/kr/entrydsm/application/administration/application/ExportJobProcessor.kt +++ b/systems/application/application-administration-application/src/main/kotlin/hs/kr/entrydsm/application/administration/application/ExportJobProcessor.kt @@ -30,7 +30,7 @@ private const val FIRST_PASS_LIST_SHEET = "1차 합격자 명단" private const val ADMISSION_FILE_SHEET = "전형 자료" private val FIRST_PASS_COLUMNS: List Any?>> = listOf( - "수험번호" to { it.examineeNumber }, + "수험번호" to { it.examineeNumber?.takeIf(String::isNotBlank).orEmpty() }, "접수번호" to { it.receiptNumber }, "성명" to { it.name }, ) @@ -139,6 +139,7 @@ class ExportJobProcessor( when (job.type) { ExportType.FIRST_PASS -> { val applicants = applicantRepository.findFirstPassApplicants() + .sortedWith(compareBy { it.examineeNumber.isNullOrBlank() }.thenBy { it.id }) current = exportJobRepository.save(current.withTotal(applicants.size)) writeFirstPassList(current, applicants) { current = exportJobRepository.save(current.processed(applicants.size)) @@ -226,7 +227,8 @@ class ExportJobProcessor( val tickets = applicants // 수험 번호가 없는 1차 합격자(강제 변경)는 뒤로 간다. 같은 자리끼리는 접수 번호 순 그대로다. - .sortedWith(compareBy(nullsLast()) { it.examineeNumber }) + .sortedWith(compareBy { it.examineeNumber.isNullOrBlank() } + .thenBy { it.examineeNumber?.takeIf(String::isNotBlank) }.thenBy { it.id }) .map { it.id to it.examineeNumber } storagePort.upload(objectKey, XLSX_CONTENT_TYPE, admissionTicketPort.render(tickets)) diff --git a/systems/application/application-administration-application/src/main/kotlin/hs/kr/entrydsm/application/administration/application/FileDocumentService.kt b/systems/application/application-administration-application/src/main/kotlin/hs/kr/entrydsm/application/administration/application/FileDocumentService.kt index 379d91bf..5a44a88e 100644 --- a/systems/application/application-administration-application/src/main/kotlin/hs/kr/entrydsm/application/administration/application/FileDocumentService.kt +++ b/systems/application/application-administration-application/src/main/kotlin/hs/kr/entrydsm/application/administration/application/FileDocumentService.kt @@ -83,7 +83,8 @@ class FileDocumentService( val applicant = ticketStage("applicant_lookup", applicantId) { applicantPort.findById(applicantId) ?: throw ApplicantNotFoundException(applicantId) } - ticket(applicantId, applicant, examineeNumber) + // 관리자 일괄 다운로드는 미발급 수험번호 칸을 비워 둔다. + ticket(applicantId, applicant, examineeNumber?.takeIf(String::isNotBlank).orEmpty()) } return ticketStage("sheet_render", null) { admissionTicketSheetPort.render(prepared) } } 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 f285772a..4527e665 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 @@ -289,6 +289,36 @@ class AdminApplicationModuleTest { assertEquals(listOf("11001", "0001", "홍길동"), fixture.rows.single()) } + @Test + fun exportsFirstPassWithBlankUnissuedNumbersAfterIssuedApplicants() { + for (issued in listOf(false, true)) { + val fixture = exportFixture(applicants = listOf( + Applicant(id = 8L, examineeNumber = null, name = "여덟", status = ApplicantStatus.FIRST_PASS), + Applicant(id = 3L, examineeNumber = if (issued) "11002" else "", name = "셋", status = ApplicantStatus.FIRST_PASS), + Applicant(id = 6L, examineeNumber = " ", name = "여섯", status = ApplicantStatus.FIRST_PASS), + Applicant(id = 7L, examineeNumber = "\t", name = "일곱", status = ApplicantStatus.FIRST_PASS), + Applicant(id = 2L, examineeNumber = if (issued) "11003" else null, name = "둘", status = ApplicantStatus.FIRST_PASS), + )) + fixture.processor.processNow(fixture.job) + assertEquals(listOf("0002", "0003", "0006", "0007", "0008"), fixture.rows.map { it[1] }) + assertEquals(if (issued) listOf("11003", "11002", "", "", "") else listOf("", "", "", "", ""), + fixture.rows.map { it[0] }) + assertEquals(ExportStatus.COMPLETED, fixture.saved.last().status) + assertEquals(5, fixture.saved.last().processedCount) + } + } + + @Test + fun essayGenerationAndStorageFailuresRemainFailed() { + val generation = exportFixture(type = ExportType.ESSAYS, essayWriter = { error("pdf failed") }) + generation.processor.processNow(generation.job) + assertEquals(ExportStatus.FAILED, generation.saved.last().status) + val storage = exportFixture(type = ExportType.ESSAYS, failUpload = true, + essayWriter = { output -> output.write("pdf".toByteArray()); 1 }) + storage.processor.processNow(storage.job) + assertEquals(ExportStatus.FAILED, storage.saved.last().status) + } + @Test fun preservesProcessedCountWhenFirstPassUploadFails() { val fixture = exportFixture(applicants = listOf(Applicant(id = 1L)), failUpload = true) diff --git a/systems/application/application-administration-application/src/test/kotlin/hs/kr/entrydsm/application/administration/application/AdmissionTicketExportTest.kt b/systems/application/application-administration-application/src/test/kotlin/hs/kr/entrydsm/application/administration/application/AdmissionTicketExportTest.kt index 07f6dd76..b74d123c 100644 --- a/systems/application/application-administration-application/src/test/kotlin/hs/kr/entrydsm/application/administration/application/AdmissionTicketExportTest.kt +++ b/systems/application/application-administration-application/src/test/kotlin/hs/kr/entrydsm/application/administration/application/AdmissionTicketExportTest.kt @@ -68,22 +68,35 @@ class AdmissionTicketExportTest { applicants.all += applicant(1, ApplicantStatus.FIRST_PASS, "100002") applicants.all += applicant(2, ApplicantStatus.PENDING, "100003") applicants.all += applicant(3, ApplicantStatus.FIRST_PASS, "100001") - // 강제 변경으로 수험 번호 없이 1차 합격이 된 지원자는 맨 뒤에 미발급으로 찍힌다. + // 번호 없는 합격자도 뒤에 접수번호 순으로 포함한다. + applicants.all += applicant(8, ApplicantStatus.FIRST_PASS, examineeNumber = null) applicants.all += applicant(4, ApplicantStatus.FIRST_PASS, examineeNumber = null) val tickets = RecordingAdmissionTicketPort() processor(tickets).processNow(ticketJob()) - assertEquals(listOf(listOf(3L to "100001", 1L to "100002", 4L to null)), tickets.requested) + assertEquals(listOf(listOf(3L to "100001", 1L to "100002", 4L to null, 8L to null)), tickets.requested) val upload = storage.uploads.single() assertEquals("dsm_Entry/backend/stag/admission-ticket/admission_tickets_exp_1.xlsx", upload.first) assertEquals("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", upload.second) - assertEquals("tickets-3|1|4", upload.third) + assertEquals("tickets-3|1|4|8", upload.third) val finished = jobs.saved.last() assertEquals(ExportStatus.COMPLETED, finished.status) assertEquals(upload.first, finished.objectKey) } + @Test + fun `번호가 전부 없는 합격자도 접수번호 순으로 수험표를 내려받는다`() { + applicants.all += applicant(8, ApplicantStatus.FIRST_PASS, null) + applicants.all += applicant(3, ApplicantStatus.FIRST_PASS, null) + applicants.all += applicant(1, ApplicantStatus.FIRST_FAIL, null) + val tickets = RecordingAdmissionTicketPort() + assertEquals(ExportStatus.PENDING, exportService.create(CreateExportCommand(ExportType.ADMISSION_TICKET)).status) + processor(tickets).processNow(ticketJob()) + assertEquals(listOf(listOf(3L to null, 8L to null)), tickets.requested) + assertEquals(ExportStatus.COMPLETED, jobs.saved.last().status) + } + @Test fun `document 가 수험표를 못 그리면 작업을 실패로 끝내고 아무것도 올리지 않는다`() { applicants.all += applicant(1, ApplicantStatus.FIRST_PASS, "100001") diff --git a/systems/application/application-administration-application/src/test/kotlin/hs/kr/entrydsm/application/administration/application/EssayZipServiceTest.kt b/systems/application/application-administration-application/src/test/kotlin/hs/kr/entrydsm/application/administration/application/EssayZipServiceTest.kt index 957f217e..29e0e059 100644 --- a/systems/application/application-administration-application/src/test/kotlin/hs/kr/entrydsm/application/administration/application/EssayZipServiceTest.kt +++ b/systems/application/application-administration-application/src/test/kotlin/hs/kr/entrydsm/application/administration/application/EssayZipServiceTest.kt @@ -6,55 +6,86 @@ import hs.kr.entrydsm.admin.domain.model.ApplicantFilter import hs.kr.entrydsm.admin.domain.port.out.ApplicantRepository import hs.kr.entrydsm.admin.domain.port.out.ApplicationEssayPdfs import hs.kr.entrydsm.admin.domain.port.out.ApplicationEssayPort +import hs.kr.entrydsm.admin.domain.port.out.EssayPdfMergePort import java.io.ByteArrayOutputStream -import org.apache.pdfbox.Loader -import org.apache.pdfbox.pdmodel.PDDocument -import org.apache.pdfbox.pdmodel.PDPage import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame import org.junit.Assert.assertThrows import org.junit.Test class EssayZipServiceTest { @Test - fun `1차 합격자의 기존 PDF를 순서대로 하나의 PDF에 병합한다`() { + fun `합격자의 두 문서와 단독 학업계획서를 순서대로 병합기에 전달한다`() { val applicants = listOf( - Applicant(id = 1, name = "홍/길동", examineeNumber = "11001", status = ApplicantStatus.FIRST_PASS), - Applicant(id = 2, name = "홍/길동", examineeNumber = "11002", status = ApplicantStatus.FIRST_PASS), - Applicant(id = 3, name = "불합격", status = ApplicantStatus.FIRST_FAIL), + Applicant(id = 1, examineeNumber = "11001", status = ApplicantStatus.FIRST_PASS), + Applicant(id = 2, examineeNumber = "11002", status = ApplicantStatus.FIRST_PASS), + Applicant(id = 3, status = ApplicantStatus.FIRST_PASS), + Applicant(id = 4, status = ApplicantStatus.FIRST_PASS), + Applicant(id = 5, status = ApplicantStatus.FIRST_FAIL), + Applicant(id = 6, status = ApplicantStatus.PENDING), + ) + val documents = listOf( + ApplicationEssayPdfs(byteArrayOf(1), null), + ApplicationEssayPdfs(byteArrayOf(2), byteArrayOf(3)), + ApplicationEssayPdfs(null, byteArrayOf(4)), + ApplicationEssayPdfs(null, null), ) val requested = mutableListOf>() + val output = ByteArrayOutputStream() + var merged = false val service = EssayPdfService(repository(applicants), object : ApplicationEssayPort { override fun render(applicantId: Long, examineeNumber: String) = error("batch 호출 필요") - override fun renderBatch(targets: List>): List { requested += targets - return targets.map { (id, _) -> - val pdf = pdf(id.toInt()) - if (id == 1L) ApplicationEssayPdfs(pdf, null) else ApplicationEssayPdfs(pdf, pdf) - } + return documents } + }, EssayPdfMergePort { actual, stream -> + assertSame(documents, actual) + assertSame(output, stream) + merged = true }) - val output = ByteArrayOutputStream() - service.writeTo(output) - - assertEquals(listOf(1L to "11001", 2L to "11002"), requested) - Loader.loadPDF(output.toByteArray()).use { assertEquals(3, it.numberOfPages) } + assertEquals(4, service.writeTo(output)) + assertEquals(listOf(1L to "11001", 2L to "11002", 3L to "", 4L to ""), requested) + assertEquals(true, merged) } @Test - fun `대상이 없으면 빈 PDF를 반환한다`() { - val output = ByteArrayOutputStream() - EssayPdfService(repository(emptyList()), ApplicationEssayPort { _, _ -> error("호출되면 안 됨") }).writeTo(output) - Loader.loadPDF(output.toByteArray()).use { assertEquals(0, it.numberOfPages) } + fun `대상이 없으면 빈 문서 목록을 병합기에 전달한다`() { + var merged = false + val service = EssayPdfService(repository(emptyList()), ApplicationEssayPort { _, _ -> error("호출되면 안 됨") }, + EssayPdfMergePort { documents, _ -> assertEquals(emptyList(), documents); merged = true }) + assertEquals(0, service.writeTo(ByteArrayOutputStream())) + assertEquals(true, merged) } @Test - fun `수험 번호가 없는 1차 합격자는 내보내지 않는다`() { - val applicant = Applicant(id = 1, status = ApplicantStatus.FIRST_PASS) - val service = EssayPdfService(repository(listOf(applicant)), ApplicationEssayPort { _, _ -> error("호출되면 안 됨") }) + fun `번호 전부 미발급과 혼합에서 공란 번호와 기존 순서 및 미발급 접수번호순을 유지한다`() { + for (issued in listOf(false, true)) { + val requested = mutableListOf>() + val applicants = listOf( + Applicant(id = 9, examineeNumber = if (issued) "11001" else "\t", status = ApplicantStatus.FIRST_PASS), + Applicant(id = 7, examineeNumber = " ", status = ApplicantStatus.FIRST_PASS), + Applicant(id = 4, examineeNumber = if (issued) " 11002 " else "", status = ApplicantStatus.FIRST_PASS), + Applicant(id = 3, status = ApplicantStatus.FIRST_PASS), + Applicant(id = 2, examineeNumber = "11003", status = ApplicantStatus.FIRST_FAIL), + ) + val service = EssayPdfService(repository(applicants), ApplicationEssayPort { id, value -> + requested += id to value + ApplicationEssayPdfs(null, null) + }, EssayPdfMergePort { documents, _ -> assertEquals(4, documents.size) }) + assertEquals(4, service.writeTo(ByteArrayOutputStream())) + assertEquals(if (issued) listOf(4L to " 11002 ", 9L to "11001", 3L to "", 7L to "") + else listOf(3L to "", 4L to "", 7L to "", 9L to ""), requested) + } + } - assertThrows(IllegalStateException::class.java) { service.writeTo(ByteArrayOutputStream()) } + @Test + fun `병합 실패는 성공 처리하지 않고 원래 예외를 전달한다`() { + val failure = IllegalStateException("merge failed") + val service = EssayPdfService(repository(listOf(Applicant(id = 1, status = ApplicantStatus.FIRST_PASS))), + ApplicationEssayPort { _, _ -> ApplicationEssayPdfs(null, null) }, EssayPdfMergePort { _, _ -> throw failure }) + assertSame(failure, assertThrows(IllegalStateException::class.java) { service.writeTo(ByteArrayOutputStream()) }) } private fun repository(applicants: List) = object : ApplicantRepository { @@ -65,11 +96,4 @@ class EssayZipServiceTest { override fun save(applicant: Applicant) = error("unused") override fun saveAll(applicants: List) = error("unused") } - - private fun pdf(pageCount: Int): ByteArray = ByteArrayOutputStream().also { output -> - PDDocument().use { document -> - repeat(pageCount) { document.addPage(PDPage()) } - document.save(output) - } - }.toByteArray() } diff --git a/systems/application/application-administration-application/src/test/kotlin/hs/kr/entrydsm/application/administration/application/FileDocumentServiceTest.kt b/systems/application/application-administration-application/src/test/kotlin/hs/kr/entrydsm/application/administration/application/FileDocumentServiceTest.kt index 9797090d..8935a743 100644 --- a/systems/application/application-administration-application/src/test/kotlin/hs/kr/entrydsm/application/administration/application/FileDocumentServiceTest.kt +++ b/systems/application/application-administration-application/src/test/kotlin/hs/kr/entrydsm/application/administration/application/FileDocumentServiceTest.kt @@ -261,7 +261,7 @@ class FileDocumentServiceTest { assertArrayEquals("xlsx".toByteArray(), xlsx) val rows = sheet.lastTickets.map { it.rows.toMap() } - assertEquals(listOf("100002", "미발급"), rows.map { it["수험번호"] }) + assertEquals(listOf("100002", ""), rows.map { it["수험번호"] }) assertEquals(listOf("0013", "0012"), rows.map { it["접수 번호"] }) assertEquals("2027학년도 대덕소프트웨어마이스터고등학교 입학전형 수험표", sheet.lastTickets.first().title) assertTrue(storage.uploaded.isEmpty()) @@ -270,6 +270,15 @@ class FileDocumentServiceTest { } } + @Test + fun `관리자 수험표의 미발급 번호는 공란이고 유효한 번호는 그대로 유지한다`() { + for ((number, expected) in listOf(null to "", "" to "", " " to "", "\t" to "", "100001" to "100001", " 100001 " to " 100001 ")) { + service.renderAdmissionTickets(listOf(APPLICANT_ID to number)) + assertEquals("입력 수험번호: <$number>", expected, sheet.lastTickets.single().rows.toMap()["수험번호"]) + } + assertTrue(storage.uploaded.isEmpty()) + } + @Test fun `큰 증명사진은 수험표 칸 폭으로 줄여 흰 바탕 JPEG로 넣고 작은 사진은 그대로 넣는다`() { val large = service.upload(photo(student(STUDENT_ID)), content()) diff --git a/systems/application/application-administration-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/port/out/ApplicationEssayPort.kt b/systems/application/application-administration-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/port/out/ApplicationEssayPort.kt index e8f63878..c80c24ad 100644 --- a/systems/application/application-administration-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/port/out/ApplicationEssayPort.kt +++ b/systems/application/application-administration-domain/src/main/kotlin/hs/kr/entrydsm/admin/domain/port/out/ApplicationEssayPort.kt @@ -1,5 +1,7 @@ package hs.kr.entrydsm.admin.domain.port.out +import java.io.OutputStream + data class ApplicationEssayPdfs(val introduction: ByteArray?, val studyPlan: ByteArray?) fun interface ApplicationEssayPort { @@ -8,3 +10,8 @@ fun interface ApplicationEssayPort { fun renderBatch(targets: List>): List = targets.map { (applicantId, examineeNumber) -> render(applicantId, examineeNumber) } } + +fun interface EssayPdfMergePort { + // 지원자별 자기소개서와 학업계획서를 받은 순서대로 합친다. + fun writeTo(documents: List, output: OutputStream) +} diff --git a/systems/application/application-administration-domain/src/main/kotlin/hs/kr/entrydsm/application/administration/domain/document/port/in/ApplicantFileUseCase.kt b/systems/application/application-administration-domain/src/main/kotlin/hs/kr/entrydsm/application/administration/domain/document/port/in/ApplicantFileUseCase.kt index 4066b85a..4d7b9b28 100644 --- a/systems/application/application-administration-domain/src/main/kotlin/hs/kr/entrydsm/application/administration/domain/document/port/in/ApplicantFileUseCase.kt +++ b/systems/application/application-administration-domain/src/main/kotlin/hs/kr/entrydsm/application/administration/domain/document/port/in/ApplicantFileUseCase.kt @@ -29,7 +29,7 @@ interface ApplicantFileUseCase { * admin 수험표 일괄 출력. 받은 순서대로 한 시트에 이어 그린 xlsx 를 올리지 않고 돌려준다. * 서비스 안쪽 gRPC 로만 부르므로 요청자 권한을 보지 않는다. * - * @param tickets applicant id 와 admin 이 발급한 수험번호. 수험번호가 null 이면 미발급으로 찍는다 + * @param tickets applicant id 와 admin 이 발급한 수험번호. 수험번호가 null 이면 칸을 비워 둔다 */ fun renderAdmissionTickets(tickets: List>): ByteArray