From a7446e3b39ab0859c46252d82399f03c13305917 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Tue, 20 May 2025 12:13:17 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=95=A1=EC=85=80=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=83=9D=EC=84=B1=20util=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payer/controller/AdminPayerController.kt | 6 ++ .../dto/response/PayerExcelFileResponse.kt | 8 +++ .../backend/domain/payer/event/PayerEvents.kt | 7 +++ .../event/listener/PayerEventListener.kt | 21 +++++++ .../domain/payer/service/PayerService.kt | 25 +++++++- .../api/backend/global/config/AsyncConfig.kt | 9 +++ .../backend/global/utils/ExcelGenerator.kt | 63 +++++++++++++++++++ .../api/backend/global/utils/ExcelRow.kt | 7 +++ 8 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/site/billilge/api/backend/domain/payer/dto/response/PayerExcelFileResponse.kt create mode 100644 src/main/kotlin/site/billilge/api/backend/domain/payer/event/PayerEvents.kt create mode 100644 src/main/kotlin/site/billilge/api/backend/domain/payer/event/listener/PayerEventListener.kt create mode 100644 src/main/kotlin/site/billilge/api/backend/global/config/AsyncConfig.kt create mode 100644 src/main/kotlin/site/billilge/api/backend/global/utils/ExcelGenerator.kt create mode 100644 src/main/kotlin/site/billilge/api/backend/global/utils/ExcelRow.kt diff --git a/src/main/kotlin/site/billilge/api/backend/domain/payer/controller/AdminPayerController.kt b/src/main/kotlin/site/billilge/api/backend/domain/payer/controller/AdminPayerController.kt index ed74ab8..d6564cc 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/payer/controller/AdminPayerController.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/payer/controller/AdminPayerController.kt @@ -4,6 +4,7 @@ import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* import site.billilge.api.backend.domain.payer.dto.request.PayerDeleteRequest import site.billilge.api.backend.domain.payer.dto.request.PayerRequest +import site.billilge.api.backend.domain.payer.dto.response.PayerExcelFileResponse import site.billilge.api.backend.domain.payer.dto.response.PayerFindAllResponse import site.billilge.api.backend.domain.payer.service.PayerService import site.billilge.api.backend.global.annotation.OnlyAdmin @@ -35,4 +36,9 @@ class AdminPayerController( payerService.deletePayers(request) return ResponseEntity.noContent().build() } + + @GetMapping("/excel") + fun getExcelFileUrl(): ResponseEntity { + return ResponseEntity.ok(payerService.getExcelFileUrl()); + } } \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/payer/dto/response/PayerExcelFileResponse.kt b/src/main/kotlin/site/billilge/api/backend/domain/payer/dto/response/PayerExcelFileResponse.kt new file mode 100644 index 0000000..8fbf182 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/payer/dto/response/PayerExcelFileResponse.kt @@ -0,0 +1,8 @@ +package site.billilge.api.backend.domain.payer.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class PayerExcelFileResponse( + @field:Schema(description = "S3에 업로드된 납부자 목록 엑셀 파일 URL") + val fileUrl: String +) diff --git a/src/main/kotlin/site/billilge/api/backend/domain/payer/event/PayerEvents.kt b/src/main/kotlin/site/billilge/api/backend/domain/payer/event/PayerEvents.kt new file mode 100644 index 0000000..b259d32 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/payer/event/PayerEvents.kt @@ -0,0 +1,7 @@ +package site.billilge.api.backend.domain.payer.event + +sealed class PayerEvent(open val payerIds: List) + +data class PayerAddEvent(override val payerIds: List) : PayerEvent(payerIds) + +data class PayerDeleteEvent(override val payerIds: List) : PayerEvent(payerIds) \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/payer/event/listener/PayerEventListener.kt b/src/main/kotlin/site/billilge/api/backend/domain/payer/event/listener/PayerEventListener.kt new file mode 100644 index 0000000..65119d2 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/domain/payer/event/listener/PayerEventListener.kt @@ -0,0 +1,21 @@ +package site.billilge.api.backend.domain.payer.event.listener + +import org.springframework.stereotype.Component +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener +import site.billilge.api.backend.domain.payer.event.PayerAddEvent +import site.billilge.api.backend.domain.payer.event.PayerDeleteEvent + +@Component +class PayerEventListener { + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + fun onPayerAdd(event: PayerAddEvent) { + + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + fun onPayerDelete(event: PayerDeleteEvent) { + + } +} \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/payer/service/PayerService.kt b/src/main/kotlin/site/billilge/api/backend/domain/payer/service/PayerService.kt index df1e9d3..e53854e 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/payer/service/PayerService.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/payer/service/PayerService.kt @@ -1,5 +1,7 @@ package site.billilge.api.backend.domain.payer.service +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.ApplicationEventPublisher import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Sort import org.springframework.stereotype.Service @@ -8,18 +10,25 @@ import site.billilge.api.backend.domain.member.entity.Member import site.billilge.api.backend.domain.member.repository.MemberRepository import site.billilge.api.backend.domain.payer.dto.request.PayerDeleteRequest import site.billilge.api.backend.domain.payer.dto.request.PayerRequest +import site.billilge.api.backend.domain.payer.dto.response.PayerExcelFileResponse import site.billilge.api.backend.domain.payer.dto.response.PayerFindAllResponse import site.billilge.api.backend.domain.payer.dto.response.PayerSummary import site.billilge.api.backend.domain.payer.entity.Payer +import site.billilge.api.backend.domain.payer.event.PayerAddEvent +import site.billilge.api.backend.domain.payer.event.PayerDeleteEvent import site.billilge.api.backend.domain.payer.repository.PayerRepository import site.billilge.api.backend.global.dto.PageableCondition import site.billilge.api.backend.global.dto.SearchCondition +import java.time.LocalDate @Service @Transactional(readOnly = true) class PayerService( + private val publisher: ApplicationEventPublisher, private val payerRepository: PayerRepository, - private val memberRepository: MemberRepository + private val memberRepository: MemberRepository, + @Value("\${cloud.aws.s3.base-url}") + private val s3BaseUrl: String, ) { fun isPayer(name: String, studentId: String): Boolean { val enrollmentYear = studentId.substring(0, 4) @@ -71,6 +80,7 @@ class PayerService( @Transactional fun addPayers(request: PayerRequest) { + val newPayers = mutableListOf() request.payers.forEach { payerItem -> val name = payerItem.name val studentId = payerItem.studentId @@ -87,11 +97,15 @@ class PayerService( this.registered = registered } - payerRepository.save(payer) + newPayers.add(payer) } registeredMember?.isFeePaid = true } + + payerRepository.saveAll(newPayers) + + publisher.publishEvent(PayerAddEvent(newPayers.map { it.id })) } @Transactional @@ -106,5 +120,12 @@ class PayerService( } payerRepository.deleteAllById(request.payerIds) + + publisher.publishEvent(PayerDeleteEvent(request.payerIds)) + } + + fun getExcelFileUrl(): PayerExcelFileResponse { + val currentYear = LocalDate.now().year + return PayerExcelFileResponse(s3BaseUrl + "/payers/payer_${currentYear}.xlsx") } } \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/global/config/AsyncConfig.kt b/src/main/kotlin/site/billilge/api/backend/global/config/AsyncConfig.kt new file mode 100644 index 0000000..71b0bd9 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/global/config/AsyncConfig.kt @@ -0,0 +1,9 @@ +package site.billilge.api.backend.global.config + +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.annotation.EnableAsync + +@EnableAsync +@Configuration +class AsyncConfig { +} \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/global/utils/ExcelGenerator.kt b/src/main/kotlin/site/billilge/api/backend/global/utils/ExcelGenerator.kt new file mode 100644 index 0000000..c3469fd --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/global/utils/ExcelGenerator.kt @@ -0,0 +1,63 @@ +package site.billilge.api.backend.global.utils + +import org.apache.poi.ss.usermodel.IndexedColors +import org.apache.poi.xssf.streaming.SXSSFSheet +import org.apache.poi.xssf.streaming.SXSSFWorkbook +import org.springframework.stereotype.Component +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream + +private const val HEADER_ROW = 0 + +@Component +class ExcelGenerator { + + fun generateByMultipleSheets( + sheetData: Map, List>> + ): ByteArrayInputStream { + val workbook = SXSSFWorkbook() + + sheetData.forEach { (sheetName, sheetContent) -> + val (headerTitles, rows) = sheetContent + val sheet = workbook.createSheet(sheetName) + styleHeaders(workbook, sheet, headerTitles) + fillData(sheet, rows, headerTitles.size) + } + + val out = ByteArrayOutputStream() + workbook.write(out) + workbook.close() + + return ByteArrayInputStream(out.toByteArray()) + } + + private fun styleHeaders(workbook: SXSSFWorkbook, sheet: SXSSFSheet, headerTitles: Array) { + val headerFont = workbook.createFont() + headerFont.bold = true + + val headerCellStyle = workbook.createCellStyle() + headerCellStyle.setFont(headerFont) + headerCellStyle.fillForegroundColor = IndexedColors.GREY_25_PERCENT.index + headerCellStyle.fillPattern = org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND + + val headerRow = sheet.createRow(HEADER_ROW) + headerTitles.forEachIndexed { col, title -> + val cell = headerRow.createCell(col) + cell.setCellValue(title) + cell.cellStyle = headerCellStyle + } + } + + private fun fillData(sheet: SXSSFSheet, rows: List, columnSize: Int) { + sheet.trackAllColumnsForAutoSizing() + + rows.forEachIndexed { index, excelRow -> + val row = sheet.createRow(index + 1) + excelRow.data.forEachIndexed { propertyIndex, property -> + row.createCell(propertyIndex).setCellValue(property) + } + } + + repeat(columnSize) { col -> sheet.autoSizeColumn(col) } + } +} \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/global/utils/ExcelRow.kt b/src/main/kotlin/site/billilge/api/backend/global/utils/ExcelRow.kt new file mode 100644 index 0000000..c717b82 --- /dev/null +++ b/src/main/kotlin/site/billilge/api/backend/global/utils/ExcelRow.kt @@ -0,0 +1,7 @@ +package site.billilge.api.backend.global.utils + +data class ExcelRow( + val data: List +) { + constructor(vararg data: String) : this(data.toList()) +} \ No newline at end of file From 42cbcc3a64826cbfb2fdc2d64300233970ae0ff1 Mon Sep 17 00:00:00 2001 From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com> Date: Thu, 22 May 2025 11:17:00 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(#85):=20=ED=95=99=EC=83=9D=ED=9A=8C?= =?UTF-8?q?=EB=B9=84=20=EB=82=A9=EB=B6=80=EC=9E=90=20=EC=97=91=EC=85=80=20?= =?UTF-8?q?=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payer/controller/AdminPayerController.kt | 21 ++++++++-- .../dto/response/PayerExcelFileResponse.kt | 8 ---- .../backend/domain/payer/event/PayerEvents.kt | 7 ---- .../event/listener/PayerEventListener.kt | 21 ---------- .../payer/repository/PayerRepository.kt | 2 + .../domain/payer/service/PayerService.kt | 39 +++++++++++-------- 6 files changed, 42 insertions(+), 56 deletions(-) delete mode 100644 src/main/kotlin/site/billilge/api/backend/domain/payer/dto/response/PayerExcelFileResponse.kt delete mode 100644 src/main/kotlin/site/billilge/api/backend/domain/payer/event/PayerEvents.kt delete mode 100644 src/main/kotlin/site/billilge/api/backend/domain/payer/event/listener/PayerEventListener.kt diff --git a/src/main/kotlin/site/billilge/api/backend/domain/payer/controller/AdminPayerController.kt b/src/main/kotlin/site/billilge/api/backend/domain/payer/controller/AdminPayerController.kt index d6564cc..33aa989 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/payer/controller/AdminPayerController.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/payer/controller/AdminPayerController.kt @@ -1,15 +1,19 @@ package site.billilge.api.backend.domain.payer.controller +import org.springframework.core.io.InputStreamResource +import org.springframework.http.ContentDisposition +import org.springframework.http.HttpHeaders import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* import site.billilge.api.backend.domain.payer.dto.request.PayerDeleteRequest import site.billilge.api.backend.domain.payer.dto.request.PayerRequest -import site.billilge.api.backend.domain.payer.dto.response.PayerExcelFileResponse import site.billilge.api.backend.domain.payer.dto.response.PayerFindAllResponse import site.billilge.api.backend.domain.payer.service.PayerService import site.billilge.api.backend.global.annotation.OnlyAdmin import site.billilge.api.backend.global.dto.PageableCondition import site.billilge.api.backend.global.dto.SearchCondition +import java.time.LocalDate +import java.time.format.DateTimeFormatter @RestController @RequestMapping("/admin/members/payers") @@ -38,7 +42,18 @@ class AdminPayerController( } @GetMapping("/excel") - fun getExcelFileUrl(): ResponseEntity { - return ResponseEntity.ok(payerService.getExcelFileUrl()); + fun createPayerExcel(): ResponseEntity { + val excel = payerService.createPayerExcel() + val currentDate = LocalDate.now() + val dateFormatter = DateTimeFormatter.ofPattern("yyyyMMdd") + val headers = HttpHeaders().apply { + contentDisposition = ContentDisposition.builder("attachment") + .filename("kmusw_payers_${dateFormatter.format(currentDate)}.xlsx") + .build() + } + + return ResponseEntity.ok() + .headers(headers) + .body(InputStreamResource((excel))) } } \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/payer/dto/response/PayerExcelFileResponse.kt b/src/main/kotlin/site/billilge/api/backend/domain/payer/dto/response/PayerExcelFileResponse.kt deleted file mode 100644 index 8fbf182..0000000 --- a/src/main/kotlin/site/billilge/api/backend/domain/payer/dto/response/PayerExcelFileResponse.kt +++ /dev/null @@ -1,8 +0,0 @@ -package site.billilge.api.backend.domain.payer.dto.response - -import io.swagger.v3.oas.annotations.media.Schema - -data class PayerExcelFileResponse( - @field:Schema(description = "S3에 업로드된 납부자 목록 엑셀 파일 URL") - val fileUrl: String -) diff --git a/src/main/kotlin/site/billilge/api/backend/domain/payer/event/PayerEvents.kt b/src/main/kotlin/site/billilge/api/backend/domain/payer/event/PayerEvents.kt deleted file mode 100644 index b259d32..0000000 --- a/src/main/kotlin/site/billilge/api/backend/domain/payer/event/PayerEvents.kt +++ /dev/null @@ -1,7 +0,0 @@ -package site.billilge.api.backend.domain.payer.event - -sealed class PayerEvent(open val payerIds: List) - -data class PayerAddEvent(override val payerIds: List) : PayerEvent(payerIds) - -data class PayerDeleteEvent(override val payerIds: List) : PayerEvent(payerIds) \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/payer/event/listener/PayerEventListener.kt b/src/main/kotlin/site/billilge/api/backend/domain/payer/event/listener/PayerEventListener.kt deleted file mode 100644 index 65119d2..0000000 --- a/src/main/kotlin/site/billilge/api/backend/domain/payer/event/listener/PayerEventListener.kt +++ /dev/null @@ -1,21 +0,0 @@ -package site.billilge.api.backend.domain.payer.event.listener - -import org.springframework.stereotype.Component -import org.springframework.transaction.event.TransactionPhase -import org.springframework.transaction.event.TransactionalEventListener -import site.billilge.api.backend.domain.payer.event.PayerAddEvent -import site.billilge.api.backend.domain.payer.event.PayerDeleteEvent - -@Component -class PayerEventListener { - - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - fun onPayerAdd(event: PayerAddEvent) { - - } - - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - fun onPayerDelete(event: PayerDeleteEvent) { - - } -} \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/payer/repository/PayerRepository.kt b/src/main/kotlin/site/billilge/api/backend/domain/payer/repository/PayerRepository.kt index a545714..ce0468a 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/payer/repository/PayerRepository.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/payer/repository/PayerRepository.kt @@ -15,4 +15,6 @@ interface PayerRepository : JpaRepository { @Query("SELECT p FROM Payer p WHERE p.name LIKE CONCAT('%', :name, '%')") fun findAllByNameContaining(@Param("name") name: String, pageable: Pageable): Page + + fun findAllByEnrollmentYear(enrollmentYear: String): List } \ No newline at end of file diff --git a/src/main/kotlin/site/billilge/api/backend/domain/payer/service/PayerService.kt b/src/main/kotlin/site/billilge/api/backend/domain/payer/service/PayerService.kt index e53854e..4ab0b56 100644 --- a/src/main/kotlin/site/billilge/api/backend/domain/payer/service/PayerService.kt +++ b/src/main/kotlin/site/billilge/api/backend/domain/payer/service/PayerService.kt @@ -1,7 +1,5 @@ package site.billilge.api.backend.domain.payer.service -import org.springframework.beans.factory.annotation.Value -import org.springframework.context.ApplicationEventPublisher import org.springframework.data.domain.PageRequest import org.springframework.data.domain.Sort import org.springframework.stereotype.Service @@ -10,25 +8,25 @@ import site.billilge.api.backend.domain.member.entity.Member import site.billilge.api.backend.domain.member.repository.MemberRepository import site.billilge.api.backend.domain.payer.dto.request.PayerDeleteRequest import site.billilge.api.backend.domain.payer.dto.request.PayerRequest -import site.billilge.api.backend.domain.payer.dto.response.PayerExcelFileResponse import site.billilge.api.backend.domain.payer.dto.response.PayerFindAllResponse import site.billilge.api.backend.domain.payer.dto.response.PayerSummary import site.billilge.api.backend.domain.payer.entity.Payer -import site.billilge.api.backend.domain.payer.event.PayerAddEvent -import site.billilge.api.backend.domain.payer.event.PayerDeleteEvent import site.billilge.api.backend.domain.payer.repository.PayerRepository import site.billilge.api.backend.global.dto.PageableCondition import site.billilge.api.backend.global.dto.SearchCondition -import java.time.LocalDate +import site.billilge.api.backend.global.utils.ExcelGenerator +import site.billilge.api.backend.global.utils.ExcelRow +import java.io.ByteArrayInputStream +import java.time.Year @Service @Transactional(readOnly = true) class PayerService( - private val publisher: ApplicationEventPublisher, private val payerRepository: PayerRepository, + private val memberRepository: MemberRepository, - @Value("\${cloud.aws.s3.base-url}") - private val s3BaseUrl: String, + + private val excelGenerator: ExcelGenerator ) { fun isPayer(name: String, studentId: String): Boolean { val enrollmentYear = studentId.substring(0, 4) @@ -104,15 +102,12 @@ class PayerService( } payerRepository.saveAll(newPayers) - - publisher.publishEvent(PayerAddEvent(newPayers.map { it.id })) } @Transactional fun deletePayers(request: PayerDeleteRequest) { val payerStudentIds = payerRepository.findAllByIds(request.payerIds) .mapNotNull { it.studentId } - .toList() memberRepository.findAllByStudentIds(payerStudentIds) .forEach { member -> @@ -120,12 +115,22 @@ class PayerService( } payerRepository.deleteAllById(request.payerIds) - - publisher.publishEvent(PayerDeleteEvent(request.payerIds)) } - fun getExcelFileUrl(): PayerExcelFileResponse { - val currentYear = LocalDate.now().year - return PayerExcelFileResponse(s3BaseUrl + "/payers/payer_${currentYear}.xlsx") + fun createPayerExcel(): ByteArrayInputStream { + val startYear = 2015 + val currentYear = Year.now().value + val headerTitles = arrayOf("이름", "학번") + val sheetData = mutableMapOf, List>>() + + for (year in startYear..currentYear) { + val yearText = "$year" + val payersByYearExcelRow = payerRepository.findAllByEnrollmentYear(yearText) + .map { payer -> ExcelRow(payer.name, payer.studentId ?: "${yearText}XXXX") } + + sheetData.put(yearText, headerTitles to payersByYearExcelRow) + } + + return excelGenerator.generateByMultipleSheets(sheetData) } } \ No newline at end of file