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