feat: 수수료 공식 갭 보완 + 8종 신규모듈 풀스택 (V116~V123)
수수료 공식 갭분석(5 병렬감사) 후 확정 버그 3건 수정 + 개념적으로 빠진 모듈 8종 풀스택 구현. [확정 버그 3건] - 1200% 누적한도(checkContractTotal)를 배치 CalcRecruitStep에 연결(기존 1차년 한도만 적용 → 두 한도 중 더 제한적인 값으로 clip+이연). +CalcRecruitStepTest - BatchInstallmentPlanGenerator planMonth 반영(하드코딩 1 제거, API판과 동일). +단위테스트 - PersistencyBonus 임계유지율 게이트 + FIXED 정액 룰 도출. +PersistencyBonusServiceTest [신규 모듈 8종] - MOD-1 도입수수료(recruiter/development) / MOD-2 선지급·차익정산 / MOD-3 환수 분할상환 - MOD-4 익월부활 재지급 / MOD-5 정산대사 입수 / MOD-6 환수 시효 / MOD-7 환수 감면 - MOD-8 채널별 수수료차등(sales_channel) 각 DB(테이블+메뉴+권한+공통코드)→VO/Mapper/Enum→Service/Controller→화면 풀스택. 배치 정산연계: AggregateStep에 도입/익월부활 가산(11종), 선지급 FIFO 상계(멱등 reverse), MOD-8 채널 우선 요율조회(NULL 폴백 하위호환). [검증] - 전체 ./gradlew build(전 모듈+전 테스트) GREEN - Flyway V116~V123 운영DB 적용(schema v115→v123) - 라이브 스모크 8 GET=200, MOD-1 RATE공식 50000 실측, 액션 POST 5xx 0건 - 적대적 코드리뷰 APPROVED(MOD-8 계산기 미연결 버그 발견·수정) 스펙: docs/DOMAIN_GAP_MOD1-8.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -50,7 +50,7 @@ public class CommissionCalculator {
|
||||
if (agent == null) return null;
|
||||
LocalDate baseDate = c.getContractDate() != null ? c.getContractDate() : LocalDate.now();
|
||||
|
||||
BigDecimal companyRate = ruleMapper.findCompanyRate(c.getProductId(), year, baseDate);
|
||||
BigDecimal companyRate = ruleMapper.findCompanyRate(c.getProductId(), year, baseDate, c.getSalesChannel());
|
||||
if (companyRate == null) {
|
||||
log.warn("[수수료율 누락] company_rate 없음 → 0 폴백(과소지급 위험): contractId={}, productId={}, year={}, baseDate={}",
|
||||
c.getContractId(), c.getProductId(), year, baseDate);
|
||||
@@ -58,7 +58,7 @@ public class CommissionCalculator {
|
||||
}
|
||||
|
||||
BigDecimal payoutRate = ruleMapper.findPayoutRate(
|
||||
agent.getGradeId(), c.getInsuranceType(), year, baseDate);
|
||||
agent.getGradeId(), c.getInsuranceType(), year, baseDate, c.getSalesChannel());
|
||||
if (payoutRate == null) {
|
||||
log.warn("[수수료율 누락] payout_rate 없음 → 0 폴백(과소지급 위험): contractId={}, gradeId={}, insuranceType={}, year={}, baseDate={}",
|
||||
c.getContractId(), agent.getGradeId(), c.getInsuranceType(), year, baseDate);
|
||||
|
||||
+7
-2
@@ -40,6 +40,9 @@ public class BatchInstallmentPlanGenerator {
|
||||
List<InstallmentPlanVO> plans = new ArrayList<>();
|
||||
for (int i = 0; i < ratios.size(); i++) {
|
||||
InstallmentRatioVO ratio = ratios.get(i);
|
||||
// 마스터의 plan_month 가 NULL 이면 연 단위(첫 달 지급), NOT NULL 이면 그 회차(월) 그대로 사용.
|
||||
int planMonth = ratio.getPlanMonth() != null ? ratio.getPlanMonth() : 1;
|
||||
|
||||
BigDecimal amount;
|
||||
if (fullSchedule && i == ratios.size() - 1) {
|
||||
amount = totalCommission.subtract(allocated);
|
||||
@@ -52,8 +55,10 @@ public class BatchInstallmentPlanGenerator {
|
||||
InstallmentPlanVO plan = new InstallmentPlanVO();
|
||||
plan.setContractId(contractId);
|
||||
plan.setPlanYear(ratio.getPlanYear());
|
||||
plan.setPlanMonth(1);
|
||||
plan.setSettleMonth(contractDate.plusYears(ratio.getPlanYear()).format(MONTH_FMT));
|
||||
plan.setPlanMonth(planMonth);
|
||||
plan.setSettleMonth(
|
||||
contractDate.plusYears(ratio.getPlanYear()).plusMonths(planMonth - 1L).format(MONTH_FMT)
|
||||
);
|
||||
plan.setExpectedAmount(amount);
|
||||
plan.setStatus(InstallmentStatus.SCHEDULED.code());
|
||||
plans.add(plan);
|
||||
|
||||
@@ -4,13 +4,17 @@ import com.ga.batch.settlement.SettlementContext;
|
||||
import com.ga.batch.settlement.domain.BatchTaxCalculator;
|
||||
import com.ga.batch.settlement.domain.TaxBreakdown;
|
||||
import com.ga.common.util.MoneyUtil;
|
||||
import com.ga.core.mapper.commission.AdvancePaymentMapper;
|
||||
import com.ga.core.mapper.commission.AdvanceRecoveryLedgerMapper;
|
||||
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.LapsedReinstatementRecoveryMapper;
|
||||
import com.ga.core.mapper.commission.LifeSupportLedgerMapper;
|
||||
import com.ga.core.mapper.commission.PersistencyBonusMapper;
|
||||
import com.ga.core.mapper.commission.RecruiterCommissionLedgerMapper;
|
||||
import com.ga.core.mapper.commission.ReferralCommissionLedgerMapper;
|
||||
import com.ga.core.mapper.commission.ReinstatementCommissionLedgerMapper;
|
||||
import com.ga.core.mapper.commission.RenewalCommissionLedgerMapper;
|
||||
@@ -24,6 +28,8 @@ 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.AdvancePaymentVO;
|
||||
import com.ga.core.vo.commission.AdvanceRecoveryLedgerVO;
|
||||
import com.ga.core.vo.commission.ClawbackReceivableResp;
|
||||
import com.ga.core.vo.commission.ClawbackReceivableVO;
|
||||
import com.ga.core.vo.commission.ClawbackRecoveryVO;
|
||||
@@ -96,6 +102,14 @@ public class AggregateStep implements Tasklet {
|
||||
private final LifeSupportLedgerMapper lifeSupportMapper;
|
||||
private final BranchMgrAllowanceLedgerMapper branchMgrAllowanceMapper;
|
||||
|
||||
// MOD-1 도입수수료 / MOD-4 익월부활재지급
|
||||
private final RecruiterCommissionLedgerMapper recruiterCommissionMapper;
|
||||
private final LapsedReinstatementRecoveryMapper lapsedReinstatementMapper;
|
||||
|
||||
// MOD-2 선지급 상계
|
||||
private final AdvancePaymentMapper advancePaymentMapper;
|
||||
private final AdvanceRecoveryLedgerMapper advanceRecoveryMapper;
|
||||
|
||||
@Override
|
||||
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
|
||||
log.info("[Step8 aggregate] settleMonth={}", ctx.getSettleMonth());
|
||||
@@ -125,6 +139,11 @@ public class AggregateStep implements Tasklet {
|
||||
Map<Long, BigDecimal> lifeSupportMap = toMap(lifeSupportMapper.aggregateByAgent(ctx.getSettleMonth()));
|
||||
Map<Long, BigDecimal> branchMgrMap = toMap(branchMgrAllowanceMapper.aggregateByAgent(ctx.getSettleMonth()));
|
||||
|
||||
// MOD-1 도입수수료 (recruiter_agent_id 기준 agentId)
|
||||
Map<Long, BigDecimal> recruiterCommMap = toMap(recruiterCommissionMapper.aggregateByAgent(ctx.getSettleMonth()));
|
||||
// MOD-4 익월부활재지급 (REPAID 상태만)
|
||||
Map<Long, BigDecimal> lapsedReinstatMap = toMap(lapsedReinstatementMapper.aggregateByAgent(ctx.getSettleMonth()));
|
||||
|
||||
// 합집합 agent_id
|
||||
Set<Long> agentIds = new HashSet<>();
|
||||
agentIds.addAll(recruitMap.keySet());
|
||||
@@ -142,6 +161,8 @@ public class AggregateStep implements Tasklet {
|
||||
agentIds.addAll(awardMap.keySet());
|
||||
agentIds.addAll(lifeSupportMap.keySet());
|
||||
agentIds.addAll(branchMgrMap.keySet());
|
||||
agentIds.addAll(recruiterCommMap.keySet());
|
||||
agentIds.addAll(lapsedReinstatMap.keySet());
|
||||
|
||||
// 활성 설계사 누락 보충
|
||||
AgentSearchParam ap = new AgentSearchParam();
|
||||
@@ -168,6 +189,9 @@ public class AggregateStep implements Tasklet {
|
||||
// 동일 월 재실행 시 중복 회수가 발생하지 않도록 보장한다.
|
||||
reverseClawbackRecovery(ctx.getSettleMonth());
|
||||
|
||||
// MOD-2 멱등 reverse: 선지급 상계 이력 역산 후 advance_payment balance 복원
|
||||
reverseAdvanceRecovery(ctx.getSettleMonth());
|
||||
|
||||
for (Long agentId : agentIds) {
|
||||
BigDecimal recruit = recruitMap.getOrDefault(agentId, BigDecimal.ZERO);
|
||||
BigDecimal maintain = maintainMap.getOrDefault(agentId, BigDecimal.ZERO);
|
||||
@@ -187,6 +211,8 @@ public class AggregateStep implements Tasklet {
|
||||
otherCommission = MoneyUtil.add(otherCommission, awardMap.getOrDefault(agentId, BigDecimal.ZERO));
|
||||
otherCommission = MoneyUtil.add(otherCommission, lifeSupportMap.getOrDefault(agentId, BigDecimal.ZERO));
|
||||
otherCommission = MoneyUtil.add(otherCommission, branchMgrMap.getOrDefault(agentId, BigDecimal.ZERO));
|
||||
otherCommission = MoneyUtil.add(otherCommission, recruiterCommMap.getOrDefault(agentId, BigDecimal.ZERO));
|
||||
otherCommission = MoneyUtil.add(otherCommission, lapsedReinstatMap.getOrDefault(agentId, BigDecimal.ZERO));
|
||||
|
||||
BigDecimal gross = MoneyUtil.add(MoneyUtil.add(MoneyUtil.add(recruit, maintain), ov), ep)
|
||||
.subtract(cb).subtract(em);
|
||||
@@ -230,6 +256,17 @@ public class AggregateStep implements Tasklet {
|
||||
log.info("[Step8 clawback] agentId={}, net={}, priorBalance={}, recovered={}, carryover={}, payable={}",
|
||||
agentId, netAmount, priorBalance, recovered, carryover, payable);
|
||||
|
||||
// MOD-2 선지급 FIFO 상계: 환수채권 상계 후 payable에서 추가 차감
|
||||
BigDecimal advanceOutstanding = advancePaymentMapper.outstandingByAgent(agentId);
|
||||
if (advanceOutstanding == null) advanceOutstanding = BigDecimal.ZERO;
|
||||
if (advanceOutstanding.signum() > 0 && payable.signum() > 0) {
|
||||
BigDecimal recoveredAdvance = payable.min(advanceOutstanding);
|
||||
payable = payable.subtract(recoveredAdvance);
|
||||
applyAdvanceRecovery(agentId, ctx.getSettleMonth(), recoveredAdvance);
|
||||
log.info("[Step8 advance] agentId={}, advanceOutstanding={}, recoveredAdvance={}, payable={}",
|
||||
agentId, advanceOutstanding, recoveredAdvance, payable);
|
||||
}
|
||||
|
||||
// settle_master UPSERT
|
||||
SettleMasterVO vo = new SettleMasterVO();
|
||||
vo.setAgentId(agentId);
|
||||
@@ -312,6 +349,74 @@ public class AggregateStep implements Tasklet {
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
|
||||
/**
|
||||
* MOD-2 멱등 reverse: settleMonth에 기록된 advance_recovery_ledger를 조회하여
|
||||
* 각 advance_payment의 balance/recoveredAmount/status를 복원한 뒤 이력을 삭제한다.
|
||||
* P9 reverseClawbackRecovery와 동일 사상.
|
||||
*/
|
||||
private void reverseAdvanceRecovery(String settleMonth) {
|
||||
// 당월 상계 이력이 있는 선지급금만 월 단위로 역산(P9 reverseClawbackRecovery와 동일 사상).
|
||||
for (Long advanceId : advanceRecoveryMapper.selectAdvanceIdsBySettleMonth(settleMonth)) {
|
||||
List<com.ga.core.vo.commission.AdvanceRecoveryLedgerVO> prevLedgers =
|
||||
advanceRecoveryMapper.selectBySettleMonth(advanceId, settleMonth);
|
||||
if (prevLedgers.isEmpty()) continue;
|
||||
|
||||
BigDecimal toRestore = prevLedgers.stream()
|
||||
.map(com.ga.core.vo.commission.AdvanceRecoveryLedgerVO::getRecoveryAmount)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
com.ga.core.vo.commission.AdvancePaymentResp ap = advancePaymentMapper.selectById(advanceId);
|
||||
if (ap == null) continue;
|
||||
|
||||
BigDecimal newRecovered = ap.getRecoveredAmount().subtract(toRestore);
|
||||
BigDecimal newBalance = ap.getBalance().add(toRestore);
|
||||
String newStatus = newRecovered.signum() == 0 ? "OUTSTANDING" : "PARTIAL";
|
||||
|
||||
AdvancePaymentVO updated = new AdvancePaymentVO();
|
||||
updated.setAdvanceId(advanceId);
|
||||
updated.setRecoveredAmount(newRecovered);
|
||||
updated.setBalance(newBalance);
|
||||
updated.setStatus(newStatus);
|
||||
updated.setMemo(ap.getMemo());
|
||||
advancePaymentMapper.update(updated);
|
||||
|
||||
advanceRecoveryMapper.deleteBySettleMonth(advanceId, settleMonth);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MOD-2 FIFO 선지급 상계 적용: 오래된 advance_month 순으로 balance를 차감하고
|
||||
* advance_recovery_ledger 이력을 INSERT한다.
|
||||
*/
|
||||
private void applyAdvanceRecovery(Long agentId, String settleMonth, BigDecimal recoveredTotal) {
|
||||
List<AdvancePaymentVO> fifo = advancePaymentMapper.selectOutstandingFifo(agentId);
|
||||
BigDecimal remaining = recoveredTotal;
|
||||
for (AdvancePaymentVO ap : fifo) {
|
||||
if (remaining.signum() == 0) break;
|
||||
|
||||
BigDecimal take = remaining.min(ap.getBalance());
|
||||
BigDecimal newRecovered = ap.getRecoveredAmount().add(take);
|
||||
BigDecimal newBalance = ap.getBalance().subtract(take);
|
||||
String newStatus = newBalance.signum() == 0 ? "CLEARED" : "PARTIAL";
|
||||
|
||||
AdvancePaymentVO updated = new AdvancePaymentVO();
|
||||
updated.setAdvanceId(ap.getAdvanceId());
|
||||
updated.setRecoveredAmount(newRecovered);
|
||||
updated.setBalance(newBalance);
|
||||
updated.setStatus(newStatus);
|
||||
updated.setMemo(ap.getMemo());
|
||||
advancePaymentMapper.update(updated);
|
||||
|
||||
AdvanceRecoveryLedgerVO ledger = new AdvanceRecoveryLedgerVO();
|
||||
ledger.setAdvanceId(ap.getAdvanceId());
|
||||
ledger.setSettleMonth(settleMonth);
|
||||
ledger.setRecoveryAmount(take);
|
||||
advanceRecoveryMapper.insert(ledger);
|
||||
|
||||
remaining = remaining.subtract(take);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 멱등 reverse: settleMonth의 기존 clawback_recovery 이력을 조회하여
|
||||
* 각 receivable의 balance/recoveredAmount/status를 복원한 뒤 회수 이력을 전량 삭제한다.
|
||||
|
||||
@@ -75,32 +75,43 @@ public class CalcRecruitStep implements Tasklet {
|
||||
LedgerVO led = calculator.calcRecruitLedger(c, ctx.getSettleMonth());
|
||||
if (led == null || MoneyUtil.isZero(led.getAgentAmount())) continue;
|
||||
|
||||
// 1200%룰 검증 (1차년도 한도)
|
||||
// 1200%룰 검증: 1차년 한도(FIRST_YEAR) + 누적 한도(TOTAL 1200%) 중
|
||||
// 더 제한적인 한도로 당월 지급액을 clip, 초과분은 분급(7년)으로 이연.
|
||||
if (c.getContractDate() != null) {
|
||||
BigDecimal originalAmount = led.getAgentAmount();
|
||||
BigDecimal allowed = originalAmount;
|
||||
|
||||
ViolationResult firstYearCheck = regulatoryChecker.checkFirstYear(
|
||||
c.getContractId(), led.getAgentAmount(), c.getContractDate());
|
||||
|
||||
c.getContractId(), originalAmount, c.getContractDate());
|
||||
if (firstYearCheck.violated()) {
|
||||
violationCount++;
|
||||
|
||||
// 1차년 한도 잔여분까지만 당월 지급, 초과분은 분급(7년)으로 이연.
|
||||
BigDecimal allowed = MoneyUtil.sub(
|
||||
BigDecimal fyAllowed = MoneyUtil.sub(
|
||||
firstYearCheck.limitAmount(), firstYearCheck.currentTotal());
|
||||
if (allowed.signum() < 0) allowed = BigDecimal.ZERO;
|
||||
BigDecimal excess = MoneyUtil.sub(led.getAgentAmount(), allowed);
|
||||
if (fyAllowed.signum() < 0) fyAllowed = BigDecimal.ZERO;
|
||||
allowed = allowed.min(fyAllowed);
|
||||
}
|
||||
|
||||
if (excess.signum() > 0) {
|
||||
// 초과분만 분급 계획 생성 (7년 35/25/15/10/7/5/3)
|
||||
List<InstallmentPlanVO> plans = installmentGenerator.generate(
|
||||
c.getContractId(), excess,
|
||||
c.getContractDate(), c.getInsuranceType());
|
||||
installmentBatch.addAll(plans);
|
||||
// 당월 원장은 한도까지만 지급(초과분 차감) + 세액 재계산
|
||||
led.setAgentAmount(allowed);
|
||||
led.setTaxAmount(MoneyUtil.tax(allowed));
|
||||
log.warn("[Step4] 1200%룰 1차년 한도초과 → clip+이연: contractId={}, 지급(한도잔여)={}, 이연(분급)={}",
|
||||
c.getContractId(), allowed, excess);
|
||||
}
|
||||
ViolationResult totalCheck = regulatoryChecker.checkContractTotal(
|
||||
c.getContractId(), originalAmount, c.getContractDate());
|
||||
if (totalCheck.violated()) {
|
||||
BigDecimal totAllowed = MoneyUtil.sub(
|
||||
totalCheck.limitAmount(), totalCheck.currentTotal());
|
||||
if (totAllowed.signum() < 0) totAllowed = BigDecimal.ZERO;
|
||||
allowed = allowed.min(totAllowed);
|
||||
}
|
||||
|
||||
BigDecimal excess = MoneyUtil.sub(originalAmount, allowed);
|
||||
if (excess.signum() > 0) {
|
||||
violationCount++;
|
||||
// 초과분만 분급 계획 생성 (7년 35/25/15/10/7/5/3)
|
||||
List<InstallmentPlanVO> plans = installmentGenerator.generate(
|
||||
c.getContractId(), excess,
|
||||
c.getContractDate(), c.getInsuranceType());
|
||||
installmentBatch.addAll(plans);
|
||||
// 당월 원장은 한도까지만 지급(초과분 차감) + 세액 재계산
|
||||
led.setAgentAmount(allowed);
|
||||
led.setTaxAmount(MoneyUtil.tax(allowed));
|
||||
log.warn("[Step4] 1200%룰 한도초과 → clip+이연: contractId={}, 지급(한도잔여)={}, 이연(분급)={}, firstYear={}, total={}",
|
||||
c.getContractId(), allowed, excess, firstYearCheck.violated(), totalCheck.violated());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,9 +54,9 @@ class CommissionCalculatorTest {
|
||||
@Test
|
||||
@DisplayName("calcRecruitLedger: premium × company_rate × payout_rate, 세금은 3.3%")
|
||||
void recruit_basic() {
|
||||
when(ruleMapper.findCompanyRate(eq(1L), eq(1), any(LocalDate.class)))
|
||||
when(ruleMapper.findCompanyRate(eq(1L), eq(1), any(LocalDate.class), any()))
|
||||
.thenReturn(new BigDecimal("7.5"));
|
||||
when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(1), any(LocalDate.class)))
|
||||
when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(1), any(LocalDate.class), any()))
|
||||
.thenReturn(new BigDecimal("60.0"));
|
||||
|
||||
LedgerVO led = calculator.calcRecruitLedger(contract, "202405");
|
||||
@@ -83,12 +83,30 @@ class CommissionCalculatorTest {
|
||||
assertThat(led.getPayMethod()).isEqualTo("MONTHLY");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("MOD-8 채널요율: 계약 sales_channel 이 요율 조회로 전달된다 (채널별 룰 선택)")
|
||||
void recruit_passesSalesChannel() {
|
||||
contract.setSalesChannel("BANCASSURANCE");
|
||||
// 채널 일치 요율(방카)만 stub — 채널이 전달되지 않으면 매칭 실패로 0 폴백됨
|
||||
when(ruleMapper.findCompanyRate(eq(1L), eq(1), any(LocalDate.class), eq("BANCASSURANCE")))
|
||||
.thenReturn(new BigDecimal("5.0"));
|
||||
when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(1), any(LocalDate.class), eq("BANCASSURANCE")))
|
||||
.thenReturn(new BigDecimal("80.0"));
|
||||
|
||||
LedgerVO led = calculator.calcRecruitLedger(contract, "202405");
|
||||
|
||||
assertThat(led.getCompanyRate()).isEqualByComparingTo("5.0");
|
||||
assertThat(led.getPayoutRate()).isEqualByComparingTo("80.0");
|
||||
// 1,000,000 × 5% = 50,000 → × 80% = 40,000 (채널 요율이 실제로 적용됨)
|
||||
assertThat(led.getAgentAmount()).isEqualByComparingTo("40000.00");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("calcMaintainLedger: 회차에 따라 commission_year 가 다름")
|
||||
void maintain_year3() {
|
||||
when(ruleMapper.findCompanyRate(eq(1L), eq(3), any(LocalDate.class)))
|
||||
when(ruleMapper.findCompanyRate(eq(1L), eq(3), any(LocalDate.class), any()))
|
||||
.thenReturn(new BigDecimal("3.0"));
|
||||
when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(3), any(LocalDate.class)))
|
||||
when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(3), any(LocalDate.class), any()))
|
||||
.thenReturn(new BigDecimal("50.0"));
|
||||
|
||||
LedgerVO led = calculator.calcMaintainLedger(contract, "202405", 3);
|
||||
@@ -115,8 +133,8 @@ class CommissionCalculatorTest {
|
||||
@Test
|
||||
@DisplayName("규정 누락 시 0 으로 폴백 → 모든 금액 0")
|
||||
void missingRates_fallbackZero() {
|
||||
when(ruleMapper.findCompanyRate(any(), any(), any())).thenReturn(null);
|
||||
when(ruleMapper.findPayoutRate(any(), any(), any(), any())).thenReturn(null);
|
||||
when(ruleMapper.findCompanyRate(any(), any(), any(), any())).thenReturn(null);
|
||||
when(ruleMapper.findPayoutRate(any(), any(), any(), any(), any())).thenReturn(null);
|
||||
|
||||
LedgerVO led = calculator.calcRecruitLedger(contract, "202405");
|
||||
|
||||
@@ -132,9 +150,9 @@ class CommissionCalculatorTest {
|
||||
@DisplayName("contractDate 가 null 이면 오늘 날짜로 대체 (예외 없음)")
|
||||
void nullContractDate_usesToday() {
|
||||
contract.setContractDate(null);
|
||||
when(ruleMapper.findCompanyRate(eq(1L), eq(1), any(LocalDate.class)))
|
||||
when(ruleMapper.findCompanyRate(eq(1L), eq(1), any(LocalDate.class), any()))
|
||||
.thenReturn(new BigDecimal("5"));
|
||||
when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(1), any(LocalDate.class)))
|
||||
when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(1), any(LocalDate.class), any()))
|
||||
.thenReturn(new BigDecimal("100"));
|
||||
|
||||
LedgerVO led = calculator.calcRecruitLedger(contract, "202405");
|
||||
@@ -148,8 +166,8 @@ class CommissionCalculatorTest {
|
||||
@DisplayName("premium 이 null 이면 0 으로 처리 (HALF_UP 0.00)")
|
||||
void nullPremium() {
|
||||
contract.setPremium(null);
|
||||
when(ruleMapper.findCompanyRate(any(), any(), any())).thenReturn(new BigDecimal("7.5"));
|
||||
when(ruleMapper.findPayoutRate(any(), any(), any(), any())).thenReturn(new BigDecimal("60"));
|
||||
when(ruleMapper.findCompanyRate(any(), any(), any(), any())).thenReturn(new BigDecimal("7.5"));
|
||||
when(ruleMapper.findPayoutRate(any(), any(), any(), any(), any())).thenReturn(new BigDecimal("60"));
|
||||
|
||||
LedgerVO led = calculator.calcRecruitLedger(contract, "202405");
|
||||
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package com.ga.batch.settlement.domain;
|
||||
|
||||
import com.ga.core.mapper.installment.InstallmentRatioMapper;
|
||||
import com.ga.core.vo.installment.InstallmentPlanVO;
|
||||
import com.ga.core.vo.installment.InstallmentRatioVO;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 분급 계획 생성기 — plan_month 반영 검증 (API InstallmentPlanGenerator와 동일 출력).
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class BatchInstallmentPlanGeneratorTest {
|
||||
|
||||
@Mock InstallmentRatioMapper ratioMapper;
|
||||
@InjectMocks BatchInstallmentPlanGenerator generator;
|
||||
|
||||
static final LocalDate CONTRACT_DATE = LocalDate.of(2024, 1, 15);
|
||||
|
||||
private InstallmentRatioVO ratio(int year, Integer month, String pct) {
|
||||
InstallmentRatioVO r = new InstallmentRatioVO();
|
||||
r.setPlanYear(year);
|
||||
r.setPlanMonth(month);
|
||||
r.setRatioPercent(new BigDecimal(pct));
|
||||
return r;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("plan_month 가 설정되면 settleMonth = 계약일 +N년 +(plan_month-1)월")
|
||||
void usesPlanMonthInSettleMonth() {
|
||||
// 1차년 4월차 100% (단일 회차)
|
||||
when(ratioMapper.selectAllActiveRatios(anyString(), anyString()))
|
||||
.thenReturn(List.of(ratio(1, 4, "100")));
|
||||
|
||||
List<InstallmentPlanVO> plans = generator.generate(
|
||||
100L, new BigDecimal("700000"), CONTRACT_DATE, "WHOLE_LIFE");
|
||||
|
||||
assertThat(plans).hasSize(1);
|
||||
InstallmentPlanVO p = plans.get(0);
|
||||
assertThat(p.getPlanMonth()).isEqualTo(4);
|
||||
// 2024-01 +1년 +3개월 = 2025-04
|
||||
assertThat(p.getSettleMonth()).isEqualTo("202504");
|
||||
assertThat(p.getExpectedAmount()).isEqualByComparingTo("700000");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("plan_month 가 NULL 이면 연 단위(첫 달) — settleMonth = 계약일 +N년")
|
||||
void nullPlanMonthDefaultsToYearStart() {
|
||||
when(ratioMapper.selectAllActiveRatios(any(), anyString()))
|
||||
.thenReturn(List.of(ratio(2, null, "100")));
|
||||
|
||||
List<InstallmentPlanVO> plans = generator.generate(
|
||||
100L, new BigDecimal("500000"), CONTRACT_DATE, null);
|
||||
|
||||
InstallmentPlanVO p = plans.get(0);
|
||||
assertThat(p.getPlanMonth()).isEqualTo(1);
|
||||
// 2024-01 +2년 +0개월 = 2026-01
|
||||
assertThat(p.getSettleMonth()).isEqualTo("202601");
|
||||
}
|
||||
}
|
||||
@@ -129,9 +129,9 @@ class SettlementScenarioTest {
|
||||
@Test
|
||||
@DisplayName("① 보험 인입 → 모집수수료 원장: 1,000,000 ×60% ×75% = 450,000, 세금 14,850")
|
||||
void s1_보험인입_모집수수료_원장계산() {
|
||||
when(ruleMapper.findCompanyRate(eq(PRODUCT_ID), eq(1), any(LocalDate.class)))
|
||||
when(ruleMapper.findCompanyRate(eq(PRODUCT_ID), eq(1), any(LocalDate.class), any()))
|
||||
.thenReturn(new BigDecimal("60.0"));
|
||||
when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(1), any(LocalDate.class)))
|
||||
when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(1), any(LocalDate.class), any()))
|
||||
.thenReturn(new BigDecimal("75.0"));
|
||||
|
||||
LedgerVO led = commissionCalculator.calcRecruitLedger(contract, "202401");
|
||||
@@ -226,9 +226,9 @@ class SettlementScenarioTest {
|
||||
@Test
|
||||
@DisplayName("④ 유지수수료 2차년: 1,000,000 ×10% ×50% = 50,000")
|
||||
void s4_유지수수료_2차년() {
|
||||
when(ruleMapper.findCompanyRate(eq(PRODUCT_ID), eq(2), any(LocalDate.class)))
|
||||
when(ruleMapper.findCompanyRate(eq(PRODUCT_ID), eq(2), any(LocalDate.class), any()))
|
||||
.thenReturn(new BigDecimal("10.0"));
|
||||
when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(2), any(LocalDate.class)))
|
||||
when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(2), any(LocalDate.class), any()))
|
||||
.thenReturn(new BigDecimal("50.0"));
|
||||
|
||||
LedgerVO led = commissionCalculator.calcMaintainLedger(contract, "202501", 2);
|
||||
@@ -241,10 +241,10 @@ class SettlementScenarioTest {
|
||||
@Test
|
||||
@DisplayName("⑤ 계약 승계: 유지수수료는 후임(박승계 등급3 40%)에게 귀속, 모집은 원설계사 유지")
|
||||
void s5_계약승계_유지수수료_후임귀속() {
|
||||
when(ruleMapper.findCompanyRate(eq(PRODUCT_ID), eq(2), any(LocalDate.class)))
|
||||
when(ruleMapper.findCompanyRate(eq(PRODUCT_ID), eq(2), any(LocalDate.class), any()))
|
||||
.thenReturn(new BigDecimal("10.0"));
|
||||
// 후임 박승계 등급3 의 2차년 지급률 40%
|
||||
when(ruleMapper.findPayoutRate(eq(3L), eq("WHOLE_LIFE"), eq(2), any(LocalDate.class)))
|
||||
when(ruleMapper.findPayoutRate(eq(3L), eq("WHOLE_LIFE"), eq(2), any(LocalDate.class), any()))
|
||||
.thenReturn(new BigDecimal("40.0"));
|
||||
|
||||
LedgerVO led = commissionCalculator.calcMaintainLedger(contract, "202501", 2, PARK);
|
||||
|
||||
@@ -57,6 +57,30 @@ class CalcRecruitStepTest {
|
||||
step = new CalcRecruitStep(ctx, contractMapper, recruitMapper,
|
||||
calculator, regulatoryChecker, installmentGenerator, installmentPlanMapper);
|
||||
when(ctx.getSettleMonth()).thenReturn(MONTH);
|
||||
// 기본: 두 한도 모두 위반 아님(각 테스트에서 필요한 한도만 violated 로 오버라이드)
|
||||
when(regulatoryChecker.checkFirstYear(any(), any(BigDecimal.class), any(LocalDate.class)))
|
||||
.thenReturn(ViolationResult.ok(BigDecimal.ZERO, null, "FIRST_YEAR_RATIO"));
|
||||
when(regulatoryChecker.checkContractTotal(any(), any(BigDecimal.class), any(LocalDate.class)))
|
||||
.thenReturn(ViolationResult.ok(BigDecimal.ZERO, null, "TOTAL_RATIO"));
|
||||
}
|
||||
|
||||
private ContractResp contract() {
|
||||
ContractResp c = new ContractResp();
|
||||
c.setContractId(CONTRACT_ID);
|
||||
c.setAgentId(10L);
|
||||
c.setInsuranceType("WHOLE_LIFE");
|
||||
c.setContractDate(CONTRACT_DATE);
|
||||
return c;
|
||||
}
|
||||
|
||||
private LedgerVO recruitLedger(ContractResp c, String amount, String tax) {
|
||||
LedgerVO led = new LedgerVO();
|
||||
led.setContractId(CONTRACT_ID);
|
||||
led.setAgentId(10L);
|
||||
led.setAgentAmount(new BigDecimal(amount));
|
||||
led.setTaxAmount(new BigDecimal(tax));
|
||||
when(calculator.calcRecruitLedger(eq(c), eq(MONTH))).thenReturn(led);
|
||||
return led;
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -100,4 +124,62 @@ class CalcRecruitStepTest {
|
||||
assertThat(saved.getAgentAmount()).isEqualByComparingTo("350000");
|
||||
assertThat(saved.getTaxAmount()).isEqualByComparingTo("11550.00");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("1200% 누적한도 잔여 300,000 < 모집수수료 450,000 → 300,000 지급 + 초과 150,000 분급 이연")
|
||||
void contractTotalLimit_clipAndDefer() {
|
||||
ContractResp c = contract();
|
||||
when(contractMapper.selectList(any())).thenReturn(List.of(c));
|
||||
recruitLedger(c, "450000", "14850");
|
||||
|
||||
// 1차년은 통과(기본), 누적 1200% 한도만 위반: 한도 12,000,000 / 기지급 11,700,000 → 잔여 300,000
|
||||
when(regulatoryChecker.checkContractTotal(eq(CONTRACT_ID), any(BigDecimal.class), any(LocalDate.class)))
|
||||
.thenReturn(ViolationResult.violated(
|
||||
new BigDecimal("11700000"), new BigDecimal("12000000"), "TOTAL_RATIO", "초과"));
|
||||
when(installmentGenerator.generate(eq(CONTRACT_ID), any(BigDecimal.class), any(LocalDate.class), anyString()))
|
||||
.thenReturn(List.of(new InstallmentPlanVO()));
|
||||
ArgumentCaptor<List<LedgerVO>> ledgerCaptor = ArgumentCaptor.forClass(List.class);
|
||||
when(recruitMapper.batchInsert(ledgerCaptor.capture())).thenReturn(1);
|
||||
|
||||
step.execute(null, null);
|
||||
|
||||
ArgumentCaptor<BigDecimal> excessCaptor = ArgumentCaptor.forClass(BigDecimal.class);
|
||||
org.mockito.Mockito.verify(installmentGenerator)
|
||||
.generate(eq(CONTRACT_ID), excessCaptor.capture(), any(LocalDate.class), anyString());
|
||||
assertThat(excessCaptor.getValue()).isEqualByComparingTo("150000");
|
||||
|
||||
LedgerVO saved = ledgerCaptor.getValue().get(0);
|
||||
assertThat(saved.getAgentAmount()).isEqualByComparingTo("300000");
|
||||
assertThat(saved.getTaxAmount()).isEqualByComparingTo("9900.00");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("1차년·누적 둘 다 위반 → 더 제한적인 누적한도 잔여 300,000 으로 clip")
|
||||
void bothLimits_mostRestrictiveApplies() {
|
||||
ContractResp c = contract();
|
||||
when(contractMapper.selectList(any())).thenReturn(List.of(c));
|
||||
recruitLedger(c, "450000", "14850");
|
||||
|
||||
// 1차년 잔여 350,000, 누적 잔여 300,000 → min(450000, 350000, 300000) = 300,000
|
||||
when(regulatoryChecker.checkFirstYear(eq(CONTRACT_ID), any(BigDecimal.class), any(LocalDate.class)))
|
||||
.thenReturn(ViolationResult.violated(
|
||||
BigDecimal.ZERO, new BigDecimal("350000"), "FIRST_YEAR_RATIO", "초과"));
|
||||
when(regulatoryChecker.checkContractTotal(eq(CONTRACT_ID), any(BigDecimal.class), any(LocalDate.class)))
|
||||
.thenReturn(ViolationResult.violated(
|
||||
new BigDecimal("11700000"), new BigDecimal("12000000"), "TOTAL_RATIO", "초과"));
|
||||
when(installmentGenerator.generate(eq(CONTRACT_ID), any(BigDecimal.class), any(LocalDate.class), anyString()))
|
||||
.thenReturn(List.of(new InstallmentPlanVO()));
|
||||
ArgumentCaptor<List<LedgerVO>> ledgerCaptor = ArgumentCaptor.forClass(List.class);
|
||||
when(recruitMapper.batchInsert(ledgerCaptor.capture())).thenReturn(1);
|
||||
|
||||
step.execute(null, null);
|
||||
|
||||
ArgumentCaptor<BigDecimal> excessCaptor = ArgumentCaptor.forClass(BigDecimal.class);
|
||||
org.mockito.Mockito.verify(installmentGenerator)
|
||||
.generate(eq(CONTRACT_ID), excessCaptor.capture(), any(LocalDate.class), anyString());
|
||||
assertThat(excessCaptor.getValue()).isEqualByComparingTo("150000");
|
||||
|
||||
LedgerVO saved = ledgerCaptor.getValue().get(0);
|
||||
assertThat(saved.getAgentAmount()).isEqualByComparingTo("300000");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user