feat(api): CommissionStatementService + ExcelStatementGenerator + Controller (도메인 P0-3-c)

도메인 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
This commit is contained in:
GA Pro
2026-05-13 00:28:08 +09:00
parent 4f5a2f0cb9
commit f5efcaa3ac
6 changed files with 395 additions and 0 deletions
@@ -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<PageResponse<CommissionStatementResp>> list(@ModelAttribute CommissionStatementSearchParam param) {
return ApiResponse.ok(statementService.list(param));
}
@GetMapping("/{statementId}")
@RequirePermission(menu = "STATEMENT", perm = "READ")
public ApiResponse<CommissionStatementResp> 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<Long> 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<Void> 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<Void> cancel(@PathVariable Long statementId) {
statementService.cancel(statementId);
return ApiResponse.ok();
}
}
@@ -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<CommissionStatementResp> 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<PaymentVO> 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<PaymentVO> allPayments = paymentMapper.selectBySettleMonth(settleMonth);
List<Long> 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());
}
}
@@ -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<PaymentVO> 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);
}
}
@@ -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<PaymentVO> 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<PaymentVO> payments, java.util.function.Function<PaymentVO, BigDecimal> getter) {
return payments.stream()
.map(p -> getter.apply(p) != null ? getter.apply(p) : BigDecimal.ZERO)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
@@ -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
) {}
@@ -34,4 +34,6 @@ public interface PaymentMapper {
List<PaymentVO> selectPendingByMonthAndBank(@Param("settleMonth") String settleMonth,
@Param("bankCode") String bankCode);
List<PaymentVO> selectBySettleMonth(@Param("settleMonth") String settleMonth);
List<PaymentVO> selectByAgentAndMonth(@Param("agentId") Long agentId,
@Param("settleMonth") String settleMonth);
}