diff --git a/gradle.properties b/gradle.properties index 6600801..0a27edb 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ org.gradle.console=plain org.gradle.logging.level=quiet org.gradle.warning.mode=summary -ywcheong.sofia.version=26b.04.01.3 +ywcheong.sofia.version=26b.04.01.4 ywcheong.sofia.jdk_version=21 \ No newline at end of file diff --git a/src/main/kotlin/ywcheong/sofia/phase/SystemPhaseService.kt b/src/main/kotlin/ywcheong/sofia/phase/SystemPhaseService.kt index 7e747be..fada371 100644 --- a/src/main/kotlin/ywcheong/sofia/phase/SystemPhaseService.kt +++ b/src/main/kotlin/ywcheong/sofia/phase/SystemPhaseService.kt @@ -191,6 +191,10 @@ class SystemPhaseService( validateCurrentPhase(entity.currentPhase, SystemPhase.SETTLEMENT) + // 모든 번역 과제 삭제 (FK: TranslationTask -> SofiaUser 이므로 사용자 삭제 전에 삭제) + logger.info { "Deleting all translation tasks during deactivation" } + translationTaskRepository.deleteAllInBatch() + // 사용자 정리 로직 when (mode) { UserRetentionMode.KEEP_ALL -> { diff --git a/src/main/kotlin/ywcheong/sofia/task/TranslationTaskController.kt b/src/main/kotlin/ywcheong/sofia/task/TranslationTaskController.kt index 795d9e7..969a71c 100644 --- a/src/main/kotlin/ywcheong/sofia/task/TranslationTaskController.kt +++ b/src/main/kotlin/ywcheong/sofia/task/TranslationTaskController.kt @@ -117,18 +117,17 @@ class TranslationTaskController( ) } - // UC-011: 성과 보고서 생성 + // 번역 과제 CSV 다운로드 @AvailableCondition(phases = [SystemPhase.RECRUITMENT, SystemPhase.TRANSLATION, SystemPhase.SETTLEMENT], permissions = [SofiaPermission.ADMIN_LEVEL]) - @GetMapping("/reports/performance.csv", produces = ["text/csv; charset=UTF-8"]) - @Operation(summary = "성과 보고서 생성", description = "CSV 포맷으로 다운로드, UTF-8 BOM 포함") - fun generatePerformanceReport(): ResponseEntity { - logger.info { "성과 보고서 생성 요청" } + @GetMapping("/csv", produces = ["text/csv; charset=UTF-8"]) + @Operation(summary = "번역 과제 CSV 다운로드", description = "모든 번역 과제 데이터를 CSV 포맷으로 다운로드") + fun downloadCsv(): ResponseEntity { + logger.info { "번역 과제 CSV 다운로드 요청" } - val reports = translationTaskCsvExportService.generatePerformanceReport() - val csv = translationTaskCsvExportService.toCsv(reports) + val csv = translationTaskCsvExportService.generateCsv() return ResponseEntity.ok() - .header("Content-Disposition", "attachment; filename=performance_report.csv") + .header("Content-Disposition", "attachment; filename=tasks.csv") .body(csv) } diff --git a/src/main/kotlin/ywcheong/sofia/task/TranslationTaskCsvExportService.kt b/src/main/kotlin/ywcheong/sofia/task/TranslationTaskCsvExportService.kt index fb64801..224c184 100644 --- a/src/main/kotlin/ywcheong/sofia/task/TranslationTaskCsvExportService.kt +++ b/src/main/kotlin/ywcheong/sofia/task/TranslationTaskCsvExportService.kt @@ -3,62 +3,60 @@ package ywcheong.sofia.task import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import ywcheong.sofia.task.user.SofiaUserTaskStatusRepository -import ywcheong.sofia.user.SofiaUserRepository +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit @Service class TranslationTaskCsvExportService( private val translationTaskRepository: TranslationTaskRepository, - private val sofiaUserRepository: SofiaUserRepository, - private val sofiaUserTaskStatusRepository: SofiaUserTaskStatusRepository, private val properties: TranslationTaskProperties, ) { private val logger = KotlinLogging.logger {} - data class UserPerformanceReport( - val studentNumber: String, - val studentName: String, - val translatedCharacterCount: Int, // 번역 자수 - val adjustedCharacterCount: Int, // 보정 자수 - val totalCharacterCount: Int, // 보정후 번역자수 (번역 자수 + 보정 자수) - val warningCount: Int, - val secondsPerCharacter: Double, - ) { - // BR-012: 1글자 = 3.942초, 예상 봉사시간 = (번역 자수 + 보정 자수) × 3.942초 - val estimatedServiceTimeSeconds: Double - get() = totalCharacterCount * secondsPerCharacter - } + private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") + .withZone(ZoneId.of("Asia/Seoul")) @Transactional(readOnly = true) - fun generatePerformanceReport(): List { - logger.info { "성과 보고서 생성 시작" } + fun generateCsv(): String { + logger.info { "과제 CSV 생성 시작" } + + val tasks = translationTaskRepository.findAll() + + val header = "과제유형,과제설명,담당자학번,담당자이름,배정유형,배정일시,완료여부,자수,지각여부,리마인드일시" + val rows = tasks.map { task -> + val taskTypeName = when (task.taskType) { + TranslationTask.TaskType.GAONNURI_POST -> "가온누리 게시글" + TranslationTask.TaskType.EXTERNAL_POST -> "외부 게시글" + } + val assignmentTypeName = when (task.assignmentType) { + TranslationTask.AssignmentType.AUTOMATIC -> "자동" + TranslationTask.AssignmentType.MANUAL -> "수동" + } + val assignedAtFormatted = formatDateTime(task.assignedAt) + val completedText = if (task.completed) "예" else "아니오" + val characterCountText = task.characterCount?.toString() ?: "" + val late = isLate(task) + val lateText = if (late) "예" else "아니오" + val remindedAtText = task.remindedAt?.let { formatDateTime(it) } ?: "" + + "$taskTypeName,${task.taskDescription},${task.assignee.studentNumber},${task.assignee.studentName},$assignmentTypeName,$assignedAtFormatted,$completedText,$characterCountText,$lateText,$remindedAtText" + } - val users = sofiaUserRepository.findAll() - val reports = users.map { user -> - val completedTasks = translationTaskRepository.findByAssigneeAndCompletedAtIsNotNull(user) - val totalCharacterCount = completedTasks.sumOf { it.characterCount ?: 0 } - val userTaskStatus = sofiaUserTaskStatusRepository.findById(user.id).orElse(null) + val csvContent = (listOf(header) + rows).joinToString("\n") + val bom = "\uFEFF" - UserPerformanceReport( - studentNumber = user.studentNumber, - studentName = user.studentName, - translatedCharacterCount = totalCharacterCount, - adjustedCharacterCount = 0, // TODO: 보정 자수 기능 구현 후 연결 - totalCharacterCount = totalCharacterCount, // TODO: 보정 자수 기능 구현 후 adjustedCharacterCount와 합산 필요 - warningCount = userTaskStatus?.warningCount ?: 0, - secondsPerCharacter = properties.secondsPerCharacter, - ) - } + logger.info { "과제 CSV 생성 완료: ${tasks.size}건" } + return bom + csvContent + } - logger.info { "성과 보고서 생성 완료: ${reports.size}명" } - return reports + private fun formatDateTime(instant: Instant): String { + return dateTimeFormatter.format(instant) } - fun toCsv(reports: List): String { - val header = "학번,이름,번역 자수,보정 자수,경고 횟수,예상 봉사시간(초)" - val rows = reports.map { report -> - "${report.studentNumber},${report.studentName},${report.translatedCharacterCount},${report.adjustedCharacterCount},${report.warningCount},${report.estimatedServiceTimeSeconds}" - } - return (listOf(header) + rows).joinToString("\n") + private fun isLate(task: TranslationTask): Boolean { + val completedAt = task.completedAt ?: return false + return ChronoUnit.SECONDS.between(task.assignedAt, completedAt) > properties.lateThresholdSeconds } } diff --git a/src/test/kotlin/ywcheong/sofia/phase/SystemPhaseTest.kt b/src/test/kotlin/ywcheong/sofia/phase/SystemPhaseTest.kt index 870d5c0..e113f87 100644 --- a/src/test/kotlin/ywcheong/sofia/phase/SystemPhaseTest.kt +++ b/src/test/kotlin/ywcheong/sofia/phase/SystemPhaseTest.kt @@ -390,14 +390,17 @@ class SystemPhaseTest( } @Test - @DisplayName("POST /system-phase/transit/deactivation - KEEP_ALL 모드로 전환 시 모든 사용자 유지") - fun `KEEP_ALL 모드로 전환하면 모든 사용자가 유지된다`() { + @DisplayName("POST /system-phase/transit/deactivation - KEEP_ALL 모드로 전환 시 모든 사용자 유지 및 과제 삭제") + fun `KEEP_ALL 모드로 전환하면 모든 사용자가 유지되고 모든 과제가 삭제된다`() { // given - SETTLEMENT 상태로 설정 val newAdminInfo = helper.setupScenarioWithAdmin(SystemPhase.SETTLEMENT) // 사용자 생성 - helper.createActiveStudent("25-001", "학생1") - helper.createActiveStudent("25-002", "학생2") + val student1 = helper.createActiveStudent("25-001", "학생1") + val student2 = helper.createActiveStudent("25-002", "학생2") helper.createAdminAndGetToken("admin2", "관리자2") + // 과제 생성 + helper.createTranslationTask(TranslationTask.TaskType.GAONNURI_POST, "과제1", student1) + helper.createTranslationTask(TranslationTask.TaskType.EXTERNAL_POST, "과제2", student2) val request = mapOf("userRetentionMode" to "KEEP_ALL") @@ -419,17 +422,28 @@ class SystemPhaseTest( status { isOk() } jsonPath("$.totalElements") { value(4) } // newAdminInfo + student1 + student2 + otherAdmin } + + // then - 모든 과제가 삭제되었음을 검증 + mockMvc.get("/tasks?page=0&size=10") { + header("Authorization", helper.adminAuthHeader(newAdminInfo.secretToken)) + }.andExpect { + status { isOk() } + jsonPath("$.totalElements") { value(0) } + } } @Test - @DisplayName("POST /system-phase/transit/deactivation - KEEP_ADMINS 모드로 전환 시 학생만 삭제") - fun `KEEP_ADMINS 모드로 전환하면 학생만 삭제되고 관리자는 유지된다`() { + @DisplayName("POST /system-phase/transit/deactivation - KEEP_ADMINS 모드로 전환 시 학생만 삭제 및 과제 삭제") + fun `KEEP_ADMINS 모드로 전환하면 학생만 삭제되고 관리자는 유지되며 모든 과제가 삭제된다`() { // given - SETTLEMENT 상태로 설정 val newAdminInfo = helper.setupScenarioWithAdmin(SystemPhase.SETTLEMENT) // 학생 2명 + 관리자 1명 생성 - helper.createActiveStudent("25-010", "학생A") - helper.createActiveStudent("25-011", "학생B") + val studentA = helper.createActiveStudent("25-010", "학생A") + val studentB = helper.createActiveStudent("25-011", "학생B") helper.createAdminAndGetToken("admin-keep", "유지될관리자") + // 과제 생성 + helper.createTranslationTask(TranslationTask.TaskType.GAONNURI_POST, "과제A", studentA) + helper.createTranslationTask(TranslationTask.TaskType.EXTERNAL_POST, "과제B", studentB) val request = mapOf("userRetentionMode" to "KEEP_ADMINS") @@ -452,17 +466,28 @@ class SystemPhaseTest( jsonPath("$.totalElements") { value(2) } // newAdminInfo + otherAdmin jsonPath("$.content[*].role") { value(mutableListOf("ADMIN", "ADMIN")) } } + + // then - 모든 과제가 삭제되었음을 검증 + mockMvc.get("/tasks?page=0&size=10") { + header("Authorization", helper.adminAuthHeader(newAdminInfo.secretToken)) + }.andExpect { + status { isOk() } + jsonPath("$.totalElements") { value(0) } + } } @Test - @DisplayName("POST /system-phase/transit/deactivation - KEEP_SELF 모드로 전환 시 요청자만 유지") - fun `KEEP_SELF 모드로 전환하면 요청한 관리자만 유지된다`() { + @DisplayName("POST /system-phase/transit/deactivation - KEEP_SELF 모드로 전환 시 요청자만 유지 및 과제 삭제") + fun `KEEP_SELF 모드로 전환하면 요청한 관리자만 유지되고 모든 과제가 삭제된다`() { // given - SETTLEMENT 상태로 설정 val newAdminInfo = helper.setupScenarioWithAdmin(SystemPhase.SETTLEMENT) // 학생 2명 + 다른 관리자 1명 생성 - helper.createActiveStudent("25-020", "삭제될학생1") - helper.createActiveStudent("25-021", "삭제될학생2") + val student1 = helper.createActiveStudent("25-020", "삭제될학생1") + val student2 = helper.createActiveStudent("25-021", "삭제될학생2") helper.createAdminAndGetToken("admin-delete", "삭제될관리자") + // 과제 생성 + helper.createTranslationTask(TranslationTask.TaskType.GAONNURI_POST, "과제1", student1) + helper.createTranslationTask(TranslationTask.TaskType.EXTERNAL_POST, "과제2", student2) val request = mapOf("userRetentionMode" to "KEEP_SELF") @@ -485,6 +510,14 @@ class SystemPhaseTest( jsonPath("$.totalElements") { value(1) } // newAdminInfo만 유지 jsonPath("$.content[0].id") { value(newAdminInfo.userId.toString()) } } + + // then - 모든 과제가 삭제되었음을 검증 + mockMvc.get("/tasks?page=0&size=10") { + header("Authorization", helper.adminAuthHeader(newAdminInfo.secretToken)) + }.andExpect { + status { isOk() } + jsonPath("$.totalElements") { value(0) } + } } } diff --git a/src/test/kotlin/ywcheong/sofia/task/TaskQueryTest.kt b/src/test/kotlin/ywcheong/sofia/task/TaskQueryTest.kt index e9dbd8d..bc3c0df 100644 --- a/src/test/kotlin/ywcheong/sofia/task/TaskQueryTest.kt +++ b/src/test/kotlin/ywcheong/sofia/task/TaskQueryTest.kt @@ -574,22 +574,22 @@ class TaskQueryTest( } @Nested - @DisplayName("성과 보고서") - inner class GeneratePerformanceReport { + @DisplayName("번역 과제 CSV 다운로드") + inner class DownloadCsv { @Test - fun `관리자가 성과 보고서를 요청하면 200과 CSV 파일을 반환한다`() { + fun `관리자가 과제 CSV를 요청하면 200과 CSV 파일을 반환한다`() { // when - val result = requestHelper.get("/tasks/reports/performance.csv", adminInfo.secretToken) + val result = requestHelper.get("/tasks/csv", adminInfo.secretToken) // then requestHelper.assertOk(result) assertThat(result.response.getHeader("Content-Type")).isEqualTo("text/csv;charset=UTF-8") - assertThat(result.response.getHeader("Content-Disposition")).isEqualTo("attachment; filename=performance_report.csv") + assertThat(result.response.getHeader("Content-Disposition")).isEqualTo("attachment; filename=tasks.csv") } @Test - fun `완료된 과제가 있으면 CSV에 번역 자수가 포함된다`() { + fun `과제가 있으면 CSV에 과제 정보가 포함된다`() { // given val student = helper.createActiveStudent("25-051", "홍길동") val task = helper.createTranslationTask( @@ -600,42 +600,43 @@ class TaskQueryTest( completeTask(task.id, 1000) // when - val result = requestHelper.get("/tasks/reports/performance.csv", adminInfo.secretToken) + val result = requestHelper.get("/tasks/csv", adminInfo.secretToken) // then requestHelper.assertOk(result) val csvContent = requestHelper.extractContentAsString(result) - assertThat(csvContent).contains("학번,이름,번역 자수,보정 자수,경고 횟수,예상 봉사시간(초)") + assertThat(csvContent).contains("과제유형,과제설명,담당자학번,담당자이름,배정유형,배정일시,완료여부,자수,지각여부,리마인드일시") + assertThat(csvContent).contains("가온누리 게시글") + assertThat(csvContent).contains("성과 측정용 과제") assertThat(csvContent).contains("25-051") assertThat(csvContent).contains("홍길동") assertThat(csvContent).contains("1000") + assertThat(csvContent).contains("예") // 완료여부 } @Test - fun `여러 사용자의 과제가 있으면 CSV에 모두 포함된다`() { + fun `여러 과제가 있으면 CSV에 모두 포함된다`() { // given val student1 = helper.createActiveStudent("25-060", "학생1") val student2 = helper.createActiveStudent("25-061", "학생2") - val task1 = helper.createTranslationTask( + helper.createTranslationTask( TranslationTask.TaskType.GAONNURI_POST, "과제1", student1 ) - val task2 = helper.createTranslationTask( + helper.createTranslationTask( TranslationTask.TaskType.EXTERNAL_POST, "과제2", student2 ) - completeTask(task1.id, 500) - completeTask(task2.id, 1500) // when - val result = requestHelper.get("/tasks/reports/performance.csv", adminInfo.secretToken) + val result = requestHelper.get("/tasks/csv", adminInfo.secretToken) // then val csvContent = requestHelper.extractContentAsString(result) - assertThat(csvContent).contains("25-060", "학생1", "500") - assertThat(csvContent).contains("25-061", "학생2", "1500") + assertThat(csvContent).contains("가온누리 게시글", "과제1", "25-060", "학생1") + assertThat(csvContent).contains("외부 게시글", "과제2", "25-061", "학생2") } } diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 05d6f9e..b693cc5 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -9,6 +9,8 @@ sofia: skill: secret-token: replace-me-11!! spring: + datasource: + type: com.zaxxer.hikari.HikariDataSource jpa: hibernate: ddl-auto: create-drop \ No newline at end of file