fix: 필수 파라미터 누락 시 500→400 정정 + 전체 메뉴 점검/도메인 문서

- GlobalExceptionHandler: MissingServletRequestParameterException 핸들러 추가.
  기존엔 미처리로 generic Exception→E500. 이제 INVALID_PARAMETER(400) 반환.
  (전수 점검 중 /api/settle/summary 가 settleMonth 누락 시 500 반환하던 결함 발견)
- smoke_allmenus.py: 전체 메뉴 화면 1차 GET 엔드포인트 67개 전수 점검 스크립트.
  admin 전권 로그인 후 5xx 만 FAIL 분류. 검증: 66 PASS + 1 정상400, 5xx 0건.
- DOMAIN_KNOWLEDGE.md: 보험 GA 수수료 정산 도메인 입문 문서
  (수수료 종류/규제룰/세무공제/12단계 정산 파이프라인/상태흐름/용어사전).

검증: 5모듈 compileJava + ga-frontend vite build + PWA tsc PASS,
전체 테스트 112건 PASS, 모바일 me/* 5개 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-05-31 10:23:44 +09:00
parent fcddfea4f6
commit 81877dcb2b
3 changed files with 313 additions and 0 deletions
+206
View File
@@ -0,0 +1,206 @@
# 보험 GA 수수료 정산 — 도메인 기본 지식
> 이 문서는 GA 수수료 정산 시스템을 처음 접하는 개발자/담당자가 **업무 도메인**을 이해하기 위한 입문서입니다.
> 코드 구조가 아니라 "이 업무가 무엇인지"를 설명합니다. 표준 가정값은 한국 보험업감독규정 기준이며,
> 실제 회사 정책과 다르면 코드가 아니라 데이터(`system_config` / `regulatory_limit` / `installment_ratio` / `chargeback_grade`)만 바꾸면 됩니다.
---
## 1. 한 문장 요약
> **보험계약을 모집·관리한 설계사에게, 보험사가 준 수수료를 규정·세금·공제를 반영해 매월 정확히 나눠주고(정산), 그 결과를 명세서·회계장부·세무신고로 남기는 업무.**
---
## 2. 등장 인물과 핵심 개념
| 용어 | 영문/코드 | 설명 |
|---|---|---|
| **GA** | General Agency | 여러 보험사 상품을 함께 파는 **보험대리점**. 우리 시스템의 주체. |
| **보험사** | Carrier / Company | 상품을 만들고 GA에 수수료를 지급하는 원수사 (삼성생명·메리츠 등). |
| **설계사** | Agent | 실제로 계약을 모집·관리하는 사람. 정산의 **수령 대상**. |
| **조직** | Organization | 설계사가 속한 지점·본부 트리. 오버라이드(상위 직책 수당)의 기준. |
| **상품** | Product | 보험 상품. 상품별로 수수료율이 다름. |
| **계약** | Contract | 고객이 가입한 보험증권 1건. 정산의 **기준 단위**. |
| **수수료** | Commission | 보험사→GA→설계사로 흐르는 돈. 종류가 매우 다양 (3장 참고). |
| **정산** | Settlement | 한 달치 수수료를 설계사별로 계산·확정·지급하는 과정. |
| **원장** | Ledger | 계산의 최소 단위 기록 (계약×월×수수료종류 1줄). |
| **정산 마스터** | settle_master | 원장을 설계사×월로 합산한 지급 확정 단위. |
| **명세서** | Statement | 설계사에게 발급하는 "이번 달 얼마 받는지" 내역서. |
**돈의 흐름:**
```
[보험사] --(원수수료 지급)--> [GA] --(설계사 몫 정산)--> [설계사]
└─ 세금 원천징수 + 공제(가지급금 등) 차감 후 실지급
```
---
## 3. 수수료의 종류 (이 도메인의 핵심)
수수료는 "한 번에 다 주는 것"이 아니라 **계약의 생애주기**에 따라 여러 종류로 나뉜다.
| 종류 | 코드 | 언제 발생 | 누구에게 | 비고 |
|---|---|---|---|---|
| **모집수수료** | recruit | 계약 체결 시 (초년도) | **원 모집설계사** | 가장 큼. 보통 분급(3-1) 대상 |
| **유지수수료** | maintain | 계약 유지되는 매월/매년 | 현재 관리설계사 | 계약 승계 시 후임에게 귀속 전환 |
| **수금수수료** | collection | 보험료가 실제 납입될 때 | 관리설계사 | 납입 실적 기반 |
| **갱신수수료** | renewal | 계약 갱신 시 | 관리설계사 | 갱신형 상품 |
| **부활수수료** | reinstatement | 실효된 계약 부활 시 | 관리설계사 | |
| **유지보수당(지속수당)** | persistency bonus | 일정 유지율 달성 시 | 설계사 | 보너스 성격 |
| **오버라이드** | override | 하위 설계사 실적 발생 시 | **상위 직책자** | 조직 트리 기반 수당 |
| **시책·인센티브** | incentive | 캠페인/목표 달성 시 | 설계사 | 한시적 프로모션 |
> **모집 vs 유지의 귀속 차이 (중요):** 설계사가 해촉·이관되면, 그 계약의 **유지수수료는 후임 설계사**에게 넘어가지만(계약 승계),
> **모집수수료는 원래 모집한 설계사**에게 그대로 남는다. (한국 고아계약 승계 표준)
---
## 4. 규제 룰 — 왜 "그냥 곱하면" 안 되는가
수수료는 법으로 한도와 지급 방식이 정해져 있다. 단순 계산이 아니라 아래 룰을 모두 통과해야 한다.
### 4-1. 1200% 룰 (보험업감독규정 제5-15조의5)
- 초년도(1차년)에 설계사에게 줄 수 있는 모집수수료 총액은 **연납보험료의 1200%**를 넘을 수 없다.
- 추가로 **1차년 한도 35%** 등 연차별 상한이 있다.
-`RegulatoryLimitChecker` 가 누적 지급액을 추적해 한도 초과분을 잘라낸다.
### 4-2. 분급 (Installment) — 나눠 지급
- 큰 모집수수료를 한 번에 주지 않고 **7년에 걸쳐 비율대로 분할** 지급.
- 표준 비율(가정): **35 / 25 / 15 / 10 / 7 / 5 / 3 (%)** — 합 100%.
-`InstallmentPlanGenerator` 가 회차별 지급 스케줄(`installment_plan`)을 만든다.
### 4-3. 차등 환수 (Chargeback) — 조기 해지 시 돌려받기
- 계약이 일찍 깨지면 이미 준 수수료의 일부를 **환수**한다. 유지 기간이 짧을수록 많이 환수.
- 표준 단계(가정):
| 유지 개월 | 환수율 |
|---|---|
| 1 ~ 12 | 100% |
| 13 ~ 24 | 50% |
| 25 ~ 36 | 25% |
| 37 ~ 84 | 10% |
| 85 ~ | 0% (환수 없음) |
-`ChargebackCalculator` 가 해지 시점의 유지 개월로 환수액을 계산.
---
## 5. 세금과 공제 — 실지급액은 어떻게 정해지나
설계사에게 주는 돈(gross)에서 **세금을 원천징수**하고 **공제**를 빼야 **실지급액(net)**이 된다.
### 5-1. 원천징수 세금 (한국 표준, `system_config` 외부화)
| 항목 | 세율 | 적용 |
|---|---|---|
| 사업소득 원천징수 | **3.3%** | 일반 설계사(사업소득자) |
| 기타소득 원천징수 | **8.8%** | 기타소득 유형 |
| 지방소득세 | 원천세의 **10%** | (3.3% 안에 0.3%로 포함되는 구조) |
| 부가가치세 | **10%** | 사업자(과세) 설계사가 세금계산서 발행 시 |
### 5-2. 공제 (Deduction)
- 가지급금(선지급) 회수, 대출 상환, 기타 차감 등을 매월 정산액에서 뺀다.
- `agent_deduction`(월별 차감 마스터) + `payment_deduction_detail`(이체 명세)로 관리.
### 5-3. 실지급액 계산 공식 (개념)
```
gross(총수수료)
− 원천세(소득세 + 지방소득세)
공제(가지급금 등)
= net(실지급액) → 설계사 계좌로 이체(펌뱅킹)
```
---
## 6. 월 정산 파이프라인 (실제 배치 12단계)
매월 `ga-batch``MonthlySettlementJob` 이 아래 순서로 실행된다. (`POST /internal/jobs/monthly-settlement {"settleMonth":"yyyyMM"}`)
| # | Step | 하는 일 |
|---|---|---|
| 1 | **receiveData** | 보험사 실적 데이터 수신 (Excel/CSV/EDI/API) |
| 2 | **transformData** | 회사별 포맷을 표준 모델로 매핑·변환 |
| 3 | **reconcile** | 수신 데이터 대사 (금액·건수 검증) |
| 4 | **calcRecruit** | 모집수수료 원장 계산 |
| 5 | **calcMaintain** | 유지수수료 원장 계산 (**계약 승계 반영** → 후임 귀속) |
| 6 | **chargebackException** | 차등 환수 + 예외 처리 |
| 7 | **calcOverride** | 오버라이드(상위 직책 수당) 계산 |
| 8 | **aggregate** | 원장을 설계사×월로 **집계 → settle_master** (세금·공제 반영) |
| 9 | **accountingEntryGenerate** | 회계 분개 생성 (차변/대변) |
| 10 | **yearEndIncomeAggregate** | 연말 사업소득 집계 (**12월 마감 시만**) |
| 11 | **withholdingTaxQuarterly** | 원천세 분기 신고 집계 (**분기 말만**) |
| 12 | **vatReportQuarterly** | 부가세 분기 신고 집계 (**분기 말만**) |
> **무결성 핵심:** 8단계 집계 후 `원장(ledger) 총합 == settle_master 총합` 이 정확히 일치해야 한다. 이게 정산이 맞았는지의 1차 검증선.
---
## 7. 정산의 상태(Status) 흐름
정산 마스터는 함부로 다시 못 바꾸도록 상태로 잠근다.
```
DRAFT(계산됨) → CONFIRMED(확정) → PAID(지급완료)
└─ 재정산은 status NOT IN ('CONFIRMED','PAID') 인 것만 갱신 (확정분 보호)
```
- **마감(Period Close)**: 한 달을 닫으면 그 달은 더 이상 수정 불가. 회계 분개에 채번(journal_no)이 찍힌다.
- **정정(Correction)**: 마감 후 오류 발견 시 정정 전표로만 바로잡는다 (원본은 보존).
- **이의신청(Dispute)**: 설계사가 금액에 이의를 제기하는 창구.
---
## 8. 정산 그 이후 — 회계·세무·지급
| 영역 | 내용 | 산출물 |
|---|---|---|
| **회계 분개** | 수수료 지급을 차변/대변 전표로 기록 | `contract_accounting_entry`, 계정과목 `account_code` |
| **회계 결산** | 월/기간 마감 + 잔액 스냅샷 | `accounting_close`, `account_balance_snapshot` |
| **원천세 신고** | 분기별 원천징수세 신고 자료 | `withholding_tax_report` |
| **부가세 신고** | 분기별 부가세 신고 자료 | `vat_report` |
| **세금계산서** | 사업자 설계사 발행분 관리 | `tax_invoice` |
| **연말 지급명세서** | 연 사업소득 신고용 | `year_end_statement` |
| **실지급(이체)** | 펌뱅킹으로 설계사 계좌 이체 | `withdraw_request`, 펌뱅킹 어댑터(KFTC) |
---
## 9. KPI / 분석
- **월별 요약 / 조직별 요약**: 지급 총액, 인원, 평균 등 (Materialized View).
- **유지율(Retention)**: 13회차/25회차 유지율 — 보험 영업 건전성의 핵심 지표.
- **이상치 탐지(Anomaly)**: 유지율 급락 등 비정상 패턴 자동 경보.
- **예측(Forecast)**: 이동평균(SMA)으로 차월 수수료 예측.
---
## 10. 자주 헷갈리는 포인트 (FAQ)
1. **"수수료를 왜 한 번에 안 주나?"** → 1200% 룰·분급 규제 + 조기 해지 환수 리스크 때문. 7년 분할이 표준.
2. **"설계사가 그만두면 그 계약 수수료는?"** → 유지수수료는 후임(승계)에게, 모집수수료는 원 설계사에게.
3. **"gross인데 통장엔 적게 들어와요"** → 원천세(3.3%) + 공제(가지급금 등)가 빠진 net이 실지급액.
4. **"확정한 달을 다시 돌렸는데 안 바뀌어요"** → 정상. CONFIRMED/PAID는 보호되고, 바로잡으려면 정정 전표를 쓴다.
5. **"표준 세율/한도가 우리 회사랑 달라요"** → 코드 수정 불필요. `system_config`·`regulatory_limit`·`installment_ratio`·`chargeback_grade` 데이터만 변경.
---
## 11. 용어 빠른 사전
| 한글 | 영문 | 한 줄 |
|---|---|---|
| 정산 | Settlement | 월별 수수료 계산·확정·지급 |
| 원장 | Ledger | 계산 최소 단위 기록 |
| 모집수수료 | Recruit commission | 계약 체결 초년도 수수료 |
| 유지수수료 | Maintain commission | 계약 유지 기간 수수료 |
| 오버라이드 | Override | 상위 직책자 수당 |
| 분급 | Installment | 수수료 분할 지급(7년) |
| 차등환수 | Chargeback | 조기 해지 시 환수 |
| 원천징수 | Withholding | 세금 미리 떼기(3.3%) |
| 공제 | Deduction | 가지급금 등 차감 |
| 마감 | Period close | 월 정산 잠금 |
| 정정 | Correction | 마감 후 오류 전표 수정 |
| 이의신청 | Dispute | 설계사 금액 이의 |
| 승계 | Succession | 해촉/이관 시 후임 귀속 전환 |
| 펌뱅킹 | Firm banking | 대량 계좌이체(KFTC) |
---
*기준: 한국 보험업감독규정 표준 가정값. 시스템 schema v87 (2026-05 기준). 표준값은 모두 데이터로 외부화되어 코드 변경 없이 조정 가능.*
@@ -10,6 +10,7 @@ import org.springframework.security.core.AuthenticationException;
import org.springframework.validation.FieldError; import org.springframework.validation.FieldError;
import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
@@ -47,6 +48,12 @@ public class GlobalExceptionHandler {
"파라미터 [" + e.getName() + "] 타입이 올바르지 않습니다"); "파라미터 [" + e.getName() + "] 타입이 올바르지 않습니다");
} }
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<ApiResponse<Void>> handleMissingParam(MissingServletRequestParameterException e) {
return build(ErrorCode.INVALID_PARAMETER,
"필수 파라미터 [" + e.getParameterName() + "] 가 누락되었습니다");
}
@ExceptionHandler(HttpRequestMethodNotSupportedException.class) @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ApiResponse<Void>> handleMethod(HttpRequestMethodNotSupportedException e) { public ResponseEntity<ApiResponse<Void>> handleMethod(HttpRequestMethodNotSupportedException e) {
return build(ErrorCode.METHOD_NOT_ALLOWED, ErrorCode.METHOD_NOT_ALLOWED.getMessage()); return build(ErrorCode.METHOD_NOT_ALLOWED, ErrorCode.METHOD_NOT_ALLOWED.getMessage());
+100
View File
@@ -0,0 +1,100 @@
# -*- coding: utf-8 -*-
"""전체 메뉴 화면 1차 데이터 로드(list) 엔드포인트 전수 점검.
admin(전권) 로그인 후 각 화면이 호출하는 GET 엔드포인트를 호출하여
5xx(코드 오류)만 FAIL 로 분류한다. 4xx(파라미터/권한 요구)는 SKIP 으로 본다."""
import json
import urllib.request
import urllib.error
BASE = "http://localhost:8082"
def call(method, path, token=None, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data, method=method)
req.add_header("Content-Type", "application/json")
if token:
req.add_header("Authorization", "Bearer " + token)
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, r.read().decode()
except urllib.error.HTTPError as e:
return e.code, e.read().decode() or "{}"
except Exception as e:
return -1, str(e)
# 1) 로그인
st, raw = call("POST", "/api/auth/login", body={"loginId": "admin", "password": "admin1234!"})
bd = json.loads(raw)
token = bd["data"]["accessToken"]
print("[LOGIN] OK roles=%s" % bd["data"].get("roles"))
# 2) 각 메뉴 화면 1차 GET (list/primary). 일부는 필수 파라미터 동봉.
endpoints = [
# 기준정보
"/api/companies", "/api/products", "/api/orgs/tree", "/api/orgs",
"/api/agents", "/api/contracts", "/api/contract-successions",
"/api/agent-contracts", "/api/agent-licenses",
# 수수료 규칙/마스터
"/api/commissions", "/api/rules/commission-rates", "/api/rules/payout",
"/api/rules/override", "/api/regulatory-limits",
"/api/installment-ratios", "/api/installment-plans",
"/api/chargeback-grades",
"/api/collection-commissions/rules", "/api/collection-commissions/ledgers",
"/api/renewal-commissions/rules", "/api/renewal-commissions/ledgers",
"/api/reinstatement-commissions/rules", "/api/reinstatement-commissions/ledgers",
"/api/persistency-bonus/rules", "/api/persistency-bonus/bonuses",
"/api/override-ledgers",
# 정산/원장/명세
"/api/settle", "/api/settle/summary", "/api/settle-corrections",
"/api/ledger/recruit", "/api/ledger/maintain",
"/api/payments", "/api/deductions", "/api/statements",
"/api/period-close",
# 세무/회계
"/api/tax-invoices", "/api/withholding-tax-reports", "/api/vat-reports",
"/api/accounting-closes", "/api/accounting-entries",
"/api/agent-annual-incomes", "/api/year-end-statements",
# 운영/워크플로우
"/api/disputes", "/api/complaints", "/api/contract-endorsements",
"/api/incentive-programs", "/api/incentive-payments",
"/api/termination-settlements",
"/api/approvals/lines", "/api/approvals/requests/mine",
"/api/withdraw-requests",
# KPI/예측
"/api/kpi/monthly-summary", "/api/kpi/org-summary",
"/api/kpi/retention", "/api/kpi/cumulative",
"/api/forecast-kpi", "/api/retention-anomalies",
# 외부연동/수신
"/api/receive/profiles", "/api/carrier-reconciliation",
"/api/external-call-logs",
# 시스템
"/api/system/users", "/api/system/roles",
"/api/data-dictionary", "/api/data-domains",
"/api/upload-templates", "/api/batch/status", "/api/batch/jobs",
]
ok, fails, skips = 0, [], []
for ep in endpoints:
st, raw = call("GET", ep, token)
if st == 200:
ok += 1
tag = "PASS"
elif 500 <= st < 600 or st == -1:
fails.append((ep, st, raw[:300]))
tag = "FAIL"
else:
skips.append((ep, st))
tag = "SKIP(%d)" % st
print(" [%s] GET %s -> %s" % (tag, ep, st))
print("\n[RESULT] total=%d PASS=%d FAIL=%d SKIP=%d" %
(len(endpoints), ok, len(fails), len(skips)))
if fails:
print("\n=== FAIL DETAIL (code error - fix target) ===")
for ep, st, raw in fails:
print(" %s [%s]\n %s" % (ep, st, raw))
if skips:
print("\n=== SKIP (4xx - param/perm required, screen may be OK) ===")
for ep, st in skips:
print(" %s [%s]" % (ep, st))