feat: P9 환수채권 이월/미수금 관리 — 수수료 갭 풀스택 (PL 자율진행)

incar CMS 메뉴(환수이월관리/환수계약리스트/위촉중환수) ↔ GA 부재 + 코드 갭
(AggregateStep net<0 시 경고만, 미회수 환수 채권화·차월상계·대손 생애주기 전무)
을 도출해 풀스택 구현. 미회수 환수가 소실되던 재무 누락을 폐쇄.

- DB(V111): clawback_receivable(채권 마스터, UNIQUE agent+origin월) +
  clawback_recovery(상계 회수 이력) + settle_master 3컬럼(clawback_recovered/
  clawback_carryover/payable_amount) + 메뉴 CLAWBACK_RECEIVABLE + 권한 + 공통코드.
- core: VO/Resp/SaveReq/SearchParam/Enum/Mapper(.java/.xml) 2테이블 + MapStruct.
  outstandingBalance(origin<settleMonth 자기상계방지) / FIFO / upsertCarryover(멱등).
- api: ClawbackReceivableService/Controller(/api/clawback-receivables) — list/detail/
  summary/write-off(OUTSTANDING·RECOVERING만, CLEARED/WRITTEN_OFF 전이)/export(@Transactional).
- batch: AggregateStep 통합 — net<0 → carryover 채권화, net>0 → FIFO 상계회수,
  reverse-먼저 방식으로 동일월 재실행 멱등. net_amount 의미 불변(무결성 보존).
  payable_amount = max(0,net) − recovered.
- fix(잠복): SettleMasterMapper.xml upsert에 신규 3컬럼 누락 → INSERT/ON CONFLICT 추가
  (배치가 VO에 set해도 DEFAULT 0으로 미영속되던 버그, PL 검증서 발견).
- frontend: ClawbackReceivable 화면(AG Grid 합계행 + 회수이력 타임라인 Drawer +
  대손/상환 모달) + api 모듈 + App 라우트.

검증(PL 직접): ./gradlew build(test포함) SUCCESSFUL. Flyway V109~V111 운영DB 적용
(schema v111). 라이브 API: GET 목록/summary 200, write-off WRITTEN_OFF 200(한글사유
영속), 종결 재처리·잘못된 전이상태 E411 차단. 배치 이월/상계/멱등/부분상계 4시나리오
실 스키마 롤백 트랜잭션 검증 PASS(CHECK balance>=0 포함). 잔여 데이터 0.

