From 81877dcb2b70739e1e6e56d25a4433f6c297c655 Mon Sep 17 00:00:00 2001 From: GA Pro Date: Sun, 31 May 2026 10:23:44 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=95=84=EC=88=98=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=20=EB=88=84=EB=9D=BD=20=EC=8B=9C=20500?= =?UTF-8?q?=E2=86=92400=20=EC=A0=95=EC=A0=95=20+=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EB=A9=94=EB=89=B4=20=EC=A0=90=EA=B2=80/=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=AC=B8=EC=84=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- DOMAIN_KNOWLEDGE.md | 206 ++++++++++++++++++ .../exception/GlobalExceptionHandler.java | 7 + smoke_allmenus.py | 100 +++++++++ 3 files changed, 313 insertions(+) create mode 100644 DOMAIN_KNOWLEDGE.md create mode 100644 smoke_allmenus.py diff --git a/DOMAIN_KNOWLEDGE.md b/DOMAIN_KNOWLEDGE.md new file mode 100644 index 0000000..04f571b --- /dev/null +++ b/DOMAIN_KNOWLEDGE.md @@ -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 기준). 표준값은 모두 데이터로 외부화되어 코드 변경 없이 조정 가능.* diff --git a/ga-common/src/main/java/com/ga/common/exception/GlobalExceptionHandler.java b/ga-common/src/main/java/com/ga/common/exception/GlobalExceptionHandler.java index a954a7c..c2c3b38 100644 --- a/ga-common/src/main/java/com/ga/common/exception/GlobalExceptionHandler.java +++ b/ga-common/src/main/java/com/ga/common/exception/GlobalExceptionHandler.java @@ -10,6 +10,7 @@ import org.springframework.security.core.AuthenticationException; import org.springframework.validation.FieldError; import org.springframework.web.HttpRequestMethodNotSupportedException; 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.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; @@ -47,6 +48,12 @@ public class GlobalExceptionHandler { "파라미터 [" + e.getName() + "] 타입이 올바르지 않습니다"); } + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity> handleMissingParam(MissingServletRequestParameterException e) { + return build(ErrorCode.INVALID_PARAMETER, + "필수 파라미터 [" + e.getParameterName() + "] 가 누락되었습니다"); + } + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) public ResponseEntity> handleMethod(HttpRequestMethodNotSupportedException e) { return build(ErrorCode.METHOD_NOT_ALLOWED, ErrorCode.METHOD_NOT_ALLOWED.getMessage()); diff --git a/smoke_allmenus.py b/smoke_allmenus.py new file mode 100644 index 0000000..66313d2 --- /dev/null +++ b/smoke_allmenus.py @@ -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))