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,46 @@
# P10 — 환수 보험사자료 업로드 (#3)
작성: PL, 2026-06-04. 사용자 "후속 갭 3종 진행" 중 첫 번째.
근거: incar `환수보험사자료 업로드`. GA는 LAPSE 계약 기반 자동환수(ChargebackExceptionStep)만 있고,
보험사가 통보한 환수자료(증권번호+환수금액)를 직접 인입하는 경로가 없음.
범위: 풀스택(DB→Core→API→Frontend). 신규 테이블 없음 — 기존 `chargeback`(V6) 재사용.
Flyway: **V112 최신 → V113**(메뉴/권한만). 금액 NUMERIC(15,2).
## 1. chargeback 테이블 (기존, 재사용)
cb_id PK / contract_id FK(nullable) / agent_id FK NN / policy_no / lapse_month INT /
cb_rate / cb_amount NN / paid_amount(0) / remain_amount NN / settle_month CHAR(6) NN /
installment_no / max_installments / status('NEW') / created_at.
## 2. 업로드 설계
- **1 업로드 = 1 settleMonth**(요청 파라미터). 파일 각 행 = 계약별 환수 통보.
- 엑셀 헤더(ChargebackUploadExcelVO, @ExcelColumn): `증권번호`(policyNo, order1) / `환수금액`(cbAmount, order2) / `실효월`(lapseMonth YYYYMM 문자열, order3, 선택) / `환수율`(cbRate, order4, 선택).
- **agent/contract 해소**: policy_no → contract 조회 → contract_id + agent_id. 미발견 행은 에러행으로 수집(스킵, 결과에 행번호+사유 반환). ContractMapper에 `selectByPolicyNo(policyNo)` 없으면 core가 추가(contract 테이블 policy_no 컬럼 확인).
- 생성 ChargebackVO: contractId/agentId(해소), policyNo, lapseMonth(YYYYMM→INT 변환, '202401'→202401 또는 경과월? **chargeback.lapse_month는 INT**: 기존 의미가 경과월(month elapsed)인지 실효연월인지 ChargebackExceptionStep 확인 후 동일 의미로 — 자동환수와 일관성. 불명확하면 실효연월 YYYYMM의 끝2자리(월)이 아니라 **경과월**로: contract_date~lapse_date 개월수 계산. 단순화를 위해 파일에 없으면 null 허용), cbRate, cbAmount, paidAmount=0, remainAmount=cbAmount, settleMonth(param), status='NEW'.
- **주의**: 자동환수(ChargebackExceptionStep)와 동일 (contract, settleMonth) 중복 생성 방지 — 동일 contract_id+settle_month NEW 존재 시 스킵 또는 갱신(중복 인입 방지). core에 `existsByContractAndMonth` 또는 service에서 selectByAgent로 확인. 최소: 같은 정산월 재업로드 시 중복 INSERT 경고만(운영자 책임). **권장: contract_id+settle_month UNIQUE 부분 보장 위해 service가 사전 조회 후 skip + 결과 리포트.**
- ExcelService.importLargeExcel(file, ChargebackUploadExcelVO.class, 500, consumer) 사용. consumer에서 해소+검증+chargebackMapper.insertBatch. 트랜잭션: @Transactional.
## 3. 조회(List) — 신규
chargeback 목록 화면이 현재 없음. 추가:
- core: ChargebackResp(+agentName/contractNo 조인) / ChargebackSearchParam(settleMonth/agentId/status/policyNo) / ChargebackMapper.selectList + countByCondition. (기존 ChargebackVO 유지)
- api: ChargebackService.list(PageHelper) / detail. 업로드는 ChargebackUploadService.upload(settleMonth, file)→ExcelImportResult-유사(성공/에러행).
## 4. API
- `POST /api/chargebacks/upload?settleMonth=YYYYMM` (multipart file) — @RequirePermission(CHARGEBACK_DATA, CREATE), @DataChangeLog. 반환: {successCount, errorCount, errors[]}.
- `GET /api/chargebacks` — 목록(@RequirePermission READ, PageResponse).
- `GET /api/chargebacks/export` — 엑셀(@Transactional).
컨트롤러 위치 `controller/settle/ChargebackController` 또는 기존 chargeback 패키지 확인.
## 5. DB (V113)
- 메뉴 `CHARGEBACK_DATA`(환수자료관리), 상위 GRP_SETTLE, path `/settle/chargeback-data`, component `settle/ChargebackData`. (V97 단계마감 등 GRP_SETTLE 패턴)
- 권한 READ/CREATE/EXPORT + 역할부여 SUPER_ADMIN·ADMIN 전체, MANAGER READ.
- 공통코드 불필요(chargeback status는 기존 코드 재사용 여부 확인 — 없으면 생략).
## 6. Frontend
- `src/api/chargeback.ts` — list/upload(FormData)/export.
- `src/pages/settle/ChargebackData.tsx` — 상단 정산월 선택 + 파일 업로드 버튼(업로드 결과 성공/에러행 표시) + AG Grid 목록(증권번호/설계사/환수금액/잔여/상태/정산월) + 엑셀. 기존 업로드 화면(UploadPage 등) 결과표시 패턴 참고.
- App.tsx 라우트 `settle/chargeback-data`.
## 7. 검증(PL)
build+test, Flyway V113, GET 목록 200, **샘플 엑셀 업로드 실호출**(정상행 INSERT + 미존재 증권번호 에러행 리포트) 라이브 확인 후 테스트 chargeback 롤백/삭제.
</content>
@@ -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
) {}
}
@@ -0,0 +1,59 @@
-- V113: 환수자료관리 메뉴/권한 등록 (P10 §5)
-- 신규 테이블 없음 — 기존 chargeback 테이블 재사용.
-- GRP_SETTLE 하위 PAGE: CHARGEBACK_DATA (sort_order 110, HQ_SETTLE=100 다음)
-- 권한: READ / CREATE / EXPORT
-- 역할: SUPER_ADMIN·ADMIN 전체, MANAGER READ 만
-- 모든 INSERT 멱등 (ON CONFLICT DO NOTHING / WHERE NOT EXISTS)
-- ============================================================
-- 1. 메뉴 INSERT: CHARGEBACK_DATA (GRP_SETTLE 하위 PAGE)
-- ============================================================
INSERT INTO menu (parent_menu_id, menu_code, menu_name, menu_type, menu_path, component_path, menu_level, sort_order)
SELECT p.menu_id, x.code, x.name, 'PAGE', x.path, x.cmpt, 2, x.sort
FROM menu p
JOIN (VALUES
('CHARGEBACK_DATA', '환수자료관리', '/settle/chargeback-data', 'settle/ChargebackData', 110)
) AS x(code, name, path, cmpt, sort)
ON p.menu_code = 'GRP_SETTLE'
WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code);
-- ============================================================
-- 2. 메뉴 권한 항목 등록 (READ / CREATE / EXPORT)
-- ============================================================
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
SELECT m.menu_id, p.code, p.name, p.sort
FROM menu m
CROSS JOIN (VALUES
('READ', '조회', 10),
('CREATE', '등록', 20),
('EXPORT', '엑셀', 60)
) AS p(code, name, sort)
WHERE m.menu_code = 'CHARGEBACK_DATA'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
-- ============================================================
-- 3. 역할별 권한 부여
-- ============================================================
-- SUPER_ADMIN / ADMIN — 전체 권한 (READ / CREATE / EXPORT)
INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
FROM role r
CROSS JOIN menu_permission mp
JOIN menu m ON m.menu_id = mp.menu_id
WHERE r.role_code IN ('SUPER_ADMIN', 'ADMIN')
AND m.menu_code = 'CHARGEBACK_DATA'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- MANAGER — READ 만
INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
FROM role r
CROSS JOIN menu_permission mp
JOIN menu m ON m.menu_id = mp.menu_id
WHERE r.role_code = 'MANAGER'
AND mp.perm_code = 'READ'
AND m.menu_code = 'CHARGEBACK_DATA'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
@@ -14,6 +14,12 @@ public interface ContractMapper {
ContractResp selectDetailById(@Param("contractId") Long contractId);
ContractVO selectById(@Param("contractId") Long contractId);
int existsByPolicyNo(@Param("policyNo") String policyNo);
/**
* 증권번호로 계약 조회 (업로드 시 agent/contract 해소용).
* 반환: ContractVO (contractId + agentId 포함). 미존재 시 null.
*/
ContractVO selectByPolicyNo(@Param("policyNo") String policyNo);
int insert(ContractVO vo);
int update(ContractVO vo);
int updateStatus(@Param("contractId") Long contractId, @Param("status") String status);
@@ -1,5 +1,7 @@
package com.ga.core.mapper.settle;
import com.ga.core.vo.settle.ChargebackResp;
import com.ga.core.vo.settle.ChargebackSearchParam;
import com.ga.core.vo.settle.ChargebackVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@@ -20,4 +22,18 @@ public interface ChargebackMapper {
List<Map<String, Object>> aggregateByAgent(@Param("settleMonth") String settleMonth);
BigDecimal sumByMonth(@Param("settleMonth") String settleMonth);
// ── P10 환수자료 업로드 / 조회 ──────────────────────────────────────────
/** 환수 목록 조회 (agentName / contractorName 조인 포함). PageHelper 페이징 사용. */
List<ChargebackResp> selectList(ChargebackSearchParam param);
/** 환수 목록 건수 (페이징 없이 count만). */
int countByCondition(ChargebackSearchParam param);
/**
* 동일 contract + settleMonth 환수 레코드 존재 여부.
* 업로드 중복 방지용. 반환값 > 0 이면 이미 존재.
*/
int existsByContractAndMonth(@Param("contractId") Long contractId,
@Param("settleMonth") String settleMonth);
}
@@ -0,0 +1,36 @@
package com.ga.core.vo.settle;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 환수 목록 조회 응답 VO.
*
* chargeback 테이블 컬럼 + 조인 필드:
* - agentName : agent.agent_name
* - contractNo: contract 식별용 (policy_no 와 동일 역할, 조인 시 포함)
*/
@Data
public class ChargebackResp {
private Long cbId;
private Long contractId;
private Long agentId;
private String agentName;
/** contract.policy_no (chargeback.policy_no와 동일 값, 조인으로 확인) */
private String policyNo;
/** contract.contractor_name */
private String contractorName;
/** 경과월 (months elapsed from contract_date to lapse_date) */
private Integer lapseMonth;
private BigDecimal cbRate;
private BigDecimal cbAmount;
private BigDecimal paidAmount;
private BigDecimal remainAmount;
private String settleMonth;
private Integer installmentNo;
private Integer maxInstallments;
private String status;
private LocalDateTime createdAt;
}
@@ -0,0 +1,21 @@
package com.ga.core.vo.settle;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 환수 목록 검색 파라미터.
*
* 상속 필드(SearchParam): pageNum / pageSize / status / searchKeyword / startDate / endDate
* 추가 필드: settleMonth / agentId / policyNo
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class ChargebackSearchParam extends SearchParam {
/** 정산월 YYYYMM */
private String settleMonth;
private Long agentId;
/** 증권번호 검색 */
private String policyNo;
}
@@ -0,0 +1,33 @@
package com.ga.core.vo.settle;
import com.ga.common.annotation.ExcelColumn;
import lombok.Data;
import java.math.BigDecimal;
/**
* 보험사 환수자료 업로드용 엑셀 VO.
*
* 파일 헤더 순서: 증권번호 / 환수금액 / 실효월 / 환수율
* - 실효월(lapseMonth): YYYYMM 문자열, 선택(없으면 null).
* 파일에 입력된 실효연월(예: 202401)을 서비스 계층에서
* contract_date 기준 경과월(INT)로 변환 후 ChargebackVO.lapseMonth에 저장.
* - 환수율(cbRate): DECIMAL(7,4), 선택.
*/
@Data
public class ChargebackUploadExcelVO {
@ExcelColumn(header = "증권번호", order = 1)
private String policyNo;
@ExcelColumn(header = "환수금액", order = 2)
private BigDecimal cbAmount;
/** 실효월 YYYYMM (선택). 서비스에서 경과월로 변환. */
@ExcelColumn(header = "실효월", order = 3)
private String lapseMonth;
/** 환수율 (선택). */
@ExcelColumn(header = "환수율", order = 4)
private BigDecimal cbRate;
}
@@ -68,6 +68,19 @@
SELECT COUNT(*) FROM contract WHERE policy_no = #{policyNo}
</select>
<!--
증권번호로 계약 조회 (P10 환수자료 업로드 시 agent/contract 해소용).
contract.policy_no 는 UNIQUE NOT NULL(V2).
contractId + agentId 만 필요하므로 ContractVOMap 재사용.
미존재 시 null 반환.
-->
<select id="selectByPolicyNo" resultMap="ContractVOMap">
SELECT contract_id, agent_id, policy_no, contract_date, status
FROM contract
WHERE policy_no = #{policyNo}
LIMIT 1
</select>
<insert id="insert" parameterType="com.ga.core.vo.product.ContractVO" useGeneratedKeys="true" keyProperty="contractId">
INSERT INTO contract (agent_id, product_id, policy_no, contractor_name, insured_name,
premium, pay_cycle, pay_period, coverage_period, status,
@@ -5,6 +5,56 @@
<resultMap id="CBMap" type="com.ga.core.vo.settle.ChargebackVO"/>
<resultMap id="CBRespMap" type="com.ga.core.vo.settle.ChargebackResp"/>
<!-- 조회 공통 컬럼: chargeback 전체 + 조인 필드 -->
<sql id="cbRespCols">
cb.cb_id,
cb.contract_id,
cb.agent_id,
a.agent_name,
cb.policy_no,
ct.contractor_name,
cb.lapse_month,
cb.cb_rate,
cb.cb_amount,
cb.paid_amount,
cb.remain_amount,
cb.settle_month,
cb.installment_no,
cb.max_installments,
cb.status,
cb.created_at
</sql>
<!-- 동적 WHERE 조건 (selectList / countByCondition 공용) -->
<sql id="cbWhere">
<where>
<if test="settleMonth != null and settleMonth != ''">
AND cb.settle_month = #{settleMonth}
</if>
<if test="agentId != null">
AND cb.agent_id = #{agentId}
</if>
<if test="status != null and status != ''">
AND cb.status = #{status}
</if>
<if test="policyNo != null and policyNo != ''">
AND cb.policy_no = #{policyNo}
</if>
<if test="searchKeyword != null and searchKeyword != ''">
AND (cb.policy_no LIKE CONCAT('%', #{searchKeyword}, '%')
OR a.agent_name LIKE CONCAT('%', #{searchKeyword}, '%'))
</if>
<if test="startDate != null and startDate != ''">
AND cb.created_at &gt;= #{startDate}::date
</if>
<if test="endDate != null and endDate != ''">
AND cb.created_at &lt;= (#{endDate}::date + INTERVAL '1 day')
</if>
</where>
</sql>
<insert id="insert" parameterType="com.ga.core.vo.settle.ChargebackVO" useGeneratedKeys="true" keyProperty="cbId">
INSERT INTO chargeback (contract_id, agent_id, policy_no, lapse_month, cb_rate,
cb_amount, paid_amount, remain_amount, settle_month,
@@ -48,4 +98,43 @@
SELECT COALESCE(SUM(cb_amount), 0) FROM chargeback WHERE settle_month = #{settleMonth}
</select>
<!-- ── P10 환수자료 업로드 / 조회 ────────────────────────────────────── -->
<!--
환수 목록 조회.
PageHelper.startPage() 호출 후 이 쿼리 실행 시 자동 페이징.
agent / contract 좌조인: contract 없는 행(보험사 직접 통보, contract_id NULL)도 포함.
-->
<select id="selectList" resultMap="CBRespMap">
SELECT <include refid="cbRespCols"/>
FROM chargeback cb
JOIN agent a ON a.agent_id = cb.agent_id
LEFT JOIN contract ct ON ct.contract_id = cb.contract_id
<include refid="cbWhere"/>
ORDER BY cb.created_at DESC, cb.cb_id DESC
</select>
<!--
페이징 없이 총 건수만 반환.
PageHelper 없이 count가 필요한 경우(별도 카운트 쿼리) 사용.
-->
<select id="countByCondition" resultType="int">
SELECT COUNT(*)
FROM chargeback cb
JOIN agent a ON a.agent_id = cb.agent_id
LEFT JOIN contract ct ON ct.contract_id = cb.contract_id
<include refid="cbWhere"/>
</select>
<!--
동일 contract_id + settle_month 환수 레코드 존재 여부.
반환값 > 0 이면 중복 → 업로드 시 해당 행 스킵 + 에러행 리포트.
-->
<select id="existsByContractAndMonth" resultType="int">
SELECT COUNT(*)
FROM chargeback
WHERE contract_id = #{contractId}
AND settle_month = #{settleMonth}
</select>
</mapper>
+6
View File
@@ -33,6 +33,9 @@ const CommissionSimulator = React.lazy(() => import('@/pages/commission/Commiss
// P9: 환수채권
const ClawbackReceivable = React.lazy(() => import('@/pages/commission/ClawbackReceivable'));
// P10: 환수자료관리
const ChargebackData = React.lazy(() => import('@/pages/settle/ChargebackData'));
// P7: 수수료 계산 7도메인
const IncomeCommission = React.lazy(() => import('@/pages/commission/IncomeCommission'));
const ReferralCommission = React.lazy(() => import('@/pages/commission/ReferralCommission'));
@@ -230,6 +233,9 @@ export default function App() {
{/* P9: 환수채권 */}
<Route path="commission/clawback-receivable" element={<ClawbackReceivable />} />
{/* P10: 환수자료관리 */}
<Route path="settle/chargeback-data" element={<ChargebackData />} />
{/* P7: 수수료 계산 7도메인 */}
<Route path="commission/income" element={<IncomeCommission />} />
<Route path="commission/referral" element={<ReferralCommission />} />
+62
View File
@@ -0,0 +1,62 @@
import api, { PageResponse, unwrap } from './request';
// ── Types ──────────────────────────────────────────
export type ChargebackStatus = 'NEW' | 'PROCESSING' | 'SETTLED' | 'CANCELLED';
export interface ChargebackRow extends Record<string, unknown> {
cbId: number;
policyNo: string;
agentId: number;
agentName: string;
contractorName: string;
lapseMonth: number | null;
cbRate: number | null;
cbAmount: number;
paidAmount: number;
remainAmount: number;
settleMonth: string;
status: ChargebackStatus;
createdAt: string;
}
export interface ChargebackSearchParam {
settleMonth?: string;
agentId?: number;
status?: string;
policyNo?: string;
pageNum?: number;
pageSize?: number;
}
export interface ChargebackUploadResult {
successCount: number;
errorCount: number;
skipCount: number;
errors: Array<{ rowNo: number; reason: string }>;
}
// ── API ───────────────────────────────────────────
export const chargebackApi = {
list: (p: ChargebackSearchParam) =>
unwrap<PageResponse<ChargebackRow>>(
api.get('/api/chargebacks', { params: p }),
),
upload: (settleMonth: string, file: File) => {
const form = new FormData();
form.append('file', file);
return unwrap<ChargebackUploadResult>(
api.post(`/api/chargebacks/upload?settleMonth=${settleMonth}`, form, {
headers: { 'Content-Type': 'multipart/form-data' },
}),
);
},
export: (p: ChargebackSearchParam) =>
api.get('/api/chargebacks/export', {
params: p,
responseType: 'blob',
}),
};
@@ -0,0 +1,303 @@
import { useRef, useState } from 'react';
import {
Alert, Button, DatePicker, Space, Table, Tag, Typography, message,
} from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { ColDef } from '@ag-grid-community/core';
import dayjs from 'dayjs';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import DataGrid from '@/components/common/DataGrid';
import PermissionButton from '@/components/common/PermissionButton';
import {
chargebackApi,
ChargebackRow,
ChargebackSearchParam,
} from '@/api/chargeback';
import { GRAY, RADIUS, SHADOW, COLOR_SUCCESS, COLOR_ERROR, COLOR_WARNING, COLOR_PRIMARY } from '@/theme/tokens';
const { Text } = Typography;
const MENU_CODE = 'CHARGEBACK_DATA';
const STATUS_COLOR: Record<string, string> = {
NEW: COLOR_PRIMARY,
PROCESSING: COLOR_WARNING,
SETTLED: COLOR_SUCCESS,
CANCELLED: COLOR_ERROR,
};
const STATUS_LABEL: Record<string, string> = {
NEW: '신규',
PROCESSING: '처리중',
SETTLED: '정산완료',
CANCELLED: '취소',
};
function StatusChip({ status }: { status: string }) {
const color = STATUS_COLOR[status] ?? GRAY[400];
const label = STATUS_LABEL[status] ?? status;
return (
<Tag
style={{
color,
background: `${color}1A`,
border: `1px solid ${color}40`,
borderRadius: RADIUS.sm,
fontWeight: 600,
fontSize: 12,
}}
>
{label}
</Tag>
);
}
const fmt = (v: unknown) =>
typeof v === 'number' ? v.toLocaleString() : (v as string | undefined) ?? '-';
const GRID_COLS: ColDef<ChargebackRow>[] = [
{ field: 'policyNo', headerName: '증권번호', width: 150, pinned: 'left' },
{ field: 'agentName', headerName: '설계사', width: 120 },
{ field: 'contractorName', headerName: '계약자', width: 120 },
{
field: 'cbAmount',
headerName: '환수금액',
flex: 1,
type: 'numericColumn',
valueFormatter: (p) => fmt(p.value),
},
{
field: 'paidAmount',
headerName: '기지급',
flex: 1,
type: 'numericColumn',
valueFormatter: (p) => fmt(p.value),
},
{
field: 'remainAmount',
headerName: '잔여',
flex: 1,
type: 'numericColumn',
valueFormatter: (p) => fmt(p.value),
},
{ field: 'lapseMonth', headerName: '경과월', width: 100 },
{
field: 'status',
headerName: '상태',
width: 120,
cellRenderer: (p: { data: ChargebackRow }) =>
p.data ? <StatusChip status={p.data.status} /> : null,
},
{ field: 'settleMonth', headerName: '정산월', width: 110 },
];
const cardStyle = {
background: '#fff',
borderRadius: RADIUS.lg,
border: `1px solid ${GRAY[100]}`,
boxShadow: SHADOW.sm,
};
export default function ChargebackData() {
const qc = useQueryClient();
// ── 업로드 상태 ──────────────────────────────────
const [uploadMonth, setUploadMonth] = useState<string>('');
const [uploadFile, setUploadFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// ── 목록 검색 파라미터 ───────────────────────────
const [params, setParams] = useState<ChargebackSearchParam>({ pageSize: 500 });
// ── 목록 조회 ──────────────────────────────────
const { data: listData, isLoading } = useQuery({
queryKey: ['chargebacks', params],
queryFn: () => chargebackApi.list(params),
retry: false,
});
// ── 업로드 뮤테이션 ──────────────────────────────
const uploadMutation = useMutation({
mutationFn: ({ month, file }: { month: string; file: File }) =>
chargebackApi.upload(month, file),
onSuccess: (result) => {
const { successCount, skipCount, errorCount } = result;
if (errorCount === 0) {
message.success(`업로드 완료 — 성공 ${successCount}건, 스킵 ${skipCount}`);
} else {
message.warning(`업로드 완료 — 성공 ${successCount}건, 스킵 ${skipCount}건, 오류 ${errorCount}`);
}
qc.invalidateQueries({ queryKey: ['chargebacks'] });
// 파일 입력 초기화
setUploadFile(null);
if (fileInputRef.current) fileInputRef.current.value = '';
},
});
const uploadResult = uploadMutation.data;
const handleUpload = () => {
if (!uploadMonth) {
message.error('정산월을 입력하세요');
return;
}
if (!uploadFile) {
message.error('파일을 선택하세요');
return;
}
uploadMutation.mutate({ month: uploadMonth, file: uploadFile });
};
const handleExport = async () => {
try {
const res = await chargebackApi.export(params);
const url = URL.createObjectURL(new Blob([res.data as BlobPart]));
const a = document.createElement('a');
a.href = url;
a.download = `환수자료_${dayjs().format('YYYYMMDD')}.xlsx`;
a.click();
URL.revokeObjectURL(url);
} catch {
/* request.ts 처리 */
}
};
const rows = listData?.list ?? [];
const pinnedBottom =
rows.length > 0
? ({
policyNo: '합계',
cbAmount: rows.reduce((s, r) => s + (r.cbAmount ?? 0), 0),
remainAmount: rows.reduce((s, r) => s + (r.remainAmount ?? 0), 0),
} as Partial<ChargebackRow>)
: undefined;
return (
<PageContainer
title="환수자료관리"
description="보험사 환수 자료 업로드 및 환수 목록 조회"
extra={
<PermissionButton menuCode={MENU_CODE} permCode="EXPORT" onClick={handleExport}>
</PermissionButton>
}
>
{/* ── 업로드 영역 ────────────────────────────── */}
<ProCard
title="보험사 환수자료 업로드"
style={{ ...cardStyle, marginBottom: 16 }}
bodyStyle={{ padding: '16px 20px' }}
>
<Space wrap size={12} align="end">
<div>
<Text style={{ fontSize: 12, color: GRAY[600], display: 'block', marginBottom: 4 }}>
</Text>
<DatePicker
picker="month"
style={{ width: 150 }}
onChange={(_, val) => setUploadMonth(Array.isArray(val) ? val[0] : val)}
format="YYYYMM"
/>
</div>
<div>
<Text style={{ fontSize: 12, color: GRAY[600], display: 'block', marginBottom: 4 }}>
(xlsx/xls)
</Text>
<Space size={8}>
<Button onClick={() => fileInputRef.current?.click()}>
{uploadFile ? uploadFile.name : '파일 선택'}
</Button>
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls"
style={{ display: 'none' }}
onChange={(e) => {
setUploadFile(e.target.files?.[0] ?? null);
}}
/>
</Space>
</div>
<PermissionButton
menuCode={MENU_CODE}
permCode="CREATE"
type="primary"
loading={uploadMutation.isPending}
onClick={handleUpload}
>
</PermissionButton>
</Space>
{/* ── 업로드 결과 ─────────────────────────── */}
{uploadResult && (
<div style={{ marginTop: 16 }}>
<Space size={24} style={{ marginBottom: 8 }}>
<Text style={{ color: COLOR_SUCCESS, fontWeight: 600 }}>
{uploadResult.successCount}
</Text>
<Text style={{ color: GRAY[500], fontWeight: 600 }}>
{uploadResult.skipCount}
</Text>
{uploadResult.errorCount > 0 && (
<Text style={{ color: COLOR_ERROR, fontWeight: 600 }}>
{uploadResult.errorCount}
</Text>
)}
</Space>
{uploadResult.errors.length > 0 && (
<Alert
type="error"
message={`오류 행 목록 (${uploadResult.errors.length}건)`}
description={
<Table
size="small"
rowKey="rowNo"
dataSource={uploadResult.errors}
pagination={false}
style={{ marginTop: 8 }}
columns={[
{ title: '행 번호', dataIndex: 'rowNo', width: 80, align: 'right' },
{ title: '사유', dataIndex: 'reason' },
]}
/>
}
style={{ marginTop: 4 }}
/>
)}
</div>
)}
</ProCard>
{/* ── 검색 폼 ────────────────────────────────── */}
<SearchForm
conditions={[
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
{ type: 'text', name: 'policyNo', label: '증권번호', span: 6 },
{ type: 'text', name: 'status', label: '상태', span: 6 },
]}
onSearch={(v) => setParams({ ...(v as ChargebackSearchParam), pageSize: 500 })}
onReset={() => setParams({ pageSize: 500 })}
/>
{/* ── AG Grid 목록 ───────────────────────────── */}
<ProCard style={cardStyle}>
<DataGrid<ChargebackRow>
rows={rows}
columns={GRID_COLS}
loading={isLoading}
height={560}
rowKey="cbId"
pinnedBottomRow={pinnedBottom}
/>
</ProCard>
</PageContainer>
);
}