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
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions src/main/kotlin/ywcheong/sofia/phase/SystemPhaseService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 -> {
Expand Down
15 changes: 7 additions & 8 deletions src/main/kotlin/ywcheong/sofia/task/TranslationTaskController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
logger.info { "성과 보고서 생성 요청" }
@GetMapping("/csv", produces = ["text/csv; charset=UTF-8"])
@Operation(summary = "번역 과제 CSV 다운로드", description = "모든 번역 과제 데이터를 CSV 포맷으로 다운로드")
fun downloadCsv(): ResponseEntity<String> {
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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserPerformanceReport> {
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<UserPerformanceReport>): 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
}
}
57 changes: 45 additions & 12 deletions src/test/kotlin/ywcheong/sofia/phase/SystemPhaseTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand 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")

Expand All @@ -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")

Expand All @@ -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) }
}
}
}

Expand Down
33 changes: 17 additions & 16 deletions src/test/kotlin/ywcheong/sofia/task/TaskQueryTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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")
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/test/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ sofia:
skill:
secret-token: replace-me-11!!
spring:
datasource:
type: com.zaxxer.hikari.HikariDataSource
jpa:
hibernate:
ddl-auto: create-drop
Loading