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
@@ -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>