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>
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
# 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` 으로 멀티스레드화
|
||||
Reference in New Issue
Block a user