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:
+85
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user