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
@@ -6,6 +6,8 @@ import com.ga.batch.settlement.domain.TaxBreakdown;
import com.ga.common.util.MoneyUtil;
import com.ga.core.mapper.commission.AwardLedgerMapper;
import com.ga.core.mapper.commission.BranchMgrAllowanceLedgerMapper;
import com.ga.core.mapper.commission.ClawbackReceivableMapper;
import com.ga.core.mapper.commission.ClawbackRecoveryMapper;
import com.ga.core.mapper.commission.CollectionCommissionLedgerMapper;
import com.ga.core.mapper.commission.LifeSupportLedgerMapper;
import com.ga.core.mapper.commission.PersistencyBonusMapper;
@@ -22,6 +24,9 @@ import com.ga.core.mapper.settle.ChargebackMapper;
import com.ga.core.mapper.settle.CommissionStatementMapper;
import com.ga.core.mapper.settle.OverrideSettleMapper;
import com.ga.core.mapper.settle.SettleMasterMapper;
import com.ga.core.vo.commission.ClawbackReceivableResp;
import com.ga.core.vo.commission.ClawbackReceivableVO;
import com.ga.core.vo.commission.ClawbackRecoveryVO;
import com.ga.core.vo.org.AgentResp;
import com.ga.core.vo.org.AgentSearchParam;
import com.ga.core.vo.org.AgentVO;
@@ -58,6 +63,7 @@ import java.util.Set;
* (사업소득 3.3% / 기타소득 8.8% + 지방소득세 10% + 부가세 10%)
* - AgentDeductionMapper: PENDING 차감 합산 → netAmount에서 차감 후 APPLIED 상태로 전이
* - CommissionStatementMapper: 설계사별 명세서 레코드(ISSUED 상태) INSERT
* - ClawbackReceivableMapper/ClawbackRecoveryMapper: 환수채권 이월/상계 (V111)
*/
@Slf4j
@Component
@@ -76,6 +82,8 @@ public class AggregateStep implements Tasklet {
private final AgentDeductionMapper deductionMapper;
private final CommissionStatementMapper statementMapper;
private final BatchTaxCalculator taxCalculator;
private final ClawbackReceivableMapper receivableMapper;
private final ClawbackRecoveryMapper recoveryMapper;
// 설계사 지급성 9종 원장 Mapper
private final CollectionCommissionLedgerMapper collectionMapper;
@@ -154,6 +162,12 @@ public class AggregateStep implements Tasklet {
List<CommissionStatementVO> statements = new ArrayList<>();
int count = 0;
// 멱등 reverse (루프 진입 전 1회):
// 이 settleMonth의 기존 clawback_recovery 이력을 이용해 각 receivable의
// balance를 복원한 뒤 회수 이력을 전량 삭제한다.
// 동일 월 재실행 시 중복 회수가 발생하지 않도록 보장한다.
reverseClawbackRecovery(ctx.getSettleMonth());
for (Long agentId : agentIds) {
BigDecimal recruit = recruitMap.getOrDefault(agentId, BigDecimal.ZERO);
BigDecimal maintain = maintainMap.getOrDefault(agentId, BigDecimal.ZERO);
@@ -194,10 +208,27 @@ public class AggregateStep implements Tasklet {
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal netAmount = tax.netAmount().subtract(deductionTotal);
if (netAmount.signum() < 0) {
log.warn("[Step8] net 음수(공제>지급): agentId={}, gross={}, deduction={}, net={} — 차월 이월 검토 필요",
agentId, gross, deductionTotal, netAmount);
// 환수채권 이월/상계 계산 (V111)
// outstandingBalance: origin_settle_month < settleMonth 인 미결 채권 잔액 합계
// (당월 발생분은 자기상계 방지를 위해 포함하지 않음)
BigDecimal priorBalance = receivableMapper.outstandingBalance(agentId, ctx.getSettleMonth());
if (priorBalance == null) priorBalance = BigDecimal.ZERO;
BigDecimal recovered;
BigDecimal carryover;
BigDecimal payable;
if (netAmount.signum() >= 0) {
recovered = netAmount.min(priorBalance); // 기존 채권 FIFO 상계 (net 한도 내)
carryover = BigDecimal.ZERO;
payable = netAmount.subtract(recovered);
} else {
recovered = BigDecimal.ZERO;
carryover = netAmount.negate(); // 음수분 신규 채권화
payable = BigDecimal.ZERO;
}
log.info("[Step8 clawback] agentId={}, net={}, priorBalance={}, recovered={}, carryover={}, payable={}",
agentId, netAmount, priorBalance, recovered, carryover, payable);
// settle_master UPSERT
SettleMasterVO vo = new SettleMasterVO();
@@ -211,12 +242,32 @@ public class AggregateStep implements Tasklet {
vo.setChargebackTotal(cb);
vo.setOtherCommissionTotal(otherCommission);
vo.setGrossAmount(gross);
vo.setTaxAmount(totalTax); // incomeTax + localTax 합산
vo.setNetAmount(netAmount); // gross - incomeTax - localTax + vat - deduction
vo.setTaxAmount(totalTax); // incomeTax + localTax 합산
vo.setNetAmount(netAmount); // gross - incomeTax - localTax + vat - deduction (불변)
vo.setClawbackRecovered(recovered);
vo.setClawbackCarryover(carryover);
vo.setPayableAmount(payable);
vo.setStatus("CALCULATED");
settleMapper.upsert(vo);
count++;
// 채권 부수효과: carryover 이월 등록 (UNIQUE upsert — 멱등)
if (carryover.signum() > 0) {
ClawbackReceivableVO rcv = new ClawbackReceivableVO();
rcv.setAgentId(agentId);
rcv.setOriginSettleMonth(ctx.getSettleMonth());
rcv.setOriginalAmount(carryover);
rcv.setBalance(carryover);
rcv.setRecoveredAmount(BigDecimal.ZERO);
rcv.setStatus("OUTSTANDING");
receivableMapper.upsertCarryover(rcv);
}
// 채권 부수효과: FIFO 상계 회수 적용
if (recovered.signum() > 0) {
applyRecovery(agentId, ctx.getSettleMonth(), recovered);
}
// 차감 적용 완료 처리
if (!agentDeductions.isEmpty()) {
agentDeductions.forEach(d -> appliedDeductionIds.add(d.getDeductionId()));
@@ -261,6 +312,95 @@ public class AggregateStep implements Tasklet {
return RepeatStatus.FINISHED;
}
/**
* 멱등 reverse: settleMonth의 기존 clawback_recovery 이력을 조회하여
* 각 receivable의 balance/recoveredAmount/status를 복원한 뒤 회수 이력을 전량 삭제한다.
*
* 알고리즘:
* 1. selectByMonth(settleMonth)로 이번 달 회수 이력 전체 조회
* 2. receivable별로 이번 달 회수액 합산
* 3. 각 receivable에 대해 selectById로 현재 상태 조회
* 4. balance += thisMonthSum, recoveredAmount -= thisMonthSum 후 status 재계산
* 5. deleteByMonth(settleMonth)로 이력 전량 삭제
*
* 재실행 안전: 1회차에 이력이 없으면 즉시 반환. 2회차에 이력이 있으면 정확히 복원.
*/
private void reverseClawbackRecovery(String settleMonth) {
List<ClawbackRecoveryVO> prev = recoveryMapper.selectByMonth(settleMonth);
if (prev.isEmpty()) {
return;
}
// receivable별로 이번 달 회수액 합산 (동일 receivable 다건 방어)
Map<Long, BigDecimal> sumByReceivable = new HashMap<>();
for (ClawbackRecoveryVO r : prev) {
sumByReceivable.merge(r.getReceivableId(), r.getRecoveredAmount(), BigDecimal::add);
}
// 각 receivable의 현재 상태를 읽어 balance/recoveredAmount 복원
for (Map.Entry<Long, BigDecimal> e : sumByReceivable.entrySet()) {
Long receivableId = e.getKey();
BigDecimal toRestore = e.getValue();
ClawbackReceivableResp current = receivableMapper.selectById(receivableId);
if (current == null) {
log.warn("[Step8 reverse] receivable not found: receivableId={}", receivableId);
continue;
}
BigDecimal newRecovered = current.getRecoveredAmount().subtract(toRestore);
BigDecimal newBalance = current.getBalance().add(toRestore);
// status 재계산: recovered가 0이면 OUTSTANDING, 0보다 크면 RECOVERING
String newStatus;
if (newRecovered.signum() == 0) {
newStatus = "OUTSTANDING";
} else {
newStatus = "RECOVERING";
}
receivableMapper.updateRecovery(receivableId, newRecovered, newBalance, newStatus, null);
}
recoveryMapper.deleteByMonth(settleMonth);
log.info("[Step8 reverse] clawback recovery reversed for settleMonth={}, receivables={}",
settleMonth, sumByReceivable.size());
}
/**
* FIFO 상계 회수 적용: 오래된 origin_settle_month 순으로 채권을 소진하며
* recoveredTotal 만큼 clawback_receivable balance를 차감하고
* clawback_recovery 이력을 INSERT한다.
*
* recovered는 priorBalance(=selectOutstandingByAgentFifo 대상 채권 합계)를
* 초과하지 않으므로 remaining은 루프 내에서 정확히 소진된다.
*/
private void applyRecovery(Long agentId, String settleMonth, BigDecimal recoveredTotal) {
List<ClawbackReceivableVO> fifoList =
receivableMapper.selectOutstandingByAgentFifo(agentId, settleMonth);
BigDecimal remaining = recoveredTotal;
for (ClawbackReceivableVO rcv : fifoList) {
if (remaining.signum() == 0) break;
BigDecimal take = remaining.min(rcv.getBalance());
BigDecimal newRecovered = rcv.getRecoveredAmount().add(take);
BigDecimal newBalance = rcv.getBalance().subtract(take);
String newStatus = newBalance.signum() == 0 ? "CLEARED" : "RECOVERING";
receivableMapper.updateRecovery(
rcv.getReceivableId(), newRecovered, newBalance, newStatus, settleMonth);
ClawbackRecoveryVO recovery = new ClawbackRecoveryVO();
recovery.setReceivableId(rcv.getReceivableId());
recovery.setSettleMonth(settleMonth);
recovery.setRecoveredAmount(take);
recoveryMapper.insertOne(recovery);
remaining = remaining.subtract(take);
}
}
private TaxType parseTaxType(AgentVO agent) {
if (agent == null || agent.getTaxType() == null) return TaxType.BUSINESS_INCOME;
try {