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:
+23
-5
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user