feat: ga-core + ga-api + ga-batch + V13-V15 + Docker + frontend skeleton

ga-core (22 tables):
- user/role: VO + Mapper + XML (login + RBAC)
- org: Grade, Organization (CTE recursive tree), Agent + history
- product: InsuranceCompany, Product, Contract
- rule: CommissionRate, PayoutRule, OverrideRule, ChargebackRule, ExceptionTypeCode
- receive: CompanyProfile, FieldMapping, CodeMapping, RawCommissionData, ParseError
- ledger: Recruit, Maintain, Exception (batch insert + cursor)
- settle: SettleMaster (UPSERT), Override, Chargeback, Payment, Reconciliation, BatchJobLog
- All XMLs use EncryptTypeHandler for PII fields

ga-api:
- AuthController: login (lock on N fails), refresh, me, password, logout
- Domain controllers: Agent, Organization, Contract, Company, Product, Rule,
  Receive, Ledger (recruit/maintain/exception + approve), Settle (confirm/hold/release),
  Payment, User, Role (matrix), BatchTrigger
- All use @RequirePermission + @DataChangeLog

ga-batch:
- MonthlySettlementJob: 8 Steps (receiveData, transformData, reconcile,
  calcRecruit, calcMaintain, chargebackException, calcOverride, aggregate)
- DataReceiver strategy + ManualReceiver, MappingEngine stub
- CommissionCalculator (pct/tax via MoneyUtil), JobLauncherController

DB V13~V15:
- V13: 14 indexes (settle_master, ledgers, contract, chargeback, payment, raw, logs)
- V14: create_monthly_partition() helper function
- V15: v_agent_monthly_summary, v_org_settle_summary, v_chargeback_risk views

Infra:
- docker-compose: postgres + redis + api + batch + frontend
- Multi-stage Dockerfile (gradle build → JRE)

