# 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` 으로 멀티스레드화
