diff --git a/docs/시나리오_E2E_보험인입_수수료정산.md b/docs/시나리오_E2E_보험인입_수수료정산.md new file mode 100644 index 0000000..92dc464 --- /dev/null +++ b/docs/시나리오_E2E_보험인입_수수료정산.md @@ -0,0 +1,168 @@ +# E2E 시나리오 — 보험이 어떻게 들어와서 수수료가 어떻게 나가는가 + +> 이 문서는 **계약 1건이 인입되어 설계사 통장에 돈이 꽂힐 때까지**의 전체 순서를 +> "등장인물 + 숫자"로 따라가는 시나리오입니다. +> 각 단계는 실제 계산 코드(`CommissionCalculator`, `RegulatoryLimitChecker`, +> `InstallmentPlanGenerator`, `ChargebackCalculator`, `TaxCalculator`)와 1:1로 대응하며, +> 이 시나리오를 그대로 검증하는 단위테스트가 `SettlementScenarioTest` 입니다. + +--- + +## 0. 등장인물 (이 시나리오의 테스트 데이터) + +| 역할 | 식별자 | 속성 | +|---|---|---| +| 원 모집설계사 **김설계** | `agentId=10`, `gradeId=5` | 사업소득(BUSINESS_INCOME) · 과세사업자(TAXABLE) | +| 후임 승계설계사 **박승계** | `agentId=20`, `gradeId=3` | (계약 승계 단계에서 등장) | +| 상위 직책자 **이지점** | `agentId=30` | 오버라이드 수령 | +| 상품 **무배당 종신보험** | `productId=1`, `WHOLE_LIFE`, 카테고리 `LIFE` | | +| 계약 | `contractId=100`, `policyNo=POL-0001` | 연납보험료 **1,000,000원**, 계약일 **2024-01-15** | + +기준 룰(테스트에서 Mock으로 주입하는 데이터): + +| 룰 | 값 | +|---|---| +| 보험사요율(company_rate) 1차년 | 60% | +| 보험사요율 2차년 | 10% | +| 지급률(payout_rate) 등급5·WHOLE_LIFE·1차년 | 75% | +| 지급률 등급5·2차년 | 50% | +| 지급률 등급3(후임)·2차년 | 40% | +| 분급 비율(7년) | 35 / 25 / 15 / 10 / 7 / 5 / 3 (%) | +| 차등환수 13~24개월 구간 | 50% | +| 1200% 한도(TOTAL_RATIO) | 12,000,000원 (연납 1,000,000 × 1200%) | +| 원천세 | 사업소득 3.3% · 지방세 = 소득세 10% · 부가세 10%(과세사업자) | + +--- + +## 1. 한눈에 보는 순서 + +``` +[보험사] 실적데이터 인입 + │ (Step1 receive → Step2 transform → Step3 reconcile) + ▼ +① 모집수수료 원장 계산 (Step4 calcRecruit) ← premium × company_rate × payout_rate + │ +② 1200% 룰 한도 체크 (RegulatoryLimitChecker) ← 누적이 한도를 넘으면 잘라냄 + │ +③ 분급(7년) 계획 생성 (InstallmentPlanGenerator) ← 큰 모집수수료를 7년에 나눠 지급 + │ +④ 유지수수료 원장 계산 (Step5 calcMaintain) ← 2차년 이후 매년 + │ └ ⑤ 계약 승계 시 후임에게 귀속 전환 (모집은 원설계사 유지) + │ +⑥ 오버라이드 계산 (Step7 calcOverride) ← 하위실적 × 율 (cap 제한) + │ +⑦ 조기 해지 환수 (Step6 chargeback) ← 유지개월 짧을수록 많이 환수 + │ +⑧ 설계사×월 집계 (Step8 aggregate) ← gross 합산 + │ └ 세금 원천징수 + 공제 차감 → net(실지급액) + ▼ +[설계사 통장] ← 펌뱅킹 이체(KFTC) +``` + +--- + +## 2. 단계별 숫자 (시나리오 본문) + +### ① 모집수수료 원장 계산 — 보험이 "들어오는" 첫 돈 +계약이 인입되면 설계사 김설계(등급5)의 모집수수료 원장 1줄이 생긴다. + +``` +① 보험사지급액 = 보험료 1,000,000 × 보험사요율 60% = 600,000 +② 설계사수수료 = 600,000 × 지급률 75% = 450,000 ← gross +③ 원장 원천세 = 450,000 × 3.3% = 14,850 +``` +→ 원장: `agent_id=10`, `commission_year=1`, `agent_amount=450,000`, `tax_amount=14,850` + +### ② 1200% 룰 한도 체크 +초년도 모집수수료 누적이 연납보험료의 1200%(=12,000,000)를 넘으면 안 된다. +- 현재 누적 0 + 450,000 = 450,000 ≤ 12,000,000 → **통과** +- 만약 추가분이 한도를 넘기면 `ViolationResult.violated` 로 막고 초과분을 잘라낸다. + +### ③ 분급(7년 분할) 계획 생성 +모집수수료 450,000을 한 번에 안 주고 7년에 나눈다. + +| 차년 | 비율 | 지급액 | +|---|---|---| +| 1 | 35% | 157,500 | +| 2 | 25% | 112,500 | +| 3 | 15% | 67,500 | +| 4 | 10% | 45,000 | +| 5 | 7% | 31,500 | +| 6 | 5% | 22,500 | +| 7 | 3% | 13,500 | +| **합** | **100%** | **450,000** | + +> **무결성 규칙:** 분급액의 **합은 원수수료와 정확히 같아야 한다.** +> 금액이 딱 나눠떨어지지 않을 때(예: 435,150원) 회차별 반올림 잔차가 생기는데, +> 이때는 **마지막 회차가 잔차를 흡수**해서 합계를 원금에 맞춘다. (→ §4 보완) + +### ④ 유지수수료 (2차년) +계약이 유지되면 2차년부터 유지수수료가 매년 발생한다. +``` +보험사지급액 = 1,000,000 × 10%(2차년 요율) = 100,000 +설계사수수료 = 100,000 × 50%(등급5 2차년) = 50,000 +``` + +### ⑤ 계약 승계 (김설계 해촉 → 박승계 후임) +김설계가 해촉되면 그 계약의 **유지수수료만** 후임 박승계(등급3)에게 넘어간다. +``` +유지수수료(후임) = 100,000 × 40%(등급3 2차년) = 40,000 → 원장 agent_id = 20(박승계) +``` +- **모집수수료는 원 모집설계사 김설계(10)에게 그대로 남는다.** (고아계약 승계 표준) + +### ⑥ 오버라이드 (상위 직책자 이지점) +하위 김설계의 실적에 대해 상위 이지점이 받는 수당. +``` +오버라이드 = 하위실적 2,000,000 × 4% = 80,000 (cap 100,000 미만이므로 그대로) +``` + +### ⑦ 조기 해지 환수 — 13개월차 해지 +계약이 13개월차(2024-01-15 → 2025-02-15)에 깨지면 13~24개월 구간 환수율 50%. +``` +환수액 = 기지급 모집수수료 450,000 × 50% = 225,000 ← 다음 정산에서 (−) 차감 +``` + +### ⑧ 집계 → 세금 → 실지급액(net) +김설계 한 달치를 settle_master로 집계하고 세금·공제를 반영한다. +과세사업자(TAXABLE) gross 450,000 기준: +``` +소득세 = 450,000 × 3.3% = 14,850 +지방세 = 14,850 × 10% = 1,485 +부가세 = 450,000 × 10% = 45,000 (과세사업자만 +) +실지급 = 450,000 − 14,850 − 1,485 + 45,000 = 478,665 +``` +- 여기에 가지급금 공제 100,000이 있으면 net = 478,665 − 100,000 = **378,665** +- 비과세(NONE) 설계사는 부가세가 없어 net = 450,000 − 14,850 − 1,485 = **433,665** + +--- + +## 3. 시나리오 ↔ 코드 ↔ 테스트 대응표 + +| 단계 | 코드 | 테스트 메서드 | +|---|---|---| +| ① 모집수수료 | `CommissionCalculator.calcRecruitLedger` | `s1_보험인입_모집수수료_원장계산` | +| ② 1200% 룰 | `BatchRegulatoryLimitChecker.checkContractTotal` | `s2_규제_1200퍼센트_한도_통과및위반` | +| ③ 분급 7년 | `BatchInstallmentPlanGenerator.generate` | `s3_분급_7년_분할_합계100퍼센트` | +| ③ 분급 잔차 | 〃 | `s3b_분급_라운딩_잔차_마지막회차_흡수` | +| ④ 유지수수료 | `CommissionCalculator.calcMaintainLedger` | `s4_유지수수료_2차년` | +| ⑤ 계약 승계 | `CommissionCalculator.calcMaintainLedger(effectiveAgentId)` | `s5_계약승계_유지수수료_후임귀속` | +| ⑦ 차등 환수 | `BatchChargebackCalculator.calculate` | `s6_조기해지_차등환수_13개월_50퍼센트` | +| ⑧ 세금/실지급 | `BatchTaxCalculator.calculate` | `s7_세금_실지급액_과세사업자` / `s8_세금_비과세` | + +--- + +## 4. 이 시나리오 작성 중 발견·보완한 사항 + +**분급 계획 합계 불일치(반올림 잔차) — 수정함.** +- 현상: 분급액을 회차별로 `총액 × 비율 / 100` 으로 각각 반올림하면, + 원금이 7로 나눠떨어지지 않을 때 분급 합계가 원금과 1~수원 어긋났다. + (예: 435,150원 → 회차 합 435,153원, +3원 누수) +- 영향: 분급 총합 ≠ 원 모집수수료 → 정산 무결성(합계 일치) 위반, 과/미지급. +- 수정: **비율 합이 정확히 100%인 정규 스케줄**일 때 마지막 회차가 + `원금 − 직전 회차 합` 으로 잔차를 흡수하도록 보정. + (비율 합이 100% 미만인 부분지급 스케줄은 기존 동작 유지 → 설계 의도 보존) +- 대상: `InstallmentPlanGenerator`(ga-api), `BatchInstallmentPlanGenerator`(ga-batch) 양쪽. + +--- + +*기준: schema v108, DOMAIN_KNOWLEDGE.md 7장 공식. 모든 금액 BigDecimal HALF_UP.* diff --git a/ga-api/src/main/java/com/ga/api/service/installment/InstallmentPlanGenerator.java b/ga-api/src/main/java/com/ga/api/service/installment/InstallmentPlanGenerator.java index 5ef75f4..ab0b9d4 100644 --- a/ga-api/src/main/java/com/ga/api/service/installment/InstallmentPlanGenerator.java +++ b/ga-api/src/main/java/com/ga/api/service/installment/InstallmentPlanGenerator.java @@ -32,11 +32,26 @@ public class InstallmentPlanGenerator { String baseDate = contractDate.format(DATE_FMT); List ratios = ratioMapper.selectAllActiveRatios(productCategory, baseDate); + // 비율 합이 정확히 100%인 정규 스케줄이면 마지막 회차가 반올림 잔차를 흡수 + // → 분급 합계 == 원 수수료 보장 (부분지급 스케줄은 회차별 반올림 유지). + boolean fullSchedule = sumRatios(ratios).compareTo(BigDecimal.valueOf(100)) == 0; + BigDecimal allocated = BigDecimal.ZERO; + List plans = new ArrayList<>(); - for (InstallmentRatioVO ratio : ratios) { + 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); + } 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()); @@ -44,13 +59,16 @@ public class InstallmentPlanGenerator { plan.setSettleMonth( contractDate.plusYears(ratio.getPlanYear()).plusMonths(planMonth - 1L).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 ratios) { + return ratios.stream() + .map(InstallmentRatioVO::getRatioPercent) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } } diff --git a/ga-api/src/main/java/com/ga/api/service/upload/UploadService.java b/ga-api/src/main/java/com/ga/api/service/upload/UploadService.java index d61f92f..7264540 100644 --- a/ga-api/src/main/java/com/ga/api/service/upload/UploadService.java +++ b/ga-api/src/main/java/com/ga/api/service/upload/UploadService.java @@ -10,7 +10,7 @@ import com.ga.core.mapper.upload.UploadTemplateMapper; import com.ga.core.vo.upload.UploadHistoryVO; import com.ga.core.vo.upload.UploadTemplateColumnVO; import com.ga.core.vo.upload.UploadTemplateVO; -import com.monitorjbl.xlsx.StreamingReader; +import com.github.pjfanning.xlsx.StreamingReader; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.poi.ss.usermodel.CellType; diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchInstallmentPlanGenerator.java b/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchInstallmentPlanGenerator.java index 8edb384..d5c436f 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchInstallmentPlanGenerator.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchInstallmentPlanGenerator.java @@ -32,20 +32,38 @@ public class BatchInstallmentPlanGenerator { String baseDate = contractDate.format(DATE_FMT); List ratios = ratioMapper.selectAllActiveRatios(productCategory, baseDate); + // 비율 합이 정확히 100%인 정규 스케줄이면 마지막 회차가 반올림 잔차를 흡수 + // → 분급 합계 == 원 수수료 보장 (부분지급 스케줄은 회차별 반올림 유지). + boolean fullSchedule = sumRatios(ratios).compareTo(BigDecimal.valueOf(100)) == 0; + BigDecimal allocated = BigDecimal.ZERO; + List 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 ratios) { + return ratios.stream() + .map(InstallmentRatioVO::getRatioPercent) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } } diff --git a/ga-batch/src/test/java/com/ga/batch/settlement/scenario/SettlementScenarioTest.java b/ga-batch/src/test/java/com/ga/batch/settlement/scenario/SettlementScenarioTest.java new file mode 100644 index 0000000..4e0e822 --- /dev/null +++ b/ga-batch/src/test/java/com/ga/batch/settlement/scenario/SettlementScenarioTest.java @@ -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 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 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 standardRatios() { + int[] pct = {35, 25, 15, 10, 7, 5, 3}; + List 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 plans) { + return plans.stream() + .map(InstallmentPlanVO::getExpectedAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } +} diff --git a/ga-common/build.gradle b/ga-common/build.gradle index f3f8d5d..0a847c9 100644 --- a/ga-common/build.gradle +++ b/ga-common/build.gradle @@ -20,9 +20,9 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5' - // Excel + // Excel (xlsx-streamer 2.2.0 → POI 5.2.5 비호환(NoSuchMethodError). 유지보수 후속 fork로 교체) api 'org.apache.poi:poi-ooxml:5.2.5' - api 'com.monitorjbl:xlsx-streamer:2.2.0' + api 'com.github.pjfanning:excel-streaming-reader:4.3.0' // OpenAPI / Swagger api 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' diff --git a/ga-common/src/main/java/com/ga/common/excel/ExcelService.java b/ga-common/src/main/java/com/ga/common/excel/ExcelService.java index ce66144..0ce84a0 100644 --- a/ga-common/src/main/java/com/ga/common/excel/ExcelService.java +++ b/ga-common/src/main/java/com/ga/common/excel/ExcelService.java @@ -4,7 +4,7 @@ import com.ga.common.code.CommonCodeService; import com.ga.common.exception.BizException; import com.ga.common.exception.ErrorCode; import com.ga.common.util.MaskUtil; -import com.monitorjbl.xlsx.StreamingReader; +import com.github.pjfanning.xlsx.StreamingReader; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/ga-common/src/test/java/com/ga/common/excel/ExcelServiceImportTest.java b/ga-common/src/test/java/com/ga/common/excel/ExcelServiceImportTest.java new file mode 100644 index 0000000..1eca71e --- /dev/null +++ b/ga-common/src/test/java/com/ga/common/excel/ExcelServiceImportTest.java @@ -0,0 +1,152 @@ +package com.ga.common.excel; + +import com.ga.common.annotation.ExcelColumn; +import com.ga.common.code.CommonCodeService; +import lombok.Getter; +import lombok.Setter; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.junit.jupiter.api.DisplayName; +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.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayOutputStream; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 엑셀 업로드(보험사 실적 수신 ① 단계) 라운드트립 테스트. + * 실제 .xlsx 바이트를 생성 → ExcelService.importLargeExcel 으로 파싱 → + * VO 매핑/타입변환/필수검증/배치 consumer 호출/에러행 수집을 검증한다. + * + * 시나리오 ① "보험이 들어오는" 입구: 보험사 실적 엑셀이 시스템으로 인입되는 경로. + */ +@ExtendWith(MockitoExtension.class) +class ExcelServiceImportTest { + + @Mock CommonCodeService codeService; // import 경로에서는 미사용 (export 전용) + + /** 보험사 실적 1행(raw)을 모사하는 업로드 VO. */ + @Getter + @Setter + public static class CarrierResultRow { + @ExcelColumn(header = "증권번호", order = 1, required = true) + private String policyNo; + @ExcelColumn(header = "설계사코드", order = 2) + private String agentCode; + @ExcelColumn(header = "보험료", order = 3) + private BigDecimal premium; + @ExcelColumn(header = "계약일", order = 4) + private LocalDate contractDate; + @ExcelColumn(header = "회차", order = 5) + private Integer commissionYear; + } + + @Test + @DisplayName("정상 행은 VO로 정확히 파싱되고, 필수(증권번호) 누락 행은 에러로 수집된다") + void 보험사실적_엑셀업로드_정상행파싱_및_필수누락행_에러수집() throws Exception { + // given: 헤더 + 정상 2행 + 증권번호 누락 1행 + byte[] xlsx = buildXlsx(sheet -> { + header(sheet); + dataRow(sheet, 1, "POL-1001", "AG-10", 1000000, "2024-01-15", 1); + dataRow(sheet, 2, "POL-1002", "AG-20", 250000.50, "2024-02-20", 2); + dataRow(sheet, 3, null, "AG-30", 500000, "2024-03-01", 1); // 증권번호 누락 → 에러 + }); + MultipartFile file = multipart(xlsx); + ExcelService excelService = new ExcelService(codeService); + + // when + List saved = new ArrayList<>(); + ExcelImportResult result = excelService.importLargeExcel( + file, CarrierResultRow.class, 100, saved::addAll); + + // then: 합계/성공/에러 카운트 + assertThat(result.getTotalCount()).isEqualTo(3); + assertThat(result.getSuccessCount()).isEqualTo(2); + assertThat(result.getErrorCount()).isEqualTo(1); + assertThat(result.getErrors()).hasSize(1); + assertThat(result.getErrors().get(0).getRowNumber()).isEqualTo(4); // 엑셀 4번째 행(1-base, 헤더 포함) + assertThat(result.getErrors().get(0).getErrorMessage()).contains("증권번호"); + + // then: 파싱 값/타입 변환 정확성 + assertThat(saved).hasSize(2); + CarrierResultRow r1 = saved.get(0); + assertThat(r1.getPolicyNo()).isEqualTo("POL-1001"); + assertThat(r1.getAgentCode()).isEqualTo("AG-10"); + assertThat(r1.getPremium()).isEqualByComparingTo("1000000"); // numeric → BigDecimal + assertThat(r1.getContractDate()).isEqualTo(LocalDate.of(2024, 1, 15)); // string → LocalDate + assertThat(r1.getCommissionYear()).isEqualTo(1); // numeric → Integer + assertThat(saved.get(1).getPremium()).isEqualByComparingTo("250000.50"); + } + + @Test + @DisplayName("배치 크기 단위로 consumer가 나누어 호출되고 전 행이 누적 저장된다") + void 배치사이즈_단위로_consumer_누적호출() throws Exception { + byte[] xlsx = buildXlsx(sheet -> { + header(sheet); + for (int i = 1; i <= 5; i++) { + dataRow(sheet, i, "POL-" + i, "AG-" + i, 100000 * i, "2024-01-15", 1); + } + }); + ExcelService excelService = new ExcelService(codeService); + + List batchSizes = new ArrayList<>(); + List saved = new ArrayList<>(); + ExcelImportResult result = excelService.importLargeExcel( + multipart(xlsx), CarrierResultRow.class, 2, batch -> { + batchSizes.add(batch.size()); + saved.addAll(batch); + }); + + // 5행 / 배치2 → [2, 2, 1] 로 consumer 3회 호출 + assertThat(batchSizes).containsExactly(2, 2, 1); + assertThat(result.getSuccessCount()).isEqualTo(5); + assertThat(result.getErrorCount()).isZero(); + assertThat(saved).hasSize(5); + } + + // ── .xlsx 빌더 헬퍼 ── + + private interface SheetWriter { void write(Sheet sheet); } + + private static byte[] buildXlsx(SheetWriter writer) throws Exception { + try (XSSFWorkbook wb = new XSSFWorkbook(); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + Sheet sheet = wb.createSheet("Sheet1"); + writer.write(sheet); + wb.write(out); + return out.toByteArray(); + } + } + + private static void header(Sheet sheet) { + Row h = sheet.createRow(0); + String[] cols = {"증권번호", "설계사코드", "보험료", "계약일", "회차"}; + for (int i = 0; i < cols.length; i++) h.createCell(i).setCellValue(cols[i]); + } + + private static void dataRow(Sheet sheet, int rowIdx, String policyNo, String agentCode, + double premium, String contractDate, int year) { + Row r = sheet.createRow(rowIdx); + if (policyNo != null) r.createCell(0).setCellValue(policyNo); // null → 셀 미생성(필수 누락) + r.createCell(1).setCellValue(agentCode); + r.createCell(2).setCellValue(premium); + r.createCell(3).setCellValue(contractDate); + r.createCell(4).setCellValue(year); + } + + private static MultipartFile multipart(byte[] xlsx) { + return new MockMultipartFile( + "file", "carrier.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", xlsx); + } +}