Files
ga-commission-system/docs/04_BATCH_SPEC.md
T
GA Pro 035175e895 docs: Agent Teams 실행 가이드 + 8개 도메인 spec 추가
00_PROMPTS.md  — Claude Code 실행 프롬프트 1~6
01_DB_SCHEMA.md — V1~V17 Flyway 스키마 + 프로젝트 뼈대
02_COMMON_SPEC.md — ga-common 공통 프레임워크 (모델/예외/MyBatis/AOP/엑셀/공통컴포넌트)
03_CORE_SPEC.md — ga-core VO 설계 원칙 + Mapper 패턴
04_BATCH_SPEC.md — ga-batch 정산 8 Step + DataReceiver/MappingEngine
05_API_SPEC.md — ga-api Controller/Service 표준 패턴 + 엔드포인트 목록
06_FRONTEND_SPEC.md — ga-frontend 13개 화면 + 공통 컴포넌트 사용법
07_INFRA_SPEC.md — Docker/Nginx + README + DEVELOPER_GUIDE
08_DBA_SPEC.md — V13~V15 인덱스/파티셔닝/뷰

실제 구현된 ga-commission-system 기준으로 작성. 코드는 변경 없음.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:15:13 +09:00

285 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 04_BATCH_SPEC — ga-batch (정산 배치 8 Step)
> Spring Batch 기반 월별 정산 잡. ga-core 의 Mapper 를 사용. 별도 실행 모듈 (포트 8081).
## 패키지 구조
```
ga-batch/src/main/java/com/ga/batch/
├── GaBatchApplication.java # @SpringBootApplication, scanBasePackages = "com.ga"
├── config/
│ └── BatchConfig.java # MonthlySettlementJob 정의
├── controller/
│ └── JobLauncherController.java # POST /run, GET /status
└── settlement/
├── SettlementContext.java # @JobScope 공유 컨텍스트
├── calc/
│ └── CommissionCalculator.java # 모집/유지 수수료 계산기
├── receive/
│ ├── DataReceiver.java # 전략 패턴 인터페이스
│ └── ManualReceiver.java # 기본 구현 (수동 업로드)
├── transform/
│ └── MappingEngine.java # raw → 표준 원장
└── step/
├── ReceiveDataStep.java # Step 1
├── TransformDataStep.java # Step 2
├── ReconcileStep.java # Step 3
├── CalcRecruitStep.java # Step 4
├── CalcMaintainStep.java # Step 5
├── ChargebackExceptionStep.java # Step 6
├── CalcOverrideStep.java # Step 7
└── AggregateStep.java # Step 8
```
build.gradle:
```groovy
apply plugin: 'org.springframework.boot'
bootJar { archiveFileName = 'ga-batch.jar'; mainClass = 'com.ga.batch.GaBatchApplication' }
jar.enabled = false
dependencies {
implementation project(':ga-core')
implementation 'org.springframework.boot:spring-boot-starter-batch'
}
```
`application.yml`:
```yaml
server.port: 8081
spring:
batch:
jdbc:
initialize-schema: always # BATCH_JOB_* 메타테이블
job: { enabled: false } # 자동 실행 끄기, 컨트롤러로만 트리거
```
---
## 1. SettlementContext (`@JobScope`)
```java
@Component @JobScope @Getter @Setter
public class SettlementContext {
@Value("#{jobParameters['settleMonth']}") private String settleMonth; // "202605"
@Value("#{jobParameters['companyCode']}") private String companyCode; // 선택 (없으면 전체)
@Value("#{jobParameters['jobLogId']}") private Long jobLogId;
// Step 간 공유 카운트
private int receiveCount, transformOk, transformErr, reconcileOk, reconcileDiff;
private int recruitCount, maintainCount, chargebackCount, exceptionCount, overrideCount;
private int aggregateCount;
}
```
---
## 2. Job 정의 (`BatchConfig`)
```java
@Configuration @RequiredArgsConstructor
public class BatchConfig {
private final JobRepository jobRepo;
private final PlatformTransactionManager txMgr;
@Bean
public Job monthlySettlementJob(
ReceiveDataStep s1, TransformDataStep s2, ReconcileStep s3,
CalcRecruitStep s4, CalcMaintainStep s5,
ChargebackExceptionStep s6, CalcOverrideStep s7, AggregateStep s8) {
return new JobBuilder("monthlySettlementJob", jobRepo)
.start(toStep("receive", s1))
.next(toStep("transform", s2))
.next(toStep("reconcile", s3))
.next(toStep("calcRecruit", s4))
.next(toStep("calcMaintain", s5))
.next(toStep("chargeback", s6))
.next(toStep("calcOverride", s7))
.next(toStep("aggregate", s8))
.build();
}
private Step toStep(String name, Tasklet t) {
return new StepBuilder(name, jobRepo).tasklet(t, txMgr).build();
}
}
```
각 Step 은 `Tasklet` (단순 단계) 또는 `Chunk` 기반.
대용량 (Step 4, 5) 은 `MyBatisCursorItemReader` + `ItemWriter` 청크 처리.
---
## 3. 8 Step 명세
### Step 1 — `receiveData`
- 입력: `companyCode` (또는 전체 활성 보험사)
- 처리: `DataReceiver` 전략 패턴
- `Excel/CSV` (수동 업로드 — 기본): `ManualReceiver`
- `EDI` (FTP), `API` (HTTP) 는 향후 확장
- 각 receiver 가 `raw_commission_data` 에 1 행씩 (`raw_content` JSON) 저장
- 우선 구현: `ManualReceiver` 만. 다른 receiver 는 인터페이스만 두고 stub.
- 출력: `ctx.receiveCount`
```java
public interface DataReceiver {
String getMethod(); // EXCEL / CSV / EDI / API / MANUAL
int receive(String companyCode, String settleMonth);
}
```
### Step 2 — `transformData` (★ 핵심)
- 입력: `raw_commission_data` 에서 `parse_status='PENDING'` 인 행
- 처리: `MappingEngine.transform(raw)` 호출
- `company_field_mapping` 로 source → target 매핑
- 변환 규칙: `TRIM / UPPER / LOWER / DATE / DIVIDE / MULTIPLY / CODE_MAP`
- `policy_no``contract` 매칭 → `contract_id`, `agent_id` 채움
- `data_type` 에 따라 `recruit_ledger` / `maintain_ledger` 에 INSERT
- 실패 시 `parse_error_log` 에 적재
- 9 가지 에러 유형:
`INVALID_INPUT / UNKNOWN_TYPE / NO_MAPPING / JSON_PARSE / REQUIRED /
TRANSFORM_FAIL / NO_POLICY / CONTRACT_NOT_FOUND / CODE_MAP_FAIL`
- 출력: `ctx.transformOk`, `ctx.transformErr`
`MappingEngine` 의 핵심 메서드:
```java
@Transactional
public Long transform(RawCommissionDataVO raw) {
// 1) companyCode/rawContent null check
// 2) data_type → target_table (RECRUIT/MAINTAIN)
// 3) selectFieldMappings(companyCode, target_table)
// 4) JSON 파싱 (ObjectMapper)
// 5) 각 매핑별 applyTransform(value, mapping, companyCode) → mapped Map
// 6) policy_no 로 contract 매칭
// 7) buildLedger(mapped, contract, raw) → recruit/maintain mapper insert
}
```
### Step 3 — `reconcile`
- 입력: 보험사 통보 합계 vs 시스템 계산 합계 (`raw_commission_data` 기준)
- 처리: `reconciliation` 테이블에 `expected/actual/diff` 적재
- 차이 임계값(시스템설정 `RECON_DIFF_THRESHOLD`) 초과 시 `recon_status='DIFF'`
- 출력: `ctx.reconcileOk`, `ctx.reconcileDiff`
### Step 4 — `calcRecruit`
- 입력: `recruit_ledger` 의 status='CALCULATED' 또는 신규 계약 (계약일이 정산월 이내)
- 처리: `CommissionCalculator.calcRecruitLedger(contract, settleMonth)`
- `contractDate` 기준 `commission_rate` (commission_year=1) → `companyAmount`
- `gradeId` + `insuranceType` + `commission_year=1` 기준 `payout_rate``agentAmount`
- `tax = agentAmount × 3.3%` (HALF_UP)
- 출력: `ctx.recruitCount`
```java
public LedgerVO calcRecruitLedger(ContractResp c, String settleMonth) {
AgentVO agent = agentMapper.selectById(c.getAgentId());
if (agent == null) return null;
LocalDate base = c.getContractDate() != null ? c.getContractDate() : LocalDate.now();
BigDecimal companyRate = ruleMapper.findCompanyRate(c.getProductId(), 1, base);
if (companyRate == null) companyRate = BigDecimal.ZERO;
BigDecimal payoutRate = ruleMapper.findPayoutRate(agent.getGradeId(),
c.getInsuranceType(), 1, base);
if (payoutRate == null) payoutRate = BigDecimal.ZERO;
BigDecimal premium = MoneyUtil.zero(c.getPremium());
BigDecimal companyAmt = MoneyUtil.pct(premium, companyRate);
BigDecimal agentAmt = MoneyUtil.pct(companyAmt, payoutRate);
BigDecimal tax = MoneyUtil.tax(agentAmt);
// LedgerVO 필드 채워서 반환
}
```
### Step 5 — `calcMaintain`
- 입력: 유지 계약 (`status='ACTIVE'`, `effective_date` 가 1~5 년차 도래)
- 처리: `CommissionCalculator.calcMaintainLedger(contract, settleMonth, year)`
- `commission_year` 만 1 → year (2~5) 로 다름
- 같은 공식 적용
- 출력: `ctx.maintainCount`
### Step 6 — `chargebackException`
- 입력: 정산월 직전 실효 계약 (`status='LAPSE'`, `lapse_date` 가 정산월 범위)
- 처리:
- `ruleMapper.findChargebackRate(companyCode, insuranceType, lapseMonthDiff, base)`
- 환수 계산: `cb_amount = base × cb_rate`
- 분할 회차(`installment_seq`) 정의 시 N 회로 분할
- 결과를 `chargeback` 에 적재
- 예외 자동 생성: `exception_type_code``auto_calc_yn='Y'` 인 코드별로
`exception_ledger` 에 기록 (수동 승인 대기)
- 출력: `ctx.chargebackCount`, `ctx.exceptionCount`
### Step 7 — `calcOverride`
- 입력: Step 4/5 에서 생성된 모집/유지 수수료 합계 (per agent)
- 처리:
- 조직 트리 상향 탐색 (CTE): 설계사 → 팀장 → 지점장 → 본부장
- 각 단계마다 `override_rule` (from_grade → to_grade) 의 `override_pct` 적용
- `cap_amount` 가 있으면 상한 적용
- `override_settle` 에 (from_agent, to_agent, base, pct, amount) 적재
- 출력: `ctx.overrideCount`
### Step 8 — `aggregate`
- 입력: 모든 원장의 설계사별 합계 + 환수 + 오버라이드 + 예외(PLUS/MINUS)
- 처리:
```
gross = recruit + maintain + override + excPlus - chargeback - excMinus
tax = gross × TAX_RATE (system_config 의 TAX_RATE, 기본 3.3%)
net = gross - tax
```
- `settle_master` 에 `(agent_id, settle_month)` UPSERT (`ON CONFLICT DO UPDATE`)
- `status = 'CALCULATED'` (확정은 별도 API)
- 활성 설계사 누락 보충: `agentMapper.selectList(status=ACTIVE)` 로 0 행도 빈 settle 생성
- 출력: `ctx.aggregateCount`
---
## 4. JobLauncherController
```java
@RestController @RequestMapping("/api/batch/settlement")
public class JobLauncherController {
private final JobLauncher launcher;
private final Job monthlySettlementJob;
private final BatchJobLogMapper logMapper;
@PostMapping("/run")
public ApiResponse<Long> run(@RequestParam String settleMonth,
@RequestParam(required=false) String companyCode) {
if (logMapper.existsRunning("monthlySettlementJob") > 0)
throw new BizException(ErrorCode.BATCH_RUNNING);
Long jobLogId = logMapper.request("monthlySettlementJob", settleMonth);
JobParameters p = new JobParametersBuilder()
.addString("settleMonth", settleMonth)
.addString("companyCode", companyCode)
.addLong ("jobLogId", jobLogId)
.addLong ("ts", System.currentTimeMillis()) // 동일 파라미터 재실행
.toJobParameters();
new Thread(() -> launcher.run(monthlySettlementJob, p)).start();
return ApiResponse.ok(jobLogId);
}
@GetMapping("/status")
public ApiResponse<BatchJobLogVO> status(@RequestParam Long jobLogId) {
return ApiResponse.ok(logMapper.selectById(jobLogId));
}
}
```
ga-api 도 동일 엔드포인트 노출 가능 — 배치 서버가 따로 떠 있을 때는
ga-api 가 `batch_job_log.status='REQUESTED'` 로 적재만 하고, ga-batch 가 폴링/직접 호출.
---
## 5. 단위 테스트 (현재 작성됨)
`ga-batch/src/test/java/com/ga/batch/`:
- `settlement/calc/CommissionCalculatorTest` (6) — 모집/유지/폴백/agent 누락
- `settlement/transform/MappingEngineTest` (13) — RECRUIT/MAINTAIN 라우팅,
TRIM/UPPER/LOWER/DATE/DIVIDE/MULTIPLY/CODE_MAP, 9 가지 에러
---
## 6. 향후 확장 (현재 단순화 부분)
1. **DataReceiver**: EDI / FTP / API 구현 (현재 ManualReceiver 만)
2. **MappingEngine**: REPLACE / SUBSTRING / CONCAT / LOOKUP 등 추가 변환 규칙
3. **이체파일**: 은행별 EBCDIC / CSV 출력 어댑터 (Step 9 으로 분리)
4. **재실행**: 특정 Step 부터 재실행 (`/api/batch/settlement/restart?fromStep=4`)
5. **병렬 처리**: Step 4/5 를 `partitioning` 으로 멀티스레드화