feat: P10(#3) 환수 보험사자료 업로드 — 보험사 통보 기반 chargeback 인입 (PL 자율진행)

incar '환수보험사자료 업로드' 대응. GA는 LAPSE 계약 기반 자동환수만 있고 보험사가
통보한 환수자료(증권번호+환수금액)를 직접 인입하는 경로가 없던 갭. 신규 테이블 없이
기존 chargeback(V6) 재사용.

- DB(V113): 메뉴 CHARGEBACK_DATA(환수자료관리, GRP_SETTLE, /settle/chargeback-data)
  + 권한 READ/CREATE/EXPORT + 역할부여.
- core: ChargebackUploadExcelVO(@ExcelColumn 증권번호/환수금액/실효월/환수율) +
  ChargebackResp/SearchParam + ChargebackMapper.selectList/countByCondition/
  existsByContractAndMonth + ContractMapper.selectByPolicyNo(contract_date 포함).
- api: ChargebackUploadService.upload(settleMonth,file) — ExcelService.importLargeExcel
  로 SAX 파싱, policy_no→contract/agent 해소(미존재=에러행), 중복(contract+월) skip,
  실효월 YYYYMM→경과월 변환(ChronoUnit), ChargebackVO status=NEW·remain=cb_amount
  insertBatch. ChargebackService.list/export. ChargebackController(/api/chargebacks
  /upload, GET 목록, export).
- frontend: ChargebackData 화면(정산월+파일 업로드, 결과 성공/스킵/에러행 표시, AG Grid
  목록 합계행, 엑셀) + api/chargeback.ts + App 라우트.

검증: build SUCCESSFUL, Flyway V113(schema v113). 라이브 업로드 e2e — 정상 2건 INSERT
(NEW, remain=cb_amount) + 미존재 증권번호 1건 에러행 리포트 + 중복 skip, GET 목록 200,
경과월 변환 동작 확인. 테스트 chargeback 전건 정리. 스펙 docs/DOMAIN_GAP_P10.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-06-05 00:51:08 +09:00
parent 90256a19a1
commit 284ecc4203
15 changed files with 1004 additions and 0 deletions
@@ -0,0 +1,56 @@
package com.ga.api.controller.settle;
import com.ga.api.service.settle.ChargebackService;
import com.ga.api.service.settle.ChargebackUploadService;
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.ChargebackResp;
import com.ga.core.vo.settle.ChargebackSearchParam;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.constraints.Pattern;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@Tag(name = "환수자료 관리")
@Validated
@RestController
@RequestMapping("/api/chargebacks")
@RequiredArgsConstructor
public class ChargebackController {
private final ChargebackService chargebackService;
private final ChargebackUploadService chargebackUploadService;
@Operation(summary = "환수자료 목록 조회")
@GetMapping
@RequirePermission(menu = "CHARGEBACK_DATA", perm = "READ")
public ApiResponse<PageResponse<ChargebackResp>> list(@ModelAttribute ChargebackSearchParam param) {
return ApiResponse.ok(chargebackService.list(param));
}
@Operation(summary = "환수자료 엑셀 다운로드")
@GetMapping("/export")
@RequirePermission(menu = "CHARGEBACK_DATA", perm = "EXPORT")
public void export(@ModelAttribute ChargebackSearchParam param, HttpServletResponse response) {
chargebackService.export(param, response);
}
@Operation(summary = "보험사 환수자료 업로드",
description = "정산월(YYYYMM) + 엑셀 파일(증권번호/환수금액/실효월/환수율). " +
"미존재 증권번호·cbAmount 오류는 에러행으로 반환. 동일 계약+정산월 중복은 스킵.")
@PostMapping("/upload")
@RequirePermission(menu = "CHARGEBACK_DATA", perm = "CREATE")
@DataChangeLog(menu = "CHARGEBACK_DATA", table = "chargeback")
public ApiResponse<ChargebackUploadService.UploadResult> upload(
@Pattern(regexp = "\\d{6}", message = "settleMonth는 YYYYMM 형식이어야 합니다")
@RequestParam String settleMonth,
@RequestParam("file") MultipartFile file) {
return ApiResponse.ok(chargebackUploadService.upload(settleMonth, file));
}
}
@@ -0,0 +1,118 @@
package com.ga.api.service.settle;
import com.ga.common.annotation.ExcelColumn;
import com.ga.common.excel.ExcelService;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.settle.ChargebackMapper;
import com.ga.core.vo.settle.ChargebackResp;
import com.ga.core.vo.settle.ChargebackSearchParam;
import jakarta.servlet.http.HttpServletResponse;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.apache.ibatis.cursor.Cursor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Iterator;
import java.util.List;
@Service
@RequiredArgsConstructor
public class ChargebackService {
private final ChargebackMapper chargebackMapper;
private final ExcelService excelService;
@Transactional(readOnly = true)
public PageResponse<ChargebackResp> list(ChargebackSearchParam param) {
param.startPage();
return PageResponse.of(chargebackMapper.selectList(param));
}
/**
* 환수 목록 엑셀 다운로드.
*
* ga-core ChargebackMapper에 selectCursorForExcel 미존재(ga-core 수정 불가)로
* selectList 전체 조회 후 List-backed Cursor로 exportLargeExcel 위임.
* 대용량 환경에서는 ga-core에 selectCursorForExcel 추가 권장 (TODO).
*/
@Transactional(readOnly = true)
public void export(ChargebackSearchParam param, HttpServletResponse response) {
List<ChargebackResp> rows = chargebackMapper.selectList(param);
List<ChargebackExcelRow> excelRows = rows.stream().map(ChargebackExcelRow::from).toList();
excelService.exportLargeExcel(
"환수자료목록",
ChargebackExcelRow.class,
() -> new ListCursor<>(excelRows),
response
);
}
// ── 엑셀 출력용 VO (ga-core 수정 불가로 api 모듈 내 정의) ────────────
@Data
public static class ChargebackExcelRow {
@ExcelColumn(header = "정산월", order = 1, width = 10)
private String settleMonth;
@ExcelColumn(header = "증권번호", order = 2, width = 16)
private String policyNo;
@ExcelColumn(header = "계약자명", order = 3, width = 14)
private String contractorName;
@ExcelColumn(header = "설계사명", order = 4, width = 12)
private String agentName;
@ExcelColumn(header = "경과월", order = 5, width = 8)
private Integer lapseMonth;
@ExcelColumn(header = "환수율", order = 6, width = 8)
private BigDecimal cbRate;
@ExcelColumn(header = "환수금액", order = 7, width = 14)
private BigDecimal cbAmount;
@ExcelColumn(header = "지급금액", order = 8, width = 14)
private BigDecimal paidAmount;
@ExcelColumn(header = "잔여금액", order = 9, width = 14)
private BigDecimal remainAmount;
@ExcelColumn(header = "상태", order = 10, width = 10)
private String status;
@ExcelColumn(header = "생성일시", order = 11, width = 18, format = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createdAt;
static ChargebackExcelRow from(ChargebackResp r) {
ChargebackExcelRow row = new ChargebackExcelRow();
row.setSettleMonth(r.getSettleMonth());
row.setPolicyNo(r.getPolicyNo());
row.setContractorName(r.getContractorName());
row.setAgentName(r.getAgentName());
row.setLapseMonth(r.getLapseMonth());
row.setCbRate(r.getCbRate());
row.setCbAmount(r.getCbAmount());
row.setPaidAmount(r.getPaidAmount());
row.setRemainAmount(r.getRemainAmount());
row.setStatus(r.getStatus());
row.setCreatedAt(r.getCreatedAt());
return row;
}
}
/** List를 MyBatis Cursor 인터페이스로 감싸는 어댑터. */
private static class ListCursor<T> implements Cursor<T> {
private final List<T> list;
ListCursor(List<T> list) { this.list = list; }
@Override public boolean isOpen() { return true; }
@Override public boolean isConsumed() { return false; }
@Override public int getCurrentIndex() { return -1; }
@Override public Iterator<T> iterator() { return list.iterator(); }
@Override public void close() { /* no-op */ }
}
}
@@ -0,0 +1,140 @@
package com.ga.api.service.settle;
import com.ga.common.excel.ExcelErrorRow;
import com.ga.common.excel.ExcelImportResult;
import com.ga.common.excel.ExcelService;
import com.ga.core.mapper.product.ContractMapper;
import com.ga.core.mapper.settle.ChargebackMapper;
import com.ga.core.vo.product.ContractVO;
import com.ga.core.vo.settle.ChargebackUploadExcelVO;
import com.ga.core.vo.settle.ChargebackVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.math.BigDecimal;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
/**
* 보험사 환수자료 업로드 서비스.
*
* 흐름:
* 1. ExcelService.importLargeExcel → ChargebackUploadExcelVO 파싱
* 2. 각 행: policyNo → ContractMapper.selectByPolicyNo 해소
* - 미존재 → 에러행 수집(스킵)
* - 동일 contractId + settleMonth 이미 존재 → skipCount++ (중복)
* - cbAmount null/<=0 → 에러행
* 3. ChargebackVO 구성 후 insertBatch
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ChargebackUploadService {
private static final DateTimeFormatter YYYYMM = DateTimeFormatter.ofPattern("yyyyMM");
private final ExcelService excelService;
private final ContractMapper contractMapper;
private final ChargebackMapper chargebackMapper;
@Transactional
public UploadResult upload(String settleMonth, MultipartFile file) {
List<ExcelErrorRow> errors = new ArrayList<>();
int[] skipCount = {0};
int[] successCount = {0};
excelService.importLargeExcel(file, ChargebackUploadExcelVO.class, 500, batch -> {
List<ChargebackVO> toInsert = new ArrayList<>(batch.size());
for (ChargebackUploadExcelVO row : batch) {
int rowSeq = successCount[0] + skipCount[0] + errors.size() + 1;
// 1. cbAmount 검증
if (row.getCbAmount() == null || row.getCbAmount().compareTo(BigDecimal.ZERO) <= 0) {
errors.add(new ExcelErrorRow(rowSeq, "cbAmount",
row.getCbAmount() == null ? null : row.getCbAmount().toPlainString(),
"환수금액은 0 초과여야 합니다"));
continue;
}
// 2. 증권번호 → 계약 해소
if (row.getPolicyNo() == null || row.getPolicyNo().isBlank()) {
errors.add(new ExcelErrorRow(rowSeq, "policyNo", null, "증권번호 누락"));
continue;
}
ContractVO contract = contractMapper.selectByPolicyNo(row.getPolicyNo());
if (contract == null) {
errors.add(new ExcelErrorRow(rowSeq, "policyNo", row.getPolicyNo(),
"증권번호에 해당하는 계약을 찾을 수 없습니다"));
continue;
}
// 3. 중복 확인 (contract_id + settle_month)
if (chargebackMapper.existsByContractAndMonth(contract.getContractId(), settleMonth) > 0) {
skipCount[0]++;
log.debug("중복 스킵: contractId={} settleMonth={}", contract.getContractId(), settleMonth);
continue;
}
// 4. lapseMonth 변환 (YYYYMM → 경과월 INT)
Integer lapseMonthInt = parseLapseMonth(row.getLapseMonth(), contract);
// 5. ChargebackVO 구성
ChargebackVO vo = new ChargebackVO();
vo.setContractId(contract.getContractId());
vo.setAgentId(contract.getAgentId());
vo.setPolicyNo(row.getPolicyNo());
vo.setLapseMonth(lapseMonthInt);
vo.setCbRate(row.getCbRate());
vo.setCbAmount(row.getCbAmount());
vo.setPaidAmount(BigDecimal.ZERO);
vo.setRemainAmount(row.getCbAmount());
vo.setSettleMonth(settleMonth);
vo.setStatus("NEW");
toInsert.add(vo);
}
if (!toInsert.isEmpty()) {
chargebackMapper.insertBatch(toInsert);
successCount[0] += toInsert.size();
}
});
return new UploadResult(successCount[0], errors.size(), skipCount[0], errors);
}
/**
* 실효월 문자열(YYYYMM)을 경과월(INT)로 변환.
* contract_date 기준 lapseDate까지 개월수를 계산한다.
* 입력 없음 / 파싱 실패 / contractDate 없음 → null 반환.
*/
private Integer parseLapseMonth(String lapseMonthStr, ContractVO contract) {
if (lapseMonthStr == null || lapseMonthStr.isBlank()) return null;
try {
YearMonth lapse = YearMonth.parse(lapseMonthStr, YYYYMM);
if (contract.getContractDate() == null) return null;
YearMonth contractYm = YearMonth.from(contract.getContractDate());
long months = ChronoUnit.MONTHS.between(contractYm, lapse);
return (int) months;
} catch (Exception e) {
log.debug("lapseMonth 파싱 실패: value={}", lapseMonthStr);
return null;
}
}
/** 업로드 결과 DTO. */
public record UploadResult(
int successCount,
int errorCount,
int skipCount,
List<ExcelErrorRow> errors
) {}
}