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:
@@ -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.*
|
||||
@@ -32,11 +32,26 @@ public class InstallmentPlanGenerator {
|
||||
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);
|
||||
// 마스터의 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<InstallmentRatioVO> ratios) {
|
||||
return ratios.stream()
|
||||
.map(InstallmentRatioVO::getRatioPercent)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<CarrierResultRow> 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<Integer> batchSizes = new ArrayList<>();
|
||||
List<CarrierResultRow> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user