동기화

This commit is contained in:
GA Pro
2026-05-14 01:21:27 +09:00
parent acea2afce5
commit 958fe6b980
379 changed files with 21786 additions and 423 deletions
@@ -0,0 +1,49 @@
package com.ga.batch.settlement.domain;
import com.ga.core.mapper.chargeback.ChargebackGradeMapper;
import com.ga.core.vo.chargeback.ChargebackGradeVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
/**
* 배치용 차등환수 계산기. ga-api ChargebackCalculator와 동일 로직.
* chargeback_grade 테이블 기반 5단계 환수율 조회.
*/
@Component
@RequiredArgsConstructor
public class BatchChargebackCalculator {
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final ChargebackGradeMapper gradeMapper;
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);
ChargebackGradeVO grade = gradeMapper.selectByMonths(productCategory, monthsElapsed, today);
if (grade == null) {
return new ChargebackResult(BigDecimal.ZERO, monthsElapsed, BigDecimal.ZERO,
"적용 환수 구간 없음");
}
BigDecimal chargebackAmount = originalCommission
.multiply(grade.getChargebackPercent())
.divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP);
return new ChargebackResult(
chargebackAmount,
monthsElapsed,
grade.getChargebackPercent(),
String.format("경과월 %d개월 → 환수율 %s%%", monthsElapsed, grade.getChargebackPercent())
);
}
}
@@ -0,0 +1,51 @@
package com.ga.batch.settlement.domain;
import com.ga.core.mapper.installment.InstallmentRatioMapper;
import com.ga.core.vo.installment.InstallmentPlanVO;
import com.ga.core.vo.installment.InstallmentRatioVO;
import com.ga.core.vo.installment.InstallmentStatus;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
/**
* 배치용 분급 계획 생성기. ga-api InstallmentPlanGenerator와 동일 로직.
* 계약 체결일 기준 1~7차년 분급 계획 생성.
*/
@Component
@RequiredArgsConstructor
public class BatchInstallmentPlanGenerator {
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final DateTimeFormatter MONTH_FMT = DateTimeFormatter.ofPattern("yyyyMM");
private final InstallmentRatioMapper ratioMapper;
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);
List<InstallmentPlanVO> plans = new ArrayList<>();
for (InstallmentRatioVO ratio : ratios) {
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.setStatus(InstallmentStatus.SCHEDULED.code());
plans.add(plan);
}
return plans;
}
}
@@ -0,0 +1,83 @@
package com.ga.batch.settlement.domain;
import com.ga.core.mapper.ledger.MaintainLedgerMapper;
import com.ga.core.mapper.ledger.RecruitLedgerMapper;
import com.ga.core.mapper.regulatory.RegulatoryLimitMapper;
import com.ga.core.vo.regulatory.RegulatoryLimitVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
/**
* 배치용 1200%룰 한도 체커. ga-api RegulatoryLimitChecker와 동일 로직.
*/
@Component
@RequiredArgsConstructor
public class BatchRegulatoryLimitChecker {
private static final String TOTAL_RATIO = "TOTAL_RATIO";
private static final String FIRST_YEAR_RATIO = "FIRST_YEAR_RATIO";
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final RegulatoryLimitMapper limitMapper;
private final RecruitLedgerMapper recruitLedgerMapper;
private final MaintainLedgerMapper maintainLedgerMapper;
/** 누적 수수료율 한도 체크 (1200%). 신계약 + 유지 원장 합산. */
public ViolationResult checkContractTotal(Long contractId, BigDecimal additionalAmount, LocalDate baseDate) {
String baseDateStr = baseDate.format(DATE_FMT);
RegulatoryLimitVO limit = limitMapper.selectActiveByType(TOTAL_RATIO, null, baseDateStr);
if (limit == null) {
return ViolationResult.ok(additionalAmount, null, TOTAL_RATIO);
}
BigDecimal currentTotal = sumPaidByContract(contractId);
BigDecimal projected = currentTotal.add(additionalAmount);
if (projected.compareTo(limit.getLimitValue()) > 0) {
return ViolationResult.violated(
currentTotal,
limit.getLimitValue(),
TOTAL_RATIO,
String.format("누적 수수료 한도 초과: 현재 %s, 추가 %s, 한도 %s %s",
currentTotal, additionalAmount, limit.getLimitValue(), limit.getUnit())
);
}
return ViolationResult.ok(currentTotal, limit.getLimitValue(), TOTAL_RATIO);
}
/** 1차년 수수료율 한도 체크. */
public ViolationResult checkFirstYear(Long contractId, BigDecimal additionalAmount, LocalDate baseDate) {
String baseDateStr = baseDate.format(DATE_FMT);
RegulatoryLimitVO limit = limitMapper.selectActiveByType(FIRST_YEAR_RATIO, null, baseDateStr);
if (limit == null) {
return ViolationResult.ok(additionalAmount, null, FIRST_YEAR_RATIO);
}
BigDecimal currentTotal = nullSafe(recruitLedgerMapper.sumFirstYearByContract(contractId));
BigDecimal projected = currentTotal.add(additionalAmount);
if (projected.compareTo(limit.getLimitValue()) > 0) {
return ViolationResult.violated(
currentTotal,
limit.getLimitValue(),
FIRST_YEAR_RATIO,
String.format("1차년 수수료 한도 초과: 현재 %s, 추가 %s, 한도 %s %s",
currentTotal, additionalAmount, limit.getLimitValue(), limit.getUnit())
);
}
return ViolationResult.ok(currentTotal, limit.getLimitValue(), FIRST_YEAR_RATIO);
}
private BigDecimal sumPaidByContract(Long contractId) {
return nullSafe(recruitLedgerMapper.sumByContract(contractId))
.add(nullSafe(maintainLedgerMapper.sumByContract(contractId)));
}
private BigDecimal nullSafe(BigDecimal v) {
return v != null ? v : BigDecimal.ZERO;
}
}
@@ -0,0 +1,45 @@
package com.ga.batch.settlement.domain;
import com.ga.common.system.SystemConfigService;
import com.ga.core.vo.org.TaxType;
import com.ga.core.vo.org.VatType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
/**
* 배치용 세금 계산기. ga-api TaxCalculator와 동일 로직, ga-batch 모듈 독립.
* 사업소득 3.3% / 기타소득 8.8% + 지방소득세 10% + 부가세 10% (TAXABLE만).
*/
@Component
@RequiredArgsConstructor
public class BatchTaxCalculator {
private final SystemConfigService configService;
public TaxBreakdown calculate(BigDecimal amount, TaxType taxType, VatType vatType) {
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 incomeTax = amount.multiply(incomeRate)
.divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP);
BigDecimal localTax = incomeTax.multiply(localRate)
.divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP);
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);
}
private BigDecimal resolveIncomeRate(TaxType taxType) {
if (TaxType.OTHER_INCOME.equals(taxType)) {
return configService.getDecimal("TAX_RATE_OTHER_INCOME", new BigDecimal("8.8"));
}
return configService.getDecimal("TAX_RATE_BUSINESS_INCOME", new BigDecimal("3.3"));
}
}
@@ -0,0 +1,11 @@
package com.ga.batch.settlement.domain;
import java.math.BigDecimal;
/** 차등환수 계산 결과. */
public record ChargebackResult(
BigDecimal chargebackAmount,
int monthsElapsed,
BigDecimal appliedPercent,
String reason
) {}
@@ -0,0 +1,11 @@
package com.ga.batch.settlement.domain;
import java.math.BigDecimal;
/** 세금 계산 결과. ga-api TaxBreakdown과 동일 구조 (모듈 독립). */
public record TaxBreakdown(
BigDecimal incomeTax,
BigDecimal localTax,
BigDecimal vat,
BigDecimal netAmount
) {}
@@ -0,0 +1,21 @@
package com.ga.batch.settlement.domain;
import java.math.BigDecimal;
/** 1200%룰 검증 결과. */
public record ViolationResult(
boolean violated,
BigDecimal currentTotal,
BigDecimal limitAmount,
String limitType,
String message
) {
public static ViolationResult ok(BigDecimal currentTotal, BigDecimal limitAmount, String limitType) {
return new ViolationResult(false, currentTotal, limitAmount, limitType, null);
}
public static ViolationResult violated(BigDecimal currentTotal, BigDecimal limitAmount,
String limitType, String message) {
return new ViolationResult(true, currentTotal, limitAmount, limitType, message);
}
}
@@ -1,18 +1,28 @@
package com.ga.batch.settlement.step;
import com.ga.batch.settlement.SettlementContext;
import com.ga.common.system.SystemConfigService;
import com.ga.batch.settlement.domain.BatchTaxCalculator;
import com.ga.batch.settlement.domain.TaxBreakdown;
import com.ga.common.util.MoneyUtil;
import com.ga.core.mapper.ledger.ExceptionLedgerMapper;
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.settle.AgentDeductionMapper;
import com.ga.core.mapper.settle.ChargebackMapper;
import com.ga.core.mapper.settle.CommissionStatementMapper;
import com.ga.core.mapper.settle.OverrideSettleMapper;
import com.ga.core.mapper.settle.SettleMasterMapper;
import com.ga.core.vo.org.AgentResp;
import com.ga.core.vo.org.AgentSearchParam;
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.settle.AgentDeductionVO;
import com.ga.core.vo.settle.CommissionStatementVO;
import com.ga.core.vo.settle.DeductionStatus;
import com.ga.core.vo.settle.SettleMasterVO;
import com.ga.core.vo.settle.StatementType;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.StepContribution;
@@ -23,12 +33,22 @@ import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
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
*/
@Slf4j
@Component
@@ -44,16 +64,19 @@ public class AggregateStep implements Tasklet {
private final OverrideSettleMapper overrideMapper;
private final ChargebackMapper chargebackMapper;
private final SettleMasterMapper settleMapper;
private final SystemConfigService configService;
private final AgentDeductionMapper deductionMapper;
private final CommissionStatementMapper statementMapper;
private final BatchTaxCalculator taxCalculator;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
log.info("[Step8 aggregate] settleMonth={}", ctx.getSettleMonth());
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()));
Map<Long, BigDecimal> overrideMap = toMap(overrideMapper.aggregateByUpper(ctx.getSettleMonth()));
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()));
Map<Long, BigDecimal> overrideMap = toMap(overrideMapper.aggregateByUpper(ctx.getSettleMonth()));
// 예외는 PLUS/MINUS 분리
Map<Long, BigDecimal> excPlus = new HashMap<>();
Map<Long, BigDecimal> excMinus = new HashMap<>();
@@ -63,10 +86,8 @@ public class AggregateStep implements Tasklet {
excMinus.put(agentId, toBd(row.get("minus")));
}
BigDecimal taxRate = configService.getDecimal("TAX_RATE", new BigDecimal("3.3"));
// 합집합 agent_id
java.util.Set<Long> agentIds = new java.util.HashSet<>();
Set<Long> agentIds = new HashSet<>();
agentIds.addAll(recruitMap.keySet());
agentIds.addAll(maintainMap.keySet());
agentIds.addAll(chargebackMap.keySet());
@@ -76,10 +97,23 @@ public class AggregateStep implements Tasklet {
// 활성 설계사 누락 보충
AgentSearchParam ap = new AgentSearchParam();
ap.setStatus("ACTIVE"); ap.setPageSize(Integer.MAX_VALUE);
for (AgentResp a : agentMapper.selectList(ap)) agentIds.add(a.getAgentId());
ap.setStatus("ACTIVE");
ap.setPageSize(Integer.MAX_VALUE);
for (AgentResp a : agentMapper.selectList(ap)) {
agentIds.add(a.getAgentId());
}
// 정산월 PENDING 차감 목록 일괄 조회 (배치 효율)
List<AgentDeductionVO> allDeductions = deductionMapper.selectPendingByMonth(ctx.getSettleMonth());
Map<Long, List<AgentDeductionVO>> deductionByAgent = new HashMap<>();
for (AgentDeductionVO d : allDeductions) {
deductionByAgent.computeIfAbsent(d.getAgentId(), k -> new ArrayList<>()).add(d);
}
List<Long> appliedDeductionIds = new ArrayList<>();
List<CommissionStatementVO> statements = new ArrayList<>();
int count = 0;
for (Long agentId : agentIds) {
BigDecimal recruit = recruitMap.getOrDefault(agentId, BigDecimal.ZERO);
BigDecimal maintain = maintainMap.getOrDefault(agentId, BigDecimal.ZERO);
@@ -90,9 +124,25 @@ public class AggregateStep implements Tasklet {
BigDecimal gross = MoneyUtil.add(MoneyUtil.add(MoneyUtil.add(recruit, maintain), ov), ep)
.subtract(cb).subtract(em);
BigDecimal tax = MoneyUtil.tax(gross, taxRate);
BigDecimal net = MoneyUtil.sub(gross, tax);
// 세금유형 조회 (없으면 기본: BUSINESS_INCOME / NONE)
AgentVO agentVO = agentMapper.selectById(agentId);
TaxType taxType = parseTaxType(agentVO);
VatType vatType = parseVatType(agentVO);
// BatchTaxCalculator로 정확한 세금 계산
TaxBreakdown tax = taxCalculator.calculate(gross, taxType, vatType);
BigDecimal totalTax = tax.incomeTax().add(tax.localTax()); // settle_master.tax_amount
// 차감 합산
List<AgentDeductionVO> agentDeductions = deductionByAgent.getOrDefault(agentId, List.of());
BigDecimal deductionTotal = agentDeductions.stream()
.map(AgentDeductionVO::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal netAmount = tax.netAmount().subtract(deductionTotal);
// settle_master UPSERT
SettleMasterVO vo = new SettleMasterVO();
vo.setAgentId(agentId);
vo.setSettleMonth(ctx.getSettleMonth());
@@ -103,17 +153,74 @@ public class AggregateStep implements Tasklet {
vo.setOverrideTotal(ov);
vo.setChargebackTotal(cb);
vo.setGrossAmount(gross);
vo.setTaxAmount(tax);
vo.setNetAmount(net);
vo.setTaxAmount(totalTax); // incomeTax + localTax 합산
vo.setNetAmount(netAmount); // gross - incomeTax - localTax + vat - deduction
vo.setStatus("CALCULATED");
settleMapper.upsert(vo);
count++;
// 차감 적용 완료 처리
if (!agentDeductions.isEmpty()) {
agentDeductions.forEach(d -> appliedDeductionIds.add(d.getDeductionId()));
}
// 명세서 레코드 생성 (commission_statement INSERT, 중복 시 skip)
CommissionStatementVO existingStmt = statementMapper.selectByAgentMonth(
agentId, ctx.getSettleMonth(), StatementType.MONTHLY.code());
if (existingStmt == null && gross.signum() != 0) {
CommissionStatementVO stmt = new CommissionStatementVO();
stmt.setAgentId(agentId);
stmt.setSettleMonth(ctx.getSettleMonth());
stmt.setStatementType(StatementType.MONTHLY.code());
stmt.setGrossAmount(gross);
stmt.setIncomeTaxAmount(tax.incomeTax());
stmt.setLocalTaxAmount(tax.localTax());
stmt.setVatAmount(tax.vat());
stmt.setDeductionAmount(deductionTotal);
stmt.setNetAmount(netAmount);
stmt.setIssueStatus("ISSUED");
stmt.setIssuedAt(LocalDateTime.now());
statements.add(stmt);
}
}
// 차감 APPLIED 상태 일괄 전이
if (!appliedDeductionIds.isEmpty()) {
deductionMapper.updateStatusBatch(appliedDeductionIds, DeductionStatus.APPLIED.name());
log.info("[Step8] deductions applied: {}", appliedDeductionIds.size());
}
// 명세서 일괄 INSERT
for (CommissionStatementVO stmt : statements) {
statementMapper.insert(stmt);
}
if (!statements.isEmpty()) {
log.info("[Step8] commission statements inserted: {}", statements.size());
}
ctx.setAggregateCount(count);
log.info("[Step8] settle master upserted: {}", count);
return RepeatStatus.FINISHED;
}
private TaxType parseTaxType(AgentVO agent) {
if (agent == null || agent.getTaxType() == null) return TaxType.BUSINESS_INCOME;
try {
return TaxType.valueOf(agent.getTaxType());
} catch (IllegalArgumentException e) {
return TaxType.BUSINESS_INCOME;
}
}
private VatType parseVatType(AgentVO agent) {
if (agent == null || agent.getVatType() == null) return VatType.NONE;
try {
return VatType.valueOf(agent.getVatType());
} catch (IllegalArgumentException e) {
return VatType.NONE;
}
}
private Map<Long, BigDecimal> toMap(List<Map<String, Object>> rows) {
Map<Long, BigDecimal> m = new HashMap<>();
for (Map<String, Object> r : rows) {
@@ -2,10 +2,15 @@ package com.ga.batch.settlement.step;
import com.ga.batch.settlement.SettlementContext;
import com.ga.batch.settlement.calc.CommissionCalculator;
import com.ga.batch.settlement.domain.BatchInstallmentPlanGenerator;
import com.ga.batch.settlement.domain.BatchRegulatoryLimitChecker;
import com.ga.batch.settlement.domain.ViolationResult;
import com.ga.common.util.DateUtil;
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.ledger.LedgerVO;
import com.ga.core.vo.product.ContractResp;
import com.ga.core.vo.product.ContractSearchParam;
@@ -25,6 +30,9 @@ import java.util.List;
* Step 4 — calcRecruit: 모집 수수료 계산 (계약 첫 회차)
*
* 원칙: 계약시점의 commission_rate / payout_rule 적용 (effective_from 기준)
*
* 추가: 1200%룰(BatchRegulatoryLimitChecker) 검증 후 한도 초과 계약은
* InstallmentPlan(분급 7년) 생성. Step 4.5 역할을 Step 4 내에서 수행.
*/
@Slf4j
@Component
@@ -36,6 +44,9 @@ public class CalcRecruitStep implements Tasklet {
private final ContractMapper contractMapper;
private final RecruitLedgerMapper recruitMapper;
private final CommissionCalculator calculator;
private final BatchRegulatoryLimitChecker regulatoryChecker;
private final BatchInstallmentPlanGenerator installmentGenerator;
private final InstallmentPlanMapper installmentPlanMapper;
private static final int BATCH_SIZE = 1000;
@@ -55,10 +66,33 @@ public class CalcRecruitStep implements Tasklet {
recruitMapper.deleteBySettleMonth(ctx.getSettleMonth());
List<LedgerVO> batch = new ArrayList<>(BATCH_SIZE);
List<InstallmentPlanVO> installmentBatch = new ArrayList<>();
int total = 0;
int violationCount = 0;
for (ContractResp c : contracts) {
LedgerVO led = calculator.calcRecruitLedger(c, ctx.getSettleMonth());
if (led == null || MoneyUtil.isZero(led.getAgentAmount())) continue;
// 1200%룰 검증 (1차년도 한도)
if (c.getContractDate() != null) {
ViolationResult firstYearCheck = regulatoryChecker.checkFirstYear(
c.getContractId(), led.getAgentAmount(), c.getContractDate());
if (firstYearCheck.violated()) {
log.warn("[Step4] 1200%룰 위반: contractId={}, msg={}",
c.getContractId(), firstYearCheck.message());
violationCount++;
// 1200%룰 위반 계약: 분급 계획 생성 (7년 35/25/15/10/7/5/3)
List<InstallmentPlanVO> plans = installmentGenerator.generate(
c.getContractId(), led.getAgentAmount(),
c.getContractDate(), c.getInsuranceType());
installmentBatch.addAll(plans);
// 위반 계약도 원장에는 기록 (감사 목적)
}
}
batch.add(led);
if (batch.size() >= BATCH_SIZE) {
recruitMapper.batchInsert(batch);
@@ -71,8 +105,15 @@ public class CalcRecruitStep implements Tasklet {
total += batch.size();
}
// 분급 계획 일괄 저장
if (!installmentBatch.isEmpty()) {
installmentPlanMapper.insertBatch(installmentBatch);
log.info("[Step4] installment plans created: {} (violations: {})",
installmentBatch.size(), violationCount);
}
ctx.setRecruitCount(total);
log.info("[Step4] recruit ledger inserted: {}", total);
log.info("[Step4] recruit ledger inserted: {}, regulatory violations: {}", total, violationCount);
return RepeatStatus.FINISHED;
}
}
@@ -1,11 +1,11 @@
package com.ga.batch.settlement.step;
import com.ga.batch.settlement.SettlementContext;
import com.ga.batch.settlement.domain.BatchChargebackCalculator;
import com.ga.batch.settlement.domain.ChargebackResult;
import com.ga.common.util.DateUtil;
import com.ga.common.util.MoneyUtil;
import com.ga.core.mapper.ledger.RecruitLedgerMapper;
import com.ga.core.mapper.product.ContractMapper;
import com.ga.core.mapper.rule.RuleMapper;
import com.ga.core.mapper.settle.ChargebackMapper;
import com.ga.core.vo.product.ContractResp;
import com.ga.core.vo.product.ContractSearchParam;
@@ -26,7 +26,8 @@ import java.util.List;
/**
* Step 6 — chargebackException: 환수 자동생성 + 예외금액 자동계산
*
* 환수 대상: 정산월에 LAPSE(실효) 된 계약 — 계약일~실효까지 경과월수로 환수율 결정.
* 환수율 계산: BatchChargebackCalculator (chargeback_grade 5단계 차등환수) 사용.
* 환수 원금: 해당 계약의 모집 원장 합산 (recruit_ledger 기준).
*/
@Slf4j
@Component
@@ -37,8 +38,8 @@ public class ChargebackExceptionStep implements Tasklet {
private final SettlementContext ctx;
private final ContractMapper contractMapper;
private final RecruitLedgerMapper recruitMapper;
private final RuleMapper ruleMapper;
private final ChargebackMapper chargebackMapper;
private final BatchChargebackCalculator chargebackCalculator;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
@@ -51,30 +52,30 @@ public class ChargebackExceptionStep implements Tasklet {
List<ChargebackVO> chargebacks = new ArrayList<>();
for (ContractResp c : lapsed) {
if (c.getLapseDate() == null) continue;
if (c.getLapseDate() == null || c.getContractDate() == null) continue;
String lapseMonth = DateUtil.getSettleMonth(c.getLapseDate());
if (!ctx.getSettleMonth().equals(lapseMonth)) continue;
if (c.getContractDate() == null) continue;
int monthsActive = DateUtil.calcMonthDiff(
DateUtil.getSettleMonth(c.getContractDate()), lapseMonth);
BigDecimal cbRate = ruleMapper.findChargebackRate(
c.getCompanyCode(), c.getInsuranceType(), monthsActive, c.getLapseDate());
if (cbRate == null || cbRate.signum() <= 0) continue;
// 모집 원장에서 원 수수료 조회
BigDecimal originalCommission = recruitMapper.sumByContract(c.getContractId());
if (originalCommission == null || originalCommission.signum() <= 0) continue;
// 모집수수료 합계 × 환수
BigDecimal originalAmount = MoneyUtil.zero(c.getPremium())
.multiply(cbRate)
.divide(MoneyUtil.HUNDRED, 2, java.math.RoundingMode.HALF_UP);
// BatchChargebackCalculator: chargeback_grade 5단계 차등환수
ChargebackResult result = chargebackCalculator.calculate(
c.getContractDate(), c.getLapseDate(),
originalCommission, c.getInsuranceType());
if (result.chargebackAmount().signum() <= 0) continue;
ChargebackVO cb = new ChargebackVO();
cb.setContractId(c.getContractId());
cb.setAgentId(c.getAgentId());
cb.setPolicyNo(c.getPolicyNo());
cb.setLapseMonth(monthsActive);
cb.setCbRate(cbRate);
cb.setCbAmount(originalAmount);
cb.setRemainAmount(originalAmount);
cb.setLapseMonth(result.monthsElapsed());
cb.setCbRate(result.appliedPercent());
cb.setCbAmount(result.chargebackAmount());
cb.setRemainAmount(result.chargebackAmount());
cb.setSettleMonth(ctx.getSettleMonth());
cb.setStatus("NEW");
chargebacks.add(cb);
@@ -84,7 +85,7 @@ public class ChargebackExceptionStep implements Tasklet {
chargebackMapper.insertBatch(chargebacks);
}
ctx.setChargebackCount(chargebacks.size());
log.info("[Step6] chargeback inserted: {}", chargebacks.size());
log.info("[Step6] chargeback inserted: {} (using BatchChargebackCalculator)", chargebacks.size());
return RepeatStatus.FINISHED;
}
}