스펙: docs/DOMAIN_GAP_P9_환수채권이월.md. 후속 갭(시상/인정실적 환수 ledger,
지급대상건 구성, 환수 보험사자료 업로드) 문서화.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-06-05 00:18:58 +09:00
parent b12858c991
commit 51ee03453c
23 changed files with 1700 additions and 6 deletions
@@ -0,0 +1,80 @@
package com.ga.core.mapper.commission;
import com.ga.core.vo.commission.ClawbackReceivableResp;
import com.ga.core.vo.commission.ClawbackReceivableSearchParam;
import com.ga.core.vo.commission.ClawbackReceivableVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
/**
* clawback_receivable Mapper — 환수채권 마스터 (V111)
*/
@Mapper
public interface ClawbackReceivableMapper {
/** 단건 조회 — 설계사명 조인 + 회수이력(collection) 포함 */
ClawbackReceivableResp selectById(@Param("receivableId") Long receivableId);
/** 목록 조회 — PageHelper 호환, 설계사명 조인 포함 */
List<ClawbackReceivableResp> selectList(ClawbackReceivableSearchParam param);
/** 설계사 + 발생월 단건 조회 (배치 결과 확인용) */
ClawbackReceivableVO selectByAgentMonth(@Param("agentId") Long agentId,
@Param("originSettleMonth") String originSettleMonth);
/**
* 배치 priorBalance 조회.
* SUM(balance) WHERE agent_id=? AND status IN('OUTSTANDING','RECOVERING')
* AND origin_settle_month < #{settleMonth}
* 당월 발생분 자기상계 방지용 — null 이면 0 반환.
*/
BigDecimal outstandingBalance(@Param("agentId") Long agentId,
@Param("settleMonth") String settleMonth);
/**
* FIFO 회수 대상 채권 목록.
* status IN('OUTSTANDING','RECOVERING') AND origin_settle_month < settleMonth
* ORDER BY origin_settle_month ASC, receivable_id ASC
*/
List<ClawbackReceivableVO> selectOutstandingByAgentFifo(@Param("agentId") Long agentId,
@Param("settleMonth") String settleMonth);
/**
* 배치 멱등 upsert.
* INSERT ... ON CONFLICT(agent_id, origin_settle_month) DO UPDATE
* SET original_amount=EXCLUDED, balance=EXCLUDED, recovered_amount=0,
* status='OUTSTANDING', updated_at=now()
*/
int upsertCarryover(ClawbackReceivableVO vo);
/**
* 회수 반영 업데이트.
* recovered_amount, balance, status, last_recovery_month, updated_at 갱신.
*/
int updateRecovery(@Param("receivableId") Long receivableId,
@Param("recoveredAmount") BigDecimal recoveredAmount,
@Param("balance") BigDecimal balance,
@Param("status") String status,
@Param("lastRecoveryMonth") String lastRecoveryMonth);
/**
* 대손/직접상환 처리.
* balance→0, status, writeoff_reason, written_off_at, written_off_by, updated_at 갱신.
*/
int writeOff(@Param("id") Long id,
@Param("status") String status,
@Param("reason") String reason,
@Param("userId") Long userId);
/**
* 설계사별 미결 잔액 집계 (summary API용).
* status IN('OUTSTANDING','RECOVERING') 대상.
*/
List<Map<String, Object>> summaryByAgent(ClawbackReceivableSearchParam param);
int countByCondition(ClawbackReceivableSearchParam param);
}
@@ -0,0 +1,44 @@
package com.ga.core.mapper.commission;
import com.ga.core.vo.commission.ClawbackRecoveryVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* clawback_recovery Mapper — 상계 회수 이력 (V111)
* 회수 생성/역전은 배치 전용, API 직접 호출 없음.
*/
@Mapper
public interface ClawbackRecoveryMapper {
/** 회수 이력 단건 INSERT */
int insertOne(ClawbackRecoveryVO vo);
/** 채권 ID 기준 전체 회수 이력 조회 (상세 Resp 조립용) */
List<ClawbackRecoveryVO> selectByReceivableId(@Param("receivableId") Long receivableId);
/**
* 채권 + 정산월 기준 회수 이력 조회 (역전 시 balance 복원 금액 확인용).
*/
List<ClawbackRecoveryVO> selectByReceivableMonth(@Param("receivableId") Long receivableId,
@Param("settleMonth") String settleMonth);
/**
* 특정 정산월의 전체 회수 이력 조회.
* 배치 reverse 단계에서 해당 월 회수 건을 일괄 조회 후 balance 복원에 사용.
*/
List<ClawbackRecoveryVO> selectByMonth(@Param("settleMonth") String settleMonth);
/**
* 채권 + 정산월 기준 회수 이력 삭제 — 배치 역전(멱등 재실행) 지원.
*/
int deleteByReceivableMonth(@Param("receivableId") Long receivableId,
@Param("settleMonth") String settleMonth);
/**
* 정산월 단위 전체 회수 이력 삭제 — 월 전체 역전 시 사용.
*/
int deleteByMonth(@Param("settleMonth") String settleMonth);
}
@@ -0,0 +1,43 @@
package com.ga.core.mapstruct;
import com.ga.core.vo.commission.ClawbackReceivableSaveReq;
import com.ga.core.vo.commission.ClawbackReceivableVO;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
/**
* ClawbackReceivable SaveReq → VO 변환.
* write-off API: Service 에서 req→VO 후 writeOff Mapper 호출.
*/
@Mapper(componentModel = "spring")
public interface ClawbackReceivableMapStruct {
@Mapping(target = "receivableId", ignore = true)
@Mapping(target = "agentId", ignore = true)
@Mapping(target = "originSettleMonth", ignore = true)
@Mapping(target = "originalAmount", ignore = true)
@Mapping(target = "recoveredAmount", ignore = true)
@Mapping(target = "balance", ignore = true)
@Mapping(target = "lastRecoveryMonth", ignore = true)
@Mapping(target = "writtenOffAt", ignore = true)
@Mapping(target = "writtenOffBy", ignore = true)
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "createdBy", ignore = true)
@Mapping(target = "updatedAt", ignore = true)
ClawbackReceivableVO toVO(ClawbackReceivableSaveReq req);
@Mapping(target = "receivableId", ignore = true)
@Mapping(target = "agentId", ignore = true)
@Mapping(target = "originSettleMonth", ignore = true)
@Mapping(target = "originalAmount", ignore = true)
@Mapping(target = "recoveredAmount", ignore = true)
@Mapping(target = "balance", ignore = true)
@Mapping(target = "lastRecoveryMonth", ignore = true)
@Mapping(target = "writtenOffAt", ignore = true)
@Mapping(target = "writtenOffBy", ignore = true)
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "createdBy", ignore = true)
@Mapping(target = "updatedAt", ignore = true)
void updateVO(ClawbackReceivableSaveReq req, @MappingTarget ClawbackReceivableVO vo);
}
@@ -0,0 +1,35 @@
package com.ga.core.vo.commission;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* clawback_receivable 조회 응답 — 설계사명 조인 + 회수이력 포함.
* 상세 조회(selectById)에서 recoveries 가 채워진다.
*/
@Data
public class ClawbackReceivableResp {
private Long receivableId;
private Long agentId;
/** agent.agent_name */
private String agentName;
private String originSettleMonth;
private BigDecimal originalAmount;
private BigDecimal recoveredAmount;
private BigDecimal balance;
/** OUTSTANDING / RECOVERING / CLEARED / WRITTEN_OFF */
private String status;
/** 공통코드 CLAWBACK_RCV_STATUS */
private String statusName;
private String lastRecoveryMonth;
private String writeoffReason;
private LocalDateTime writtenOffAt;
private Long writtenOffBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/** 회수 이력 (상세 조회 시 populate) */
private List<ClawbackRecoveryResp> recoveries;
}
@@ -0,0 +1,25 @@
package com.ga.core.vo.commission;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* clawback_receivable 대손/직접상환 처리 요청 (write-off 전용).
* 수동 회수 엔드포인트 없음 — 회수는 배치 상계만.
*/
@Data
public class ClawbackReceivableSaveReq {
/**
* 전이할 상태: CLEARED(직접상환) 또는 WRITTEN_OFF(대손).
* OUTSTANDING/RECOVERING 인 채권에만 적용 가능.
*/
@NotBlank
private String status;
/** 대손/직접상환 사유 */
@NotNull
@NotBlank
private String writeoffReason;
}
@@ -0,0 +1,18 @@
package com.ga.core.vo.commission;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* clawback_receivable 검색 조건.
* status 는 부모 SearchParam 에 이미 있음.
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class ClawbackReceivableSearchParam extends SearchParam {
private Long agentId;
private String agentName;
/** 발생 정산월 (YYYYMM) */
private String originSettleMonth;
}
@@ -0,0 +1,29 @@
package com.ga.core.vo.commission;
import com.fasterxml.jackson.annotation.JsonValue;
/**
* 환수채권 상태 코드 (CLAWBACK_RCV_STATUS 그룹, V111)
* clawback_receivable.status 에서 사용.
*/
public enum ClawbackReceivableStatus {
OUTSTANDING("미회수"),
RECOVERING("회수중"),
CLEARED("상환완료"),
WRITTEN_OFF("대손");
private final String label;
ClawbackReceivableStatus(String label) {
this.label = label;
}
@JsonValue
public String getCode() {
return this.name();
}
public String getLabel() {
return label;
}
}
@@ -0,0 +1,35 @@
package com.ga.core.vo.commission;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* clawback_receivable — 환수채권 마스터 (V111)
* balance = original_amount - recovered_amount
*/
@Data
public class ClawbackReceivableVO {
private Long receivableId;
private Long agentId;
/** 미수 발생 정산월 (YYYYMM) */
private String originSettleMonth;
/** 발생액 — 양수, 당월 |net| */
private BigDecimal originalAmount;
/** 누적 상계 회수액 */
private BigDecimal recoveredAmount;
/** 잔액 = original_amount - recovered_amount */
private BigDecimal balance;
/** OUTSTANDING / RECOVERING / CLEARED / WRITTEN_OFF */
private String status;
/** 최근 회수 정산월 (YYYYMM) */
private String lastRecoveryMonth;
/** 대손/직접상환 사유 */
private String writeoffReason;
private LocalDateTime writtenOffAt;
private Long writtenOffBy;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,20 @@
package com.ga.core.vo.commission;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* clawback_recovery 조회 응답.
*/
@Data
public class ClawbackRecoveryResp {
private Long recoveryId;
private Long receivableId;
/** 회수(상계) 정산월 (YYYYMM) */
private String settleMonth;
/** 이 월 이 채권에서 회수한 액 */
private BigDecimal recoveredAmount;
private LocalDateTime createdAt;
}
@@ -0,0 +1,21 @@
package com.ga.core.vo.commission;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* clawback_recovery — 상계 회수 이력 (V111)
* 배치 FIFO 상계 시 INSERT, 재실행(reverse) 시 DELETE.
*/
@Data
public class ClawbackRecoveryVO {
private Long recoveryId;
private Long receivableId;
/** 회수(상계) 정산월 (YYYYMM) */
private String settleMonth;
/** 이 월 이 채권에서 회수한 액 */
private BigDecimal recoveredAmount;
private LocalDateTime createdAt;
}
@@ -26,4 +26,8 @@ public class SettleMasterVO {
private LocalDateTime confirmedAt;
private String holdReason;
private String holdReasonCode; // V32 추가: HOLD_REASON 코드 (NEED_DOC/UNDER_REVIEW/DISPUTE_OPEN 등)
// V111 추가: 환수채권 이월/미수금 관리
private BigDecimal clawbackRecovered; // 당월 채권 상계 회수액
private BigDecimal clawbackCarryover; // 당월 발생→이월된 미수액
private BigDecimal payableAmount; // 실지급 = max(0,net) - recovered
}
@@ -0,0 +1,197 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ga.core.mapper.commission.ClawbackReceivableMapper">
<!-- VO: INSERT/UPDATE/배치 용도 -->
<resultMap id="VOMap" type="com.ga.core.vo.commission.ClawbackReceivableVO"/>
<!-- Resp: 목록 조회 용도 (조인 포함, recoveries 없음) -->
<resultMap id="RespMap" type="com.ga.core.vo.commission.ClawbackReceivableResp"/>
<!-- 상세 Resp: recoveries collection 포함 -->
<resultMap id="RespDetailMap" type="com.ga.core.vo.commission.ClawbackReceivableResp">
<id property="receivableId" column="receivable_id"/>
<result property="agentId" column="agent_id"/>
<result property="agentName" column="agent_name"/>
<result property="originSettleMonth" column="origin_settle_month"/>
<result property="originalAmount" column="original_amount"/>
<result property="recoveredAmount" column="recovered_amount"/>
<result property="balance" column="balance"/>
<result property="status" column="status"/>
<result property="statusName" column="status_name"/>
<result property="lastRecoveryMonth" column="last_recovery_month"/>
<result property="writeoffReason" column="writeoff_reason"/>
<result property="writtenOffAt" column="written_off_at"/>
<result property="writtenOffBy" column="written_off_by"/>
<result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/>
<collection property="recoveries"
ofType="com.ga.core.vo.commission.ClawbackRecoveryResp"
select="com.ga.core.mapper.commission.ClawbackRecoveryMapper.selectByReceivableId"
column="receivable_id"/>
</resultMap>
<!-- 목록/단건 조회 컬럼 — 설계사명 조인 + 공통코드명 포함 -->
<sql id="rcvJoinCols">
r.receivable_id, r.agent_id, a.agent_name,
r.origin_settle_month,
r.original_amount, r.recovered_amount, r.balance,
r.status,
(SELECT cc.code_name FROM common_code cc
WHERE cc.group_code = 'CLAWBACK_RCV_STATUS' AND cc.code = r.status) AS status_name,
r.last_recovery_month,
r.writeoff_reason, r.written_off_at, r.written_off_by,
r.created_at, r.updated_at
</sql>
<sql id="rcvJoins">
FROM clawback_receivable r
LEFT JOIN agent a ON a.agent_id = r.agent_id
</sql>
<!-- 단건 조회 — 회수이력은 RespDetailMap collection 으로 자동 populate -->
<select id="selectById" resultMap="RespDetailMap">
SELECT <include refid="rcvJoinCols"/>
<include refid="rcvJoins"/>
WHERE r.receivable_id = #{receivableId}
</select>
<!-- 목록 조회 — PageHelper startPage() 호환 -->
<select id="selectList" resultMap="RespMap">
SELECT <include refid="rcvJoinCols"/>
<include refid="rcvJoins"/>
<where>
<if test="agentId != null">AND r.agent_id = #{agentId}</if>
<if test="agentName != null and agentName != ''">AND a.agent_name LIKE '%' || #{agentName} || '%'</if>
<if test="status != null and status != ''">AND r.status = #{status}</if>
<if test="originSettleMonth != null and originSettleMonth != ''">AND r.origin_settle_month = #{originSettleMonth}</if>
</where>
ORDER BY r.origin_settle_month DESC, r.agent_id, r.receivable_id DESC
</select>
<!-- 설계사 + 발생월 단건 (배치 확인·reverse 용도) -->
<select id="selectByAgentMonth" resultMap="VOMap">
SELECT receivable_id, agent_id, origin_settle_month,
original_amount, recovered_amount, balance,
status, last_recovery_month,
writeoff_reason, written_off_at, written_off_by,
created_at, created_by, updated_at
FROM clawback_receivable
WHERE agent_id = #{agentId}
AND origin_settle_month = #{originSettleMonth}
</select>
<!--
배치 priorBalance: 당월(settleMonth) 이전에 발생한 미결 잔액 합계.
origin_settle_month < settleMonth 로 당월 발생분 자기상계 방지.
COALESCE 로 null → 0 변환.
-->
<select id="outstandingBalance" resultType="java.math.BigDecimal">
SELECT COALESCE(SUM(balance), 0)
FROM clawback_receivable
WHERE agent_id = #{agentId}
AND status IN ('OUTSTANDING', 'RECOVERING')
AND origin_settle_month &lt; #{settleMonth}
</select>
<!--
FIFO 회수 대상 목록: 오래된 발생월 우선(ASC), 동월 내 receivable_id ASC.
배치 applyCarryoverAndRecovery 단계 3에서 순회하며 상계.
-->
<select id="selectOutstandingByAgentFifo" resultMap="VOMap">
SELECT receivable_id, agent_id, origin_settle_month,
original_amount, recovered_amount, balance,
status, last_recovery_month,
writeoff_reason, written_off_at, written_off_by,
created_at, created_by, updated_at
FROM clawback_receivable
WHERE agent_id = #{agentId}
AND status IN ('OUTSTANDING', 'RECOVERING')
AND origin_settle_month &lt; #{settleMonth}
ORDER BY origin_settle_month ASC, receivable_id ASC
</select>
<!--
배치 멱등 upsert.
UNIQUE(agent_id, origin_settle_month) 충돌 시 발생액·잔액을 덮어쓰고
회수액=0, 상태=OUTSTANDING 으로 초기화.
재실행 시 동일 채권이 중복 생성되지 않음.
-->
<insert id="upsertCarryover">
INSERT INTO clawback_receivable
(agent_id, origin_settle_month, original_amount, recovered_amount, balance,
status, created_by, created_at)
VALUES
(#{agentId}, #{originSettleMonth}, #{originalAmount}, 0, #{balance},
'OUTSTANDING', #{createdBy}, NOW())
ON CONFLICT (agent_id, origin_settle_month) DO UPDATE
SET original_amount = EXCLUDED.original_amount,
balance = EXCLUDED.balance,
recovered_amount = 0,
status = 'OUTSTANDING',
updated_at = NOW()
</insert>
<!--
배치 회수 반영: recovered_amount, balance, status, last_recovery_month 갱신.
status 는 배치가 계산 후 전달 (RECOVERING 또는 CLEARED).
-->
<update id="updateRecovery">
UPDATE clawback_receivable
SET recovered_amount = #{recoveredAmount},
balance = #{balance},
status = #{status},
last_recovery_month = #{lastRecoveryMonth},
updated_at = NOW()
WHERE receivable_id = #{receivableId}
</update>
<!--
대손/직접상환 write-off: balance→0, 상태 전이, 사유·일시·처리자 기록.
OUTSTANDING/RECOVERING 인 경우만 Service 레이어에서 검증 후 호출.
-->
<update id="writeOff">
UPDATE clawback_receivable
SET balance = 0,
status = #{status},
writeoff_reason = #{reason},
written_off_at = NOW(),
written_off_by = #{userId},
updated_at = NOW()
WHERE receivable_id = #{id}
</update>
<!-- 설계사별 미결 잔액 집계 (summary API) — 미결 상태만 -->
<select id="summaryByAgent" resultType="map">
SELECT r.agent_id AS "agentId",
a.agent_name AS "agentName",
COUNT(*) AS "count",
SUM(r.original_amount) AS "originalTotal",
SUM(r.recovered_amount) AS "recoveredTotal",
SUM(r.balance) AS "balanceTotal"
FROM clawback_receivable r
LEFT JOIN agent a ON a.agent_id = r.agent_id
WHERE r.status IN ('OUTSTANDING', 'RECOVERING')
<if test="agentId != null">AND r.agent_id = #{agentId}</if>
<if test="originSettleMonth != null and originSettleMonth != ''">
AND r.origin_settle_month = #{originSettleMonth}
</if>
GROUP BY r.agent_id, a.agent_name
ORDER BY SUM(r.balance) DESC
</select>
<!-- 페이징 count 쿼리 -->
<select id="countByCondition" resultType="int">
SELECT COUNT(*)
FROM clawback_receivable r
LEFT JOIN agent a ON a.agent_id = r.agent_id
<where>
<if test="agentId != null">AND r.agent_id = #{agentId}</if>
<if test="agentName != null and agentName != ''">AND a.agent_name LIKE '%' || #{agentName} || '%'</if>
<if test="status != null and status != ''">AND r.status = #{status}</if>
<if test="originSettleMonth != null and originSettleMonth != ''">AND r.origin_settle_month = #{originSettleMonth}</if>
</where>
</select>
</mapper>
@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ga.core.mapper.commission.ClawbackRecoveryMapper">
<resultMap id="VOMap" type="com.ga.core.vo.commission.ClawbackRecoveryVO"/>
<!-- Resp resultMap — ClawbackRecoveryResp 는 VO와 동일 컬럼 구성 -->
<resultMap id="RespMap" type="com.ga.core.vo.commission.ClawbackRecoveryResp"/>
<!-- 회수 이력 단건 INSERT — useGeneratedKeys 로 recoveryId 반환 -->
<insert id="insertOne" useGeneratedKeys="true" keyProperty="recoveryId">
INSERT INTO clawback_recovery
(receivable_id, settle_month, recovered_amount, created_at)
VALUES
(#{receivableId}, #{settleMonth}, #{recoveredAmount}, NOW())
</insert>
<!-- 채권 ID 기준 전체 회수 이력 조회 — 상세 Resp collection 용도 -->
<select id="selectByReceivableId" resultMap="RespMap">
SELECT recovery_id, receivable_id, settle_month, recovered_amount, created_at
FROM clawback_recovery
WHERE receivable_id = #{receivableId}
ORDER BY settle_month ASC, recovery_id ASC
</select>
<!--
채권 + 정산월 기준 회수 이력 조회.
배치 reverse 단계에서 복원할 금액 합산 시 사용.
-->
<select id="selectByReceivableMonth" resultMap="VOMap">
SELECT recovery_id, receivable_id, settle_month, recovered_amount, created_at
FROM clawback_recovery
WHERE receivable_id = #{receivableId}
AND settle_month = #{settleMonth}
ORDER BY recovery_id ASC
</select>
<!--
특정 정산월 전체 회수 이력 조회.
배치 applyCarryoverAndRecovery reverse 단계 1:
해당 월 회수 건 전체를 조회 → receivable.balance 복원 → 이 메서드로 조회한 결과를 순회.
-->
<select id="selectByMonth" resultMap="VOMap">
SELECT recovery_id, receivable_id, settle_month, recovered_amount, created_at
FROM clawback_recovery
WHERE settle_month = #{settleMonth}
ORDER BY receivable_id ASC, recovery_id ASC
</select>
<!--
채권 + 정산월 기준 회수 이력 삭제.
배치 단일 채권 역전 시 사용.
-->
<delete id="deleteByReceivableMonth">
DELETE FROM clawback_recovery
WHERE receivable_id = #{receivableId}
AND settle_month = #{settleMonth}
</delete>
<!--
정산월 단위 전체 회수 이력 삭제.
배치 월 전체 재정산(reverse) 시 사용. deleteByMonth 후 채권 balance 복원 필요.
-->
<delete id="deleteByMonth">
DELETE FROM clawback_recovery
WHERE settle_month = #{settleMonth}
</delete>
</mapper>
@@ -72,13 +72,16 @@
INSERT INTO settle_master (agent_id, settle_month, recruit_total, maintain_total,
exception_plus, exception_minus, override_total, chargeback_total,
other_commission_total,
gross_amount, tax_amount, net_amount, status, calc_date)
gross_amount, tax_amount, net_amount,
clawback_recovered, clawback_carryover, payable_amount,
status, calc_date)
VALUES (#{agentId}, #{settleMonth},
COALESCE(#{recruitTotal}, 0), COALESCE(#{maintainTotal}, 0),
COALESCE(#{exceptionPlus}, 0), COALESCE(#{exceptionMinus}, 0),
COALESCE(#{overrideTotal}, 0), COALESCE(#{chargebackTotal}, 0),
COALESCE(#{otherCommissionTotal}, 0),
COALESCE(#{grossAmount}, 0), COALESCE(#{taxAmount}, 0), COALESCE(#{netAmount}, 0),
COALESCE(#{clawbackRecovered}, 0), COALESCE(#{clawbackCarryover}, 0), COALESCE(#{payableAmount}, 0),
COALESCE(#{status},'CALCULATED'), NOW())
ON CONFLICT (agent_id, settle_month) DO UPDATE SET
recruit_total = EXCLUDED.recruit_total,
@@ -91,6 +94,9 @@
gross_amount = EXCLUDED.gross_amount,
tax_amount = EXCLUDED.tax_amount,
net_amount = EXCLUDED.net_amount,
clawback_recovered = EXCLUDED.clawback_recovered,
clawback_carryover = EXCLUDED.clawback_carryover,
payable_amount = EXCLUDED.payable_amount,
status = EXCLUDED.status,
calc_date = NOW()
WHERE settle_master.status NOT IN ('CONFIRMED','PAID')