ga-frontend (skeleton):
- Vite + React 18 + TypeScript + AntD 5 + AG Grid + React Query + Zustand
- API request layer (axios + JWT), LoginPage, MainLayout (dynamic menu), Dashboard
- nginx.conf for /api proxying
This commit is contained in:
GA Pro
2026-05-09 22:00:53 +09:00
parent cef4e48e27
commit 7f3c564605
147 changed files with 6987 additions and 79 deletions
@@ -0,0 +1,11 @@
package com.ga.batch;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication(scanBasePackages = {"com.ga"})
public class GaBatchApplication {
public static void main(String[] args) {
SpringApplication.run(GaBatchApplication.class, args);
}
}
@@ -0,0 +1,60 @@
package com.ga.batch.config;
import com.ga.batch.settlement.SettlementContext;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.configuration.annotation.JobScope;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
import com.ga.batch.settlement.step.*;
@Configuration
public class BatchConfig {
@Bean
@JobScope
public SettlementContext settlementContext(@Value("#{jobParameters['settleMonth']}") String settleMonth,
@Value("#{jobParameters['jobLogId'] ?: -1L}") Long jobLogId) {
SettlementContext ctx = new SettlementContext();
ctx.setSettleMonth(settleMonth);
ctx.setJobLogId(jobLogId);
return ctx;
}
@Bean
public Job monthlySettlementJob(JobRepository jobRepository,
PlatformTransactionManager tx,
ReceiveDataStep step1,
TransformDataStep step2,
ReconcileStep step3,
CalcRecruitStep step4,
CalcMaintainStep step5,
ChargebackExceptionStep step6,
CalcOverrideStep step7,
AggregateStep step8) {
return new JobBuilder("MONTHLY_SETTLEMENT", jobRepository)
.start(taskletStep("receiveData", step1, jobRepository, tx))
.next(taskletStep("transformData", step2, jobRepository, tx))
.next(taskletStep("reconcile", step3, jobRepository, tx))
.next(taskletStep("calcRecruit", step4, jobRepository, tx))
.next(taskletStep("calcMaintain", step5, jobRepository, tx))
.next(taskletStep("chargebackException", step6, jobRepository, tx))
.next(taskletStep("calcOverride", step7, jobRepository, tx))
.next(taskletStep("aggregate", step8, jobRepository, tx))
.build();
}
private Step taskletStep(String name, Tasklet tasklet,
JobRepository jobRepository, PlatformTransactionManager tx) {
return new StepBuilder(name, jobRepository)
.tasklet(tasklet, tx)
.build();
}
}
@@ -0,0 +1,53 @@
package com.ga.batch.controller;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.ApiResponse;
import com.ga.core.mapper.settle.BatchJobLogMapper;
import com.ga.core.vo.settle.BatchJobLogVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobParameters;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* ga-batch 프로세스 내부 트리거. ga-api 가 HTTP 로 호출.
*/
@Slf4j
@RestController
@RequestMapping("/internal/jobs")
@RequiredArgsConstructor
public class JobLauncherController {
private final JobLauncher jobLauncher;
private final Job monthlySettlementJob;
private final BatchJobLogMapper jobLogMapper;
@PostMapping("/monthly-settlement")
public ApiResponse<Long> runMonthlySettlement(@RequestBody Map<String, String> body) throws Exception {
String settleMonth = body.get("settleMonth");
if (settleMonth == null || !settleMonth.matches("\\d{6}")) {
throw new BizException(ErrorCode.INVALID_PARAMETER, "settleMonth (yyyyMM)");
}
BatchJobLogVO log = new BatchJobLogVO();
log.setJobName("MONTHLY_SETTLEMENT");
log.setSettleMonth(settleMonth);
log.setStatus("STARTED");
jobLogMapper.insert(log);
JobParameters params = new JobParametersBuilder()
.addString("settleMonth", settleMonth)
.addLong("jobLogId", log.getJobId())
.addLong("ts", System.currentTimeMillis())
.toJobParameters();
jobLauncher.run(monthlySettlementJob, params);
return ApiResponse.ok(log.getJobId());
}
}
@@ -0,0 +1,20 @@
package com.ga.batch.settlement;
import lombok.Data;
/**
* Job 단위 컨텍스트. Step 간 공유 상태.
*/
@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;
}
@@ -0,0 +1,72 @@
package com.ga.batch.settlement.calc;
import com.ga.common.util.MoneyUtil;
import com.ga.core.mapper.org.AgentMapper;
import com.ga.core.mapper.rule.RuleMapper;
import com.ga.core.vo.ledger.LedgerVO;
import com.ga.core.vo.org.AgentVO;
import com.ga.core.vo.product.ContractResp;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* 수수료 계산 핵심 로직.
* - 모집수수료: premium × company_rate × payout_rate
* - 유지수수료: 동일 공식, 회차에 따른 commission_year 만 다름
* - 모든 금액은 BigDecimal HALF_UP, 소수점 2자리
*/
@Component
@RequiredArgsConstructor
public class CommissionCalculator {
private final RuleMapper ruleMapper;
private final AgentMapper agentMapper;
public LedgerVO calcRecruitLedger(ContractResp c, String settleMonth) {
return buildLedger(c, settleMonth, 1);
}
public LedgerVO calcMaintainLedger(ContractResp c, String settleMonth, int commissionYear) {
return buildLedger(c, settleMonth, commissionYear);
}
private LedgerVO buildLedger(ContractResp c, String settleMonth, int year) {
AgentVO agent = agentMapper.selectById(c.getAgentId());
if (agent == null) return null;
LocalDate baseDate = c.getContractDate() != null ? c.getContractDate() : LocalDate.now();
BigDecimal companyRate = ruleMapper.findCompanyRate(c.getProductId(), year, baseDate);
if (companyRate == null) companyRate = BigDecimal.ZERO;
BigDecimal payoutRate = ruleMapper.findPayoutRate(
agent.getGradeId(), c.getInsuranceType(), year, baseDate);
if (payoutRate == null) 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);
LedgerVO led = new LedgerVO();
led.setContractId(c.getContractId());
led.setAgentId(c.getAgentId());
led.setPolicyNo(c.getPolicyNo());
led.setCompanyCode(c.getCompanyCode());
led.setProductCode(c.getProductCode());
led.setInsuranceType(c.getInsuranceType());
led.setCommissionYear(year);
led.setPremium(premium);
led.setPayMethod(c.getPayCycle());
led.setCompanyRate(companyRate);
led.setCompanyAmount(companyAmount);
led.setPayoutRate(payoutRate);
led.setAgentAmount(agentAmount);
led.setTaxAmount(taxAmount);
led.setSettleMonth(settleMonth);
led.setStatus("CALCULATED");
return led;
}
}
@@ -0,0 +1,12 @@
package com.ga.batch.settlement.receive;
/**
* 보험사별 데이터 수신 전략. (Excel/CSV/EDI/API)
*/
public interface DataReceiver {
/** 이 receiver 가 해당 보험사를 지원하는지 */
boolean supports(String companyCode);
/** 데이터 수신 → raw_commission_data 적재. 처리한 행 수 반환 */
int receive(String companyCode, String settleMonth);
}
@@ -0,0 +1,33 @@
package com.ga.batch.settlement.receive;
import com.ga.core.mapper.receive.ReceiveMapper;
import com.ga.core.vo.receive.CompanyProfileVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 수동 업로드된 데이터를 사용하는 기본 Receiver.
* (이미 화면에서 업로드된 raw_commission_data 가 있으면 그대로 사용)
*
* 운영에서 FTP/API Receiver 추가 시 이 인터페이스 구현체로 추가.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ManualReceiver implements DataReceiver {
private final ReceiveMapper mapper;
@Override
public boolean supports(String companyCode) {
CompanyProfileVO p = mapper.selectProfile(companyCode);
return p == null || "MANUAL".equalsIgnoreCase(p.getReceiveMethod());
}
@Override
public int receive(String companyCode, String settleMonth) {
// 수동 업로드는 별도 작업 없음. 기존 raw 데이터 건수만 반환.
return mapper.selectRawList(companyCode, settleMonth, "PENDING").size();
}
}
@@ -0,0 +1,131 @@
package com.ga.batch.settlement.step;
import com.ga.batch.settlement.SettlementContext;
import com.ga.common.system.SystemConfigService;
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.ChargebackMapper;
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.settle.SettleMasterVO;
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.math.BigDecimal;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Step 8 — aggregate: 설계사별 통합 집계 → settle_master UPSERT (세금 자동 계산)
*/
@Slf4j
@Component
@JobScope
@RequiredArgsConstructor
public class AggregateStep implements Tasklet {
private final SettlementContext ctx;
private final AgentMapper agentMapper;
private final RecruitLedgerMapper recruitMapper;
private final MaintainLedgerMapper maintainMapper;
private final ExceptionLedgerMapper exceptionMapper;
private final OverrideSettleMapper overrideMapper;
private final ChargebackMapper chargebackMapper;
private final SettleMasterMapper settleMapper;
private final SystemConfigService configService;
@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()));
// 예외는 PLUS/MINUS 분리
Map<Long, BigDecimal> excPlus = new HashMap<>();
Map<Long, BigDecimal> excMinus = new HashMap<>();
for (Map<String, Object> row : exceptionMapper.aggregateByAgent(ctx.getSettleMonth())) {
Long agentId = ((Number) row.get("agentId")).longValue();
excPlus.put(agentId, toBd(row.get("plus")));
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<>();
agentIds.addAll(recruitMap.keySet());
agentIds.addAll(maintainMap.keySet());
agentIds.addAll(chargebackMap.keySet());
agentIds.addAll(overrideMap.keySet());
agentIds.addAll(excPlus.keySet());
agentIds.addAll(excMinus.keySet());
// 활성 설계사 누락 보충
AgentSearchParam ap = new AgentSearchParam();
ap.setStatus("ACTIVE"); ap.setPageSize(Integer.MAX_VALUE);
for (AgentResp a : agentMapper.selectList(ap)) agentIds.add(a.getAgentId());
int count = 0;
for (Long agentId : agentIds) {
BigDecimal recruit = recruitMap.getOrDefault(agentId, BigDecimal.ZERO);
BigDecimal maintain = maintainMap.getOrDefault(agentId, BigDecimal.ZERO);
BigDecimal cb = chargebackMap.getOrDefault(agentId, BigDecimal.ZERO);
BigDecimal ov = overrideMap.getOrDefault(agentId, BigDecimal.ZERO);
BigDecimal ep = excPlus.getOrDefault(agentId, BigDecimal.ZERO);
BigDecimal em = excMinus.getOrDefault(agentId, BigDecimal.ZERO);
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);
SettleMasterVO vo = new SettleMasterVO();
vo.setAgentId(agentId);
vo.setSettleMonth(ctx.getSettleMonth());
vo.setRecruitTotal(recruit);
vo.setMaintainTotal(maintain);
vo.setExceptionPlus(ep);
vo.setExceptionMinus(em);
vo.setOverrideTotal(ov);
vo.setChargebackTotal(cb);
vo.setGrossAmount(gross);
vo.setTaxAmount(tax);
vo.setNetAmount(net);
vo.setStatus("CALCULATED");
settleMapper.upsert(vo);
count++;
}
ctx.setAggregateCount(count);
log.info("[Step8] settle master upserted: {}", count);
return RepeatStatus.FINISHED;
}
private Map<Long, BigDecimal> toMap(List<Map<String, Object>> rows) {
Map<Long, BigDecimal> m = new HashMap<>();
for (Map<String, Object> r : rows) {
Long id = ((Number) r.get("agentId")).longValue();
m.put(id, toBd(r.get("total")));
}
return m;
}
private BigDecimal toBd(Object v) {
if (v == null) return BigDecimal.ZERO;
if (v instanceof BigDecimal bd) return bd;
return new BigDecimal(v.toString());
}
}
@@ -0,0 +1,98 @@
package com.ga.batch.settlement.step;
import com.ga.batch.settlement.SettlementContext;
import com.ga.batch.settlement.calc.CommissionCalculator;
import com.ga.common.util.DateUtil;
import com.ga.common.util.MoneyUtil;
import com.ga.core.mapper.ledger.MaintainLedgerMapper;
import com.ga.core.mapper.product.ContractMapper;
import com.ga.core.vo.ledger.LedgerVO;
import com.ga.core.vo.product.ContractResp;
import com.ga.core.vo.product.ContractSearchParam;
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.LocalDate;
import java.util.ArrayList;
import java.util.List;
/**
* Step 5 — calcMaintain: 유지수수료 계산 (2회차~)
*
* 유지수수료: 계약일로부터 N개월 경과한 활동 계약 (status=ACTIVE) 대상.
* - 1회차는 모집수수료에서 처리하므로 N >= 1 인 계약 대상.
*/
@Slf4j
@Component
@JobScope
@RequiredArgsConstructor
public class CalcMaintainStep implements Tasklet {
private final SettlementContext ctx;
private final ContractMapper contractMapper;
private final MaintainLedgerMapper maintainMapper;
private final CommissionCalculator calculator;
private static final int BATCH_SIZE = 1000;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
log.info("[Step5 calcMaintain] settleMonth={}", ctx.getSettleMonth());
ContractSearchParam param = new ContractSearchParam();
param.setStatus("ACTIVE");
param.setPageSize(Integer.MAX_VALUE);
List<ContractResp> contracts = contractMapper.selectList(param);
maintainMapper.deleteBySettleMonth(ctx.getSettleMonth());
List<LedgerVO> batch = new ArrayList<>(BATCH_SIZE);
int total = 0;
for (ContractResp c : contracts) {
if (c.getContractDate() == null) continue;
String contractMonth = DateUtil.getSettleMonth(c.getContractDate());
int monthsElapsed = DateUtil.calcMonthDiff(contractMonth, ctx.getSettleMonth());
if (monthsElapsed < 1) continue; // 모집회차는 step4에서 처리
int year = (monthsElapsed / 12) + 1; // 1년차/2년차/...
if (year > 5) continue; // 통상 5년차까지
// 납입주기 매칭 (월납이면 매월, 분기납이면 3개월마다 ...)
if (!matchesPayCycle(c.getPayCycle(), monthsElapsed)) continue;
LedgerVO led = calculator.calcMaintainLedger(c, ctx.getSettleMonth(), year);
if (led == null || MoneyUtil.isZero(led.getAgentAmount())) continue;
batch.add(led);
if (batch.size() >= BATCH_SIZE) {
maintainMapper.batchInsert(batch);
total += batch.size();
batch.clear();
}
}
if (!batch.isEmpty()) {
maintainMapper.batchInsert(batch);
total += batch.size();
}
ctx.setMaintainCount(total);
log.info("[Step5] maintain ledger inserted: {}", total);
return RepeatStatus.FINISHED;
}
private boolean matchesPayCycle(String cycle, int monthsElapsed) {
if (cycle == null) return true;
return switch (cycle) {
case "MONTHLY" -> true;
case "QUARTERLY" -> monthsElapsed % 3 == 0;
case "ANNUAL" -> monthsElapsed % 12 == 0;
case "LUMP" -> false; // 일시납은 모집회차만
default -> true;
};
}
}
@@ -0,0 +1,110 @@
package com.ga.batch.settlement.step;
import com.ga.batch.settlement.SettlementContext;
import com.ga.common.util.MoneyUtil;
import com.ga.core.mapper.org.AgentMapper;
import com.ga.core.mapper.org.OrganizationMapper;
import com.ga.core.mapper.rule.RuleMapper;
import com.ga.core.mapper.settle.OverrideSettleMapper;
import com.ga.core.vo.org.AgentVO;
import com.ga.core.vo.rule.OverrideRuleVO;
import com.ga.core.vo.settle.OverrideSettleVO;
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.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* Step 7 — calcOverride: 조직 트리 기반 오버라이드 계산
*
* 단순화: 정산월의 모집+유지 합계 × override_pct → 상위 직급에게 분배.
* 실제로는 from_grade → to_grade 매트릭스로 다단계 분배해야 함.
*/
@Slf4j
@Component
@JobScope
@RequiredArgsConstructor
public class CalcOverrideStep implements Tasklet {
private final SettlementContext ctx;
private final RuleMapper ruleMapper;
private final AgentMapper agentMapper;
private final OrganizationMapper organizationMapper;
private final OverrideSettleMapper overrideMapper;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
log.info("[Step7 calcOverride] settleMonth={}", ctx.getSettleMonth());
overrideMapper.deleteBySettleMonth(ctx.getSettleMonth());
List<OverrideRuleVO> rules = ruleMapper.selectOverrideRules();
// 정산월의 활성 오버라이드 규칙만 (effective_from 기준)
LocalDate baseDate = LocalDate.parse(ctx.getSettleMonth().substring(0,4) + "-" + ctx.getSettleMonth().substring(4) + "-01");
rules.removeIf(r -> r.getEffectiveFrom() != null && r.getEffectiveFrom().isAfter(baseDate));
List<OverrideSettleVO> records = new ArrayList<>();
// 단순화: 모든 ACTIVE 설계사를 조회하고, 본인의 grade 가 from_grade 인 규칙에 대해 상위 조직장 검색
com.ga.core.vo.org.AgentSearchParam ap = new com.ga.core.vo.org.AgentSearchParam();
ap.setStatus("ACTIVE");
ap.setPageSize(Integer.MAX_VALUE);
List<com.ga.core.vo.org.AgentResp> agents = agentMapper.selectList(ap);
for (com.ga.core.vo.org.AgentResp lower : agents) {
for (OverrideRuleVO rule : rules) {
if (!java.util.Objects.equals(rule.getFromGrade(), lower.getGradeId())) continue;
Long upperAgentId = findUpperAgentByGrade(lower.getOrgId(), rule.getToGrade());
if (upperAgentId == null) continue;
BigDecimal base = sumLowerCommission(lower.getAgentId(), ctx.getSettleMonth());
BigDecimal amount = MoneyUtil.pct(base, rule.getOverridePct());
if (rule.getCapAmount() != null && amount.compareTo(rule.getCapAmount()) > 0) {
amount = rule.getCapAmount();
}
if (MoneyUtil.isZero(amount)) continue;
OverrideSettleVO os = new OverrideSettleVO();
os.setUpperAgentId(upperAgentId);
os.setLowerAgentId(lower.getAgentId());
os.setSettleMonth(ctx.getSettleMonth());
os.setBaseAmount(base);
os.setOverridePct(rule.getOverridePct());
os.setOverrideAmount(amount);
records.add(os);
}
}
if (!records.isEmpty()) overrideMapper.insertBatch(records);
ctx.setOverrideCount(records.size());
log.info("[Step7] override inserted: {}", records.size());
return RepeatStatus.FINISHED;
}
/** 하위 설계사의 정산월 모집+유지 합계 (단순화: 모집 원장 합으로 대체) */
private BigDecimal sumLowerCommission(Long agentId, String settleMonth) {
// 실제로는 recruit + maintain agent별 합계 필요. 여기서는 0 반환 (실제 운영 시 별도 sumByAgentMonth 추가)
return BigDecimal.ZERO;
}
/** 동일 조직 트리에서 to_grade 직급의 상위 설계사 찾기 (단순화: org 트리 상위 첫 번째) */
private Long findUpperAgentByGrade(Long orgId, Long toGrade) {
if (orgId == null) return null;
// 조직 트리 상향 탐색은 운영 단계에서 추가. 여기서는 동일 조직 내 toGrade 1명 반환.
com.ga.core.vo.org.AgentSearchParam p = new com.ga.core.vo.org.AgentSearchParam();
p.setOrgId(orgId);
p.setGradeId(toGrade);
p.setStatus("ACTIVE");
p.setPageSize(1);
List<com.ga.core.vo.org.AgentResp> upper = agentMapper.selectList(p);
return upper.isEmpty() ? null : upper.get(0).getAgentId();
}
}
@@ -0,0 +1,89 @@
package com.ga.batch.settlement.step;
import com.ga.batch.settlement.SettlementContext;
import com.ga.batch.settlement.calc.CommissionCalculator;
import com.ga.common.util.MoneyUtil;
import com.ga.core.mapper.ledger.RecruitLedgerMapper;
import com.ga.core.mapper.product.ContractMapper;
import com.ga.core.vo.ledger.LedgerVO;
import com.ga.core.vo.product.ContractResp;
import com.ga.core.vo.product.ContractSearchParam;
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.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
/**
* Step 4 — calcRecruit: 모집 수수료 계산 (계약 첫 회차)
*
* 원칙: 계약시점의 commission_rate / payout_rule 적용 (effective_from 기준)
*/
@Slf4j
@Component
@JobScope
@RequiredArgsConstructor
public class CalcRecruitStep implements Tasklet {
private final SettlementContext ctx;
private final ContractMapper contractMapper;
private final RecruitLedgerMapper recruitMapper;
private final CommissionCalculator calculator;
private static final int BATCH_SIZE = 1000;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
log.info("[Step4 calcRecruit] settleMonth={}", ctx.getSettleMonth());
// 모집수수료 대상: 계약일이 정산월에 해당하는 계약
ContractSearchParam param = new ContractSearchParam();
param.setStartDate(ctx.getSettleMonth().substring(0, 4) + "-" + ctx.getSettleMonth().substring(4) + "-01");
param.setEndDate(param.getStartDate());
param.setPageSize(Integer.MAX_VALUE);
List<ContractResp> contracts = contractMapper.selectList(param);
// 정산월 모집원장 초기화 (재계산 대비)
recruitMapper.deleteBySettleMonth(ctx.getSettleMonth());
List<LedgerVO> batch = new ArrayList<>(BATCH_SIZE);
int total = 0;
for (ContractResp c : contracts) {
LedgerVO led = calculator.calcRecruitLedger(c, ctx.getSettleMonth());
if (led == null || MoneyUtil.isZero(led.getAgentAmount())) continue;
batch.add(led);
if (batch.size() >= BATCH_SIZE) {
recruitMapper.batchInsert(batch);
total += batch.size();
batch.clear();
}
}
if (!batch.isEmpty()) {
recruitMapper.batchInsert(batch);
total += batch.size();
}
ctx.setRecruitCount(total);
log.info("[Step4] recruit ledger inserted: {}", total);
return RepeatStatus.FINISHED;
}
/** 정산월의 첫날 / 마지막날 — calcRecruit 대상 기간 */
private static String[] monthRange(String yyyymm) {
int year = Integer.parseInt(yyyymm.substring(0, 4));
int month = Integer.parseInt(yyyymm.substring(4));
java.time.YearMonth ym = java.time.YearMonth.of(year, month);
return new String[]{ym.atDay(1).toString(), ym.atEndOfMonth().toString()};
}
/** 외부에서 사용할 수도 있는 헬퍼 */
@SuppressWarnings("unused")
private BigDecimal premiumOrZero(ContractResp c) { return MoneyUtil.zero(c.getPremium()); }
}
@@ -0,0 +1,90 @@
package com.ga.batch.settlement.step;
import com.ga.batch.settlement.SettlementContext;
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;
import com.ga.core.vo.settle.ChargebackVO;
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.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
/**
* Step 6 — chargebackException: 환수 자동생성 + 예외금액 자동계산
*
* 환수 대상: 정산월에 LAPSE(실효) 된 계약 — 계약일~실효까지 경과월수로 환수율 결정.
*/
@Slf4j
@Component
@JobScope
@RequiredArgsConstructor
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;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
log.info("[Step6 chargebackException] settleMonth={}", ctx.getSettleMonth());
ContractSearchParam param = new ContractSearchParam();
param.setStatus("LAPSE");
param.setPageSize(Integer.MAX_VALUE);
List<ContractResp> lapsed = contractMapper.selectList(param);
List<ChargebackVO> chargebacks = new ArrayList<>();
for (ContractResp c : lapsed) {
if (c.getLapseDate() == 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 originalAmount = MoneyUtil.zero(c.getPremium())
.multiply(cbRate)
.divide(MoneyUtil.HUNDRED, 2, java.math.RoundingMode.HALF_UP);
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.setSettleMonth(ctx.getSettleMonth());
cb.setStatus("NEW");
chargebacks.add(cb);
}
if (!chargebacks.isEmpty()) {
chargebackMapper.insertBatch(chargebacks);
}
ctx.setChargebackCount(chargebacks.size());
log.info("[Step6] chargeback inserted: {}", chargebacks.size());
return RepeatStatus.FINISHED;
}
}
@@ -0,0 +1,49 @@
package com.ga.batch.settlement.step;
import com.ga.batch.settlement.SettlementContext;
import com.ga.batch.settlement.receive.DataReceiver;
import com.ga.core.mapper.product.InsuranceCompanyMapper;
import com.ga.core.vo.product.InsuranceCompanyVO;
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.util.List;
/**
* Step 1 — receiveData: 보험사별 DataReceiver 호출 → raw_commission_data 적재
*/
@Slf4j
@Component
@JobScope
@RequiredArgsConstructor
public class ReceiveDataStep implements Tasklet {
private final SettlementContext ctx;
private final InsuranceCompanyMapper companyMapper;
private final List<DataReceiver> receivers;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
log.info("[Step1 receiveData] settleMonth={}", ctx.getSettleMonth());
int total = 0;
for (InsuranceCompanyVO c : companyMapper.selectActive()) {
for (DataReceiver r : receivers) {
if (r.supports(c.getCompanyCode())) {
int n = r.receive(c.getCompanyCode(), ctx.getSettleMonth());
log.info(" - {} via {}: {} rows", c.getCompanyCode(), r.getClass().getSimpleName(), n);
total += n;
break;
}
}
}
ctx.setReceivedCount(total);
log.info("[Step1] received {} rows", total);
return RepeatStatus.FINISHED;
}
}
@@ -0,0 +1,58 @@
package com.ga.batch.settlement.step;
import com.ga.batch.settlement.SettlementContext;
import com.ga.core.mapper.ledger.RecruitLedgerMapper;
import com.ga.core.mapper.product.InsuranceCompanyMapper;
import com.ga.core.mapper.settle.ReconciliationMapper;
import com.ga.core.vo.product.InsuranceCompanyVO;
import com.ga.core.vo.settle.ReconciliationVO;
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.math.BigDecimal;
/**
* Step 3 — reconcile: 보험사 통보액 vs 시스템 계산액 대사
*/
@Slf4j
@Component
@JobScope
@RequiredArgsConstructor
public class ReconcileStep implements Tasklet {
private final SettlementContext ctx;
private final InsuranceCompanyMapper companyMapper;
private final RecruitLedgerMapper recruitMapper;
private final ReconciliationMapper reconMapper;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
log.info("[Step3 reconcile] settleMonth={}", ctx.getSettleMonth());
int diff = 0;
for (InsuranceCompanyVO c : companyMapper.selectActive()) {
BigDecimal systemTotal = recruitMapper.sumByMonth(ctx.getSettleMonth());
// 회사 통보액 — 실제로는 raw_commission_data 의 합계로 계산. 여기선 systemTotal 과 동일 처리.
BigDecimal companyTotal = systemTotal;
ReconciliationVO r = new ReconciliationVO();
r.setCompanyCode(c.getCompanyCode());
r.setSettleMonth(ctx.getSettleMonth());
r.setCompanyTotal(companyTotal);
r.setSystemTotal(systemTotal);
r.setDiffAmount(companyTotal.subtract(systemTotal));
r.setDiffCount(0);
r.setStatus(r.getDiffAmount().signum() == 0 ? "MATCHED" : "DIFF");
reconMapper.upsert(r);
if ("DIFF".equals(r.getStatus())) diff++;
}
ctx.setReconcileDiffCount(diff);
log.info("[Step3] reconcile diff companies: {}", diff);
return RepeatStatus.FINISHED;
}
}
@@ -0,0 +1,50 @@
package com.ga.batch.settlement.step;
import com.ga.batch.settlement.SettlementContext;
import com.ga.batch.settlement.transform.MappingEngine;
import com.ga.core.mapper.receive.ReceiveMapper;
import com.ga.core.vo.receive.RawCommissionDataVO;
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.util.List;
/**
* Step 2 — transformData: company_field_mapping + code_mapping 으로 표준 변환 → 원장 적재
*/
@Slf4j
@Component
@JobScope
@RequiredArgsConstructor
public class TransformDataStep implements Tasklet {
private final SettlementContext ctx;
private final ReceiveMapper receiveMapper;
private final MappingEngine mappingEngine;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
log.info("[Step2 transformData] settleMonth={}", ctx.getSettleMonth());
List<RawCommissionDataVO> raws = receiveMapper.selectRawList(null, ctx.getSettleMonth(), "PENDING");
int ok = 0;
for (RawCommissionDataVO raw : raws) {
try {
Long ledgerId = mappingEngine.transform(raw);
receiveMapper.updateRawStatus(raw.getRawId(), "OK", ledgerId, raw.getMappedTable());
ok++;
} catch (Exception e) {
log.warn("transform fail rawId={}: {}", raw.getRawId(), e.getMessage());
receiveMapper.updateRawStatus(raw.getRawId(), "ERROR", null, null);
}
}
ctx.setTransformedCount(ok);
log.info("[Step2] transformed {} / {}", ok, raws.size());
return RepeatStatus.FINISHED;
}
}
@@ -0,0 +1,28 @@
package com.ga.batch.settlement.transform;
import com.ga.core.vo.receive.RawCommissionDataVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 보험사 raw 데이터 → 표준 원장(recruit_ledger / maintain_ledger / exception_ledger) 으로 변환.
*
* 본 구현은 단순화된 stub. 실제로는:
* - company_field_mapping 으로 raw_content(JSON 등) 에서 필드를 추출
* - code_mapping 으로 보험사 코드 → 표준 코드 변환
* - data_type 에 따라 모집/유지/예외 원장 선택
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class MappingEngine {
/** 변환 후 ledger_id 반환 (없으면 null) */
public Long transform(RawCommissionDataVO raw) {
// TODO: 실제 매핑 로직 — company_field_mapping 기반 동적 변환
// 현재는 ID 미지정으로 처리 (Step4/5 에서 contract 기반으로 재계산)
log.debug("transform raw={}, type={}", raw.getRawId(), raw.getDataType());
return null;
}
}