Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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<ApplicationEssayPdfs>, 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<Long>): AdminDomainException {
val causes = generateSequence<Throwable>(error) { it.cause }.toList()
val code = when {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <T> unused(type: Class<T>): T = type.cast(java.lang.reflect.Proxy.newProxyInstance(
javaClass.classLoader, arrayOf(type)) { _, method, _ -> error("Unexpected ${method.name}") })

/** 요강의 인터넷접수 후 출력 서식 수 — 입학원서·개인정보 동의·자기소개서·추천서·금연 동의·흡연검사 동의 */
private val FORM_COUNT = 6

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]

Expand Down
Original file line number Diff line number Diff line change
@@ -1,37 +1,29 @@
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")
@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<Applicant> { 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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ private const val FIRST_PASS_LIST_SHEET = "1차 합격자 명단"
private const val ADMISSION_FILE_SHEET = "전형 자료"

private val FIRST_PASS_COLUMNS: List<Pair<String, (Applicant) -> Any?>> = listOf(
"수험번호" to { it.examineeNumber },
"수험번호" to { it.examineeNumber?.takeIf(String::isNotBlank).orEmpty() },
"접수번호" to { it.receiptNumber },
"성명" to { it.name },
)
Expand Down Expand Up @@ -139,6 +139,7 @@ class ExportJobProcessor(
when (job.type) {
ExportType.FIRST_PASS -> {
val applicants = applicantRepository.findFirstPassApplicants()
.sortedWith(compareBy<Applicant> { it.examineeNumber.isNullOrBlank() }.thenBy { it.id })
current = exportJobRepository.save(current.withTotal(applicants.size))
writeFirstPassList(current, applicants) {
current = exportJobRepository.save(current.processed(applicants.size))
Expand Down Expand Up @@ -226,7 +227,8 @@ class ExportJobProcessor(

val tickets = applicants
// 수험 번호가 없는 1차 합격자(강제 변경)는 뒤로 간다. 같은 자리끼리는 접수 번호 순 그대로다.
.sortedWith(compareBy(nullsLast<String>()) { it.examineeNumber })
.sortedWith(compareBy<Applicant> { 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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading