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