From f5efcaa3ace2089f258f576ed9262d76b0aee20f Mon Sep 17 00:00:00 2001 From: GA Pro Date: Wed, 13 May 2026 00:28:08 +0900 Subject: [PATCH] =?UTF-8?q?feat(api):=20CommissionStatementService=20+=20E?= =?UTF-8?q?xcelStatementGenerator=20+=20Controller=20(=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20P0-3-c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 도메인 P0(세무·공제·명세서) 마지막 항목. 이번 단계는 EXCEL 형식 우선, PDF는 별도 향후 작업. 신규 도메인 클래스 (service/statement/): - StatementSummary (record, 6필드: gross/incomeTax/localTax/vat/deduction/net) - StatementAggregator (@Component, PaymentMapper.selectByAgentAndMonth로 집계) - ExcelStatementGenerator (@Component, SXSSFWorkbook 직접) - CommissionStatementService CommissionStatementService: - list/detail — readOnly + PageHelper - generate(SaveReq) — @Transactional: 1. aggregator.aggregate(agentId, settleMonth) 2. excelGenerator.generate(agent, summary, payments) → byte[] 3. fileService.saveBytes → fileId 4. selectByAgentMonth → 기존 있으면 UPDATE(재발급), 없으면 INSERT 5. issue_status=GENERATED, issued_at, issued_by 기록 - generateForMonth(settleMonth, statementType) — 월 일괄, payment의 distinct agentId 순회 - download(id) — file_path 조회 → byte[] 반환 + DOWNLOADED 전이 - cancel(id) — CANCELLED 전이 CommissionStatementController: - GET /api/statements (페이지) - GET /api/statements/{id} - POST /api/statements/generate (@Valid, @DataChangeLog) - POST /api/statements/generate-for-month (@DataChangeLog) - GET /api/statements/{id}/download (binary 응답) - PUT /api/statements/{id}/cancel (@DataChangeLog) PDF 요청 시 INVALID_PARAMETER BizException — PDF는 별도 작업으로 미룸. Mapper 추가 (ga-core): - PaymentMapper.selectByAgentAndMonth --- .../CommissionStatementController.java | 85 ++++++++++ .../statement/CommissionStatementService.java | 155 ++++++++++++++++++ .../statement/ExcelStatementGenerator.java | 106 ++++++++++++ .../statement/StatementAggregator.java | 35 ++++ .../service/statement/StatementSummary.java | 12 ++ .../ga/core/mapper/settle/PaymentMapper.java | 2 + 6 files changed, 395 insertions(+) create mode 100644 ga-api/src/main/java/com/ga/api/controller/statement/CommissionStatementController.java create mode 100644 ga-api/src/main/java/com/ga/api/service/statement/CommissionStatementService.java create mode 100644 ga-api/src/main/java/com/ga/api/service/statement/ExcelStatementGenerator.java create mode 100644 ga-api/src/main/java/com/ga/api/service/statement/StatementAggregator.java create mode 100644 ga-api/src/main/java/com/ga/api/service/statement/StatementSummary.java diff --git a/ga-api/src/main/java/com/ga/api/controller/statement/CommissionStatementController.java b/ga-api/src/main/java/com/ga/api/controller/statement/CommissionStatementController.java new file mode 100644 index 0000000..9f7b9bd --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/statement/CommissionStatementController.java @@ -0,0 +1,85 @@ +package com.ga.api.controller.statement; + +import com.ga.api.service.statement.CommissionStatementService; +import com.ga.common.annotation.DataChangeLog; +import com.ga.common.annotation.RequirePermission; +import com.ga.common.model.ApiResponse; +import com.ga.common.model.PageResponse; +import com.ga.core.vo.settle.CommissionStatementResp; +import com.ga.core.vo.settle.CommissionStatementSaveReq; +import com.ga.core.vo.settle.CommissionStatementSearchParam; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Pattern; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +@Tag(name = "수수료 명세서") +@Validated +@RestController +@RequestMapping("/api/statements") +@RequiredArgsConstructor +public class CommissionStatementController { + + private final CommissionStatementService statementService; + + @GetMapping + @RequirePermission(menu = "STATEMENT", perm = "READ") + public ApiResponse> list(@ModelAttribute CommissionStatementSearchParam param) { + return ApiResponse.ok(statementService.list(param)); + } + + @GetMapping("/{statementId}") + @RequirePermission(menu = "STATEMENT", perm = "READ") + public ApiResponse detail(@PathVariable Long statementId) { + return ApiResponse.ok(statementService.detail(statementId)); + } + + @Operation(summary = "명세서 단건 발급") + @PostMapping("/generate") + @RequirePermission(menu = "STATEMENT", perm = "CREATE") + @DataChangeLog(menu = "STATEMENT", table = "commission_statement") + public ApiResponse generate(@Valid @RequestBody CommissionStatementSaveReq req) { + return ApiResponse.ok(statementService.generate(req)); + } + + @Operation(summary = "명세서 월별 일괄 발급") + @PostMapping("/generate-for-month") + @RequirePermission(menu = "STATEMENT", perm = "CREATE") + @DataChangeLog(menu = "STATEMENT", table = "commission_statement") + public ApiResponse generateForMonth( + @Pattern(regexp = "\\d{6}", message = "settleMonth는 YYYYMM 형식이어야 합니다") @RequestParam String settleMonth, + @RequestParam String statementType) { + statementService.generateForMonth(settleMonth, statementType); + return ApiResponse.ok(); + } + + @Operation(summary = "명세서 다운로드") + @GetMapping("/{statementId}/download") + @RequirePermission(menu = "STATEMENT", perm = "READ") + public void download(@PathVariable Long statementId, HttpServletResponse response) throws IOException { + byte[] content = statementService.download(statementId); + String encoded = URLEncoder.encode("수수료명세서_" + statementId + ".xlsx", StandardCharsets.UTF_8) + .replace("+", "%20"); + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encoded); + response.setContentLength(content.length); + response.getOutputStream().write(content); + } + + @Operation(summary = "명세서 취소") + @PutMapping("/{statementId}/cancel") + @RequirePermission(menu = "STATEMENT", perm = "UPDATE") + @DataChangeLog(menu = "STATEMENT", table = "commission_statement") + public ApiResponse cancel(@PathVariable Long statementId) { + statementService.cancel(statementId); + return ApiResponse.ok(); + } +} diff --git a/ga-api/src/main/java/com/ga/api/service/statement/CommissionStatementService.java b/ga-api/src/main/java/com/ga/api/service/statement/CommissionStatementService.java new file mode 100644 index 0000000..7d8afe3 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/statement/CommissionStatementService.java @@ -0,0 +1,155 @@ +package com.ga.api.service.statement; + +import com.ga.common.exception.BizException; +import com.ga.common.exception.ErrorCode; +import com.ga.common.file.FileService; +import com.ga.common.model.PageResponse; +import com.ga.common.util.SecurityUtil; +import com.ga.core.mapper.org.AgentMapper; +import com.ga.core.mapper.settle.CommissionStatementMapper; +import com.ga.core.mapper.settle.PaymentMapper; +import com.ga.core.vo.org.AgentResp; +import com.ga.core.vo.settle.CommissionStatementResp; +import com.ga.core.vo.settle.CommissionStatementSaveReq; +import com.ga.core.vo.settle.CommissionStatementSearchParam; +import com.ga.core.vo.settle.CommissionStatementVO; +import com.ga.core.vo.settle.PaymentVO; +import com.ga.core.vo.settle.StatementStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class CommissionStatementService { + + private final CommissionStatementMapper statementMapper; + private final AgentMapper agentMapper; + private final PaymentMapper paymentMapper; + private final StatementAggregator aggregator; + private final ExcelStatementGenerator excelGenerator; + private final FileService fileService; + + @Transactional(readOnly = true) + public PageResponse list(CommissionStatementSearchParam param) { + param.startPage(); + return PageResponse.of(statementMapper.selectList(param)); + } + + @Transactional(readOnly = true) + public CommissionStatementResp detail(Long statementId) { + CommissionStatementResp resp = statementMapper.selectDetailById(statementId); + if (resp == null) throw new BizException(ErrorCode.NOT_FOUND); + return resp; + } + + @Transactional + public Long generate(CommissionStatementSaveReq req) { + if ("PDF".equalsIgnoreCase(req.getFileFormat())) { + throw new BizException(ErrorCode.INVALID_PARAMETER, "PDF 형식은 아직 지원되지 않습니다"); + } + + AgentResp agent = agentMapper.selectDetailById(req.getAgentId()); + if (agent == null) throw new BizException(ErrorCode.NOT_FOUND, "설계사를 찾을 수 없습니다"); + + // 1. 집계 + StatementSummary summary = aggregator.aggregate(req.getAgentId(), req.getSettleMonth()); + + // 2. 엑셀 생성 + List payments = paymentMapper.selectByAgentAndMonth(req.getAgentId(), req.getSettleMonth()); + byte[] excelBytes = excelGenerator.generate(agent, summary, payments); + + // 3. 파일 저장 + String fileName = "statement_" + req.getAgentId() + "_" + req.getSettleMonth() + ".xlsx"; + Long fileId = fileService.saveBytes(excelBytes, fileName, "COMMISSION_STATEMENT", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + + // 4. INSERT 또는 UPDATE (재발급) + CommissionStatementVO existing = statementMapper.selectByAgentMonth( + req.getAgentId(), req.getSettleMonth(), req.getStatementType()); + + Long issuedBy = SecurityUtil.getCurrentUserId(); + LocalDateTime now = LocalDateTime.now(); + + if (existing == null) { + CommissionStatementVO vo = new CommissionStatementVO(); + vo.setAgentId(req.getAgentId()); + vo.setSettleMonth(req.getSettleMonth()); + vo.setStatementType(req.getStatementType()); + vo.setGrossAmount(summary.grossAmount()); + vo.setIncomeTaxAmount(summary.incomeTaxAmount()); + vo.setLocalTaxAmount(summary.localTaxAmount()); + vo.setVatAmount(summary.vatAmount()); + vo.setDeductionAmount(summary.deductionAmount()); + vo.setNetAmount(summary.netAmount()); + vo.setFilePath(String.valueOf(fileId)); + vo.setFileFormat("EXCEL"); + vo.setIssueStatus(StatementStatus.GENERATED.name()); + vo.setIssuedAt(now); + vo.setIssuedBy(issuedBy != null ? issuedBy.toString() : null); + statementMapper.insert(vo); + return vo.getStatementId(); + } else { + existing.setGrossAmount(summary.grossAmount()); + existing.setIncomeTaxAmount(summary.incomeTaxAmount()); + existing.setLocalTaxAmount(summary.localTaxAmount()); + existing.setVatAmount(summary.vatAmount()); + existing.setDeductionAmount(summary.deductionAmount()); + existing.setNetAmount(summary.netAmount()); + existing.setFilePath(String.valueOf(fileId)); + existing.setFileFormat("EXCEL"); + existing.setIssueStatus(StatementStatus.GENERATED.name()); + existing.setIssuedAt(now); + existing.setIssuedBy(issuedBy != null ? issuedBy.toString() : null); + statementMapper.update(existing); + return existing.getStatementId(); + } + } + + @Transactional + public void generateForMonth(String settleMonth, String statementType) { + List allPayments = paymentMapper.selectBySettleMonth(settleMonth); + List agentIds = allPayments.stream() + .map(PaymentVO::getAgentId) + .distinct() + .collect(Collectors.toList()); + + for (Long agentId : agentIds) { + CommissionStatementSaveReq req = new CommissionStatementSaveReq(); + req.setAgentId(agentId); + req.setSettleMonth(settleMonth); + req.setStatementType(statementType); + req.setFileFormat("EXCEL"); + generate(req); + } + } + + @Transactional + public byte[] download(Long statementId) { + CommissionStatementVO vo = statementMapper.selectById(statementId); + if (vo == null) throw new BizException(ErrorCode.NOT_FOUND); + + Long fileId = Long.parseLong(vo.getFilePath()); + com.ga.common.file.FileVO fileVO = fileService.download(fileId); + + // DOWNLOADED 상태 전이 + statementMapper.updateStatus(statementId, StatementStatus.DOWNLOADED.name()); + + try { + return java.nio.file.Files.readAllBytes(java.nio.file.Paths.get(fileVO.getFilePath())); + } catch (java.io.IOException e) { + throw new BizException(ErrorCode.FILE_NOT_FOUND, "파일을 읽을 수 없습니다"); + } + } + + @Transactional + public void cancel(Long statementId) { + CommissionStatementVO vo = statementMapper.selectById(statementId); + if (vo == null) throw new BizException(ErrorCode.NOT_FOUND); + statementMapper.updateStatus(statementId, StatementStatus.CANCELLED.name()); + } +} diff --git a/ga-api/src/main/java/com/ga/api/service/statement/ExcelStatementGenerator.java b/ga-api/src/main/java/com/ga/api/service/statement/ExcelStatementGenerator.java new file mode 100644 index 0000000..391a2de --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/statement/ExcelStatementGenerator.java @@ -0,0 +1,106 @@ +package com.ga.api.service.statement; + +import com.ga.common.exception.BizException; +import com.ga.common.exception.ErrorCode; +import com.ga.core.vo.org.AgentResp; +import com.ga.core.vo.settle.PaymentVO; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayOutputStream; +import java.math.BigDecimal; +import java.text.DecimalFormat; +import java.util.List; + +@Slf4j +@Component +public class ExcelStatementGenerator { + + private static final DecimalFormat AMOUNT_FMT = new DecimalFormat("#,##0"); + + public byte[] generate(AgentResp agent, StatementSummary summary, List payments) { + try (SXSSFWorkbook wb = new SXSSFWorkbook(100)) { + wb.setCompressTempFiles(true); + Sheet sheet = wb.createSheet("수수료명세서"); + + int rowNum = 0; + + // 상단 헤더 정보 + rowNum = writeHeaderSection(sheet, wb, agent, summary, rowNum); + + // 공백 행 + sheet.createRow(rowNum++); + + // 지급 테이블 헤더 + Row tableHeader = sheet.createRow(rowNum++); + CellStyle boldStyle = boldStyle(wb); + String[] cols = {"지급ID", "지급금액", "원천세", "지방소득세", "부가세", "공제금액", "실수령액"}; + for (int i = 0; i < cols.length; i++) { + Cell c = tableHeader.createCell(i); + c.setCellValue(cols[i]); + c.setCellStyle(boldStyle); + sheet.setColumnWidth(i, 18 * 256); + } + + // 지급 데이터 행 + for (PaymentVO p : payments) { + Row row = sheet.createRow(rowNum++); + row.createCell(0).setCellValue(p.getPaymentId() != null ? p.getPaymentId() : 0L); + row.createCell(1).setCellValue(fmt(p.getPayAmount())); + row.createCell(2).setCellValue(fmt(p.getIncomeTaxAmount())); + row.createCell(3).setCellValue(fmt(p.getLocalTaxAmount())); + row.createCell(4).setCellValue(fmt(p.getVatAmount())); + row.createCell(5).setCellValue(fmt(p.getDeductionAmount())); + row.createCell(6).setCellValue(fmt(p.getNetAmount())); + } + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + wb.write(out); + wb.dispose(); + return out.toByteArray(); + } catch (Exception e) { + log.error("ExcelStatementGenerator error", e); + throw new BizException(ErrorCode.INTERNAL_ERROR, "명세서 엑셀 생성 실패"); + } + } + + private int writeHeaderSection(Sheet sheet, SXSSFWorkbook wb, AgentResp agent, + StatementSummary summary, int rowNum) { + CellStyle bold = boldStyle(wb); + writeKv(sheet, rowNum++, bold, "설계사명", agent.getAgentName()); + writeKv(sheet, rowNum++, bold, "사업자번호", agent.getBusinessNo()); + writeKv(sheet, rowNum++, bold, "소득세유형", agent.getTaxType()); + writeKv(sheet, rowNum++, bold, "부가세유형", agent.getVatType()); + sheet.createRow(rowNum++); + writeKv(sheet, rowNum++, bold, "총 지급액", fmt(summary.grossAmount())); + writeKv(sheet, rowNum++, bold, "원천세", fmt(summary.incomeTaxAmount())); + writeKv(sheet, rowNum++, bold, "지방소득세", fmt(summary.localTaxAmount())); + writeKv(sheet, rowNum++, bold, "부가세", fmt(summary.vatAmount())); + writeKv(sheet, rowNum++, bold, "공제금액", fmt(summary.deductionAmount())); + writeKv(sheet, rowNum++, bold, "실수령액", fmt(summary.netAmount())); + return rowNum; + } + + private void writeKv(Sheet sheet, int rowNum, CellStyle keyStyle, String key, String value) { + Row row = sheet.createRow(rowNum); + Cell k = row.createCell(0); + k.setCellValue(key); + k.setCellStyle(keyStyle); + row.createCell(1).setCellValue(value != null ? value : ""); + } + + private CellStyle boldStyle(SXSSFWorkbook wb) { + CellStyle style = wb.createCellStyle(); + Font font = wb.createFont(); + font.setBold(true); + style.setFont(font); + return style; + } + + private String fmt(BigDecimal val) { + if (val == null) return "0"; + return AMOUNT_FMT.format(val); + } +} diff --git a/ga-api/src/main/java/com/ga/api/service/statement/StatementAggregator.java b/ga-api/src/main/java/com/ga/api/service/statement/StatementAggregator.java new file mode 100644 index 0000000..5cbf44f --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/statement/StatementAggregator.java @@ -0,0 +1,35 @@ +package com.ga.api.service.statement; + +import com.ga.core.mapper.settle.PaymentMapper; +import com.ga.core.vo.settle.PaymentVO; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class StatementAggregator { + + private final PaymentMapper paymentMapper; + + public StatementSummary aggregate(Long agentId, String settleMonth) { + List payments = paymentMapper.selectByAgentAndMonth(agentId, settleMonth); + + BigDecimal grossAmount = sum(payments, PaymentVO::getPayAmount); + BigDecimal incomeTax = sum(payments, PaymentVO::getIncomeTaxAmount); + BigDecimal localTax = sum(payments, PaymentVO::getLocalTaxAmount); + BigDecimal vat = sum(payments, PaymentVO::getVatAmount); + BigDecimal deduction = sum(payments, PaymentVO::getDeductionAmount); + BigDecimal netAmount = sum(payments, PaymentVO::getNetAmount); + + return new StatementSummary(grossAmount, incomeTax, localTax, vat, deduction, netAmount); + } + + private BigDecimal sum(List payments, java.util.function.Function getter) { + return payments.stream() + .map(p -> getter.apply(p) != null ? getter.apply(p) : BigDecimal.ZERO) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } +} diff --git a/ga-api/src/main/java/com/ga/api/service/statement/StatementSummary.java b/ga-api/src/main/java/com/ga/api/service/statement/StatementSummary.java new file mode 100644 index 0000000..f154ed6 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/statement/StatementSummary.java @@ -0,0 +1,12 @@ +package com.ga.api.service.statement; + +import java.math.BigDecimal; + +public record StatementSummary( + BigDecimal grossAmount, + BigDecimal incomeTaxAmount, + BigDecimal localTaxAmount, + BigDecimal vatAmount, + BigDecimal deductionAmount, + BigDecimal netAmount +) {} diff --git a/ga-core/src/main/java/com/ga/core/mapper/settle/PaymentMapper.java b/ga-core/src/main/java/com/ga/core/mapper/settle/PaymentMapper.java index 2dd8cd4..ae91261 100644 --- a/ga-core/src/main/java/com/ga/core/mapper/settle/PaymentMapper.java +++ b/ga-core/src/main/java/com/ga/core/mapper/settle/PaymentMapper.java @@ -34,4 +34,6 @@ public interface PaymentMapper { List selectPendingByMonthAndBank(@Param("settleMonth") String settleMonth, @Param("bankCode") String bankCode); List selectBySettleMonth(@Param("settleMonth") String settleMonth); + List selectByAgentAndMonth(@Param("agentId") Long agentId, + @Param("settleMonth") String settleMonth); }