feat: 1200%룰 이연 분급 도래월 지급(PayInstallmentStep) + 코드베이스 신입교육 주석
PayInstallmentStep(Step 4.5): 1200%룰로 이연된 분급(installment_plan SCHEDULED)을 도래월(settle_month=정산월)에 recruit_ledger로 편입해 실제 지급. 이전엔 이연만 되고 도래월 지급 절차가 없어 차액이 영구 미지급되던 갭을 메움. - BatchConfig job flow에 step4b(calcRecruit→payInstallment→calcMaintain) 등록 - InstallmentPlanMapper.resetPaidToScheduledByMonth(+XML)로 재실행 멱등 (진입 시 PAID→SCHEDULED 역산, Step4 deleteBySettleMonth가 원장 정리) - RecruitLedgerMapper insert에 reconcile_status 컬럼, SettlementContext.installmentPaidCount - 단위테스트 3건(정상지급/멱등/계약미존재) GREEN 신입교육 주석: 컨트롤러/서비스/공통/프론트 전반에 도메인·코드 설명 주석 추가 (동작 변경 없음 — 빌드/테스트/타입체크 전부 GREEN으로 무회귀 확인). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,23 @@ import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
|
||||
/**
|
||||
* ga-batch 모듈 부트 진입점.
|
||||
*
|
||||
* 이 모듈은 GA(보험대리점)의 "월정산 엔진"이다. 매월 보험사로부터 받은 수수료 데이터를
|
||||
* 받아서(수신) → 표준화(변환) → 대사 → 모집/유지 수수료 계산 → 환수/오버라이드 계산 →
|
||||
* 설계사별 합산(정산마스터 생성) → 회계분개 → (분기/연말) 세무집계 순서로 처리한다.
|
||||
*
|
||||
* 신입 참고 용어:
|
||||
* - GA: General Agency. 여러 보험사 상품을 함께 파는 대형 보험대리점.
|
||||
* - 정산(settlement): 한 달 동안 설계사가 번 수수료를 모아 세금·차감·환수를 반영해
|
||||
* "실지급액(payable)"을 산출하는 작업.
|
||||
*
|
||||
* 애너테이션 설명:
|
||||
* - scanBasePackages = "com.ga": ga-core/ga-common 등 형제 모듈의 Bean(Mapper, 유틸)까지
|
||||
* 한 번에 컴포넌트 스캔하기 위해 루트 패키지를 지정.
|
||||
* - @EnableCaching: SystemConfigService 등이 세율·설정값을 캐싱해 반복 DB 조회를 줄임.
|
||||
*/
|
||||
@SpringBootApplication(scanBasePackages = {"com.ga"})
|
||||
@EnableCaching
|
||||
public class GaBatchApplication {
|
||||
|
||||
@@ -15,9 +15,32 @@ import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
import com.ga.batch.settlement.step.*;
|
||||
|
||||
/**
|
||||
* Spring Batch 월정산 Job/Step 구성 클래스.
|
||||
*
|
||||
* 핵심 개념:
|
||||
* - Job: 한 번의 월정산 실행 전체(13개 Step의 직렬 파이프라인).
|
||||
* - Step: Job 안의 한 단계. 여기서는 전부 Tasklet 방식(한 메서드가 끝나면 그 Step 완료).
|
||||
* - JobScope: Job 1회 실행 단위로 새 Bean을 만든다. 정산월 같은 실행별 파라미터를 주입받기 위함.
|
||||
*
|
||||
* 실행 순서(아래 monthlySettlementJob 의 start→next 체인):
|
||||
* receiveData(1) → transformData(2) → reconcile(3) → calcRecruit(4) → payInstallment(4b)
|
||||
* → calcMaintain(5) → chargebackException(6) → calcOverride(7) → aggregate(8)
|
||||
* → accountingEntryGenerate(8b) → yearEndIncomeAggregate(9) → withholdingTaxQuarterly(11)
|
||||
* → vatReportQuarterly(12).
|
||||
* 앞 단계가 만든 원장(ledger) 데이터를 뒤 단계가 읽어 쓰므로 순서가 중요하다.
|
||||
*/
|
||||
@Configuration
|
||||
public class BatchConfig {
|
||||
|
||||
/**
|
||||
* Step 간 공유되는 실행 컨텍스트(SettlementContext) Bean.
|
||||
*
|
||||
* jobParameters 에서 값을 읽어 채운다:
|
||||
* - settleMonth: 정산 대상 월(yyyyMM). 거의 모든 Step 이 이 값으로 데이터를 필터링한다.
|
||||
* - jobLogId: 실행 로그 PK. SpEL '?: -1L' 은 파라미터가 없으면 -1L 로 폴백한다는 뜻.
|
||||
* @JobScope 라서 Job 1회 실행마다 새로 생성된다(동시 실행 시 값이 섞이지 않음).
|
||||
*/
|
||||
@Bean
|
||||
@JobScope
|
||||
public SettlementContext settlementContext(@Value("#{jobParameters['settleMonth']}") String settleMonth,
|
||||
@@ -28,6 +51,10 @@ public class BatchConfig {
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* 월정산 Job 정의. 주입받은 13개 Step(step1~step12)을 정해진 순서로 연결한다.
|
||||
* start() 가 첫 Step, 이후 next() 가 직렬로 이어진다. 한 Step 이 실패하면 Job 도 중단된다.
|
||||
*/
|
||||
@Bean
|
||||
public Job monthlySettlementJob(JobRepository jobRepository,
|
||||
PlatformTransactionManager tx,
|
||||
@@ -35,6 +62,7 @@ public class BatchConfig {
|
||||
TransformDataStep step2,
|
||||
ReconcileStep step3,
|
||||
CalcRecruitStep step4,
|
||||
PayInstallmentStep step4b,
|
||||
CalcMaintainStep step5,
|
||||
ChargebackExceptionStep step6,
|
||||
CalcOverrideStep step7,
|
||||
@@ -48,6 +76,7 @@ public class BatchConfig {
|
||||
.next(taskletStep("transformData", step2, jobRepository, tx))
|
||||
.next(taskletStep("reconcile", step3, jobRepository, tx))
|
||||
.next(taskletStep("calcRecruit", step4, jobRepository, tx))
|
||||
.next(taskletStep("payInstallment", step4b, jobRepository, tx))
|
||||
.next(taskletStep("calcMaintain", step5, jobRepository, tx))
|
||||
.next(taskletStep("chargebackException", step6, jobRepository, tx))
|
||||
.next(taskletStep("calcOverride", step7, jobRepository, tx))
|
||||
@@ -59,6 +88,11 @@ public class BatchConfig {
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tasklet 한 개를 단일 Step 으로 감싸는 헬퍼.
|
||||
* 모든 Step 이 같은 패턴이라 중복을 줄이려고 분리했다.
|
||||
* tx(트랜잭션 매니저)를 넘기므로 Tasklet 의 execute() 는 하나의 트랜잭션 안에서 실행된다.
|
||||
*/
|
||||
private Step taskletStep(String name, Tasklet tasklet,
|
||||
JobRepository jobRepository, PlatformTransactionManager tx) {
|
||||
return new StepBuilder(name, jobRepository)
|
||||
|
||||
@@ -28,25 +28,38 @@ public class JobLauncherController {
|
||||
private final Job monthlySettlementJob;
|
||||
private final BatchJobLogMapper jobLogMapper;
|
||||
|
||||
/**
|
||||
* 월정산 Job 을 트리거하는 내부 엔드포인트. ga-api 가 사용자의 정산 실행 요청을 받아
|
||||
* 이 엔드포인트로 HTTP 호출하면 여기서 Spring Batch Job 을 기동한다.
|
||||
*
|
||||
* @param body 요청 본문. "settleMonth" 키에 정산월(yyyyMM, 예: "202503")이 담긴다.
|
||||
* @return 생성된 배치 실행 로그 PK(jobId). 호출 측이 진행상태 조회에 사용.
|
||||
*/
|
||||
@PostMapping("/monthly-settlement")
|
||||
public ApiResponse<Long> runMonthlySettlement(@RequestBody Map<String, String> body) throws Exception {
|
||||
// 1) 입력 검증: settleMonth 는 반드시 6자리 숫자(yyyyMM). 아니면 즉시 거부.
|
||||
String settleMonth = body.get("settleMonth");
|
||||
if (settleMonth == null || !settleMonth.matches("\\d{6}")) {
|
||||
throw new BizException(ErrorCode.INVALID_PARAMETER, "settleMonth (yyyyMM)");
|
||||
}
|
||||
|
||||
// 2) 실행 로그 선기록(STARTED). insert 후 log.jobId 에 자동 채번된 PK 가 채워진다.
|
||||
BatchJobLogVO log = new BatchJobLogVO();
|
||||
log.setJobName("MONTHLY_SETTLEMENT");
|
||||
log.setSettleMonth(settleMonth);
|
||||
log.setStatus("STARTED");
|
||||
jobLogMapper.insert(log);
|
||||
|
||||
// 3) Job 파라미터 구성. settleMonth/jobLogId 는 Step 들이 사용.
|
||||
// ts(현재시각 밀리초)는 "같은 정산월을 여러 번 재실행"할 수 있도록 매 실행을 고유하게 만든다.
|
||||
// (Spring Batch 는 동일 파라미터 Job 재실행을 막으므로 유니크 키가 필요)
|
||||
JobParameters params = new JobParametersBuilder()
|
||||
.addString("settleMonth", settleMonth)
|
||||
.addLong("jobLogId", log.getJobId())
|
||||
.addLong("ts", System.currentTimeMillis())
|
||||
.toJobParameters();
|
||||
|
||||
// 4) Job 기동. 기본 JobLauncher 는 동기 실행이라 Job 이 끝날 때까지 블로킹된다.
|
||||
jobLauncher.run(monthlySettlementJob, params);
|
||||
return ApiResponse.ok(log.getJobId());
|
||||
}
|
||||
|
||||
@@ -3,18 +3,26 @@ package com.ga.batch.settlement;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* Job 단위 컨텍스트. Step 간 공유 상태.
|
||||
* Job 단위 컨텍스트. 한 번의 월정산 실행 동안 Step 들이 공유하는 상태 보관소.
|
||||
*
|
||||
* 역할:
|
||||
* - settleMonth/jobLogId: 모든 Step 이 참조하는 실행 식별 정보(어느 달을, 어느 로그로).
|
||||
* - *Count 필드들: 각 Step 이 처리한 건수를 기록해 두는 진행 카운터.
|
||||
* (모니터링/로그용. 뒤 Step 의 계산 로직에 직접 쓰이지는 않음)
|
||||
*
|
||||
* @Data(Lombok)로 getter/setter 가 자동 생성된다.
|
||||
*/
|
||||
@Data
|
||||
public class SettlementContext {
|
||||
private String settleMonth; // yyyyMM
|
||||
private Long jobLogId;
|
||||
private int receivedCount;
|
||||
private int transformedCount;
|
||||
private int reconcileDiffCount;
|
||||
private int recruitCount;
|
||||
private int maintainCount;
|
||||
private int chargebackCount;
|
||||
private int overrideCount;
|
||||
private int aggregateCount;
|
||||
private String settleMonth; // 정산 대상 월 (yyyyMM, 예: 202503)
|
||||
private Long jobLogId; // batch_job_log PK (실행 로그 추적용)
|
||||
private int receivedCount; // Step1: 수신한 raw 데이터 행 수
|
||||
private int transformedCount; // Step2: 표준 원장으로 변환 성공한 건수
|
||||
private int reconcileDiffCount; // Step3: 보험사 통보액 vs 계산액 불일치 회사 수
|
||||
private int recruitCount; // Step4: 생성한 모집수수료 원장 건수
|
||||
private int installmentPaidCount; // Step4b: 분급(선지급 분할) 지급 건수
|
||||
private int maintainCount; // Step5: 생성한 유지수수료 원장 건수
|
||||
private int chargebackCount; // Step6: 생성한 환수 건수
|
||||
private int overrideCount; // Step7: 생성한 오버라이드(상위수당) 건수
|
||||
private int aggregateCount; // Step8: settle_master 로 집계된 설계사 수
|
||||
}
|
||||
|
||||
@@ -15,22 +15,33 @@ import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 수수료 계산 핵심 로직.
|
||||
* - 모집수수료: premium × company_rate × payout_rate
|
||||
* - 유지수수료: 동일 공식, 회차에 따른 commission_year 만 다름
|
||||
* - 모든 금액은 BigDecimal HALF_UP, 소수점 2자리
|
||||
*
|
||||
* 도메인 배경:
|
||||
* - 모집수수료: 계약을 새로 따냈을 때 받는 수수료. 보통 1회차(첫 정산).
|
||||
* - 유지수수료: 그 계약이 계속 유지될 때 회차(년차)마다 받는 수수료.
|
||||
* 둘 다 계산식은 같고 commission_year(몇 년차냐)만 다르다.
|
||||
*
|
||||
* 2단계 비율 구조(중요):
|
||||
* 1) company_rate(회사지급률): 보험사가 GA 에게 주는 비율. premium(보험료) 기준.
|
||||
* 2) payout_rate(설계사지급률): GA 가 받은 금액 중 설계사 등급(grade)에 따라 떼어주는 비율.
|
||||
* → 설계사가 받는 금액 = premium × company_rate × payout_rate.
|
||||
*
|
||||
* 모든 금액은 BigDecimal 로 계산하며 MoneyUtil 이 HALF_UP(반올림)·소수 2자리를 처리한다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class CommissionCalculator {
|
||||
|
||||
private final RuleMapper ruleMapper;
|
||||
private final AgentMapper agentMapper;
|
||||
private final RuleMapper ruleMapper; // 회사지급률/설계사지급률 등 수수료율 룰 조회
|
||||
private final AgentMapper agentMapper; // 설계사 등급(gradeId) 조회용
|
||||
|
||||
/** 모집수수료(1회차) 원장 1건 생성. 귀속 설계사는 계약의 모집설계사. */
|
||||
public LedgerVO calcRecruitLedger(ContractResp c, String settleMonth) {
|
||||
return buildLedger(c, settleMonth, 1, c.getAgentId());
|
||||
}
|
||||
|
||||
/** 유지수수료 원장 1건 생성. commissionYear 로 몇 년차인지 지정. 귀속은 원 모집설계사. */
|
||||
public LedgerVO calcMaintainLedger(ContractResp c, String settleMonth, int commissionYear) {
|
||||
return buildLedger(c, settleMonth, commissionYear, c.getAgentId());
|
||||
}
|
||||
@@ -45,11 +56,27 @@ public class CommissionCalculator {
|
||||
effectiveAgentId != null ? effectiveAgentId : c.getAgentId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 수수료 원장(LedgerVO) 1건을 만드는 실제 계산 본체.
|
||||
*
|
||||
* @param year commission_year. 수수료율 룰은 회차(년차)별로 다를 수 있어 조회 키로 쓰인다.
|
||||
* @param agentId 수수료 귀속 설계사. 승계가 있으면 후임 설계사가 들어온다.
|
||||
*
|
||||
* 처리:
|
||||
* 1) 설계사 조회(등급 gradeId 필요). 없으면 null 반환(계산 불가).
|
||||
* 2) baseDate(=계약일, 없으면 오늘)로 시점에 유효한 수수료율 룰을 찾는다.
|
||||
* 3) company_rate / payout_rate 를 곱해 회사수령액→설계사지급액 산출.
|
||||
* 4) 원천세(tax) 미리 계산해 원장에 보관.
|
||||
*
|
||||
* 안전 폴백: 율이 없으면 0 으로 두고 경고 로그만 남긴다(계산 중단보다 과소지급 표시가 낫다는 정책).
|
||||
*/
|
||||
private LedgerVO buildLedger(ContractResp c, String settleMonth, int year, Long agentId) {
|
||||
AgentVO agent = agentMapper.selectById(agentId);
|
||||
if (agent == null) return null;
|
||||
// 율 조회 기준일: 계약일 우선. 없으면 오늘 날짜로 폴백.
|
||||
LocalDate baseDate = c.getContractDate() != null ? c.getContractDate() : LocalDate.now();
|
||||
|
||||
// 회사지급률: 상품/회차/시점/판매채널에 맞는 율을 룰 테이블에서 조회.
|
||||
BigDecimal companyRate = ruleMapper.findCompanyRate(c.getProductId(), year, baseDate, c.getSalesChannel());
|
||||
if (companyRate == null) {
|
||||
log.warn("[수수료율 누락] company_rate 없음 → 0 폴백(과소지급 위험): contractId={}, productId={}, year={}, baseDate={}",
|
||||
@@ -57,6 +84,7 @@ public class CommissionCalculator {
|
||||
companyRate = BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
// 설계사지급률: 설계사 등급/보험종류/회차/시점/판매채널에 맞는 율을 조회.
|
||||
BigDecimal payoutRate = ruleMapper.findPayoutRate(
|
||||
agent.getGradeId(), c.getInsuranceType(), year, baseDate, c.getSalesChannel());
|
||||
if (payoutRate == null) {
|
||||
@@ -65,10 +93,11 @@ public class CommissionCalculator {
|
||||
payoutRate = BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
BigDecimal premium = MoneyUtil.zero(c.getPremium());
|
||||
BigDecimal companyAmount = MoneyUtil.pct(premium, companyRate);
|
||||
BigDecimal agentAmount = MoneyUtil.pct(companyAmount, payoutRate);
|
||||
BigDecimal taxAmount = MoneyUtil.tax(agentAmount);
|
||||
// 핵심 계산식 (2단계 비율):
|
||||
BigDecimal premium = MoneyUtil.zero(c.getPremium()); // 보험료(null 이면 0)
|
||||
BigDecimal companyAmount = MoneyUtil.pct(premium, companyRate); // 보험사 → GA 수령액
|
||||
BigDecimal agentAmount = MoneyUtil.pct(companyAmount, payoutRate); // GA → 설계사 지급액
|
||||
BigDecimal taxAmount = MoneyUtil.tax(agentAmount); // 설계사 지급액 기준 원천세(미리 계산)
|
||||
|
||||
LedgerVO led = new LedgerVO();
|
||||
led.setContractId(c.getContractId());
|
||||
|
||||
+22
-3
@@ -12,8 +12,15 @@ import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
|
||||
/**
|
||||
* 배치용 차등환수 계산기. ga-api ChargebackCalculator와 동일 로직.
|
||||
* chargeback_grade 테이블 기반 5단계 환수율 조회.
|
||||
* 배치용 차등환수(差等還收) 계산기. ga-api ChargebackCalculator와 동일 로직을 배치 모듈에 복제한 것.
|
||||
*
|
||||
* 환수란?
|
||||
* 계약이 단기에 해지/실효되면, 이미 지급한 모집수수료의 전부 또는 일부를 다시 회수하는 것.
|
||||
* "차등환수"는 계약 후 경과 기간이 짧을수록 더 많이(높은 비율로) 환수하는 구조다.
|
||||
* (예: 3개월 내 해지 → 100% 환수, 6개월 → 70%, ... 시간이 지날수록 환수율이 낮아짐)
|
||||
*
|
||||
* chargeback_grade 테이블에 (상품군, 경과월 구간) → 환수율(%) 이 단계별로 정의돼 있고,
|
||||
* 경과월에 맞는 구간 1건을 조회해 환수율을 얻는다.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@@ -23,18 +30,30 @@ public class BatchChargebackCalculator {
|
||||
|
||||
private final ChargebackGradeMapper gradeMapper;
|
||||
|
||||
/**
|
||||
* 환수금액 계산.
|
||||
*
|
||||
* @param contractDate 계약일
|
||||
* @param terminationDate 해지/실효일
|
||||
* @param originalCommission 원래 지급했던 모집수수료(환수 원금)
|
||||
* @param productCategory 상품군. 같은 경과월이라도 상품군에 따라 환수율이 다를 수 있어 조회 키.
|
||||
* @return 환수금액 + 경과월 + 적용 환수율 + 사유 문자열
|
||||
*/
|
||||
public ChargebackResult calculate(LocalDate contractDate, LocalDate terminationDate,
|
||||
BigDecimal originalCommission, String productCategory) {
|
||||
// 계약일 ~ 해지일 사이 경과 개월 수. 이 값으로 환수율 구간을 찾는다.
|
||||
int monthsElapsed = (int) ChronoUnit.MONTHS.between(contractDate, terminationDate);
|
||||
String today = LocalDate.now().format(DATE_FMT);
|
||||
String today = LocalDate.now().format(DATE_FMT); // 룰의 유효기간(시점) 판정용
|
||||
|
||||
ChargebackGradeVO grade = gradeMapper.selectByMonths(productCategory, monthsElapsed, today);
|
||||
|
||||
// 해당하는 환수 구간이 없으면 환수 0 (예: 환수기간을 이미 지난 오래된 계약).
|
||||
if (grade == null) {
|
||||
return new ChargebackResult(BigDecimal.ZERO, monthsElapsed, BigDecimal.ZERO,
|
||||
"적용 환수 구간 없음");
|
||||
}
|
||||
|
||||
// 환수금액 = 원수수료 × 환수율(%) / 100. 원 단위 반올림(scale 0, HALF_UP).
|
||||
BigDecimal chargebackAmount = originalCommission
|
||||
.multiply(grade.getChargebackPercent())
|
||||
.divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP);
|
||||
|
||||
+29
-5
@@ -17,6 +17,14 @@ import java.util.List;
|
||||
/**
|
||||
* 배치용 분급 계획 생성기. ga-api InstallmentPlanGenerator와 동일 로직.
|
||||
* 계약 체결일 기준 1~7차년 분급 계획 생성.
|
||||
*
|
||||
* [신입용 설명]
|
||||
* - 분급(分給): 1200%룰 한도를 초과해 당월에 다 줄 수 없는 수수료 차액을, 여러 해(연차)에
|
||||
* 나눠 지급하도록 "지급 예정표"를 만드는 것. 이연(deferral)이라고도 한다.
|
||||
* - 연차별 비율은 installment_ratio 마스터에 저장(상품군/시점별). 보험 표준은 7년에
|
||||
* 35/25/15/10/7/5/3(%) 형태(합 100%)지만, 마스터 값에 따라 달라질 수 있다.
|
||||
* - 여기서는 금액만 쪼개고 status=SCHEDULED(지급 예정)로 둔다. 실제 지급은 도래월에
|
||||
* PayInstallmentStep이 이 계획을 읽어 처리한다.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@@ -27,15 +35,25 @@ public class BatchInstallmentPlanGenerator {
|
||||
|
||||
private final InstallmentRatioMapper ratioMapper;
|
||||
|
||||
/**
|
||||
* 이연 대상 금액(totalCommission)을 연차별 비율로 쪼개 분급 계획 목록을 만든다.
|
||||
*
|
||||
* @param contractId 계약 ID
|
||||
* @param totalCommission 이연(분급)할 총액. CalcRecruitStep에서는 한도 초과분(excess)이 들어온다.
|
||||
* @param contractDate 계약 체결일. 각 회차의 도래 정산월(settleMonth) 계산 기준.
|
||||
* @param productCategory 상품군. 적용할 분급 비율 마스터를 고르는 키.
|
||||
* @return SCHEDULED 상태의 분급 계획 목록(연차 수만큼). 호출부가 한 번에 INSERT한다.
|
||||
*/
|
||||
public List<InstallmentPlanVO> generate(Long contractId, BigDecimal totalCommission,
|
||||
LocalDate contractDate, String productCategory) {
|
||||
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;
|
||||
BigDecimal allocated = BigDecimal.ZERO; // 지금까지 배분한 누계(마지막 회차 잔차 흡수 계산용)
|
||||
|
||||
List<InstallmentPlanVO> plans = new ArrayList<>();
|
||||
for (int i = 0; i < ratios.size(); i++) {
|
||||
@@ -45,8 +63,11 @@ public class BatchInstallmentPlanGenerator {
|
||||
|
||||
BigDecimal amount;
|
||||
if (fullSchedule && i == ratios.size() - 1) {
|
||||
// 정규 스케줄의 마지막 회차: 남은 전액을 배정 → 회차별 반올림으로 생긴 ±1원 잔차를 흡수.
|
||||
// (예: 100원을 3등분하면 33/33/33=99원이 되는데, 마지막을 34원으로 맞춰 합계 100원 보장.)
|
||||
amount = totalCommission.subtract(allocated);
|
||||
} else {
|
||||
// 일반 회차: 총액 × 해당 연차 비율 ÷ 100, 원 단위(소수 0자리) 반올림.
|
||||
amount = totalCommission.multiply(ratio.getRatioPercent())
|
||||
.divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP);
|
||||
}
|
||||
@@ -54,18 +75,21 @@ public class BatchInstallmentPlanGenerator {
|
||||
|
||||
InstallmentPlanVO plan = new InstallmentPlanVO();
|
||||
plan.setContractId(contractId);
|
||||
plan.setPlanYear(ratio.getPlanYear());
|
||||
plan.setPlanMonth(planMonth);
|
||||
plan.setPlanYear(ratio.getPlanYear()); // 몇 차년(1~7) 지급분인지
|
||||
plan.setPlanMonth(planMonth); // 그 해의 몇 월 회차인지
|
||||
// 도래 정산월 = 계약일 + planYear년 + (planMonth-1)개월, "yyyyMM" 문자열.
|
||||
// planMonth가 1이면 그 연차의 첫 달, 2면 한 달 뒤 식. PayInstallmentStep이 이 값으로 지급 시점을 찾는다.
|
||||
plan.setSettleMonth(
|
||||
contractDate.plusYears(ratio.getPlanYear()).plusMonths(planMonth - 1L).format(MONTH_FMT)
|
||||
);
|
||||
plan.setExpectedAmount(amount);
|
||||
plan.setStatus(InstallmentStatus.SCHEDULED.code());
|
||||
plan.setExpectedAmount(amount); // 그 회차에 지급할 예정 금액
|
||||
plan.setStatus(InstallmentStatus.SCHEDULED.code()); // 아직 미지급(예정) 상태로 생성
|
||||
plans.add(plan);
|
||||
}
|
||||
return plans;
|
||||
}
|
||||
|
||||
/** 분급 비율들의 합(%)을 구한다. 100%인지로 정규 스케줄(잔차 흡수 대상) 여부를 판정. */
|
||||
private BigDecimal sumRatios(List<InstallmentRatioVO> ratios) {
|
||||
return ratios.stream()
|
||||
.map(InstallmentRatioVO::getRatioPercent)
|
||||
|
||||
+39
-8
@@ -36,17 +36,29 @@ public class BatchRegulatoryLimitChecker {
|
||||
private final MaintainLedgerMapper maintainLedgerMapper;
|
||||
private final ContractMapper contractMapper;
|
||||
|
||||
/** 누적 수수료율 한도 체크 (1200%). 신계약 + 유지 원장 합산. */
|
||||
/**
|
||||
* 누적 수수료율 한도 체크 (1200%). 신계약 + 유지 원장 합산.
|
||||
*
|
||||
* [의미] 이 계약에 대해 지금까지 지급한 모집·유지 수수료 누계(currentTotal)에
|
||||
* 이번에 추가로 줄 금액(additionalAmount)을 더한 값(projected)이
|
||||
* 1200% 한도(연환산보험료 × 1200% / 100 = 1년치 보험료)를 넘는지 본다.
|
||||
*
|
||||
* @param contractId 대상 계약 ID
|
||||
* @param additionalAmount 이번에 추가로 지급하려는 모집수수료(규제 적용 전 원금액)
|
||||
* @param baseDate 한도 기준일(보통 계약 체결일). 시점별 유효 한도를 고르는 데 사용.
|
||||
* @return 한도 초과면 violated, 아니면 ok. limitAmount/currentTotal로 잔여 지급가능액 계산 가능.
|
||||
*/
|
||||
public ViolationResult checkContractTotal(Long contractId, BigDecimal additionalAmount, LocalDate baseDate) {
|
||||
String baseDateStr = baseDate.format(DATE_FMT);
|
||||
// baseDate 시점에 유효한 TOTAL_RATIO(1200%) 한도 설정을 조회. 설정이 없으면 규제 미적용 → ok.
|
||||
RegulatoryLimitVO limit = limitMapper.selectActiveByType(TOTAL_RATIO, null, baseDateStr);
|
||||
if (limit == null) {
|
||||
return ViolationResult.ok(additionalAmount, null, TOTAL_RATIO);
|
||||
}
|
||||
|
||||
BigDecimal limitKrw = resolveLimitKrw(limit, contractId);
|
||||
BigDecimal currentTotal = sumPaidByContract(contractId);
|
||||
BigDecimal projected = currentTotal.add(additionalAmount);
|
||||
BigDecimal limitKrw = resolveLimitKrw(limit, contractId); // 비율 한도를 KRW 절대금액 한도로 환산
|
||||
BigDecimal currentTotal = sumPaidByContract(contractId); // 지금까지 지급한 누계(신계약+유지)
|
||||
BigDecimal projected = currentTotal.add(additionalAmount); // 이번 지급까지 반영한 예상 누계
|
||||
|
||||
if (projected.compareTo(limitKrw) > 0) {
|
||||
return ViolationResult.violated(
|
||||
@@ -60,15 +72,23 @@ public class BatchRegulatoryLimitChecker {
|
||||
return ViolationResult.ok(currentTotal, limitKrw, TOTAL_RATIO);
|
||||
}
|
||||
|
||||
/** 1차년 수수료율 한도 체크. */
|
||||
/**
|
||||
* 1차년 수수료율 한도 체크.
|
||||
*
|
||||
* [의미] 1200% 누적 한도와 별개로, 계약 "첫 1년"에 몰아줄 수 있는 모집수수료 상한
|
||||
* (FIRST_YEAR_RATIO, 기본 35%)을 검사한다. 1차년 누계는 신계약(모집) 원장의 1차년분만 합산한다
|
||||
* (유지수수료는 1차년 개념이 없으므로 제외).
|
||||
*/
|
||||
public ViolationResult checkFirstYear(Long contractId, BigDecimal additionalAmount, LocalDate baseDate) {
|
||||
String baseDateStr = baseDate.format(DATE_FMT);
|
||||
// baseDate 시점에 유효한 FIRST_YEAR_RATIO 한도 조회. 없으면 규제 미적용 → ok.
|
||||
RegulatoryLimitVO limit = limitMapper.selectActiveByType(FIRST_YEAR_RATIO, null, baseDateStr);
|
||||
if (limit == null) {
|
||||
return ViolationResult.ok(additionalAmount, null, FIRST_YEAR_RATIO);
|
||||
}
|
||||
|
||||
BigDecimal limitKrw = resolveLimitKrw(limit, contractId);
|
||||
BigDecimal limitKrw = resolveLimitKrw(limit, contractId); // 1차년 한도를 KRW로 환산
|
||||
// 1차년에 이미 지급한 모집수수료 누계만 합산(유지수수료 제외).
|
||||
BigDecimal currentTotal = nullSafe(recruitLedgerMapper.sumFirstYearByContract(contractId));
|
||||
BigDecimal projected = currentTotal.add(additionalAmount);
|
||||
|
||||
@@ -84,23 +104,34 @@ public class BatchRegulatoryLimitChecker {
|
||||
return ViolationResult.ok(currentTotal, limitKrw, FIRST_YEAR_RATIO);
|
||||
}
|
||||
|
||||
/** 비율 한도(PERCENT)를 계약 연환산보험료 기준 KRW 한도로 환산. */
|
||||
/**
|
||||
* 비율 한도(PERCENT)를 계약 연환산보험료 기준 KRW 한도로 환산.
|
||||
*
|
||||
* regulatory_limit.limit_value는 1200, 35 같은 "비율(%)" 값이므로 그대로 금액과 비교할 수 없다.
|
||||
* 단위(unit)가 PERCENT면: 연환산보험료(annualPremium) × 비율 / 100 으로 실제 KRW 한도를 만든다.
|
||||
* 예) 연환산보험료 1,200,000 × 1200% / 100 = 14,400,000원 (= 1년치 보험료의 1200%만큼)
|
||||
* 단위가 PERCENT가 아니면(KRW 등): limit_value를 그대로 절대금액 한도로 사용.
|
||||
*/
|
||||
private BigDecimal resolveLimitKrw(RegulatoryLimitVO limit, Long contractId) {
|
||||
if (!UNIT_PERCENT.equalsIgnoreCase(limit.getUnit())) {
|
||||
return limit.getLimitValue();
|
||||
return limit.getLimitValue(); // 이미 KRW 등 절대금액 → 환산 불필요
|
||||
}
|
||||
ContractVO contract = contractMapper.selectById(contractId);
|
||||
// 계약/보험료가 없으면 한도 환산 기준이 0이 되어 한도도 0(=거의 전액 이연)이 된다.
|
||||
BigDecimal annualPremium = (contract != null && contract.getPremium() != null)
|
||||
? contract.getPremium() : BigDecimal.ZERO;
|
||||
// 연환산보험료 × 비율 ÷ 100, 원 단위 소수 2자리 반올림.
|
||||
return annualPremium.multiply(limit.getLimitValue())
|
||||
.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
/** 계약별 누적 지급액 = 모집(신계약) 원장 합 + 유지 원장 합. 1200% 누적 한도 비교용. */
|
||||
private BigDecimal sumPaidByContract(Long contractId) {
|
||||
return nullSafe(recruitLedgerMapper.sumByContract(contractId))
|
||||
.add(nullSafe(maintainLedgerMapper.sumByContract(contractId)));
|
||||
}
|
||||
|
||||
/** 집계 결과가 NULL(지급 이력 없음)이면 0으로 치환해 NPE/계산오류를 막는다. */
|
||||
private BigDecimal nullSafe(BigDecimal v) {
|
||||
return v != null ? v : BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,21 @@ import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
|
||||
/**
|
||||
* 배치용 세금 계산기. ga-api TaxCalculator와 동일 로직, ga-batch 모듈 독립.
|
||||
* 사업소득 3.3% / 기타소득 8.8% + 지방소득세 10% + 부가세 10% (TAXABLE만).
|
||||
* 배치용 세금 계산기. ga-api TaxCalculator와 동일 로직을 ga-batch 모듈에 독립 복제.
|
||||
*
|
||||
* 세금 구조(설계사 보수에 대한 원천징수):
|
||||
* - 소득세(원천세):
|
||||
* · 사업소득(BUSINESS_INCOME): 3.3% — 대부분의 전속/등록 설계사.
|
||||
* · 기타소득(OTHER_INCOME): 8.8% — 일시적/비정규 소득.
|
||||
* (※ 3.3% = 소득세 3% + 지방소득세 0.3% 처럼 흔히 합쳐 부르지만, 여기서는
|
||||
* 소득세 본세와 지방소득세를 분리 계산한다. 아래 참조.)
|
||||
* - 지방소득세(localTax): 소득세(본세)의 10%.
|
||||
* - 부가세(vat): 과세사업자(VatType.TAXABLE)인 설계사만 보수의 10% 가산. 면세면 0.
|
||||
*
|
||||
* net(실수령액) = 원금 - 소득세 - 지방소득세 + 부가세.
|
||||
* (부가세는 설계사가 받아 국가에 납부하는 돈이라 지급액에 더해진다)
|
||||
*
|
||||
* 세율은 SystemConfigService 로 외부화되어 있어 운영 중 조정 가능(기본값은 코드의 폴백).
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@@ -19,6 +32,12 @@ public class BatchTaxCalculator {
|
||||
|
||||
private final SystemConfigService configService;
|
||||
|
||||
/**
|
||||
* 세금 분해 계산.
|
||||
* @param amount 과세 대상 금액(설계사 보수, gross).
|
||||
* @param taxType 사업소득/기타소득 구분 → 소득세율 결정.
|
||||
* @param vatType 과세/면세 구분 → 부가세 가산 여부 결정.
|
||||
*/
|
||||
public TaxBreakdown calculate(BigDecimal amount, TaxType taxType, VatType vatType) {
|
||||
// 음수/0 정산(환수 우위 등)은 원천징수 대상이 아님 → 세금 0, net=원금(차월 이월).
|
||||
// 음수 원천세가 분기 원천세신고 SUM에 전파되는 것을 차단.
|
||||
@@ -27,22 +46,27 @@ public class BatchTaxCalculator {
|
||||
return new TaxBreakdown(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, base);
|
||||
}
|
||||
|
||||
BigDecimal incomeRate = resolveIncomeRate(taxType);
|
||||
BigDecimal localRate = configService.getDecimal("TAX_RATE_LOCAL_INCOME", new BigDecimal("10.0"));
|
||||
BigDecimal vatRate = configService.getDecimal("TAX_RATE_VAT", new BigDecimal("10.0"));
|
||||
BigDecimal incomeRate = resolveIncomeRate(taxType); // 3.3% 또는 8.8%
|
||||
BigDecimal localRate = configService.getDecimal("TAX_RATE_LOCAL_INCOME", new BigDecimal("10.0")); // 소득세의 10%
|
||||
BigDecimal vatRate = configService.getDecimal("TAX_RATE_VAT", new BigDecimal("10.0")); // 부가세 10%
|
||||
|
||||
// 소득세(본세) = 금액 × 소득세율 / 100. 원 단위 반올림.
|
||||
BigDecimal incomeTax = amount.multiply(incomeRate)
|
||||
.divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP);
|
||||
// 지방소득세 = 소득세(본세) × 10% (금액이 아니라 소득세에 부과하는 점에 주의).
|
||||
BigDecimal localTax = incomeTax.multiply(localRate)
|
||||
.divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP);
|
||||
// 부가세: 과세사업자만 금액의 10% 가산, 면세면 0.
|
||||
BigDecimal vat = VatType.TAXABLE.equals(vatType)
|
||||
? amount.multiply(vatRate).divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP)
|
||||
: BigDecimal.ZERO;
|
||||
// 실수령 = 원금 - 소득세 - 지방소득세 + 부가세.
|
||||
BigDecimal netAmount = amount.subtract(incomeTax).subtract(localTax).add(vat);
|
||||
|
||||
return new TaxBreakdown(incomeTax, localTax, vat, netAmount);
|
||||
}
|
||||
|
||||
/** 소득세율 결정: 기타소득이면 8.8%, 그 외(사업소득 등)는 3.3%. 값은 설정에서 조회. */
|
||||
private BigDecimal resolveIncomeRate(TaxType taxType) {
|
||||
if (TaxType.OTHER_INCOME.equals(taxType)) {
|
||||
return configService.getDecimal("TAX_RATE_OTHER_INCOME", new BigDecimal("8.8"));
|
||||
|
||||
@@ -2,7 +2,14 @@ package com.ga.batch.settlement.domain;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/** 차등환수 계산 결과. */
|
||||
/**
|
||||
* 차등환수 계산 결과(불변 값 객체, record).
|
||||
*
|
||||
* @param chargebackAmount 환수금액(회수할 돈). 환수 대상이 아니면 0.
|
||||
* @param monthsElapsed 계약일~해지일 경과 개월 수(환수율 구간 판정 근거).
|
||||
* @param appliedPercent 실제 적용된 환수율(%).
|
||||
* @param reason 판정 사유(로그/명세 표시용 사람이 읽는 설명).
|
||||
*/
|
||||
public record ChargebackResult(
|
||||
BigDecimal chargebackAmount,
|
||||
int monthsElapsed,
|
||||
|
||||
+16
-4
@@ -9,10 +9,17 @@ import java.math.RoundingMode;
|
||||
|
||||
/**
|
||||
* 환수채권 연체이자 계산기 (V125).
|
||||
* 당월 적립 연체이자 = 잔액 × (연이자율 / 100 / 12), HALF_UP scale 0(원).
|
||||
* 이자율 0(기본값) 이면 0 반환 → 기존 동작과 동일(안전 폴백).
|
||||
*
|
||||
* 적립은 매 정산월 1개월분이며, 잔액 기준이므로 월복리로 누적된다.
|
||||
* 배경:
|
||||
* 환수금(설계사가 GA 에 돌려줘야 하는 돈)을 즉시 갚지 못하면 "환수채권(미수금)"으로 남는다.
|
||||
* 이 미상환 채권 잔액에 매월 연체이자를 붙여 회수를 독려/보전한다.
|
||||
*
|
||||
* 계산식:
|
||||
* 당월 적립 연체이자 = 잔액 × (연이자율 / 100 / 12), 원 단위 반올림(HALF_UP, scale 0).
|
||||
* (1200 = 100[퍼센트→비율] × 12[연→월] 를 한 번에 나눈 것)
|
||||
*
|
||||
* 안전 폴백: 연이자율이 0(기본값)이면 항상 0 → 이자 기능을 켜기 전 기존 동작과 완전히 동일.
|
||||
* 적립은 매 정산월 1개월분이며, 잔액(이자 포함) 기준이므로 월복리로 누적된다.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@@ -20,12 +27,17 @@ public class ClawbackInterestCalculator {
|
||||
|
||||
private final SystemConfigService configService;
|
||||
|
||||
/** 당월 적립 연체이자. 잔액·이자율이 0 이하이면 0. */
|
||||
/**
|
||||
* 당월 적립할 연체이자 금액.
|
||||
* 잔액 또는 연이자율이 0 이하이면 0(이자 없음).
|
||||
* @param balance 환수채권의 현재 미상환 잔액.
|
||||
*/
|
||||
public BigDecimal monthlyInterest(BigDecimal balance) {
|
||||
BigDecimal annualRate = configService.getDecimal("CLAWBACK_OVERDUE_INTEREST_RATE", BigDecimal.ZERO);
|
||||
if (annualRate.signum() <= 0 || balance == null || balance.signum() <= 0) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
// 잔액 × 연이자율 ÷ 1200 = 1개월분 이자.
|
||||
return balance.multiply(annualRate)
|
||||
.divide(BigDecimal.valueOf(1200), 0, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,14 @@ package com.ga.batch.settlement.domain;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/** 세금 계산 결과. ga-api TaxBreakdown과 동일 구조 (모듈 독립). */
|
||||
/**
|
||||
* 세금 계산 결과(불변 값 객체, record). ga-api TaxBreakdown과 동일 구조(모듈 독립).
|
||||
*
|
||||
* @param incomeTax 소득세 본세(사업소득 3.3% 또는 기타소득 8.8%).
|
||||
* @param localTax 지방소득세(소득세 본세의 10%).
|
||||
* @param vat 부가세(과세사업자만, 보수의 10%; 면세면 0).
|
||||
* @param netAmount 실수령액 = 원금 - 소득세 - 지방소득세 + 부가세.
|
||||
*/
|
||||
public record TaxBreakdown(
|
||||
BigDecimal incomeTax,
|
||||
BigDecimal localTax,
|
||||
|
||||
@@ -2,7 +2,16 @@ package com.ga.batch.settlement.domain;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/** 1200%룰 검증 결과. */
|
||||
/**
|
||||
* 1200%룰 검증 결과(불변 객체).
|
||||
*
|
||||
* [필드 의미]
|
||||
* - violated : 한도 초과 여부(true면 초과 → 호출부가 clip+이연 처리).
|
||||
* - currentTotal : 이번 지급 전까지 이미 지급한 누계(KRW). 잔여 지급가능액 = limitAmount - currentTotal.
|
||||
* - limitAmount : KRW로 환산된 한도(연환산보험료 × 비율 / 100). 한도 설정이 없으면 null일 수 있음.
|
||||
* - limitType : 어떤 한도인지(TOTAL_RATIO=누적 1200% / FIRST_YEAR_RATIO=1차년).
|
||||
* - message : 위반 시 로그/안내용 설명 문자열(정상이면 null).
|
||||
*/
|
||||
public record ViolationResult(
|
||||
boolean violated,
|
||||
BigDecimal currentTotal,
|
||||
@@ -10,10 +19,12 @@ public record ViolationResult(
|
||||
String limitType,
|
||||
String message
|
||||
) {
|
||||
/** 한도 이내(정상) 결과 생성. violated=false, message 없음. */
|
||||
public static ViolationResult ok(BigDecimal currentTotal, BigDecimal limitAmount, String limitType) {
|
||||
return new ViolationResult(false, currentTotal, limitAmount, limitType, null);
|
||||
}
|
||||
|
||||
/** 한도 초과(위반) 결과 생성. violated=true, 사유 message 포함. */
|
||||
public static ViolationResult violated(BigDecimal currentTotal, BigDecimal limitAmount,
|
||||
String limitType, String message) {
|
||||
return new ViolationResult(true, currentTotal, limitAmount, limitType, message);
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
package com.ga.batch.settlement.receive;
|
||||
|
||||
/**
|
||||
* 보험사별 데이터 수신 전략. (Excel/CSV/EDI/API)
|
||||
* 보험사별 데이터 수신 전략(Strategy 패턴).
|
||||
*
|
||||
* 보험사마다 수수료 명세를 주는 방식이 다르다(엑셀/CSV 수동 업로드, EDI, API 연동 등).
|
||||
* 각 방식을 이 인터페이스의 구현체로 만들고, Step1(ReceiveDataStep)이 보험사별로
|
||||
* supports() 가 true 인 구현체를 골라 receive() 를 호출한다.
|
||||
* 수신한 원천 데이터는 raw_commission_data 테이블에 그대로(raw) 적재된다.
|
||||
*/
|
||||
public interface DataReceiver {
|
||||
/** 이 receiver 가 해당 보험사를 지원하는지 */
|
||||
/** 이 receiver 가 해당 보험사(companyCode)의 수신 방식을 지원하는지 여부. */
|
||||
boolean supports(String companyCode);
|
||||
|
||||
/** 데이터 수신 → raw_commission_data 적재. 처리한 행 수 반환 */
|
||||
/** 데이터 수신 → raw_commission_data 적재. 처리한(적재/확인한) 행 수 반환. */
|
||||
int receive(String companyCode, String settleMonth);
|
||||
}
|
||||
|
||||
@@ -21,13 +21,15 @@ public class ManualReceiver implements DataReceiver {
|
||||
|
||||
@Override
|
||||
public boolean supports(String companyCode) {
|
||||
// 회사 프로필이 없거나(미설정) receiveMethod 가 "MANUAL" 이면 이 기본 receiver 가 담당.
|
||||
CompanyProfileVO p = mapper.selectProfile(companyCode);
|
||||
return p == null || "MANUAL".equalsIgnoreCase(p.getReceiveMethod());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int receive(String companyCode, String settleMonth) {
|
||||
// 수동 업로드는 별도 작업 없음. 기존 raw 데이터 건수만 반환.
|
||||
// 수동 업로드는 이미 화면에서 raw_commission_data 에 적재돼 있으므로 추가 수신 작업이 없다.
|
||||
// 처리 대상(PENDING) 건수만 세어 반환한다.
|
||||
return mapper.selectRawList(companyCode, settleMonth, "PENDING").size();
|
||||
}
|
||||
}
|
||||
|
||||
+10
-5
@@ -19,10 +19,14 @@ import java.util.List;
|
||||
/**
|
||||
* Step 8b — accountingEntryGenerate: 회계 자동분개
|
||||
*
|
||||
* 정산월 기준 recruit_ledger + maintain_ledger → contract_accounting_entry INSERT.
|
||||
* - 차변(8350 지급수수료) / 대변(2530 미지급금) 한 쌍을 단일 행으로 저장.
|
||||
* - journal_no는 NULL(미전기). 마감 UI에서 채번.
|
||||
* - 멱등성: 재실행 시 해당 정산월의 미전기 분개를 먼저 삭제 후 재생성(마감 채번분은 보존).
|
||||
* 정산으로 확정된 수수료를 회계 전표(분개)로 바꿔 contract_accounting_entry 에 적재한다.
|
||||
* - 정산월 기준 recruit_ledger + maintain_ledger 를 읽어 분개 1행씩 생성.
|
||||
* - 분개 원리: 차변(8350 지급수수료, 비용) / 대변(2530 미지급금, 부채) 한 쌍을 단일 행으로 저장.
|
||||
* (아직 돈은 안 나갔지만 "줘야 할 비용"으로 인식 → 추후 지급 시 미지급금이 줄어듦)
|
||||
* - journal_no(전표번호)는 NULL(미전기 상태). 회계 마감 UI 에서 사람이 확정하며 채번한다.
|
||||
*
|
||||
* 멱등성(중요): 같은 정산월을 다시 돌려도 중복 분개가 쌓이지 않도록,
|
||||
* 재실행 시 "미전기" 분개만 먼저 지우고 재생성한다. 이미 마감(전표번호 부여)된 분개는 보존.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -56,10 +60,11 @@ public class AccountingEntryGenerateStep implements Tasklet {
|
||||
List<ContractAccountingEntryVO> batch = new ArrayList<>(BATCH_SIZE);
|
||||
int total = 0;
|
||||
|
||||
// 금액 0 인 분개는 의미 없으므로 건너뛰고, 나머지를 BATCH_SIZE 단위로 모아 일괄 INSERT.
|
||||
for (ContractAccountingEntryVO entry : source) {
|
||||
if (entry.getAmount() == null || entry.getAmount().signum() == 0) continue;
|
||||
entry.setEntryDate(today);
|
||||
entry.setJournalNo(null);
|
||||
entry.setJournalNo(null); // 미전기 상태로 생성(마감 시 채번)
|
||||
|
||||
batch.add(entry);
|
||||
if (batch.size() >= BATCH_SIZE) {
|
||||
|
||||
@@ -68,14 +68,27 @@ import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Step 8 — aggregate: 설계사별 통합 집계 → settle_master UPSERT
|
||||
* Step 8 — aggregate: 설계사별 통합 집계 → settle_master UPSERT (월정산의 최종 합산 단계)
|
||||
*
|
||||
* 통합 내용:
|
||||
* - BatchTaxCalculator: 설계사 세금유형(TaxType/VatType)에 따라 정확한 세금 계산
|
||||
* (사업소득 3.3% / 기타소득 8.8% + 지방소득세 10% + 부가세 10%)
|
||||
* - AgentDeductionMapper: PENDING 차감 합산 → netAmount에서 차감 후 APPLIED 상태로 전이
|
||||
* - CommissionStatementMapper: 설계사별 명세서 레코드(ISSUED 상태) INSERT
|
||||
* - ClawbackReceivableMapper/ClawbackRecoveryMapper: 환수채권 이월/상계 (V111)
|
||||
* 이 Step 이 하는 일(설계사 1명 기준):
|
||||
* 1) 앞 Step 들이 만든 여러 원장(모집·유지·오버라이드·예외·환수 + 지급성 9종 등)을
|
||||
* 설계사별로 합산해 gross(세전 총액)를 만든다.
|
||||
* 2) 세금 계산(BatchTaxCalculator): 사업소득 3.3% / 기타소득 8.8% + 지방소득세 + 부가세.
|
||||
* 3) 차감(AgentDeduction): 대출상환·각종 공제 등 PENDING 차감을 빼고 APPLIED 로 전이.
|
||||
* 4) 환수채권 FIFO 상계(V111): 과거 미상환 환수채권을 이번 달 지급액으로 우선 회수.
|
||||
* net 이 음수면 그 음수분을 신규 환수채권으로 이월(carryover).
|
||||
* 5) 선지급 FIFO 상계(MOD-2): 미리 당겨준 선지급금을 지급액에서 회수.
|
||||
* 6) 최종 payable(실지급액)을 포함해 settle_master 에 UPSERT.
|
||||
* 7) 명세서(commission_statement) 생성, 환수 원천세 역분개(V124), 연체이자 적립(V125) 등 부수효과.
|
||||
*
|
||||
* 멱등성: 같은 정산월 재실행 시 환수회수/선지급상계/이자적립/원천세역분개 이력을 먼저
|
||||
* reverse(역산·삭제)한 뒤 재계산하므로 중복이 발생하지 않는다.
|
||||
*
|
||||
* 용어:
|
||||
* - 오버라이드(override): 하위 설계사 실적에 대해 상위 직급자에게 주는 상위수당.
|
||||
* - 분급/선지급: 수수료를 미리 당겨 지급(advance)하고 이후 정산에서 상계 회수.
|
||||
* - 원천세 역분개: 환수로 소득이 줄면 이미 떼어 신고한 원천세가 과대 신고가 되므로,
|
||||
* 음(-)의 조정 분개를 적재해 분기 원천세 신고액을 바로잡는다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -123,6 +136,7 @@ public class AggregateStep implements Tasklet {
|
||||
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
|
||||
log.info("[Step8 aggregate] settleMonth={}", ctx.getSettleMonth());
|
||||
|
||||
// 원장별 설계사 합계를 Map<agentId, 금액> 으로 미리 모은다(이후 설계사 루프에서 O(1) 조회).
|
||||
Map<Long, BigDecimal> recruitMap = toMap(recruitMapper.aggregateByAgent(ctx.getSettleMonth()));
|
||||
Map<Long, BigDecimal> maintainMap = toMap(maintainMapper.aggregateByAgent(ctx.getSettleMonth()));
|
||||
Map<Long, BigDecimal> chargebackMap = toMap(chargebackMapper.aggregateByAgent(ctx.getSettleMonth()));
|
||||
@@ -230,6 +244,8 @@ public class AggregateStep implements Tasklet {
|
||||
otherCommission = MoneyUtil.add(otherCommission, recruiterCommMap.getOrDefault(agentId, BigDecimal.ZERO));
|
||||
otherCommission = MoneyUtil.add(otherCommission, lapsedReinstatMap.getOrDefault(agentId, BigDecimal.ZERO));
|
||||
|
||||
// gross(세전 총액) = (모집 + 유지 + 오버라이드 + 예외가산) - 환수 - 예외차감 + 지급성9종.
|
||||
// 환수(cb)·예외차감(em)은 빼는 항목. 음수가 될 수 있다(환수가 수입보다 클 때).
|
||||
BigDecimal gross = MoneyUtil.add(MoneyUtil.add(MoneyUtil.add(recruit, maintain), ov), ep)
|
||||
.subtract(cb).subtract(em);
|
||||
gross = MoneyUtil.add(gross, otherCommission);
|
||||
@@ -249,6 +265,7 @@ public class AggregateStep implements Tasklet {
|
||||
.map(AgentDeductionVO::getAmount)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
// net = 세후 실수령 - 차감합계. 이 net 으로 아래에서 환수채권/선지급 상계를 수행.
|
||||
BigDecimal netAmount = tax.netAmount().subtract(deductionTotal);
|
||||
|
||||
// 환수채권 이월/상계 계산 (V111)
|
||||
@@ -257,14 +274,17 @@ public class AggregateStep implements Tasklet {
|
||||
BigDecimal priorBalance = receivableMapper.outstandingBalance(agentId, ctx.getSettleMonth());
|
||||
if (priorBalance == null) priorBalance = BigDecimal.ZERO;
|
||||
|
||||
BigDecimal recovered;
|
||||
BigDecimal carryover;
|
||||
BigDecimal payable;
|
||||
BigDecimal recovered; // 이번 달 net 으로 회수한 과거 환수채권액
|
||||
BigDecimal carryover; // 다음 달로 넘길 신규 환수채권액
|
||||
BigDecimal payable; // 환수상계 후 남은 지급액(이후 선지급 상계로 더 줄 수 있음)
|
||||
if (netAmount.signum() >= 0) {
|
||||
// net 이 양수: 받을 돈이 있으니 과거 채권을 net 한도 내에서 우선 회수.
|
||||
recovered = netAmount.min(priorBalance); // 기존 채권 FIFO 상계 (net 한도 내)
|
||||
carryover = BigDecimal.ZERO;
|
||||
payable = netAmount.subtract(recovered);
|
||||
} else {
|
||||
// net 이 음수: 이번 달은 오히려 마이너스(환수 우위) → 줄 돈 없음.
|
||||
// 음수분의 절대값을 새 환수채권으로 다음 달에 이월한다.
|
||||
recovered = BigDecimal.ZERO;
|
||||
carryover = netAmount.negate(); // 음수분 신규 채권화
|
||||
payable = BigDecimal.ZERO;
|
||||
@@ -571,6 +591,7 @@ public class AggregateStep implements Tasklet {
|
||||
}
|
||||
}
|
||||
|
||||
/** 설계사 세금유형 파싱. 미설정/이상값이면 가장 흔한 사업소득(3.3%)으로 안전 폴백. */
|
||||
private TaxType parseTaxType(AgentVO agent) {
|
||||
if (agent == null || agent.getTaxType() == null) return TaxType.BUSINESS_INCOME;
|
||||
try {
|
||||
@@ -580,6 +601,7 @@ public class AggregateStep implements Tasklet {
|
||||
}
|
||||
}
|
||||
|
||||
/** 설계사 부가세유형 파싱. 미설정/이상값이면 부가세 없음(NONE)으로 안전 폴백. */
|
||||
private VatType parseVatType(AgentVO agent) {
|
||||
if (agent == null || agent.getVatType() == null) return VatType.NONE;
|
||||
try {
|
||||
@@ -589,6 +611,7 @@ public class AggregateStep implements Tasklet {
|
||||
}
|
||||
}
|
||||
|
||||
/** SQL 집계 결과(rows: {agentId, total} 목록)를 Map<agentId, 합계>로 변환. */
|
||||
private Map<Long, BigDecimal> toMap(List<Map<String, Object>> rows) {
|
||||
Map<Long, BigDecimal> m = new HashMap<>();
|
||||
for (Map<String, Object> r : rows) {
|
||||
@@ -598,6 +621,7 @@ public class AggregateStep implements Tasklet {
|
||||
return m;
|
||||
}
|
||||
|
||||
/** DB 에서 온 숫자 객체(null/BigDecimal/기타)를 안전하게 BigDecimal 로. null 이면 0. */
|
||||
private BigDecimal toBd(Object v) {
|
||||
if (v == null) return BigDecimal.ZERO;
|
||||
if (v instanceof BigDecimal bd) return bd;
|
||||
|
||||
@@ -29,8 +29,13 @@ import java.util.Map;
|
||||
/**
|
||||
* Step 5 — calcMaintain: 유지수수료 계산 (2회차~)
|
||||
*
|
||||
* 유지수수료: 계약일로부터 N개월 경과한 활동 계약 (status=ACTIVE) 대상.
|
||||
* - 1회차는 모집수수료에서 처리하므로 N >= 1 인 계약 대상.
|
||||
* 유지수수료: 계약이 계속 살아있을 때(status=ACTIVE) 회차마다 추가로 지급하는 수수료.
|
||||
* - 1회차(첫 달)는 모집수수료(Step4)에서 처리하므로, 여기서는 계약일로부터 1개월 이상
|
||||
* 경과한 계약만 대상으로 한다.
|
||||
* - 납입주기(월납/분기납/연납/일시납)에 따라 해당 월에 유지수수료가 발생하는지 달라진다.
|
||||
* - 계약 승계가 있으면 유지수수료 귀속을 후임 설계사로 옮긴다(아래 successorByContract).
|
||||
*
|
||||
* 멱등성: 시작 시 해당 정산월의 기존 maintain_ledger 를 삭제하고 다시 생성한다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -101,6 +106,11 @@ public class CalcMaintainStep implements Tasklet {
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이번 경과월에 유지수수료가 발생하는 납입주기인지 판정.
|
||||
* 월납은 매월, 분기납은 3개월마다, 연납은 12개월마다 보험료를 내므로 그때만 수수료가 생긴다.
|
||||
* 일시납(LUMP)은 1회 납입이라 모집회차 외에는 유지수수료가 없다.
|
||||
*/
|
||||
private boolean matchesPayCycle(String cycle, int monthsElapsed) {
|
||||
if (cycle == null) return true;
|
||||
return switch (cycle) {
|
||||
|
||||
@@ -29,13 +29,20 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Step 7 — calcOverride: 조직 트리 기반 오버라이드 계산.
|
||||
* Step 7 — calcOverride: 조직 트리 기반 오버라이드(상위수당) 계산.
|
||||
*
|
||||
* 오버라이드란?
|
||||
* 하위 설계사가 올린 실적의 일부를, 그를 관리하는 상위 직급자에게 추가로 주는 수당.
|
||||
* (예: 팀원이 100만원 실적이면, 팀장에게 그 5%를 오버라이드로 지급)
|
||||
*
|
||||
* 정책:
|
||||
* - 모든 ACTIVE 설계사를 순회하며 본인의 grade 와 일치하는 from_grade 규칙을 찾음
|
||||
* - 본인 조직 + 모든 상위 조직에서 to_grade 직급의 ACTIVE 설계사 1명을 찾아 분배 (가장 가까운 조직 우선)
|
||||
* - base = 하위 설계사의 정산월 모집 + 유지 합계
|
||||
* - amount = base × override_pct (cap 적용)
|
||||
* - 모든 ACTIVE 설계사(=하위)를 순회하며, 본인의 grade(직급)와 일치하는 from_grade 규칙을 찾음.
|
||||
* - 본인 조직에서 출발해 상위 조직으로 올라가며 to_grade 직급의 ACTIVE 설계사 1명(=상위 수령자)을
|
||||
* 찾아 분배한다. 가장 가까운(낮은) 조직의 상위자를 우선.
|
||||
* - base(기준금액) = 하위 설계사의 정산월 모집 + 유지 합계.
|
||||
* - amount = base × override_pct. 룰에 상한(cap)이 있으면 그 이하로 자른다.
|
||||
*
|
||||
* 멱등성: 시작 시 해당 정산월 override_settle 를 삭제하고 재생성.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -58,11 +65,12 @@ public class CalcOverrideStep implements Tasklet {
|
||||
overrideMapper.deleteBySettleMonth(ctx.getSettleMonth());
|
||||
List<OverrideRuleVO> rules = ruleMapper.selectOverrideRules();
|
||||
|
||||
// 정산월 1일을 기준일로 삼아, 이 시점에 유효한(effectiveFrom~effectiveTo 범위 내) 룰만 남긴다.
|
||||
LocalDate baseDate = LocalDate.parse(
|
||||
ctx.getSettleMonth().substring(0, 4) + "-" +
|
||||
ctx.getSettleMonth().substring(4) + "-01");
|
||||
rules.removeIf(r -> r.getEffectiveFrom() != null && r.getEffectiveFrom().isAfter(baseDate));
|
||||
rules.removeIf(r -> r.getEffectiveTo() != null && r.getEffectiveTo().isBefore(baseDate));
|
||||
rules.removeIf(r -> r.getEffectiveFrom() != null && r.getEffectiveFrom().isAfter(baseDate)); // 아직 시작 전 룰 제외
|
||||
rules.removeIf(r -> r.getEffectiveTo() != null && r.getEffectiveTo().isBefore(baseDate)); // 이미 종료된 룰 제외
|
||||
|
||||
AgentSearchParam ap = new AgentSearchParam();
|
||||
ap.setStatus("ACTIVE");
|
||||
|
||||
@@ -30,10 +30,25 @@ import java.util.List;
|
||||
/**
|
||||
* Step 4 — calcRecruit: 모집 수수료 계산 (계약 첫 회차)
|
||||
*
|
||||
* [신입용 도메인 설명]
|
||||
* - 모집수수료(recruit commission): 보험 계약을 "모집(=판매)"한 대가로 설계사(에이전트)에게
|
||||
* 지급하는 수수료. 계약 체결 첫 회차에 가장 크게 발생한다. (이와 대비되는 개념이
|
||||
* 계약 유지 기간 동안 매달 지급되는 "유지수수료(maintain commission)"다.)
|
||||
* - 이 Step은 "정산월(settleMonth)"에 계약일이 속하는 신규 계약들을 모아, 계약별로 모집수수료
|
||||
* 원장(ledger) 1건을 만들어 recruit_ledger 테이블에 적재하는 일을 한다.
|
||||
*
|
||||
* 원칙: 계약시점의 commission_rate / payout_rule 적용 (effective_from 기준)
|
||||
* → 수수료율·지급규칙은 시점마다 바뀔 수 있으므로, "계약 체결 당시" 유효했던 율/규칙을 쓴다.
|
||||
*
|
||||
* [1200%룰 = 이 Step의 핵심]
|
||||
* 보험업감독규정 제5-15조의5. 첫 1년간 설계사에게 지급하는 모집수수료 총액을
|
||||
* "월납보험료의 1200%"(= 연환산보험료의 100%, 즉 1년치 보험료) 이내로 제한하는 규제.
|
||||
* 과도한 선지급(시책)으로 인한 불완전판매·승환계약을 막기 위한 장치다.
|
||||
*
|
||||
* 추가: 1200%룰(BatchRegulatoryLimitChecker) 검증 후 한도 초과 계약은
|
||||
* InstallmentPlan(분급 7년) 생성. Step 4.5 역할을 Step 4 내에서 수행.
|
||||
* → 한도를 넘는 초과분은 당월에 다 주지 않고(=clip), 7년에 걸쳐 나눠 지급(=분급/이연)한다.
|
||||
* 이연된 차액은 도래월에 별도 Step(PayInstallmentStep)이 installment_plan을 보고 지급한다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -51,11 +66,23 @@ public class CalcRecruitStep implements Tasklet {
|
||||
|
||||
private static final int BATCH_SIZE = 1000;
|
||||
|
||||
/**
|
||||
* 배치 실행 본체. 정산월의 신규 계약을 순회하며 모집수수료 원장을 만들고,
|
||||
* 1200%룰 한도 초과분은 분급 계획으로 이연한 뒤 원장/분급을 일괄 저장한다.
|
||||
*
|
||||
* 처리 흐름:
|
||||
* 1) 정산월에 계약일이 속하는 계약 조회
|
||||
* 2) 기존 정산월 모집원장 삭제(재계산 멱등성 확보)
|
||||
* 3) 계약별로: 모집수수료 계산 → 1200%룰 clip → (초과 시) 분급 생성 → 원장 적재
|
||||
* 4) 원장/분급 배치 INSERT
|
||||
*/
|
||||
@Override
|
||||
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
|
||||
log.info("[Step4 calcRecruit] settleMonth={}", ctx.getSettleMonth());
|
||||
|
||||
// 모집수수료 대상: 계약일이 정산월에 해당하는 계약
|
||||
// getMonthRange("yyyyMM") → [그 달 1일, 말일] 형태의 시작/종료일 문자열을 반환.
|
||||
// pageSize=MAX_VALUE 로 정산월 전체 계약을 페이징 없이 한 번에 가져온다(배치 특성).
|
||||
String[] range = DateUtil.getMonthRange(ctx.getSettleMonth());
|
||||
ContractSearchParam param = new ContractSearchParam();
|
||||
param.setStartDate(range[0]);
|
||||
@@ -64,6 +91,7 @@ public class CalcRecruitStep implements Tasklet {
|
||||
List<ContractResp> contracts = contractMapper.selectList(param);
|
||||
|
||||
// 정산월 모집원장 초기화 (재계산 대비)
|
||||
// → 같은 정산월을 다시 돌려도 중복 적재되지 않도록 먼저 지운다(멱등성). 배치 재실행 안전장치.
|
||||
recruitMapper.deleteBySettleMonth(ctx.getSettleMonth());
|
||||
|
||||
List<LedgerVO> batch = new ArrayList<>(BATCH_SIZE);
|
||||
@@ -72,42 +100,54 @@ public class CalcRecruitStep implements Tasklet {
|
||||
int violationCount = 0;
|
||||
|
||||
for (ContractResp c : contracts) {
|
||||
// calcRecruitLedger: 계약의 수수료율/지급규칙으로 이번 회차 모집수수료 원장을 계산.
|
||||
// led == null(대상 아님) 또는 지급액 0원이면 원장을 만들 필요가 없으므로 건너뛴다.
|
||||
LedgerVO led = calculator.calcRecruitLedger(c, ctx.getSettleMonth());
|
||||
if (led == null || MoneyUtil.isZero(led.getAgentAmount())) continue;
|
||||
|
||||
// 1200%룰 검증: 1차년 한도(FIRST_YEAR) + 누적 한도(TOTAL 1200%) 중
|
||||
// 더 제한적인 한도로 당월 지급액을 clip, 초과분은 분급(7년)으로 이연.
|
||||
// - 두 한도 모두 검사하고, 각 한도가 허용하는 "잔여 지급가능액" 중 더 작은 값까지만 지급한다.
|
||||
// - 즉 둘 중 어느 한 규제라도 더 빡빡하면 그쪽 한도가 당월 지급액을 결정한다(min 연산).
|
||||
if (c.getContractDate() != null) {
|
||||
BigDecimal originalAmount = led.getAgentAmount();
|
||||
BigDecimal allowed = originalAmount;
|
||||
BigDecimal originalAmount = led.getAgentAmount(); // 규제 적용 전, 원래 계산된 모집수수료
|
||||
BigDecimal allowed = originalAmount; // 당월 실제 지급 허용액(아래에서 한도로 깎아 나감)
|
||||
|
||||
// (1) 1차년 한도 검사: 계약 첫 1년 동안 지급한 모집수수료 누계가 한도(예: 연환산보험료×35%)를 넘는지.
|
||||
ViolationResult firstYearCheck = regulatoryChecker.checkFirstYear(
|
||||
c.getContractId(), originalAmount, c.getContractDate());
|
||||
if (firstYearCheck.violated()) {
|
||||
// 잔여 지급가능액 = 1차년 한도 - 이미 1차년에 지급한 누계
|
||||
BigDecimal fyAllowed = MoneyUtil.sub(
|
||||
firstYearCheck.limitAmount(), firstYearCheck.currentTotal());
|
||||
if (fyAllowed.signum() < 0) fyAllowed = BigDecimal.ZERO;
|
||||
allowed = allowed.min(fyAllowed);
|
||||
if (fyAllowed.signum() < 0) fyAllowed = BigDecimal.ZERO; // 이미 한도를 넘었으면 더 줄 수 없음(음수 방지)
|
||||
allowed = allowed.min(fyAllowed); // 더 작은 쪽으로 clip
|
||||
}
|
||||
|
||||
// (2) 누적 1200% 한도 검사: 신계약+유지 원장을 합산한 계약 전체 누계가 1200% 한도를 넘는지.
|
||||
ViolationResult totalCheck = regulatoryChecker.checkContractTotal(
|
||||
c.getContractId(), originalAmount, c.getContractDate());
|
||||
if (totalCheck.violated()) {
|
||||
// 잔여 지급가능액 = 1200% 한도 - 이미 지급한 누계(신계약+유지)
|
||||
BigDecimal totAllowed = MoneyUtil.sub(
|
||||
totalCheck.limitAmount(), totalCheck.currentTotal());
|
||||
if (totAllowed.signum() < 0) totAllowed = BigDecimal.ZERO;
|
||||
allowed = allowed.min(totAllowed);
|
||||
if (totAllowed.signum() < 0) totAllowed = BigDecimal.ZERO; // 음수 방지
|
||||
allowed = allowed.min(totAllowed); // 1차년 한도와 비교해 더 작은 쪽으로 clip
|
||||
}
|
||||
|
||||
// 초과분 = 원래 지급액 - 한도로 허용된 지급액. 양수면 한도를 넘은 것.
|
||||
BigDecimal excess = MoneyUtil.sub(originalAmount, allowed);
|
||||
if (excess.signum() > 0) {
|
||||
violationCount++;
|
||||
// 초과분만 분급 계획 생성 (7년 35/25/15/10/7/5/3)
|
||||
// → 한도로 잘린 차액(excess)을 7개 연차로 쪼개 installment_plan에 미래 지급 예정으로 적재.
|
||||
// 당월 지급분(allowed)은 분급 대상이 아니다(이미 이번 달에 지급되므로).
|
||||
List<InstallmentPlanVO> plans = installmentGenerator.generate(
|
||||
c.getContractId(), excess,
|
||||
c.getContractDate(), c.getInsuranceType());
|
||||
installmentBatch.addAll(plans);
|
||||
// 당월 원장은 한도까지만 지급(초과분 차감) + 세액 재계산
|
||||
// → 지급액이 줄었으니 원천세(tax)도 줄어든 지급액 기준으로 다시 계산해야 일관성이 맞는다.
|
||||
led.setAgentAmount(allowed);
|
||||
led.setTaxAmount(MoneyUtil.tax(allowed));
|
||||
log.warn("[Step4] 1200%룰 한도초과 → clip+이연: contractId={}, 지급(한도잔여)={}, 이연(분급)={}, firstYear={}, total={}",
|
||||
@@ -116,20 +156,24 @@ public class CalcRecruitStep implements Tasklet {
|
||||
}
|
||||
|
||||
// 한도로 전액 이연되어 당월 지급액이 0이면 원장 미생성
|
||||
// → allowed가 0까지 깎인 경우(=전부 분급으로 넘어간 경우). 0원 원장을 남기지 않는다.
|
||||
// 분급 계획은 위에서 이미 만들었으므로 금액 손실은 없다(나중 도래월에 지급됨).
|
||||
if (MoneyUtil.isZero(led.getAgentAmount())) continue;
|
||||
batch.add(led);
|
||||
// BATCH_SIZE(1000)건씩 모아 한 번에 INSERT → DB 왕복 횟수를 줄여 대량 처리 성능 확보.
|
||||
if (batch.size() >= BATCH_SIZE) {
|
||||
recruitMapper.batchInsert(batch);
|
||||
total += batch.size();
|
||||
batch.clear();
|
||||
}
|
||||
}
|
||||
// 루프 종료 후 1000건 미만으로 남은 잔여 원장 마저 적재.
|
||||
if (!batch.isEmpty()) {
|
||||
recruitMapper.batchInsert(batch);
|
||||
total += batch.size();
|
||||
}
|
||||
|
||||
// 분급 계획 일괄 저장
|
||||
// 분급 계획 일괄 저장 (한도 초과 계약들에 대해 생성된 7년 분급 예정분을 한 번에 INSERT)
|
||||
if (!installmentBatch.isEmpty()) {
|
||||
installmentPlanMapper.insertBatch(installmentBatch);
|
||||
log.info("[Step4] installment plans created: {} (violations: {})",
|
||||
|
||||
@@ -24,10 +24,13 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Step 6 — chargebackException: 환수 자동생성 + 예외금액 자동계산
|
||||
* Step 6 — chargebackException: 환수 자동생성
|
||||
*
|
||||
* 환수율 계산: BatchChargebackCalculator (chargeback_grade 5단계 차등환수) 사용.
|
||||
* 환수 원금: 해당 계약의 모집 원장 합산 (recruit_ledger 기준).
|
||||
* 이번 정산월에 실효(LAPSE)된 계약을 찾아, 단기 해지에 따른 환수금을 자동 생성한다.
|
||||
* - 대상: status=LAPSE 이면서 실효월(lapseDate 의 월)이 이번 정산월인 계약.
|
||||
* - 환수율: BatchChargebackCalculator (chargeback_grade 5단계 차등환수)로 계산.
|
||||
* - 환수 원금: 그 계약에 이미 지급했던 모집수수료 합계(recruit_ledger 기준).
|
||||
* - 결과: chargeback 레코드(status=NEW) 생성. 이후 Step8 에서 설계사 정산에 반영된다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.ga.batch.settlement.step;
|
||||
|
||||
import com.ga.batch.settlement.SettlementContext;
|
||||
import com.ga.common.util.MoneyUtil;
|
||||
import com.ga.core.mapper.installment.InstallmentPlanMapper;
|
||||
import com.ga.core.mapper.ledger.RecruitLedgerMapper;
|
||||
import com.ga.core.mapper.product.ContractMapper;
|
||||
import com.ga.core.vo.installment.InstallmentPlanVO;
|
||||
import com.ga.core.vo.installment.InstallmentStatus;
|
||||
import com.ga.core.vo.ledger.LedgerVO;
|
||||
import com.ga.core.vo.product.ContractVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.batch.core.StepContribution;
|
||||
import org.springframework.batch.core.configuration.annotation.JobScope;
|
||||
import org.springframework.batch.core.scope.context.ChunkContext;
|
||||
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||
import org.springframework.batch.repeat.RepeatStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Step 4.5 — payInstallment: 1200%룰 이연 차액(분급)의 도래월 지급.
|
||||
*
|
||||
* CalcRecruitStep(Step4)이 1차년/1200% 한도 초과분을 installment_plan(SCHEDULED, 7년)으로
|
||||
* 이연하지만, 도래월에 실제 지급하는 절차가 없으면 차액이 영구 미지급된다. 이 스텝은
|
||||
* 정산월에 도래(settle_month=정산월)한 SCHEDULED 분급을 recruit_ledger에 편입하여
|
||||
* Step8 aggregate(settle_master.recruitTotal/gross)로 흐르게 한다.
|
||||
*
|
||||
* 멱등성: 정산 Job 재실행 시 Step4의 deleteBySettleMonth(status='CALCULATED')가 이미
|
||||
* 당월 원장을 정리하므로, 이 스텝은 진입 시 PAID→SCHEDULED 역산 후 재지급한다.
|
||||
* 분급 첫 회차는 plan_year≥1(계약 1년 후)이라 clip한 당월과 절대 겹치지 않는다.
|
||||
*
|
||||
* 한도와의 관계(의도된 동작): 이 스텝은 1200%룰 검사기(BatchRegulatoryLimitChecker)를
|
||||
* 호출하지 않는다. 따라서 한 번 이연된 분급은 자신의 7년 일정대로 끝까지 지급되며 다시
|
||||
* clip 되지 않는다. 지급된 분급은 recruit_ledger 행으로 남아 이후 같은 계약에 '새로' 들어오는
|
||||
* 모집수수료의 누적 한도(checkContractTotal=sumByContract) 계산에 포함되는데, 이는 계약이
|
||||
* 실제로 받은 금액을 누적에 반영하는 정상 동작이다(분급분을 한도에서 빼야 하는 버그가 아님).
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@JobScope
|
||||
@RequiredArgsConstructor
|
||||
public class PayInstallmentStep implements Tasklet {
|
||||
|
||||
private final SettlementContext ctx;
|
||||
private final InstallmentPlanMapper planMapper;
|
||||
private final ContractMapper contractMapper;
|
||||
private final RecruitLedgerMapper recruitMapper;
|
||||
|
||||
@Override
|
||||
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
|
||||
String settleMonth = ctx.getSettleMonth();
|
||||
log.info("[Step4.5 payInstallment] settleMonth={}", settleMonth);
|
||||
|
||||
// 멱등 역산: 당월 기지급 분급을 SCHEDULED로 되돌림 (재실행 안전).
|
||||
// 대응 원장은 Step4 deleteBySettleMonth가 이미 정리함.
|
||||
planMapper.resetPaidToScheduledByMonth(settleMonth);
|
||||
|
||||
List<InstallmentPlanVO> scheduled = planMapper.selectScheduledByMonth(settleMonth);
|
||||
List<LedgerVO> ledgers = new ArrayList<>();
|
||||
int paid = 0;
|
||||
|
||||
for (InstallmentPlanVO plan : scheduled) {
|
||||
if (MoneyUtil.isZero(plan.getExpectedAmount())) continue;
|
||||
|
||||
ContractVO contract = contractMapper.selectById(plan.getContractId());
|
||||
if (contract == null) {
|
||||
log.warn("[Step4.5] 계약 미존재 → 분급 미지급 skip: planId={}, contractId={}",
|
||||
plan.getPlanId(), plan.getContractId());
|
||||
continue;
|
||||
}
|
||||
|
||||
ledgers.add(toLedger(plan, contract, settleMonth));
|
||||
|
||||
plan.setStatus(InstallmentStatus.PAID.code());
|
||||
plan.setPaidAmount(plan.getExpectedAmount());
|
||||
plan.setPaidAt(LocalDateTime.now());
|
||||
planMapper.update(plan);
|
||||
paid++;
|
||||
}
|
||||
|
||||
if (!ledgers.isEmpty()) {
|
||||
recruitMapper.batchInsert(ledgers);
|
||||
}
|
||||
|
||||
ctx.setInstallmentPaidCount(paid);
|
||||
log.info("[Step4.5] installment paid → recruit ledger: {} (expected total rows scheduled={})",
|
||||
paid, scheduled.size());
|
||||
return RepeatStatus.FINISHED;
|
||||
}
|
||||
|
||||
/** 분급 계획 + 계약으로 모집원장 행 생성. agentAmount=이연 차액, status=CALCULATED(재실행 정리 대상). */
|
||||
private LedgerVO toLedger(InstallmentPlanVO plan, ContractVO contract, String settleMonth) {
|
||||
LedgerVO led = new LedgerVO();
|
||||
led.setContractId(contract.getContractId());
|
||||
led.setAgentId(contract.getAgentId());
|
||||
led.setPolicyNo(contract.getPolicyNo());
|
||||
led.setPremium(contract.getPremium());
|
||||
led.setPayMethod(contract.getPayCycle());
|
||||
led.setCommissionYear(plan.getPlanYear());
|
||||
led.setAgentAmount(plan.getExpectedAmount());
|
||||
led.setTaxAmount(MoneyUtil.tax(plan.getExpectedAmount()));
|
||||
// 분급 지급분은 시스템 내부 산출(이연 차액)이라 보험사 자료와 대사할 대상이 아니므로
|
||||
// MATCHED(자동 대사 완료)로 표기한다. 일반 모집원장(보험사 자료 기반)은 PENDING 유지.
|
||||
led.setReconcileStatus("MATCHED");
|
||||
led.setSettleMonth(settleMonth);
|
||||
led.setStatus("CALCULATED");
|
||||
return led;
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,11 @@ import org.springframework.stereotype.Component;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Step 1 — receiveData: 보험사별 DataReceiver 호출 → raw_commission_data 적재
|
||||
* Step 1 — receiveData: 보험사별 데이터 수신
|
||||
*
|
||||
* 활성 보험사 목록을 돌면서, 각 보험사를 지원하는 DataReceiver(수신 전략)를 골라 호출한다.
|
||||
* receiver 가 수수료 원천 데이터를 raw_commission_data 에 적재하고, 적재 행 수를 합산해
|
||||
* 컨텍스트에 기록한다. 정산 파이프라인의 입구(데이터 들여오기) 단계.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@@ -32,6 +36,7 @@ public class ReceiveDataStep implements Tasklet {
|
||||
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
|
||||
log.info("[Step1 receiveData] settleMonth={}", ctx.getSettleMonth());
|
||||
int total = 0;
|
||||
// 활성 보험사마다, 그 회사를 지원하는 첫 번째 receiver 를 찾아 수신 실행(찾으면 break).
|
||||
for (InsuranceCompanyVO c : companyMapper.selectActive()) {
|
||||
for (DataReceiver r : receivers) {
|
||||
if (r.supports(c.getCompanyCode())) {
|
||||
|
||||
@@ -18,7 +18,14 @@ import org.springframework.stereotype.Component;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* Step 3 — reconcile: 보험사 통보액 vs 시스템 계산액 대사
|
||||
* Step 3 — reconcile: 대사(對査)
|
||||
*
|
||||
* 대사란 "보험사가 통보한 수수료 총액"과 "우리 시스템이 계산/집계한 총액"을 맞춰보는 검증이다.
|
||||
* 회사별로 두 금액의 차이를 reconciliation 에 기록하고, 차이가 0 이면 MATCHED, 아니면 DIFF.
|
||||
* DIFF 인 회사는 사람이 원인을 확인해야 한다.
|
||||
*
|
||||
* 주의(현재 구현 한계): companyTotal 을 systemTotal 과 동일하게 두어 항상 MATCHED 가 된다.
|
||||
* 실제 운영에서는 raw_commission_data 의 통보액 합계로 companyTotal 을 산출해야 한다(아래 주석 참고).
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
|
||||
@@ -16,7 +16,12 @@ import org.springframework.stereotype.Component;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Step 2 — transformData: company_field_mapping + code_mapping 으로 표준 변환 → 원장 적재
|
||||
* Step 2 — transformData: 원천 데이터 표준화
|
||||
*
|
||||
* Step1 이 적재한 PENDING 상태 raw_commission_data 를 한 건씩 MappingEngine 으로 변환해
|
||||
* 표준 원장(recruit_ledger / maintain_ledger)에 적재한다.
|
||||
* 변환 성공이면 raw 상태를 OK(+생성 원장 PK), 실패면 ERROR 로 갱신한다.
|
||||
* 한 건의 변환 실패가 전체 배치를 멈추지 않도록 try/catch 로 건별 처리한다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
|
||||
@@ -12,11 +12,13 @@ import org.springframework.batch.repeat.RepeatStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Step 12 (conditional) — vatReportQuarterly: 분기 부가세 집계
|
||||
* Step 12 (조건부 실행) — vatReportQuarterly: 분기 부가세 집계
|
||||
*
|
||||
* 분기 마감월(3/6/9/12월)에만 실행 (settleMonth ends with "03"/"06"/"09"/"12").
|
||||
* tax_invoice 테이블 기준 해당 분기 SUPPLY/PURCHASE SUM → vat_report UPSERT (분기 1건).
|
||||
* SQL은 VatReportMapper.upsertAggregate에 위임.
|
||||
* 부가세 신고는 분기 단위로 한다. 이 Step 은 그 분기 부가세 신고용 집계를 만든다.
|
||||
* - 분기 마감월(3/6/9/12월)일 때만 실행, 그 외 월은 건너뜀.
|
||||
* - tax_invoice(세금계산서) 기준 해당 분기 매출(SUPPLY)/매입(PURCHASE) SUM → vat_report 에
|
||||
* 분기당 1건 UPSERT. (낼 부가세 = 매출세액 - 매입세액)
|
||||
* - 실제 집계 SQL 은 VatReportMapper.upsertAggregate 에 위임.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
|
||||
+6
-4
@@ -12,11 +12,13 @@ import org.springframework.batch.repeat.RepeatStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Step 11 (conditional) — withholdingTaxQuarterly: 분기 원천세 집계
|
||||
* Step 11 (조건부 실행) — withholdingTaxQuarterly: 분기 원천세 집계
|
||||
*
|
||||
* 분기 마감월(3/6/9/12월)에만 실행 (settleMonth ends with "03"/"06"/"09"/"12").
|
||||
* payment 테이블 기준 해당 분기 설계사별 SUM → withholding_tax_report UPSERT.
|
||||
* SQL은 WithholdingTaxReportMapper.upsertQuarterlyAggregate에 위임.
|
||||
* 원천세(설계사 보수에서 미리 떼어둔 소득세)는 분기 단위로 국세청에 신고·납부한다.
|
||||
* 이 Step 은 그 분기 신고용 집계를 만든다.
|
||||
* - 분기 마감월(3/6/9/12월)일 때만 실행, 그 외 월은 건너뜀.
|
||||
* - payment 테이블 기준 해당 분기 설계사별 원천세 SUM → withholding_tax_report 에 UPSERT.
|
||||
* - 실제 집계 SQL 은 WithholdingTaxReportMapper.upsertQuarterlyAggregate 에 위임.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
|
||||
@@ -12,11 +12,13 @@ import org.springframework.batch.repeat.RepeatStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Step 9 (conditional) — yearEndIncomeAggregate: 연말 사업소득 집계
|
||||
* Step 9 (조건부 실행) — yearEndIncomeAggregate: 연말 사업소득 집계
|
||||
*
|
||||
* 12월 마감 시에만 실행 (settleMonth ends with "-12" 또는 "12").
|
||||
* payment 테이블 기준으로 해당 연도 설계사별 SUM → agent_annual_income UPSERT.
|
||||
* SQL은 AgentAnnualIncomeMapper.upsertAggregate에 위임.
|
||||
* 설계사에게 1년 동안 지급한 소득을 연 단위로 합산한다. 연말정산/지급명세서 제출 등
|
||||
* 세무 신고의 기초 자료가 된다.
|
||||
* - 12월 마감일 때만 실행한다(그 외 월은 건너뜀). 한 해가 끝나야 연간 합계가 확정되기 때문.
|
||||
* - payment 테이블 기준 해당 연도 설계사별 SUM → agent_annual_income 에 UPSERT.
|
||||
* - 실제 집계 SQL 은 AgentAnnualIncomeMapper.upsertAggregate 에 위임(여기서는 호출만).
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
|
||||
@@ -49,8 +49,15 @@ public class MappingEngine {
|
||||
private final MaintainLedgerMapper maintainMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* raw 데이터 1건을 표준 원장 1건으로 변환·적재한다.
|
||||
* 각 단계에서 실패하면 parse_error_log 에 사유를 남기고 null 을 반환한다(이 건은 건너뜀).
|
||||
* 성공 시 생성된 원장 PK(ledgerId)를 반환.
|
||||
* @Transactional: 원장 INSERT 가 한 트랜잭션으로 묶인다.
|
||||
*/
|
||||
@Transactional
|
||||
public Long transform(RawCommissionDataVO raw) {
|
||||
// 입력 기본 검증: 회사코드/원본내용이 없으면 변환 불가.
|
||||
if (raw.getCompanyCode() == null || raw.getRawContent() == null) {
|
||||
saveError(raw, "INVALID_INPUT", null, null, "company_code 또는 raw_content 누락");
|
||||
return null;
|
||||
@@ -79,6 +86,7 @@ public class MappingEngine {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 매핑 규칙대로 source_field 값을 꺼내 transform 적용 후 target_field 이름으로 모은다.
|
||||
Map<String, Object> mapped = new HashMap<>();
|
||||
for (CompanyFieldMappingVO m : mappings) {
|
||||
JsonNode v = root.get(m.getSourceField());
|
||||
@@ -97,6 +105,7 @@ public class MappingEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// 증권번호(policy_no)로 우리 시스템의 계약을 매칭한다. 못 찾으면 적재 불가.
|
||||
String policyNo = (String) mapped.get("policy_no");
|
||||
if (policyNo == null) {
|
||||
saveError(raw, "NO_POLICY", "policy_no", null, "policy_no 누락");
|
||||
@@ -118,6 +127,7 @@ public class MappingEngine {
|
||||
return led.getLedgerId();
|
||||
}
|
||||
|
||||
/** data_type 값을 적재 대상 원장 테이블명으로 매핑. 모집→recruit_ledger, 유지→maintain_ledger. */
|
||||
private String resolveTargetTable(String dataType) {
|
||||
if (dataType == null) return null;
|
||||
return switch (dataType.toUpperCase()) {
|
||||
@@ -127,6 +137,11 @@ public class MappingEngine {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 규칙(transform_rule)에 따라 원본 문자열 값을 가공한다.
|
||||
* TRIM/UPPER/LOWER: 문자 정리, DATE: 날짜 파싱, DIVIDE/MULTIPLY: 단위 환산(예: 천원→원),
|
||||
* CODE_MAP: 보험사 코드 → 표준 코드 매핑. 규칙이 없으면 dataType 에 맞춰 타입만 변환.
|
||||
*/
|
||||
private Object applyTransform(String value, CompanyFieldMappingVO m, String companyCode) {
|
||||
if (value == null) {
|
||||
return convertType(m.getDefaultValue(), m.getDataType());
|
||||
@@ -151,6 +166,7 @@ public class MappingEngine {
|
||||
};
|
||||
}
|
||||
|
||||
/** 문자열을 dataType(STRING/NUMBER/INT/LONG/DATE)에 맞는 자바 타입으로 변환. 숫자는 천단위 콤마 제거. */
|
||||
private Object convertType(String value, String dataType) {
|
||||
if (value == null) return null;
|
||||
if (dataType == null) return value;
|
||||
@@ -164,6 +180,7 @@ public class MappingEngine {
|
||||
};
|
||||
}
|
||||
|
||||
/** 날짜 파싱. pattern 지정 시 그 형식, 없으면 yyyyMMdd 시도, 둘 다 실패하면 ISO(yyyy-MM-dd) 시도. */
|
||||
private LocalDate parseDate(String s, String pattern) {
|
||||
if (s == null || s.isBlank()) return null;
|
||||
try {
|
||||
@@ -176,12 +193,14 @@ public class MappingEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/** 단위 환산용. divide=true 면 나누기(2자리 반올림), false 면 곱하기. factor 없으면 1. */
|
||||
private BigDecimal divideOrMultiply(String value, String factor, boolean divide) {
|
||||
BigDecimal num = new BigDecimal(value.replace(",", ""));
|
||||
BigDecimal f = new BigDecimal(factor != null && !factor.isBlank() ? factor : "1");
|
||||
return divide ? num.divide(f, 2, RoundingMode.HALF_UP) : num.multiply(f);
|
||||
}
|
||||
|
||||
/** 증권번호로 계약 조회. 목록에서 첫 건의 PK 를 얻어 상세를 다시 읽는다(상세 필드 확보용). */
|
||||
private ContractVO findContractByPolicyNo(String policyNo) {
|
||||
ContractSearchParam p = new ContractSearchParam();
|
||||
p.setPolicyNo(policyNo);
|
||||
@@ -191,6 +210,11 @@ public class MappingEngine {
|
||||
return contractMapper.selectById(list.get(0).getContractId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 매핑 결과(mapped) + 계약 정보로 원장 VO 를 채운다.
|
||||
* 보험사가 이미 금액/율을 통보한 경우(외부 통보형)이므로 여기서는 그 값을 그대로 쓰고,
|
||||
* 누락 시 계약값/0 으로 폴백한다. 세금(tax_amount)만 설계사지급액 기준으로 자체 계산.
|
||||
*/
|
||||
private LedgerVO buildLedger(Map<String, Object> mapped, ContractVO contract, RawCommissionDataVO raw) {
|
||||
LedgerVO led = new LedgerVO();
|
||||
led.setContractId(contract.getContractId());
|
||||
@@ -225,6 +249,7 @@ public class MappingEngine {
|
||||
try { return Integer.parseInt(v.toString()); } catch (Exception e) { return fallback; }
|
||||
}
|
||||
|
||||
/** 변환 실패 1건을 parse_error_log 에 기록(상태 OPEN). 운영자가 화면에서 보고 보정한다. */
|
||||
private void saveError(RawCommissionDataVO raw, String type, String field, String value, String msg) {
|
||||
log.warn("MappingEngine error rawId={} type={} field={} value={}: {}",
|
||||
raw.getRawId(), type, field, value, msg);
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.ga.batch.settlement.step;
|
||||
|
||||
import com.ga.batch.settlement.SettlementContext;
|
||||
import com.ga.core.mapper.installment.InstallmentPlanMapper;
|
||||
import com.ga.core.mapper.ledger.RecruitLedgerMapper;
|
||||
import com.ga.core.mapper.product.ContractMapper;
|
||||
import com.ga.core.vo.installment.InstallmentPlanVO;
|
||||
import com.ga.core.vo.ledger.LedgerVO;
|
||||
import com.ga.core.vo.product.ContractVO;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
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.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 1200%룰 이연 차액(분급)의 도래월 지급(PayInstallmentStep) 검증.
|
||||
* - 정상 지급: SCHEDULED 분급 → recruit_ledger 편입 + plan PAID 마킹
|
||||
* - 멱등 재실행: 진입 시 PAID→SCHEDULED 역산 호출
|
||||
* - 계약 미존재: 원장 미생성 + PAID 마킹 안 함
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class PayInstallmentStepTest {
|
||||
|
||||
@Mock SettlementContext ctx;
|
||||
@Mock InstallmentPlanMapper planMapper;
|
||||
@Mock ContractMapper contractMapper;
|
||||
@Mock RecruitLedgerMapper recruitMapper;
|
||||
|
||||
PayInstallmentStep step;
|
||||
|
||||
static final String MONTH = "202501";
|
||||
static final Long CONTRACT_ID = 100L;
|
||||
static final Long AGENT_ID = 10L;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
step = new PayInstallmentStep(ctx, planMapper, contractMapper, recruitMapper);
|
||||
when(ctx.getSettleMonth()).thenReturn(MONTH);
|
||||
}
|
||||
|
||||
private InstallmentPlanVO plan(BigDecimal expected, int planYear) {
|
||||
InstallmentPlanVO p = new InstallmentPlanVO();
|
||||
p.setPlanId(1L);
|
||||
p.setContractId(CONTRACT_ID);
|
||||
p.setPlanYear(planYear);
|
||||
p.setPlanMonth(1);
|
||||
p.setSettleMonth(MONTH);
|
||||
p.setExpectedAmount(expected);
|
||||
p.setStatus("SCHEDULED");
|
||||
return p;
|
||||
}
|
||||
|
||||
private ContractVO contract() {
|
||||
ContractVO c = new ContractVO();
|
||||
c.setContractId(CONTRACT_ID);
|
||||
c.setAgentId(AGENT_ID);
|
||||
c.setPolicyNo("P-100");
|
||||
c.setPremium(new BigDecimal("1000000"));
|
||||
return c;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("도래월 SCHEDULED 분급 100,000 → recruit_ledger 1행(tax 3,300) 편입 + plan PAID")
|
||||
void payScheduled_insertsLedgerAndMarksPaid() {
|
||||
when(planMapper.selectScheduledByMonth(MONTH))
|
||||
.thenReturn(List.of(plan(new BigDecimal("100000"), 2)));
|
||||
when(contractMapper.selectById(CONTRACT_ID)).thenReturn(contract());
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<List<LedgerVO>> ledgerCaptor = ArgumentCaptor.forClass(List.class);
|
||||
when(recruitMapper.batchInsert(ledgerCaptor.capture())).thenReturn(1);
|
||||
|
||||
step.execute(null, null);
|
||||
|
||||
LedgerVO led = ledgerCaptor.getValue().get(0);
|
||||
assertThat(led.getAgentId()).isEqualTo(AGENT_ID);
|
||||
assertThat(led.getContractId()).isEqualTo(CONTRACT_ID);
|
||||
assertThat(led.getSettleMonth()).isEqualTo(MONTH);
|
||||
assertThat(led.getAgentAmount()).isEqualByComparingTo("100000");
|
||||
assertThat(led.getTaxAmount()).isEqualByComparingTo("3300.00");
|
||||
assertThat(led.getCommissionYear()).isEqualTo(2);
|
||||
assertThat(led.getStatus()).isEqualTo("CALCULATED");
|
||||
|
||||
// plan PAID 마킹 (expected=paidAmount)
|
||||
ArgumentCaptor<InstallmentPlanVO> planCaptor = ArgumentCaptor.forClass(InstallmentPlanVO.class);
|
||||
verify(planMapper).update(planCaptor.capture());
|
||||
assertThat(planCaptor.getValue().getStatus()).isEqualTo("PAID");
|
||||
assertThat(planCaptor.getValue().getPaidAmount()).isEqualByComparingTo("100000");
|
||||
assertThat(planCaptor.getValue().getPaidAt()).isNotNull();
|
||||
|
||||
verify(ctx).setInstallmentPaidCount(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("멱등 재실행: 진입 시 PAID→SCHEDULED 역산을 먼저 호출")
|
||||
void idempotent_resetsPaidFirst() {
|
||||
when(planMapper.selectScheduledByMonth(MONTH)).thenReturn(List.of());
|
||||
|
||||
step.execute(null, null);
|
||||
|
||||
verify(planMapper).resetPaidToScheduledByMonth(MONTH);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("계약 미존재 분급 → 원장 미생성 + PAID 마킹 안 함")
|
||||
void missingContract_skip() {
|
||||
when(planMapper.selectScheduledByMonth(MONTH))
|
||||
.thenReturn(List.of(plan(new BigDecimal("100000"), 2)));
|
||||
when(contractMapper.selectById(CONTRACT_ID)).thenReturn(null);
|
||||
|
||||
step.execute(null, null);
|
||||
|
||||
verify(recruitMapper, never()).batchInsert(any());
|
||||
verify(planMapper, never()).update(any());
|
||||
verify(ctx).setInstallmentPaidCount(0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user