test: E2E 수수료 정산 시나리오 테스트 + 엑셀업로드/분급 버그 수정

- 시나리오 문서: 보험 인입→수수료 지급 8단계 (docs/시나리오_E2E_보험인입_수수료정산.md)
- SettlementScenarioTest(9건): 계약1건 생애주기를 실제 계산기로 단계별 검증
- ExcelServiceImportTest(2건): 실 .xlsx 업로드 라운드트립 검증
- fix(CRITICAL): 엑셀 업로드 런타임 깨짐 — xlsx-streamer 2.2.0 ↔ poi 5.2.5
  비호환(NoSuchMethodError). excel-streaming-reader 4.3.0(유지보수 fork)로 교체.
  영향: ExcelService.importLargeExcel + UploadService(/api/upload 실엔드포인트)
- fix: 분급 계획 반올림 잔차로 분급합계≠원금(정산 무결성 위반). 비율합 100%
  정규스케줄일 때 마지막 회차가 잔차 흡수. ga-api/ga-batch 양쪽.
- 전체 build+test GREEN: 148건 0실패

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-06-03 10:02:55 +09:00
parent 1d77671ea0
commit 3d50286f4a
8 changed files with 667 additions and 14 deletions
@@ -32,20 +32,38 @@ public class BatchInstallmentPlanGenerator {
String baseDate = contractDate.format(DATE_FMT);
List<InstallmentRatioVO> ratios = ratioMapper.selectAllActiveRatios(productCategory, baseDate);
// 비율 합이 정확히 100%인 정규 스케줄이면 마지막 회차가 반올림 잔차를 흡수
// → 분급 합계 == 원 수수료 보장 (부분지급 스케줄은 회차별 반올림 유지).
boolean fullSchedule = sumRatios(ratios).compareTo(BigDecimal.valueOf(100)) == 0;
BigDecimal allocated = BigDecimal.ZERO;
List<InstallmentPlanVO> plans = new ArrayList<>();
for (InstallmentRatioVO ratio : ratios) {
for (int i = 0; i < ratios.size(); i++) {
InstallmentRatioVO ratio = ratios.get(i);
BigDecimal amount;
if (fullSchedule && i == ratios.size() - 1) {
amount = totalCommission.subtract(allocated);
} else {
amount = totalCommission.multiply(ratio.getRatioPercent())
.divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP);
}
allocated = allocated.add(amount);
InstallmentPlanVO plan = new InstallmentPlanVO();
plan.setContractId(contractId);
plan.setPlanYear(ratio.getPlanYear());
plan.setPlanMonth(1);
plan.setSettleMonth(contractDate.plusYears(ratio.getPlanYear()).format(MONTH_FMT));
plan.setExpectedAmount(
totalCommission.multiply(ratio.getRatioPercent())
.divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP)
);
plan.setExpectedAmount(amount);
plan.setStatus(InstallmentStatus.SCHEDULED.code());
plans.add(plan);
}
return plans;
}
private BigDecimal sumRatios(List<InstallmentRatioVO> ratios) {
return ratios.stream()
.map(InstallmentRatioVO::getRatioPercent)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
@@ -0,0 +1,297 @@
package com.ga.batch.settlement.scenario;
import com.ga.batch.settlement.calc.CommissionCalculator;
import com.ga.batch.settlement.domain.BatchChargebackCalculator;
import com.ga.batch.settlement.domain.BatchInstallmentPlanGenerator;
import com.ga.batch.settlement.domain.BatchRegulatoryLimitChecker;
import com.ga.batch.settlement.domain.BatchTaxCalculator;
import com.ga.batch.settlement.domain.ChargebackResult;
import com.ga.batch.settlement.domain.TaxBreakdown;
import com.ga.common.system.SystemConfigService;
import com.ga.core.mapper.chargeback.ChargebackGradeMapper;
import com.ga.core.mapper.installment.InstallmentRatioMapper;
import com.ga.core.mapper.ledger.MaintainLedgerMapper;
import com.ga.core.mapper.ledger.RecruitLedgerMapper;
import com.ga.core.mapper.org.AgentMapper;
import com.ga.core.mapper.regulatory.RegulatoryLimitMapper;
import com.ga.core.mapper.rule.RuleMapper;
import com.ga.core.vo.chargeback.ChargebackGradeVO;
import com.ga.core.vo.installment.InstallmentPlanVO;
import com.ga.core.vo.installment.InstallmentRatioVO;
import com.ga.core.vo.ledger.LedgerVO;
import com.ga.core.vo.org.AgentVO;
import com.ga.core.vo.org.TaxType;
import com.ga.core.vo.org.VatType;
import com.ga.core.vo.product.ContractResp;
import com.ga.core.vo.regulatory.RegulatoryLimitVO;
import com.ga.batch.settlement.domain.ViolationResult;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
/**
* E2E 시나리오 테스트 — "보험이 어떻게 들어와서 수수료가 어떻게 나가는가".
*
* 계약 1건(POL-0001, 연납 1,000,000원, 김설계 등급5 모집)의 생애주기를
* 실제 계산 코드로 단계별로 따라가며 검증한다.
* 시나리오 문서: docs/시나리오_E2E_보험인입_수수료정산.md
*
* 등장인물·룰은 Mock 으로 주입(=테스트 데이터)하며 숫자는 DOMAIN_KNOWLEDGE 7장 예시와 일치한다.
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class SettlementScenarioTest {
// ── 계산기가 의존하는 Mapper/Service (테스트 데이터 주입 지점) ──
@Mock RuleMapper ruleMapper;
@Mock AgentMapper agentMapper;
@Mock ChargebackGradeMapper chargebackGradeMapper;
@Mock InstallmentRatioMapper installmentRatioMapper;
@Mock RegulatoryLimitMapper regulatoryLimitMapper;
@Mock RecruitLedgerMapper recruitLedgerMapper;
@Mock MaintainLedgerMapper maintainLedgerMapper;
@Mock SystemConfigService configService;
// ── 실제 계산 코드 (테스트 대상) ──
CommissionCalculator commissionCalculator;
BatchRegulatoryLimitChecker regulatoryChecker;
BatchInstallmentPlanGenerator installmentGenerator;
BatchChargebackCalculator chargebackCalculator;
BatchTaxCalculator taxCalculator;
// ── 시나리오 상수 ──
static final Long CONTRACT_ID = 100L;
static final Long KIM = 10L; // 원 모집설계사 김설계 (등급5)
static final Long PARK = 20L; // 후임 승계설계사 박승계 (등급3)
static final Long PRODUCT_ID = 1L;
static final String LIFE = "LIFE";
static final LocalDate CONTRACT_DATE = LocalDate.of(2024, 1, 15);
ContractResp contract;
@BeforeEach
void setUp() {
commissionCalculator = new CommissionCalculator(ruleMapper, agentMapper);
regulatoryChecker = new BatchRegulatoryLimitChecker(
regulatoryLimitMapper, recruitLedgerMapper, maintainLedgerMapper);
installmentGenerator = new BatchInstallmentPlanGenerator(installmentRatioMapper);
chargebackCalculator = new BatchChargebackCalculator(chargebackGradeMapper);
taxCalculator = new BatchTaxCalculator(configService);
// 계약 (보험 인입)
contract = new ContractResp();
contract.setContractId(CONTRACT_ID);
contract.setAgentId(KIM);
contract.setProductId(PRODUCT_ID);
contract.setPolicyNo("POL-0001");
contract.setCompanyCode("SAMSUNG");
contract.setProductCode("PRD-WL-001");
contract.setInsuranceType("WHOLE_LIFE");
contract.setPremium(new BigDecimal("1000000"));
contract.setPayCycle("YEARLY");
contract.setContractDate(CONTRACT_DATE);
// 설계사 마스터
AgentVO kim = new AgentVO();
kim.setAgentId(KIM);
kim.setGradeId(5L);
AgentVO park = new AgentVO();
park.setAgentId(PARK);
park.setGradeId(3L);
lenient().when(agentMapper.selectById(KIM)).thenReturn(kim);
lenient().when(agentMapper.selectById(PARK)).thenReturn(park);
// 세율: configService 는 기본값(2번째 인자)을 그대로 반환하도록
lenient().when(configService.getDecimal(anyString(), any(BigDecimal.class)))
.thenAnswer(inv -> inv.getArgument(1));
}
@Test
@DisplayName("① 보험 인입 → 모집수수료 원장: 1,000,000 ×60% ×75% = 450,000, 세금 14,850")
void s1_보험인입_모집수수료_원장계산() {
when(ruleMapper.findCompanyRate(eq(PRODUCT_ID), eq(1), any(LocalDate.class)))
.thenReturn(new BigDecimal("60.0"));
when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(1), any(LocalDate.class)))
.thenReturn(new BigDecimal("75.0"));
LedgerVO led = commissionCalculator.calcRecruitLedger(contract, "202401");
assertThat(led.getAgentId()).isEqualTo(KIM);
assertThat(led.getCommissionYear()).isEqualTo(1);
assertThat(led.getCompanyAmount()).isEqualByComparingTo("600000.00"); // 보험사지급액
assertThat(led.getAgentAmount()).isEqualByComparingTo("450000.00"); // gross
assertThat(led.getTaxAmount()).isEqualByComparingTo("14850.00"); // 3.3%
}
@Test
@DisplayName("② 1200% 룰: 누적 한도 이내면 통과, 초과분은 위반으로 차단")
void s2_규제_1200퍼센트_한도_통과및위반() {
RegulatoryLimitVO limit = new RegulatoryLimitVO();
limit.setLimitType("TOTAL_RATIO");
limit.setLimitValue(new BigDecimal("12000000")); // 1,000,000 × 1200%
limit.setUnit("KRW");
when(regulatoryLimitMapper.selectActiveByType(eq("TOTAL_RATIO"), any(), anyString()))
.thenReturn(limit);
when(recruitLedgerMapper.sumByContract(CONTRACT_ID)).thenReturn(new BigDecimal("450000"));
when(maintainLedgerMapper.sumByContract(CONTRACT_ID)).thenReturn(BigDecimal.ZERO);
// 추가 100만원 → 누적 550,000 ≤ 12,000,000 : 통과
ViolationResult ok = regulatoryChecker.checkContractTotal(
CONTRACT_ID, new BigDecimal("100000"), CONTRACT_DATE);
assertThat(ok.violated()).isFalse();
// 추가 1,200만원 → 누적 12,450,000 > 12,000,000 : 위반
ViolationResult bad = regulatoryChecker.checkContractTotal(
CONTRACT_ID, new BigDecimal("12000000"), CONTRACT_DATE);
assertThat(bad.violated()).isTrue();
assertThat(bad.currentTotal()).isEqualByComparingTo("450000");
}
@Test
@DisplayName("③ 분급 7년 분할: 35/25/15/10/7/5/3 → 합계 정확히 450,000")
void s3_분급_7년_분할_합계100퍼센트() {
when(installmentRatioMapper.selectAllActiveRatios(anyString(), anyString()))
.thenReturn(standardRatios());
List<InstallmentPlanVO> plans = installmentGenerator.generate(
CONTRACT_ID, new BigDecimal("450000"), CONTRACT_DATE, LIFE);
assertThat(plans).hasSize(7);
assertThat(plans.get(0).getExpectedAmount()).isEqualByComparingTo("157500"); // 35%
assertThat(plans.get(6).getExpectedAmount()).isEqualByComparingTo("13500"); // 3%
assertThat(sumExpected(plans)).isEqualByComparingTo("450000");
}
@Test
@DisplayName("③ 분급 라운딩 잔차: 나눠떨어지지 않는 435,150도 합계가 원금과 정확히 일치")
void s3b_분급_라운딩_잔차_마지막회차_흡수() {
when(installmentRatioMapper.selectAllActiveRatios(anyString(), anyString()))
.thenReturn(standardRatios());
BigDecimal original = new BigDecimal("435150");
List<InstallmentPlanVO> plans = installmentGenerator.generate(
CONTRACT_ID, original, CONTRACT_DATE, LIFE);
// 무결성: 분급 합계 == 원 수수료 (반올림 잔차가 새지 않아야 함)
assertThat(sumExpected(plans)).isEqualByComparingTo(original);
}
@Test
@DisplayName("④ 유지수수료 2차년: 1,000,000 ×10% ×50% = 50,000")
void s4_유지수수료_2차년() {
when(ruleMapper.findCompanyRate(eq(PRODUCT_ID), eq(2), any(LocalDate.class)))
.thenReturn(new BigDecimal("10.0"));
when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(2), any(LocalDate.class)))
.thenReturn(new BigDecimal("50.0"));
LedgerVO led = commissionCalculator.calcMaintainLedger(contract, "202501", 2);
assertThat(led.getAgentId()).isEqualTo(KIM);
assertThat(led.getCommissionYear()).isEqualTo(2);
assertThat(led.getAgentAmount()).isEqualByComparingTo("50000.00");
}
@Test
@DisplayName("⑤ 계약 승계: 유지수수료는 후임(박승계 등급3 40%)에게 귀속, 모집은 원설계사 유지")
void s5_계약승계_유지수수료_후임귀속() {
when(ruleMapper.findCompanyRate(eq(PRODUCT_ID), eq(2), any(LocalDate.class)))
.thenReturn(new BigDecimal("10.0"));
// 후임 박승계 등급3 의 2차년 지급률 40%
when(ruleMapper.findPayoutRate(eq(3L), eq("WHOLE_LIFE"), eq(2), any(LocalDate.class)))
.thenReturn(new BigDecimal("40.0"));
LedgerVO led = commissionCalculator.calcMaintainLedger(contract, "202501", 2, PARK);
// 후임에게 귀속 + 후임 등급 지급률로 계산
assertThat(led.getAgentId()).isEqualTo(PARK);
assertThat(led.getAgentAmount()).isEqualByComparingTo("40000.00"); // 100,000 × 40%
// 모집수수료는 여전히 원 모집설계사 김설계
assertThat(contract.getAgentId()).isEqualTo(KIM);
}
@Test
@DisplayName("⑦ 조기 해지 환수: 13개월차 해지 → 50% → 450,000 ×50% = 225,000")
void s6_조기해지_차등환수_13개월_50퍼센트() {
ChargebackGradeVO grade = new ChargebackGradeVO();
grade.setProductCategory(LIFE);
grade.setMonthsFrom(13);
grade.setMonthsTo(24);
grade.setChargebackPercent(new BigDecimal("50"));
when(chargebackGradeMapper.selectByMonths(eq(LIFE), eq(13), anyString()))
.thenReturn(grade);
ChargebackResult r = chargebackCalculator.calculate(
CONTRACT_DATE, CONTRACT_DATE.plusMonths(13),
new BigDecimal("450000"), LIFE);
assertThat(r.monthsElapsed()).isEqualTo(13);
assertThat(r.chargebackAmount()).isEqualByComparingTo("225000");
}
@Nested
@DisplayName("⑧ 집계 → 세금 → 실지급액(net)")
class TaxStage {
@Test
@DisplayName("과세사업자(TAXABLE): 450,000 → net 478,665, 원천세 16,335")
void s7_세금_실지급액_과세사업자() {
TaxBreakdown t = taxCalculator.calculate(
new BigDecimal("450000"), TaxType.BUSINESS_INCOME, VatType.TAXABLE);
assertThat(t.incomeTax()).isEqualByComparingTo("14850"); // 3.3%
assertThat(t.localTax()).isEqualByComparingTo("1485"); // 소득세의 10%
assertThat(t.vat()).isEqualByComparingTo("45000"); // 10%
assertThat(t.netAmount()).isEqualByComparingTo("478665");
// settle_master.tax_amount = 소득세 + 지방세
assertThat(t.incomeTax().add(t.localTax())).isEqualByComparingTo("16335");
}
@Test
@DisplayName("비과세(NONE): 부가세 없음 → net 433,665")
void s8_세금_비과세() {
TaxBreakdown t = taxCalculator.calculate(
new BigDecimal("450000"), TaxType.BUSINESS_INCOME, VatType.NONE);
assertThat(t.vat()).isEqualByComparingTo("0");
assertThat(t.netAmount()).isEqualByComparingTo("433665");
}
}
// ── 헬퍼: 표준 7년 분급 비율 (35/25/15/10/7/5/3) ──
private static List<InstallmentRatioVO> standardRatios() {
int[] pct = {35, 25, 15, 10, 7, 5, 3};
List<InstallmentRatioVO> list = new ArrayList<>();
for (int i = 0; i < pct.length; i++) {
InstallmentRatioVO r = new InstallmentRatioVO();
r.setPlanYear(i + 1);
r.setRatioPercent(new BigDecimal(pct[i]));
list.add(r);
}
return list;
}
private static BigDecimal sumExpected(List<InstallmentPlanVO> plans) {
return plans.stream()
.map(InstallmentPlanVO::getExpectedAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}