feat: 계약 승계(contract succession) — 유지수수료 귀속 후임 전환 (V86)

설계사 해촉/이관 시 보험계약의 유지수수료 귀속을 승계월 기준 후임 설계사로 전환.
모집수수료는 원 모집설계사 귀속 유지(고아계약 승계 표준).

- DB V86: contract_succession 테이블 + 인덱스 + 메뉴/권한(GRP_PRD) + 공통코드 2그룹 + 테스트데이터 1건
- core: ContractSuccession VO/Resp/SaveReq/SearchParam + Mapper(.java/.xml)
  selectEffectiveSuccessions(settleMonth): 계약별 최근 ACTIVE 승계 1건 (DISTINCT ON)
- api: ContractSuccessionService/Controller (/api/contract-successions, CRUD+cancel)
- batch: CalcMaintainStep 이 effective 승계 사전로드(N+1 회피) 후 effectiveAgentId 결정,
  CommissionCalculator.calcMaintainLedger 오버로드로 후임 등급 payout + ledger agent_id 후임 기록.
  AggregateStep 은 ledger agent_id 집계라 무변경.
- frontend: api/succession.ts + ContractSuccessions.tsx + App.tsx 라우트

검증: 5모듈 compileJava SUCCESS / ga-frontend tsc 0 / Flyway v86 적용 /
  API CRUD+cancel+from==to 가드(E411) 라이브 PASS / resolver 쿼리 라이브 검증(계약1→후임 agent2).
배치 풀잡 e2e 는 정산월 원장 재생성(파괴적)이라 미실행.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-05-29 21:28:00 +09:00
parent d8d64625a9
commit bd5ff4ec14
14 changed files with 660 additions and 6 deletions
@@ -26,15 +26,25 @@ public class CommissionCalculator {
private final AgentMapper agentMapper;
public LedgerVO calcRecruitLedger(ContractResp c, String settleMonth) {
return buildLedger(c, settleMonth, 1);
return buildLedger(c, settleMonth, 1, c.getAgentId());
}
public LedgerVO calcMaintainLedger(ContractResp c, String settleMonth, int commissionYear) {
return buildLedger(c, settleMonth, commissionYear);
return buildLedger(c, settleMonth, commissionYear, c.getAgentId());
}
private LedgerVO buildLedger(ContractResp c, String settleMonth, int year) {
AgentVO agent = agentMapper.selectById(c.getAgentId());
/**
* 계약 승계 반영 유지수수료 계산.
* effectiveAgentId 가 후임 설계사이면 해당 설계사 등급의 payout 비율로 계산되고
* 원장 agent_id 도 후임으로 기록된다. null 이면 원 모집설계사로 동작.
*/
public LedgerVO calcMaintainLedger(ContractResp c, String settleMonth, int commissionYear, Long effectiveAgentId) {
return buildLedger(c, settleMonth, commissionYear,
effectiveAgentId != null ? effectiveAgentId : c.getAgentId());
}
private LedgerVO buildLedger(ContractResp c, String settleMonth, int year, Long agentId) {
AgentVO agent = agentMapper.selectById(agentId);
if (agent == null) return null;
LocalDate baseDate = c.getContractDate() != null ? c.getContractDate() : LocalDate.now();
@@ -52,7 +62,7 @@ public class CommissionCalculator {
LedgerVO led = new LedgerVO();
led.setContractId(c.getContractId());
led.setAgentId(c.getAgentId());
led.setAgentId(agentId);
led.setPolicyNo(c.getPolicyNo());
led.setCompanyCode(c.getCompanyCode());
led.setProductCode(c.getProductCode());
@@ -6,9 +6,11 @@ import com.ga.common.util.DateUtil;
import com.ga.common.util.MoneyUtil;
import com.ga.core.mapper.ledger.MaintainLedgerMapper;
import com.ga.core.mapper.product.ContractMapper;
import com.ga.core.mapper.succession.ContractSuccessionMapper;
import com.ga.core.vo.ledger.LedgerVO;
import com.ga.core.vo.product.ContractResp;
import com.ga.core.vo.product.ContractSearchParam;
import com.ga.core.vo.succession.ContractSuccessionVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.StepContribution;
@@ -20,7 +22,9 @@ import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Step 5 — calcMaintain: 유지수수료 계산 (2회차~)
@@ -37,6 +41,7 @@ public class CalcMaintainStep implements Tasklet {
private final SettlementContext ctx;
private final ContractMapper contractMapper;
private final MaintainLedgerMapper maintainMapper;
private final ContractSuccessionMapper successionMapper;
private final CommissionCalculator calculator;
private static final int BATCH_SIZE = 1000;
@@ -52,6 +57,15 @@ public class CalcMaintainStep implements Tasklet {
maintainMapper.deleteBySettleMonth(ctx.getSettleMonth());
// 계약 승계 반영: 정산월 기준 유효 승계(계약→후임설계사) 사전 로드 (N+1 회피)
Map<Long, Long> successorByContract = new HashMap<>();
for (ContractSuccessionVO s : successionMapper.selectEffectiveSuccessions(ctx.getSettleMonth())) {
successorByContract.put(s.getContractId(), s.getToAgentId());
}
if (!successorByContract.isEmpty()) {
log.info("[Step5] active successions applied: {}", successorByContract.size());
}
List<LedgerVO> batch = new ArrayList<>(BATCH_SIZE);
int total = 0;
@@ -66,7 +80,9 @@ public class CalcMaintainStep implements Tasklet {
// 납입주기 매칭 (월납이면 매월, 분기납이면 3개월마다 ...)
if (!matchesPayCycle(c.getPayCycle(), monthsElapsed)) continue;
LedgerVO led = calculator.calcMaintainLedger(c, ctx.getSettleMonth(), year);
// 승계된 계약이면 유지수수료 귀속을 후임 설계사로 (없으면 원 모집설계사)
Long effectiveAgentId = successorByContract.getOrDefault(c.getContractId(), c.getAgentId());
LedgerVO led = calculator.calcMaintainLedger(c, ctx.getSettleMonth(), year, effectiveAgentId);
if (led == null || MoneyUtil.isZero(led.getAgentAmount())) continue;
batch.add(led);
if (batch.size() >= BATCH_SIZE) {