51 Commits

Author SHA1 Message Date
GA Pro 2a27aa31a0 fix: 모집정산 재실행 시 1200% clip 분급 UNIQUE 충돌 — 멱등성 보강
CalcRecruitStep(Step4)이 1200%룰 clip 초과분을 installment_plan(7년)으로 생성할 때,
재계산을 위해 recruit_ledger는 deleteBySettleMonth로 지우면서 분급은 정리하지 않아
같은 정산월을 재실행하면 동일 계약이 또 clip되어 동일 분급키(contract_id,plan_year,
plan_month)로 INSERT → UNIQUE 충돌로 Job 실패하던 버그.

수정: insertBatch 직전에 clip된 계약들의 SCHEDULED(미지급) 분급을 먼저 삭제
(InstallmentPlanMapper.deleteScheduledByContractIds). PAID 분급 이력은 보존하여
PayInstallmentStep 지급분과 충돌하지 않음.

검증: CalcRecruitStepTest 멱등 테스트(clip 시 delete→insert 순서/대상계약 검증) 추가,
전체 build SUCCESS. 라이브 E2E: 202605 정산을 연속 2회 실행 → 둘 다 COMPLETED,
분급 196건 불변(중복 0), settle_master gross 동일, 무결성 유지.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 00:09:35 +09:00
GA Pro 17ab1098ec feat: 1200%룰 이연 분급 도래월 지급(PayInstallmentStep) + 코드베이스 신입교육 주석
PayInstallmentStep(Step 4.5): 1200%룰로 이연된 분급(installment_plan SCHEDULED)을
도래월(settle_month=정산월)에 recruit_ledger로 편입해 실제 지급. 이전엔 이연만 되고
도래월 지급 절차가 없어 차액이 영구 미지급되던 갭을 메움.
- BatchConfig job flow에 step4b(calcRecruit→payInstallment→calcMaintain) 등록
- InstallmentPlanMapper.resetPaidToScheduledByMonth(+XML)로 재실행 멱등
  (진입 시 PAID→SCHEDULED 역산, Step4 deleteBySettleMonth가 원장 정리)
- RecruitLedgerMapper insert에 reconcile_status 컬럼, SettlementContext.installmentPaidCount
- 단위테스트 3건(정상지급/멱등/계약미존재) GREEN

신입교육 주석: 컨트롤러/서비스/공통/프론트 전반에 도메인·코드 설명 주석 추가
(동작 변경 없음 — 빌드/테스트/타입체크 전부 GREEN으로 무회귀 확인).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:40:28 +09:00
GA Pro 5f43c945f0 동기화 2026-06-08 21:34:10 +09:00
GA Pro 711f3b5e16 docs: 수수료 레퍼런스에 갭보완 4종(§3-7) 반영 + 간이지급명세서 주민번호 화면 마스킹
- 수수료_계산공식_전체레퍼런스.md: §3-7 감사 보완 4종(A 지급보류/B 환수원천세역분개/
  C 환수채권연체이자/D 간이지급명세서) 추가, schema v126 표기
- SimplifiedPaymentReport: 화면 표시용 주민번호 마스킹(생년월일+성별구분 이후 *).
  엑셀 export는 국세청 제출용이라 원본 유지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:27:26 +09:00
GA Pro fbbabf2d04 feat: 수수료 갭 보완 4종 — 지급보류/환수원천세역분개/연체이자/간이지급명세서 (V124~V126)
감사(서브에이전트 3영역)로 발견한 실제 결함/규제 갭만 선별 보완.

- A 지급보류(HOLD) 지급배치 누락 버그: PaymentBatchItemMapper.insertFromSettleMaster
  status NOT IN('PAID','HOLD') — 보류건이 지급배치에 포함되어 실제 이체되던 결함 수정
- B 환수 원천세 역분개: withholding_tax_adjustment(V124) 멱등 적재 +
  분기 원천세신고 집계 netting(payment UNION ALL adjustment). settle_master 금액흐름 불변
- C 환수채권 연체이자: clawback_receivable.accrued_interest + clawback_interest(V125) +
  ClawbackInterestCalculator(연율/1200), config 기본 0 = 안전폴백, 기존 FIFO 상계가 이자 회수
- D 사업소득 간이지급명세서(월별): commission_statement(MONTHLY) 위 읽기전용 리포트 +
  메뉴 REPORT_SIMPLIFIED_PAYMENT(V126) + 프론트 SimplifiedPaymentReport

검증: ./gradlew build 전모듈 GREEN, 신규 단위테스트 6건, ga-frontend tsc -b PASS,
Flyway V124~V126 운영DB 적용(v126), 적대리뷰 APPROVED(LOW 1건 GREATEST 클램프 즉수정),
B/C/D 라이브DB 검증(롤백) + D admin e2e list/export 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:22:13 +09:00
GA Pro ff6d399dd3 feat: 수수료 공식 갭 보완 + 8종 신규모듈 풀스택 (V116~V123)
수수료 공식 갭분석(5 병렬감사) 후 확정 버그 3건 수정 + 개념적으로 빠진 모듈 8종 풀스택 구현.

[확정 버그 3건]
- 1200% 누적한도(checkContractTotal)를 배치 CalcRecruitStep에 연결(기존 1차년 한도만 적용
  → 두 한도 중 더 제한적인 값으로 clip+이연). +CalcRecruitStepTest
- BatchInstallmentPlanGenerator planMonth 반영(하드코딩 1 제거, API판과 동일). +단위테스트
- PersistencyBonus 임계유지율 게이트 + FIXED 정액 룰 도출. +PersistencyBonusServiceTest

[신규 모듈 8종]
- MOD-1 도입수수료(recruiter/development) / MOD-2 선지급·차익정산 / MOD-3 환수 분할상환
- MOD-4 익월부활 재지급 / MOD-5 정산대사 입수 / MOD-6 환수 시효 / MOD-7 환수 감면
- MOD-8 채널별 수수료차등(sales_channel)
각 DB(테이블+메뉴+권한+공통코드)→VO/Mapper/Enum→Service/Controller→화면 풀스택.
배치 정산연계: AggregateStep에 도입/익월부활 가산(11종), 선지급 FIFO 상계(멱등 reverse),
MOD-8 채널 우선 요율조회(NULL 폴백 하위호환).

[검증]
- 전체 ./gradlew build(전 모듈+전 테스트) GREEN
- Flyway V116~V123 운영DB 적용(schema v115→v123)
- 라이브 스모크 8 GET=200, MOD-1 RATE공식 50000 실측, 액션 POST 5xx 0건
- 적대적 코드리뷰 APPROVED(MOD-8 계산기 미연결 버그 발견·수정)

스펙: docs/DOMAIN_GAP_MOD1-8.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:33:13 +09:00
GA Pro 00ead64c4c feat: P12(#2) 지급대상건 구성·수정 — 정산월 지급대상 배치 구성/확정 (PL 자율진행)
incar 지급대상건구성/수정/조회 대응. 기존 settle_master 건별 confirm/hold 위에
정산월 단위 지급대상 배치를 구성→포함/제외 편집→확정하는 워크플로우 레이어 추가
(AccountingClose DRAFT/마감 패턴 준용, settle_master 상태는 미변경=결합 최소화).

- DB(V115): payment_batch(배치 마스터, status DRAFT/FINALIZED/CANCELLED, total_count/
  total_payable) + payment_batch_item(건, settle_id FK, payable 스냅샷, included/exclude_reason,
  UNIQUE(batch_id,settle_id)) + 메뉴 PAYMENT_TARGET(GRP_SETTLE) + 권한 READ/CREATE/UPDATE/
  EXECUTE + 공통코드 PAYMENT_BATCH_STATUS.
- core: PaymentBatch/Item VO/Resp/SaveReq/SearchParam/Enum + Mapper. insertFromSettleMaster
  (INSERT-SELECT 자동채움: settle_month·payable>0·status!=PAID) + recalcTotals + updateInclude.
- api: PaymentBatchService create(자동채움+집계)/list/detail/updateItem(DRAFT만,재집계)/
  finalize(포함0건 거부)/cancel/export. Controller /api/payment-batches.
- frontend: PaymentTarget 화면(배치 목록 + 구성 모달 + 상세 Drawer items 포함/제외 토글 +
  합계 + 확정/취소/엑셀) + api 모듈 + App 라우트. export 권한 READ로 정렬.

검증: build+test SUCCESSFUL, Flyway V115(schema v115). 라이브 e2e: 202603 create→39건
자동채움(합 15,438,386.9) → 1건 제외 → 38건 재집계(정확히 −35,079) → finalize → 확정후
수정 400 차단 → cancel. 테스트 배치 정리. 스펙 docs/DOMAIN_GAP_P12.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 01:10:50 +09:00
GA Pro 934e45d295 feat: P11(#1) 시상/신인시상 환수 — award_ledger 환수 처리 (PL 자율진행)
incar 유지율시상환수/신인도입유치자시상환수 대응. award_ledger(V92)는 지급만 있고
환수가 없던 갭. 정착지원금/생보운영지원/지점장수당의 clawback_amount+net_amount 패턴으로
통일(신인시상=award_type NEW_RECRUIT로 기존재 → 별도 테이블 불필요).

- DB(V114): award_ledger에 clawback_amount/net_amount 컬럼 추가 + net=award 백필 +
  CHECK(clawback>=0) + status CHECK에 CLAWED_BACK 추가(pg_constraint 동적 탐색 DROP/재생성).
- core: AwardLedgerVO/Resp/SaveReq에 clawbackAmount/netAmount. AwardLedgerMapper
  aggregateByAgent SUM(award_amount)→SUM(net_amount), insert에 두 컬럼, applyClawback UPDATE.
- api: AwardService.createLedger가 net=award−clawback 계산(clawback≤award 검증). 신규
  applyClawback(ledgerId, {clawbackAmount,reason}) — 초과/취소상태 E411, status=CLAWED_BACK.
  PUT /api/awards/ledgers/{id}/clawback.
- frontend: Award 원장 그리드에 환수액/실지급 컬럼 + 합계 + 환수처리 모달(PermissionButton UPDATE).
- AggregateStep 무변경: aggregateByAgent가 net 반환 → 환수가 other_commission 자동 차감,
  net<0이면 P9 환수채권 이월에 자연 연계.

검증: build+test SUCCESSFUL, Flyway V114(schema v114). 라이브: award 100000 원장 →
clawback 30000 → net 70000·CLAWED_BACK, 초과환수 200000 → E411, aggregate net=70000 반영
확인. 테스트 데이터 정리. 스펙 docs/DOMAIN_GAP_P11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 00:59:48 +09:00
GA Pro 284ecc4203 feat: P10(#3) 환수 보험사자료 업로드 — 보험사 통보 기반 chargeback 인입 (PL 자율진행)
incar '환수보험사자료 업로드' 대응. GA는 LAPSE 계약 기반 자동환수만 있고 보험사가
통보한 환수자료(증권번호+환수금액)를 직접 인입하는 경로가 없던 갭. 신규 테이블 없이
기존 chargeback(V6) 재사용.

- DB(V113): 메뉴 CHARGEBACK_DATA(환수자료관리, GRP_SETTLE, /settle/chargeback-data)
  + 권한 READ/CREATE/EXPORT + 역할부여.
- core: ChargebackUploadExcelVO(@ExcelColumn 증권번호/환수금액/실효월/환수율) +
  ChargebackResp/SearchParam + ChargebackMapper.selectList/countByCondition/
  existsByContractAndMonth + ContractMapper.selectByPolicyNo(contract_date 포함).
- api: ChargebackUploadService.upload(settleMonth,file) — ExcelService.importLargeExcel
  로 SAX 파싱, policy_no→contract/agent 해소(미존재=에러행), 중복(contract+월) skip,
  실효월 YYYYMM→경과월 변환(ChronoUnit), ChargebackVO status=NEW·remain=cb_amount
  insertBatch. ChargebackService.list/export. ChargebackController(/api/chargebacks
  /upload, GET 목록, export).
- frontend: ChargebackData 화면(정산월+파일 업로드, 결과 성공/스킵/에러행 표시, AG Grid
  목록 합계행, 엑셀) + api/chargeback.ts + App 라우트.

검증: build SUCCESSFUL, Flyway V113(schema v113). 라이브 업로드 e2e — 정상 2건 INSERT
(NEW, remain=cb_amount) + 미존재 증권번호 1건 에러행 리포트 + 중복 skip, GET 목록 200,
경과월 변환 동작 확인. 테스트 chargeback 전건 정리. 스펙 docs/DOMAIN_GAP_P10.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 00:51:08 +09:00
GA Pro 90256a19a1 feat: P9 #4 — 출금 가용한도를 환수채권 상계 후 실지급액(payable_amount)으로 전환
P9에서 산출하던 payable_amount(=환수 상계 후 실지급액)가 출금 한도에 미반영
(cosmetic)이던 것을 연결. 이제 환수채권 회수가 모바일 출금 가용액을 정확히 줄인다.

- V112: 기존 settle_master.payable_amount 백필 = GREATEST(net_amount,0)
  (V111에서 DEFAULT 0 추가됨 → 구 데이터 보정. clawback 무활동 행만 대상=멱등).
- core: SettleMasterMapper.xml cols에 clawback_recovered/carryover/payable_amount 추가,
  SettleMasterResp 3필드 노출.
- api: MobileMeService.withdrawableBalance 한도 산정을 net→payable_amount 기준으로
  (payable null이면 max(net,0) 폴백). WithdrawableBalance DTO에 payableAmount 추가,
  remainingAmount = payable − used. createWithdrawRequest 가드도 자동 반영.
- PWA: withdraw.ts 타입 + WithdrawCreate 드롭다운/카드를 '실지급가능(payable)' 표기로.

검증: ./gradlew build(test포함) SUCCESSFUL, PWA tsc 0오류. Flyway V112 적용(schema v112).
백필 150/150 행 payable==max(net,0). 라이브 agent01 withdrawable-balance가 payableAmount
노출 + remaining=payable−used(13492−151000=-137508) 정확.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 00:29:50 +09:00
GA Pro 51ee03453c feat: P9 환수채권 이월/미수금 관리 — 수수료 갭 풀스택 (PL 자율진행)
incar CMS 메뉴(환수이월관리/환수계약리스트/위촉중환수) ↔ GA 부재 + 코드 갭
(AggregateStep net<0 시 경고만, 미회수 환수 채권화·차월상계·대손 생애주기 전무)
을 도출해 풀스택 구현. 미회수 환수가 소실되던 재무 누락을 폐쇄.

- DB(V111): clawback_receivable(채권 마스터, UNIQUE agent+origin월) +
  clawback_recovery(상계 회수 이력) + settle_master 3컬럼(clawback_recovered/
  clawback_carryover/payable_amount) + 메뉴 CLAWBACK_RECEIVABLE + 권한 + 공통코드.
- core: VO/Resp/SaveReq/SearchParam/Enum/Mapper(.java/.xml) 2테이블 + MapStruct.
  outstandingBalance(origin<settleMonth 자기상계방지) / FIFO / upsertCarryover(멱등).
- api: ClawbackReceivableService/Controller(/api/clawback-receivables) — list/detail/
  summary/write-off(OUTSTANDING·RECOVERING만, CLEARED/WRITTEN_OFF 전이)/export(@Transactional).
- batch: AggregateStep 통합 — net<0 → carryover 채권화, net>0 → FIFO 상계회수,
  reverse-먼저 방식으로 동일월 재실행 멱등. net_amount 의미 불변(무결성 보존).
  payable_amount = max(0,net) − recovered.
- fix(잠복): SettleMasterMapper.xml upsert에 신규 3컬럼 누락 → INSERT/ON CONFLICT 추가
  (배치가 VO에 set해도 DEFAULT 0으로 미영속되던 버그, PL 검증서 발견).
- frontend: ClawbackReceivable 화면(AG Grid 합계행 + 회수이력 타임라인 Drawer +
  대손/상환 모달) + api 모듈 + App 라우트.

검증(PL 직접): ./gradlew build(test포함) SUCCESSFUL. Flyway V109~V111 운영DB 적용
(schema v111). 라이브 API: GET 목록/summary 200, write-off WRITTEN_OFF 200(한글사유
영속), 종결 재처리·잘못된 전이상태 E411 차단. 배치 이월/상계/멱등/부분상계 4시나리오
실 스키마 롤백 트랜잭션 검증 PASS(CHECK balance>=0 포함). 잔여 데이터 0.

스펙: docs/DOMAIN_GAP_P9_환수채권이월.md. 후속 갭(시상/인정실적 환수 ledger,
지급대상건 구성, 환수 보험사자료 업로드) 문서화.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 00:18:58 +09:00
GA Pro b12858c991 docs: 수수료 종류별 계산공식 전체 정리 + 신입교육 문서 보완
- docs/수수료_계산공식_전체레퍼런스.md 신규: 모든 수수료 종류의 실제 구현
  계산식(코드 기준, 감사 수정 반영) — 모집/유지 3곱, 지급성 11종, 조직·실적,
  횡단(1200%clip/분급/환수/세금/집계net/회계예수금분개).
- 신입교육_보험과수수료_완전기초.md 보완:
  - 6-A 수수료 종류별 계산식 하나씩(미니 숫자예시) — 생애주기/성과조직/소개지원/회사레벨
  - 6-B 기타수수료(예외 가감 PLUS/MINUS, 시책, 해촉정산, 본사정산, 보증보험)
  - 목차 갱신

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:41:36 +09:00
GA Pro e2fdc88754 fix: 잔여 백로그 전건 처리 — 회계 예수금/1200%clip/환수가드/net경보 (PL 자율진행)
- fix(a, MED): 회계 분개에 원천세 예수금 leg 분리. (차)8350 지급수수료 gross /
  (대)2530 미지급금 net + (대)2540 예수금 원천세. 기존엔 미지급금에 gross 계상 +
  원천세 분개 누락. account_code '2540' 예수금 추가(V110), 분개쿼리 2-leg 전환.
- fix(b, HIGH): 1200%룰 1차년 한도초과 시 clip+이연. 한도잔여까지만 당월 지급,
  초과분만 분급(7년)으로 이연. 전액 이연 시 당월 원장 미생성 + 세액 재계산.
  (기존: 전액지급 + 전액 분급계획 → 설계 불일치)
- fix(e/f, HIGH/MED): 정착지원금/생보운영지원 환수액 ≤ 지원금 검증으로 net 음수 차단.
  AggregateStep net 음수(공제>지급) 경보 로깅 추가.
- (g) 검토결과 무변경: TaxCalculator/BatchTaxCalculator는 의도된 동일로직 쌍둥이,
  V80 매직팩터는 샘플시드일 뿐 계산경로 아님. 두 쌍둥이 모두 음수가드 적용됨.
- (d) 무변경: 승계 "최신 to_agent=현재귀속자"는 다단계체인에 정합, from==agent_id
  가드는 체인 회귀 유발하므로 정책확정 전 보류(문서화).
- test: CalcRecruitStepTest(clip+이연), SettlingSupportServiceTest(환수>지원 거부).
- 전체 build+test GREEN: 155건 0실패.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:27:53 +09:00
GA Pro 60c4cbae78 fix: 오버라이드 insurance_type_cond 미적용 과지급 수정 (PL 우선순위 진행)
- fix(HIGH): CalcOverrideStep base 가 override_rule.insurance_type_cond 를 무시하고
  하위 설계사의 전 보험종류 실적 합계에 율을 곱해 과지급. 룰에 보험종류 조건이
  지정되면(LIFE 등) 해당 종류 실적만 base 로 집계하도록 수정.
  (현 시드는 insurance_type_cond NULL 이라 잠복상태였으나 종류한정 룰 도입 시 과지급)
- add: RecruitLedgerMapper/MaintainLedgerMapper.sumByAgentMonthAndType + XML
- test: CalcOverrideStepTest — LIFE 한정 룰 base 필터 검증
- 전체 build+test GREEN: 153건 0실패

잔여 백로그(정책결정 필요 — 자동수정 보류): 1200%룰 초과분 clip/이연(지급정책),
계약승계 from검증(다단계 체인+contract.agent_id 갱신정책), 정착지원금 환수공식(입력
스키마), 회계 원천세 예수금 분개(회계정책), net음수 클램프(이월저장), 세금 3중경로 통일.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:38:09 +09:00
GA Pro eac0b813d6 fix: 수수료 계산 전수감사 — 1200%룰 단위버그 외 5건 수정
적대적 코드감사(4영역)에서 검증된 정확성 결함 수정:
- fix(HIGH): 1200%룰 단위 불일치. regulatory_limit.limit_value(PERCENT:1200,35)를
  KRW 금액과 직접 비교 → 사실상 "1200원/35원 룰"로 망가져 거의 모든 계약이 위반
  처리. 연환산보험료×비율/100로 KRW 환산 후 비교. RegulatoryLimitChecker(api/batch)
  양쪽, ContractMapper 주입(시그니처 무변경).
- fix(HIGH): 음수 gross(환수>지급) 세금 계산이 음수 원천세 생성 → net 왜곡 +
  분기 원천세신고 SUM 잠식. gross<=0 시 세금0·net=원금 가드. Tax/BatchTaxCalculator.
- fix(HIGH): AwardService 시상 threshold_amount 미적용 → 기준 미달자에게 시상
  전액 지급. threshold 미만 차단 + FIXED award_fixed null 가드.
- fix(MED): 차등환수 최저구간 months_from 1→0(V109). 체결 1개월내 즉시해지가
  grade=null로 환수 0원 처리되던 누락 보정.
- fix(MED): company_rate/payout_rate NULL→0 폴백 무경보. log.warn 추가로
  데이터 공백에 의한 과소지급 추적 가능.

테스트 4건 추가(단위변환/음수세금/시상threshold 경계). 전체 build+test GREEN: 152건 0실패.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:19:53 +09:00
GA Pro 3d50286f4a test: E2E 수수료 정산 시나리오 테스트 + 엑셀업로드/분급 버그 수정
- 시나리오 문서: 보험 인입→수수료 지급 8단계 (docs/시나리오_E2E_보험인입_수수료정산.md)
- SettlementScenarioTest(9건): 계약1건 생애주기를 실제 계산기로 단계별 검증
- ExcelServiceImportTest(2건): 실 .xlsx 업로드 라운드트립 검증
- fix(CRITICAL): 엑셀 업로드 런타임 깨짐 — xlsx-streamer 2.2.0 ↔ poi 5.2.5
  비호환(NoSuchMethodError). excel-streaming-reader 4.3.0(유지보수 fork)로 교체.
  영향: ExcelService.importLargeExcel + UploadService(/api/upload 실엔드포인트)
- fix: 분급 계획 반올림 잔차로 분급합계≠원금(정산 무결성 위반). 비율합 100%
  정규스케줄일 때 마지막 회차가 잔차 흡수. ga-api/ga-batch 양쪽.
- 전체 build+test GREEN: 148건 0실패

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:02:55 +09:00
GA Pro 1d77671ea0 feat: 리포트 3종 백엔드 구현(실적/조직별/환수위험) + Excel export 트랜잭션 수정
- ReportController/Service/Mapper(.xml) + VO 3종(PerfReportRow/OrgReportRow/ChargebackRiskRow)
  - /api/report/performance: settle_month별 settle_master 집계(설계사수/모집/유지/실지급)
  - /api/report/org: 정산월 기준 organization 조인 조직별 집계
  - /api/report/chargeback-risk: recruit_ledger 계약별 경과월 + chargeback_grade 환수율/위험등급
  - 각 read(List) + export(Cursor→xlsx). 신규 테이블 없음(읽기전용 집계)
- ExcelService.exportLargeExcel @Transactional(readOnly=true) 추가 — Cursor 가 세션 밖에서
  닫혀 모든 export(기존 agent 포함)가 500 나던 잠복버그 수정. 1줄로 export 인프라 전체 복구

검증: 3 read 200(실데이터), 3 export 200(유효 xlsx), agent export 복구 확인.
Playwright 84화면 재실행 FAIL 0 (PASS59/WARN25, WARN은 차트/폼 등 탐지한계)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:51:37 +09:00
GA Pro 81871615cd fix: Playwright 전메뉴 기능테스트로 발견한 화면 버그 4건 수정
- products: /api/products 가 bare 배열 반환 → PageResponse 정렬(PageHelper).
  contracts/rules-commission 의 products?.list.map 크래시 + products 빈목록 동시 해결
- grade-eval: gradeEvalRules 가 PageResponse(.list) 추출하도록 수정 (rowData.map 크래시)
- batch: batch.ts 를 실제 백엔드(/settlement/run body, /jobs/{id}, /jobs)로 정렬 +
  VO필드(jobId/currentStep/processedCount/finishedAt/durationSec) 어댑터 매핑

검증: Playwright 84화면 재실행 FAIL 7→3 (잔여 3건은 미구현 /api/report/*)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 00:32:45 +09:00
GA Pro 93ac94558f feat: PWA FCM 클라이언트 실통합 + 어댑터 키 주입 런처
- firebase SDK 통합: initPush getToken 활성화(동적 import) + isSupported 가드
- public/firebase-messaging-sw.js 백그라운드 수신 SW(웹 config 쿼리스트링 전달)
- run-api-with-adapters.sh: adapter.env.local 로드 후 ga-api bootRun
- 키 파일(adapter.env.local / .env.local)은 git-ignored 템플릿으로 별도 제공

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 22:56:44 +09:00
GA Pro 5d5c2d53aa fix: 적대적 코드리뷰 후속 — 신규 도메인 5건 버그 수정
서브에이전트 4영역 적대적 리뷰(P7/P8 서비스·매퍼·배치·어댑터)에서 확정된 결함 수정.

- [HIGH] HqSettleItem update 시 status가 NULL로 덮어써짐(SaveReq에 status 없음)
  → status = COALESCE(#{status}, status) 로 수정 불변 보장.
- [MED] 소개·이관수수료 RATE/FIXED 분기를 요청 commissionRate 유무로 판정 → 활성 룰의
  calc_type 을 권위로 사용(FIXED=fixed_amount, RATE=요청rate||룰rate), 룰 없을 때만 요청rate 폴백.
- [MED] 지점장수당 cap 미적용(주석-코드 불일치) → SaveReq.capAmount 추가 + allowance = min(base×rate, cap).
- [MED] PUSH 외부호출 감사로그 요약 누락 → ExternalCallLoggingAspect applyRequest/applyResponse 에
  PushRequest/PushResponse 분기 추가(토큰 마스킹, messageId/sent/resultCode).
- [LOW] 공동계약 update 지분율 검증 키 불일치 → 기존 레코드 contractId 기준으로 검증/갱신(계약 이동 불가).

배치 통합 리뷰 결과 CLEAN(무결성·집계·upsert 정확). 전체 ./gradlew build(test 포함) SUCCESSFUL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:27:14 +09:00
GA Pro 51f340e36e chore: incar 14도메인 데모 샘플데이터(V108) + 외부연동 활성화 가이드
- V108__incar도메인_샘플데이터.sql: P7/P8 14개 도메인 대표 샘플 시드(멱등 NOT EXISTS,
  기준월 202605). 신규 화면이 빈 화면으로 보이지 않도록 — 기존 V65/V69/V80 패턴.
  ROLLBACK 트랜잭션 사전검증(오류 0) 후 Flyway V108 success.
  라이브 확인: 12개 신규 엔드포인트 전부 200 + 샘플 rows 반환.
- docs/외부연동_활성화_가이드.md: KFTC/Toast/FCM 어댑터 운영 활성화 절차
  (전환 스위치 app.adapter.*, 환경변수 매트릭스, 사전조건, 검증법).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:09:12 +09:00
GA Pro 204e3b6975 feat: 푸시 알림 파이프라인 — PushAdapter(FCM HTTP v1) + 디바이스토큰 등록 + 디스패치
마지막 외부연동 영역(푸시)을 채널=FCM HTTP v1 로 확정해 서버측 완전 구현.
Toast/KFTC 와 동일 패턴: 실 API·config-gated(app.adapter.push, 기본 mock)·fail-fast.

- ga-common: PushAdapter 인터페이스 + PushRequest/PushResponse
- ga-external-adapter: MockPushAdapter(기본) + FcmPushAdapter(POST /v1/projects/{id}/messages:send,
  Bearer, notification+data 매핑, name→messageId) + FcmProperties. 의존성 추가 없음.
- V107 user_device_token(user_id/token/platform/is_active, UNIQUE token) + UserDeviceTokenVO/Mapper(upsert/selectActiveByUser/deactivate)
- ga-api:
  - PushDispatchService.sendToUser — 사용자 활성 토큰 전부에 best-effort 푸시(예외 흡수)
  - MobileMe: POST /api/mobile/me/device-tokens (등록 upsert) + /unregister (본인검증 deactivate)
  - WithdrawBankSettleService.notify 에 푸시 디스패치 연동(출금 SETTLED/FAILED 시 인앱+푸시)
  - ExternalCallLoggingAspect 에 PushAdapter 포인트컷 추가(외부호출 감사로그)
  - application.yml app.adapter.push + app.adapter.fcm.* 설정
- ga-mobile-pwa: api/push.ts(register/unregister) + push/initPush.ts(config-gated, VITE_FCM_* 미설정 시 no-op) + 로그인 성공 시 initPush() 훅
- AdapterFailFastTest 에 FCM fail-fast 케이스 추가

검증: 전체 ./gradlew build(test) SUCCESSFUL, PWA 빌드 OK, Flyway V107 success,
디바이스토큰 등록/해제/검증 라이브 e2e PASS(등록200·해제200·is_active=N·빈토큰400).
실 푸시 발송은 Firebase 프로젝트(서버 FCM_PROJECT_ID/ACCESS_TOKEN + PWA VITE_FCM_* + firebase SDK) 주입 후 가능.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 21:01:58 +09:00
GA Pro 78ffd5a56a feat: 외부연동 어댑터 실구현 — NHN Toast(SMS/알림톡) + KFTC 오픈뱅킹 입금이체
공개 문서화된 실제 API 스펙에 맞춰 스켈레톤을 RestClient 기반 실구현으로 교체.
모두 @ConditionalOnProperty 로 config-gated, 기본은 Mock 유지 → 운영 영향 0.

- ToastMessageAdapter: SMS v3.0(/sms/v3.0/appKeys/{k}/sender/sms) + 알림톡 v2.3
  (/alimtalk/v2.3/appkeys/{k}/messages). X-Secret-Key 인증, MessageChannel 분기.
  응답 header.isSuccessful/resultCode + requestId 매핑. ToastProperties 외부화.
- KftcBankTransferAdapter: 입금이체(/v2.0/transfer/deposit/acnt_num) + 이체결과조회.
  Bearer 토큰, 문서화된 KFTC 필드(bank_tran_id/req_list/rsp_code) 매핑. KftcProperties 외부화.
- 키/토큰 누락 시 fail-fast(IllegalStateException) — 잘못된 운영 적용 즉시 감지.
- application.yml app.adapter.{kftc,toast}.* 설정(env placeholder, 빈 기본값) 추가.
- 의존성 추가 없음(ga-common 의 spring-web RestClient + Jackson 재사용).
- AdapterFailFastTest 3건(설정누락 차단/알림톡 templateCode 필수) 추가.

제외: FCM 푸시 — MessageAdapter 인터페이스/PWA 연동 계약 미정으로 추측구현 회피.
실거래 검증은 운영 키(계약·AppKey·약정계좌·토큰) 주입 후 테스트베드에서 수행 필요.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:50:44 +09:00
GA Pro bfc724669b feat: incar 갭 반영 수수료/운영 14개 도메인 풀스택 + 배치통합 + 신입교육자료
incar CMS(116메뉴) 벤치마킹 갭분석으로 ga-pro에 없던 14개 도메인을 풀스택 추가.

P7 수수료 계산 7종 (V88~V96):
- 수입수수료+수지차, 소개·이관수수료, 정착지원금, 인정실적+환산율,
  시상, 생보운영지원수수료, 지점장수당
- 각 rule/ledger + 공식(보험료×요율, 수입−지급 등) Service 내장

P8 운영/정산 7종 (V97~V105):
- 단계마감, 본사정산, 등급평가, 공동계약, 보증보험, 적립금, 수수료시뮬레이터
- close/reopen/apply/simulate/upsert 등 액션 + 손익·잔액·등급 산정 로직

배치 통합 (V106):
- settle_master.other_commission_total 컬럼 추가(DEFAULT 0, 백필)
- 설계사 지급성 9종 원장 aggregateByAgent → AggregateStep gross 합산
- 빈 원장 0 → 기존 정산결과·무결성 불변(비파괴적)

테스트: P7/P8 Service 공식 단위테스트 21건(ga-api:test 57건 전체 통과)
프론트: 화면 14개 + React.lazy 코드 스플리팅(단일 4MB → 130+ 청크)
문서: DOMAIN_GAP_P7/P8.md, 신입교육_보험과수수료_완전기초.md, DOMAIN_KNOWLEDGE/HANDOFF 갱신

검증: 전체 ./gradlew build(test 포함) SUCCESSFUL, Flyway V88~V106 success,
GET+액션POST smoke 5xx 0건, 9개 집계쿼리 SQL 유효성 확인, 공식 런타임 실측.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 15:45:10 +09:00
GA Pro 70620463b4 docs: 도메인 지식 문서 신입사원 교육용 확장
- 목차(TOC) 추가
- §3 보험 기초(보험을 처음 접한다면) — 보험료/보험종류/유지율/위촉해촉 등 입문 용어
- §7 수수료 계산 공식과 숫자 예시 — 기본 3단계 공식(보험료×보험사요율×지급률)
  + 모집/분급(7년)/차등환수/오버라이드/세금상세 worked example
  (CommissionCalculator·TaxCalculator·CalcOverrideStep 실제 코드 기준)
- §13 용어 사전 확장(보험 기초 용어 + 요율 용어 추가)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 10:48:27 +09:00
GA Pro 81877dcb2b 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>
2026-05-31 10:23:44 +09:00
GA Pro fcddfea4f6 test: SqlSessionFactory 테스트 환경 문제 해결 — 전체 112건 PASS
@SpringBootTest 컨텍스트가 @MapperScan(com.ga) 으로 모든 매퍼 MapperFactoryBean 을 만드는데
test 프로파일이 MybatisAutoConfiguration 을 제외해 SqlSessionFactory 가 없어 깨지던 문제.
기존엔 매퍼를 하나씩 @MockBean 으로 막았으나 신규 매퍼 누락 시마다 컨텍스트 로딩 실패.

- TestMyBatisConfig: 미접속 PGSimpleDataSource + SqlSessionFactory 제공 → 전 매퍼 빈 생성, 목록 갱신 불필요
- AbstractControllerTest @Import 에 TestMyBatisConfig 추가
- SettleControllerTest hold stub 2인자→3인자 정정(testCompile 실패 해소)
- HANDOFF §3-17 추가 + 테스트 상태 서술 정정

검증: ./gradlew test --rerun-tasks → 112건 PASS (ga-common 57 / ga-api 36 / ga-batch 19, 실패 0). DB 무관.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 23:08:11 +09:00
GA Pro e407263370 fix: 회계분개 멱등성 — 동일 정산월 재실행 시 분개 중복 INSERT 방지 (V87)
AccountingEntryGenerateStep(Step8b)이 selectBySettleMonth 결과를 무조건 insert 하여
동일 정산월 재정산 시 분개가 누적 중복되던 버그. payment_id 가 항상 NULL(원장 기반)이라
기존 주석의 existsByPaymentId 중복방어는 불가능했음.

- V87: contract_accounting_entry.settle_month 컬럼 추가 + 기존데이터 백필(description 6자리) + 미전기 부분 인덱스
- core: VO settleMonth 필드, Mapper deleteUnpostedBySettleMonth, insertBatch/selectBySettleMonth 에 settle_month 반영
- batch: 재생성 전 deleteUnpostedBySettleMonth 호출(마감 채번분 journal_no IS NOT NULL 보존)

검증: ga-core/ga-batch/ga-api compileJava SUCCESS, Flyway v87 적용(백필 433건 NULL 0),
  202605 정산 2회 실행 → 193건 유지(중복 0, 수정 전이라면 386). 멱등성 PASS. 테스트데이터 롤백 완료.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 22:58:49 +09:00
GA Pro a5ae95e34c docs: HANDOFF §3-16 계약 승계(V86) 추가 + 재개 절차 갱신(v86/ga-batch 트리거)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 22:16:17 +09:00
GA Pro 4dbc688f79 fix: AgentDeductionMapper.updateStatusBatch XML 구문 누락 보강
AggregateStep(Step8) 이 차감 APPLIED 일괄 전이로 호출하는 updateStatusBatch 가
인터페이스에만 선언되고 XML 구문이 없어, PENDING 차감이 있는 정산월 집계 시
'Invalid bound statement (not found)' 로 aggregate 전체가 FAILED 되던 잠복 버그.
updateStatus 동일 패턴 + foreach IN 으로 구현. (계약승계 e2e 중 발견, 승계와 무관)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 21:44:34 +09:00
GA Pro bd5ff4ec14 feat: 계약 승계(contract succession) — 유지수수료 귀속 후임 전환 (V86)
설계사 해촉/이관 시 보험계약의 유지수수료 귀속을 승계월 기준 후임 설계사로 전환.
모집수수료는 원 모집설계사 귀속 유지(고아계약 승계 표준).

- DB V86: contract_succession 테이블 + 인덱스 + 메뉴/권한(GRP_PRD) + 공통코드 2그룹 + 테스트데이터 1건
- core: ContractSuccession VO/Resp/SaveReq/SearchParam + Mapper(.java/.xml)
  selectEffectiveSuccessions(settleMonth): 계약별 최근 ACTIVE 승계 1건 (DISTINCT ON)
- api: ContractSuccessionService/Controller (/api/contract-successions, CRUD+cancel)
- batch: CalcMaintainStep 이 effective 승계 사전로드(N+1 회피) 후 effectiveAgentId 결정,
  CommissionCalculator.calcMaintainLedger 오버로드로 후임 등급 payout + ledger agent_id 후임 기록.
  AggregateStep 은 ledger agent_id 집계라 무변경.
- frontend: api/succession.ts + ContractSuccessions.tsx + App.tsx 라우트

검증: 5모듈 compileJava SUCCESS / ga-frontend tsc 0 / Flyway v86 적용 /
  API CRUD+cancel+from==to 가드(E411) 라이브 PASS / resolver 쿼리 라이브 검증(계약1→후임 agent2).
배치 풀잡 e2e 는 정산월 원장 재생성(파괴적)이라 미실행.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 21:28:00 +09:00
GA Pro d8d64625a9 feat: P6 후속 4 — 출금 폼 잔여 한도 미리 표시
- 백엔드 GET /api/mobile/me/settle-masters/{id}/withdrawable-balance
  · 응답 {settleId, netAmount, usedAmount, remainingAmount}
  · 본인 settle_master 검증 후 net - sumActiveAmountByMaster 반환
  · createWithdrawRequest 가 동일 조회 로직 재사용 (중복 제거)
- PWA WithdrawCreate: settleMaster 선택 시 잔액 카드 표시 + amount 클라이언트 사전 검증

검증: ga-api compileJava BUILD SUCCESSFUL / ga-mobile-pwa tsc --noEmit 0 오류
(라이브 e2e 는 DB/docker 미가동으로 차단 — 환경 복구 후 스모크 예정)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 20:52:17 +09:00
GA Pro 366f49c3f0 feat: P6 후속 3 — 출금 잔여한도 검증 + 알림 모두읽음 + HANDOFF 갱신
출금 잔여 한도 검증:
- WithdrawRequestMapper.sumActiveAmountByMaster(settleMasterId) 추가
  · CANCELLED 외 모든 활성 출금의 amount SUM (COALESCE 0)
- MobileMeService.createWithdrawRequest 에 잔여 한도 가드 추가
  · 1) settle_master 본인 소유 확인 (FORBIDDEN)
  · 2) 잔여 = net_amount - 활성 출금합계
  · 3) 요청 amount > 잔여 시 INVALID_PARAMETER(E411) 던짐
  · 메시지에 잔여 금액 명시 ("출금 가능 잔액(N)을 초과합니다")

PWA 알림 모두읽음:
- POST /api/mobile/me/notifications/read-all (기존 UserNotificationMapper.markAllRead 재사용)
- NoticeList 내알림 탭 헤더에 "모두 읽음 (N)" 버튼 (안읽음 카운트 표시)
- 성공 시 invalidate notifications + summary 쿼리

검증:
- ga-api compileJava + ga-mobile-pwa tsc PASS
- e2e (agent01 / userId=4 / agentId=1):
  · settle#51 net=35,079원, 기존 사용 5,000원
  · amount=999,999 → E411 "출금 가능 잔액(30079.00)을 초과합니다"
  · amount=100   → success, requestId=5
  · markAllRead → 200, summary.unread=0
- 이전 e2e 검증 (총 9건) 모두 PASS

HANDOFF.md §3-15 갱신 (2026-05-27 P6 후속 전체 정리 + agent01 테스트 계정 명시).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 00:00:17 +09:00
GA Pro dfce9e5c02 feat: P6 후속 2 — PWA 본인 프로필/출금취소 + SETTLED/FAILED 자동 알림
PWA 본인 프로필:
- Profile.tsx 신규 (phone 자기수정 Dialog + 비밀번호 변경 + 로그아웃)
- TabBar 에 "내정보" 탭 추가 (총 5탭)
- 백엔드 PUT /api/mobile/me/profile/phone (MobileMeService.updateMyPhone, 토큰에서 userId 강제)
- 비밀번호 변경은 기존 /api/auth/password 재사용

PWA 출금 신청 취소:
- 백엔드 POST /api/mobile/me/withdraw-requests/{id}/cancel
  · 본인 agentId 검증 후 기존 WithdrawRequestService.cancel 호출
  · REQUESTED 상태에서만 가능 (기존 검증 재사용)
- WithdrawList 행을 SwipeAction 으로 감싸 "취소" 액션 노출
  · REQUESTED 상태 행만 SwipeAction 활성

출금 SETTLED/FAILED 본인 알림 자동 발행:
- WithdrawBankSettleService 가 SETTLED/FAILED 전이 시 user_notification INSERT
- 신청자(requestedBy) 에게 RefType=WITHDRAW, refId=requestId 로 발행
- 알림 발행 실패해도 동기화 흐름 비차단 (warn 로그만)
- PWA NoticeList 의 "내 알림" 탭에서 즉시 확인 가능

검증:
- ga-api compileJava + ga-frontend/ga-mobile-pwa tsc PASS
- PUT /api/mobile/me/profile/phone (admin) → 200, GET /api/auth/me 의 phone 변경 확인
- POST /api/mobile/me/withdraw-requests/1/cancel (admin) → E403 (agentId null 보안 강제)
- POST /api/internal/bank-settle → 200 data=0 (이번엔 ACCEPTED 잔여 없음, 알림 발행 경로 코드상 검증)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 23:18:39 +09:00
GA Pro 2c9bf710bc feat: P6 후속 — PWA 출금폼/명세상세/토큰refresh + 펌뱅킹 SETTLED 동기화 + user phone UI
외부 SDK 차단 항목 외 P6 잔여 운영성 작업을 일괄 완료. DB 변경 없음(v85 유지).

PWA:
- 출금 신청 작성 폼 (WithdrawCreate.tsx + POST /api/mobile/me/withdraw-requests + GET /settle-masters)
  · 클라이언트가 보낸 agentId/userId 무시, 토큰에서 강제 결정
- 명세서 상세 + 엑셀 다운로드 (StatementDetail.tsx + GET /api/mobile/me/statements/{id}[/download])
  · 본인 agentId 검증 후 기존 CommissionStatementService.download() 재사용
- 토큰 자동 refresh (request.ts interceptor + authStore.refreshToken)
  · 401 시 단일 refresh 호출로 합치고 broadcast, login/refresh 자체 401 은 무한루프 차단

펌뱅킹 SETTLED 동기화:
- WithdrawBankSettleService + POST /api/internal/bank-settle (BankSettleController)
- Mapper: selectAcceptedTransfers / markSettled / markSettleFailed
- 어댑터 queryStatus 폴링 결과로 SETTLED→COMPLETED, FAILED→APPROVED(재시도풀) 전이
- app.bank-settle.cron 옵션 (기본 비활성)

사용자 phone 등록 UI:
- PUT /api/system/users/{id}/phone — NotBlank 검증 없는 단일 패치 endpoint
- UserList 에 phone 컬럼 + "전화번호" 액션 → Antd Modal 인라인 편집
- 결재 SMS 발신 전제 충족

검증 (9/9 PASS, 라이브):
- ga-common/ga-core/ga-external-adapter/ga-api compileJava BUILD SUCCESSFUL
- ga-frontend / ga-mobile-pwa tsc --noEmit PASS
- POST /api/internal/bank-settle → data=3 (3건 COMPLETED 전이 확인)
- PUT /api/system/users/1/phone → 200, 이후 GET 으로 phone 노출 확인
- 보안 강제: admin(agentId=null)으로 /api/mobile/me/{statements,settle-masters,withdraw-requests} → E403
- POST /api/auth/refresh → 200, 새 accessToken 발급
- 3개 서비스 라이브: ga-api :8082 / ga-frontend :3000 / ga-mobile-pwa :3001

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:53:59 +09:00
GA Pro ee3e0d40c5 feat: P6 모바일 PWA 보안 강화 — /api/mobile/me/* 서버측 본인 필터
스캐폴드의 클라이언트 agentId 쿼리 필터(변조 가능)를
서버측 강제 필터로 교체. 토큰 → user → agentId 자동 추출.

백엔드 (ga-api):
- MobileMeService — 클라이언트 param 의 agentId/userId 는 무시·덮어쓰기.
  agentId 미연결 시 BizException(FORBIDDEN). PageHelper 로 정확한 total 산출.
- MobileMeController — GET /api/mobile/me/{summary,statements,
  withdraw-requests,notifications} + POST /notifications/{id}/read.
  별도 메뉴 권한 없이 인증만 통과 (PWA 사용자 전체 대상).

ga-mobile-pwa:
- api/statement.ts, api/withdraw.ts → /api/mobile/me/* 호출, agentId 제거
- api/notification.ts 신규 — list/markRead/summary
- Home.tsx — summary() 단일 호출로 3카운트 + 안 읽음 Badge
- NoticeList.tsx — Tabs(내 알림 / 공지). 알림 클릭 markRead + summary 무효화
- Statement/WithdrawList — E403(설계사 미연결) 시 ErrorBlock 안내

라이브 검증:
- ga-api 컴파일 BUILD SUCCESSFUL · 재기동 OK
- PWA tsc PASS · vite build PASS (precache 538KB, gzip 176KB)
- admin(agentId=null): /summary → 200 OK 0/0/0,
  /statements → E403 차단 정상, /notifications → 200 OK total=0

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:32:19 +09:00
GA Pro 23fdd8474b feat: P6 모바일 셀프 조회 PWA 스캐폴드 (ga-mobile-pwa)
설계사 본인 정산명세서/출금/공지를 모바일에서 조회하는 PWA 신규 모듈.
데스크탑 admin UI(ga-frontend)와 별도. 백엔드 무변경 — 기존 ga-api(8082)
재사용. 토큰 키도 mobile.accessToken 으로 분리.

스택:
- Vite + React 18 + TS + antd-mobile@5
- vite-plugin-pwa (manifest + service worker autoUpdate)
- React Query + Zustand (persist) + react-router-dom
- 포트 3001, /api → 8082 dev 프록시

화면 5종 (NavBar + List + PullToRefresh + TabBar):
- /login         — POST /api/auth/login + GET /api/auth/me
- /              — 홈 (요약 카드 + 메뉴, 로그아웃)
- /statement     — GET /api/statements?agentId=…  (본인 명세서)
- /withdraw      — GET /api/withdraw-requests?agentId=…  (본인 출금이력)
- /notice        — GET /api/notices/active

PWA:
- manifest: name/icon/theme/standalone, ko, /
- /api/** NetworkFirst 5s 타임아웃 / 5분 캐시 (오프라인 시 직전 응답)
- 아이콘은 SVG placeholder (192/512) — 본 배포 시 PNG 권장

검증:
- npm install 407 packages
- tsc --noEmit PASS
- npm run build PASS — manifest.webmanifest + sw.js + workbox-*.js
  자동 생성, precache 8 entries 528KB, 번들 gzip 173KB
- vite dev :3001 LISTENING + ga-api :8082 정상
- /api/statements total=112, /api/withdraw-requests total=3,
  /api/notices/active 0건, /api/auth/me 정상

잔여 (스캐폴드 이후):
- 백엔드 /api/mobile/me/* 전용 endpoint (서버측 본인 필터 강제)
- 출금 신청 작성 폼, 명세서 상세, 본인 알림(user_notification)
- 토큰 만료 자동 refresh, 푸시 알림 (FCM)
- 실 아이콘 PNG, manualChunks 분리

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:22:18 +09:00
GA Pro 4a7a4eeb7a chore: 검증용 토큰/임시 파일 제거 + .gitignore 보강
이전 커밋(5ee8321)에 검증 중 만든 login.json/token.txt 가 실수로 포함되어
JWT 토큰이 히스토리에 노출됨. 토큰은 이미 expired 되었지만(7200s) 즉시 제거.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:58:05 +09:00
GA Pro 5ee8321ef2 feat: P6 외부연동 호출 감사 이력 (V85)
펌뱅킹/SMS·카카오 어댑터 모든 호출을 external_call_log 테이블에 AOP 자동 영속화.
외부 SDK 통합 후에도 그대로 재사용. 운영 감사·장애 추적·SDK 검증 용도.

- V85: external_call_log 테이블 + 인덱스 4종 (called_at desc / type / target / failures)
       + SYSTEM_EXTERNAL_CALL 메뉴 + READ 권한 (SUPER_ADMIN/ADMIN)
- ga-core: ExternalCallLogVO/Resp/SearchParam + Mapper (insertOne / selectList / selectById)
- ga-api/aop: ExternalCallLoggingAspect — BankTransferAdapter+/MessageAdapter+
  모든 메서드 @Around 영속화. 호출자 트랜잭션과 분리(REQUIRES_NEW),
  request/response 는 PII 마스킹된 JSON 한 줄 요약(계좌·전화 뒤 4자리, 이름 첫글자만),
  영속화 자체가 실패해도 비즈니스 흐름 비차단(warn 로그만), 예외는 그대로 propagate.
- ga-api: ExternalCallLogService + Controller GET /api/external-call-logs[/{id}]
- ga-frontend: ExternalCallLogList.tsx + api/externalCallLog.ts + App.tsx 라우트
  /system/external-call-log (ProTable + Drawer 상세)

라이브 검증:
- Flyway V85 자동 적용 → 운영 DB schema v85
- 5모듈 컴파일 BUILD SUCCESSFUL + ga-frontend tsc --noEmit 통과
- 시나리오: withdraw#3 신규 → 결재 advance ×2 → 펌뱅킹 호출
  external_call_log #1: BANK/MockBankTransferAdapter/requestTransfer/
  success=true/durMs=3/target=WITHDRAW#3/accountNoMasked="****0123"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 22:56:27 +09:00
GA Pro 0e8c563a9a feat: P6 어댑터 PoC 보강 — Resp 노출 / phone 매핑 / 재시도 배치 / SDK 분기
1. WithdrawRequestResp / VO / Mapper.xml / 화면에 transfer_tx_id/transfer_status/transfer_at 노출
2. UserMapper.selectFirstActiveContactByRoleCode — step.roleCode → user.phone 1차 매핑
   - ApprovalService.sendStepNotification 이 실제 결재자 user_id/phone 사용 (기존 0L 하드코딩 해소)
   - phone 미확보 시 SMS skip (DB 알림은 유지)
3. 펌뱅킹 재시도 — WithdrawBankRetryService + POST /api/internal/bank-retry
   - WithdrawRequestMapper.selectFailedTransfers 추가
   - @EnableScheduling + cron 옵션(기본 비활성), app.bank-retry.limit
4. 어댑터 프로파일 분기 — @ConditionalOnProperty
   - app.adapter.bank: mock(기본) | kftc → KftcBankTransferAdapter 스켈레톤
   - app.adapter.message: mock(기본) | toast → ToastMessageAdapter 스켈레톤
   - 활성화 시 UnsupportedOperationException 명시
5. V83(menu_code 오기로 0건 적용) + V84(실제 WITHDRAW 메뉴에 EXECUTE 권한 보강)
2026-05-24 02:45:13 +09:00
GA Pro b04fa79840 feat: P6 외부연동 어댑터 모듈 PoC (펌뱅킹/SMS·카카오)
신규 모듈 ga-external-adapter — 외부 SDK 의존성을 격리.
실제 SDK 통합 시 이 모듈의 구현체만 교체하면 ga-api/ga-common 무변경.

- ga-common.adapter.bank: BankTransferAdapter 인터페이스 + Request/Response/Status
- ga-common.adapter.message: MessageAdapter 인터페이스 + Channel(SMS/KAKAO)/Request/Response
- ga-external-adapter: MockBankTransferAdapter / MockMessageAdapter (로그 + 마스킹)
- V82: withdraw_request.transfer_tx_id/transfer_status/transfer_at + 부분 인덱스
- ApprovalService.handleApprovalCallback(WITHDRAW) — 최종 승인 시 펌뱅킹 어댑터 호출
  - 성공: status APPROVED → SENT 전이, transfer_tx_id 영속화
  - 실패: APPROVED 유지 + fail_reason 기록 (재시도 배치는 추후)
- ApprovalService.sendStepNotification — DB 알림 외 SMS 어댑터 호출 (non-critical)
- WithdrawRequestMapper.updateTransferResult 추가
2026-05-24 02:10:06 +09:00
GA Pro c005ad705a feat: 분급 비율 회차(월) 단위 등록 지원 (V81)
- installment_ratio.plan_month INT NULL 컬럼 추가
  - NULL = 연 단위(연차 합계, 기존 동작)
  - 1~12 = 해당 회차(월) 단위 비율
- UNIQUE 재정의: NULLS NOT DISTINCT (product_category, plan_year, plan_month, effective_from)
- InstallmentPlanGenerator: planMonth=NULL이면 1, NOT NULL이면 그대로 사용해 settle_month 계산
- 분급비율 화면: 회차(월) 컬럼·입력 추가, "(연차-1)*12+월" 통합 회차 표기
2026-05-24 01:27:47 +09:00
GA Pro 3f52596a8b fix: 전체 메뉴 점검 — 회계분개/KPI 매퍼 컬럼 오류 + 결재 mine/pending 엔드포인트
전체 화면 API 점검(85종) 중 발견한 오류 수정:

- ContractAccountingEntryMapper: ac.contract_no 미존재 -> ac.policy_no AS contract_no
  (contract 테이블 컬럼은 policy_no. 데이터 적재 후 표면화된 버그)
- KpiMapper: mv_*.refreshed_at 가 now() 기반 timestamptz 인데 VO는 LocalDateTime
  -> refreshed_at::timestamp 캐스팅 (monthly/org/cumulative/retention 4종)
- ApprovalController/Service: /requests/mine, /requests/pending 엔드포인트 부재로
  /requests/{requestId} 에 매칭돼 400 -> 두 엔드포인트 신규 추가

검증: 전체 엔드포인트 ERROR 0건. 정산 배치 데이터 + KPI MV 새로고침으로
KPI 대시보드 4화면(월별/조직별/누적/유지율) 정상 표시.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:41:26 +09:00
GA Pro c2a92d13cb fix: 정산 배치 파이프라인 정합 — 매퍼 NULL 파라미터 + override_settle 제약 + 정산지급 데이터
정산/지급 화면이 전부 비어있던 원인: 운영 DB에서 정산 배치가 실행된 적 없음.
MONTHLY_SETTLEMENT 배치를 돌리는 과정에서 발견한 버그 수정 + 데이터 구성.

매퍼 버그 (PG: could not determine data type of parameter):
- RegulatoryLimitMapper/ChargebackGradeMapper/InstallmentRatioMapper:
  #{productCategory} 가 IS NULL 비교에 쓰여 타입 추론 실패 -> jdbcType=VARCHAR 명시
- ForecastKpiMonthlyMapper: #{targetOrgId} 동일 -> jdbcType=BIGINT 명시

스키마 (V79):
- override_settle.settle_id NOT NULL 완화 — calcOverride(Step7)는 settle_master
  생성(Step8) 전 단계라 settle_id 를 채울 수 없음

데이터 (V80):
- 배치 실행 결과: settle_master 150 / 원장 433 / 명세서 112 / 분개 433 (2026-02~04)
- payment 는 settle_master 파생 생성, 운영성 도메인(공제/마감/세금계산서/이의신청/
  시책/정정)은 대표 샘플 시드 (멱등 NOT EXISTS)

검증: 배치 12스텝 COMPLETED, 정산/지급 10개 엔드포인트 200 OK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:48:08 +09:00
GA Pro 6b44be2101 fix: 실행.bat — gradlew 경로를 .\ 명시로 수정 (실행 검증 완료)
원인: NoDefaultCurrentDirectoryInExePath 환경에서 cmd가 현재 폴더의
gradlew.bat 을 못 찾음. 백엔드 기동 라인을 'cd /d %~dp0 && .\gradlew.bat'
으로 수정. 한글/chcp 제거(UTF-8 배치 파서 깨짐 회피)해 순수 ASCII로 작성.

검증: 실행.bat 더블클릭(Start-Process) -> 백엔드(8082)+프론트(3000) 정상 기동,
localhost:3000 화면 200, 프록시 로그인 성공, /api/year-end-statements 정상.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:10:23 +09:00
GA Pro dd3c46a7c9 docs: 프로젝트 한 번에 실행하는 방법 (실행.bat + 실행방법.md)
비개발자도 더블클릭 한 번으로 백엔드(ga-api 8082)+프론트(ga-frontend 3000)를
동시에 띄우도록 실행.bat 추가. 실행/접속/종료/문제해결을 실행방법.md에 정리.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:36:13 +09:00
GA Pro 49f08f001d feat: P5-W3 본론 완료 — 회계결산/이상치예측/연말명세서 (V74~V78)
P5-W2 원천세·부가세 분기신고 미커밋분 + P5-W3 메뉴/권한 보강(V74) + P5-W3 본론(V75~V78)을 일괄 정리.

- V75 accounting_close / account_balance_snapshot (회계 결산)
- V76 retention_anomaly_alert / forecast_kpi_monthly (이상치·예측 KPI)
- V77 year_end_statement (연말 지급명세서)
- V78 P5-W3 본론 메뉴 4 + 권한 + 공통코드 7그룹 + 테스트데이터
- api: AccountingClose/YearEndStatement/ForecastKpi/RetentionAnomaly Service+Controller
- batch: BatchConfig Step 11/12 (원천세·부가세 분기) 연결
- frontend: 4화면(AccountingClose/YearEndStatement/RetentionAnomaly/ForecastKpi) + API 모듈 4 + 라우트
- frontend: usePermission/PermissionButton EXECUTE 권한 누락 보강
- fix: YearEndStatementMapper.xml 미존재 컬럼 a.agent_code 참조 제거

통합검증: Flyway V74~V78 운영DB 적용(v78), 4모듈 compileJava + 프론트 build PASS,
스모크 GET 8/8 + 쓰기 4/4 PASS, verify_v74.py 20/20 PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:22:34 +09:00
GA Pro d5ed642c80 동기화 2026-05-15 00:25:18 +09:00
ysukkyu 88e14edffd 1 2026-05-14 18:14:49 +09:00
GA Pro c0105c6847 동기화 2026-05-14 01:22:47 +09:00
GA Pro 958fe6b980 동기화 2026-05-14 01:21:27 +09:00
1225 changed files with 79989 additions and 1039 deletions
+4 -1
View File
@@ -3,5 +3,8 @@
"env": { "env": {
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
}, },
"teammateMode": "auto" "teammateMode": "auto",
"worktree": {
"bgIsolation": "none"
}
} }
+4
View File
@@ -45,3 +45,7 @@ target/
# vite/tsc 빌드 산출물 # vite/tsc 빌드 산출물
ga-frontend/tsconfig.tsbuildinfo ga-frontend/tsconfig.tsbuildinfo
ga-frontend/dist/ ga-frontend/dist/
login.json
token.txt
__pycache__/
.omc/
+264
View File
@@ -0,0 +1,264 @@
# P5-W3 전수 감사 입력 스펙 (PL 작성, 2026-05-17)
목적: 보험 수수료 시스템의 **메뉴/권한/라우트 정합성**을 전수 감사하고 누락분을 전부 보강한다.
PL이 소스에서 직접 추출한 신뢰 가능한 입력 2종을 아래 둔다. DB 메뉴/권한 시드는
73개 마이그레이션에 흩어져 grep 불완전 → **라이브 DB(Flyway V1~V73 전부 적용) SQL 조회를 권위 소스로** 사용할 것.
## 1. 프론트 App.tsx 실제 라우트 (스크린 존재 위치 = 정본)
메뉴 menu_path 는 이 경로와 **정확히 일치**해야 한다. 불일치 시 menu_path/component_path 를 이 값으로 UPDATE.
```
org/tree org/agents companies products contracts
rules/commission rules/payout rules/override rules/chargeback rules/exceptions
rules/regulatory rules/installment-ratios rules/chargeback-grades
receive/data receive/mapping
ledger/recruit ledger/maintain ledger/exception
settle settle/batch settle/deductions settle/period-close settle/corrections
settle/tax-invoices settle/disputes settle/incentives
payments
report/performance report/org report/chargeback-risk
kpi/monthly kpi/org kpi/retention kpi/cumulative
commission/annual-income commission/accounting-journal commission/withholding-tax commission/vat-report
commission/type commission/master commission/collection commission/renewal
commission/reinstatement commission/persistency commission/override-ledger
agent/contracts agent/licenses agent/trainings
contracts/endorsements contracts/complaints
system/approvals system/notices payments/withdraw
system/users system/roles system/menus system/codes system/config system/logs
system/upload-templates system/data-domains system/data-dictionary
```
알려진 불일치(라우트 ≠ 현재 DB menu_path):
- commission/reinstatement ↔ DB /commission/reinstate
- commission/persistency ↔ DB /commission/persist
- commission/override-ledger↔ DB /commission/override
- agent/contracts ↔ DB /org/agent-contract
- agent/licenses ↔ DB /org/agent-license
- agent/trainings ↔ DB /org/agent-training
- system/approvals ↔ DB /system/approval
- system/notices ↔ DB /system/notice
- payments/withdraw ↔ DB /system/withdraw
- contracts/endorsements ↔ DB 메뉴 없음(추정) — 신규 필요
- contracts/complaints ↔ DB 메뉴 없음(추정) — 신규 필요
## 2. 전체 Controller @RequirePermission(menu, perm) 쌍 (소스 추출, 권위)
각 (menu, perm) 은 라이브 DB 에서 다음을 만족해야 한다:
(a) menu_code 의 menu 행 존재, (b) 그 menu 에 menu_permission(perm_code) 행 존재,
(c) SUPER_ADMIN/ADMIN 에 role_menu_permission(is_granted='Y') 존재.
하나라도 없으면 해당 엔드포인트 403 → 보강 대상.
특히 action 계열 perm 이 미시드일 가능성 큼(P5-W1 EXECUTE→V72, P5-W2 EXECUTE→V73 동일 클래스 버그):
- EXECUTE: ANNUAL_INCOME(V72 보정완료), WITHHOLDING_TAX/VAT_REPORT(V73 보정완료)
- APPROVE: APPROVAL, BATCH_RUN, DISPUTE, INCENTIVE_PAY, LEDGER_EXCEPTION, SETTLE_CORRECTION, SETTLE_LIST, TERM_SETTLE ← 전수 확인 필수
전체 쌍:
```
ACCOUNTING_JOURNAL: CREATE READ UPDATE
AGENT_CONTRACT: CREATE READ UPDATE DELETE
AGENT_LICENSE: CREATE READ UPDATE DELETE
AGENT_TRAINING: CREATE READ UPDATE DELETE
ANNUAL_INCOME: READ EXECUTE
APPROVAL: READ CREATE UPDATE APPROVE
ATTACHMENT: READ CREATE DELETE
BATCH_RUN: READ APPROVE
CHARGEBACK_GRADE: READ CREATE UPDATE
COLLECT_COMM: READ CREATE UPDATE DELETE
COMM_MASTER: READ UPDATE DELETE
COMPANY: READ CREATE UPDATE
COMPLAINT: READ CREATE UPDATE DELETE
CONTRACT_LIST: READ CREATE UPDATE
DEDUCTION: READ CREATE UPDATE
DISPUTE: READ CREATE UPDATE APPROVE
ENDORSE: READ CREATE UPDATE DELETE
INCENTIVE_PAY: READ CREATE UPDATE APPROVE
INCENTIVE_PROG: READ CREATE UPDATE
INSTALLMENT_PLAN: READ CREATE UPDATE
INSTALLMENT_RATIO: READ CREATE
KPI_CUMULATIVE: READ
KPI_MONTHLY: READ
KPI_ORG: READ
KPI_RETENTION: READ
LEDGER_EXCEPTION: READ CREATE APPROVE
LEDGER_MAINTAIN: READ EXPORT
LEDGER_RECRUIT: READ EXPORT
NOTICE: READ CREATE UPDATE DELETE
ORG_AGENT: READ CREATE UPDATE EXPORT
ORG_TREE: READ CREATE UPDATE DELETE
OVERRIDE_LEDGER: READ
PAYMENT: READ UPDATE EXPORT
PERIOD_CLOSE: READ CREATE UPDATE
PERSIST_BONUS: READ CREATE UPDATE DELETE
PRODUCT: READ CREATE UPDATE DELETE
RECEIVE_DATA: READ UPDATE
RECEIVE_MAPPING: READ CREATE UPDATE DELETE
RECON_OUT: READ CREATE UPDATE
REGULATORY_LIMIT: READ CREATE UPDATE
REINSTATE_COMM: READ CREATE UPDATE DELETE
RENEWAL_COMM: READ CREATE UPDATE DELETE
RULE_CHARGEBACK: READ CREATE UPDATE DELETE
RULE_COMMISSION: READ CREATE UPDATE DELETE
RULE_EXCEPTION: READ CREATE UPDATE
RULE_OVERRIDE: READ CREATE UPDATE DELETE
RULE_PAYOUT: READ CREATE UPDATE DELETE
SETTLE_CORRECTION: READ CREATE UPDATE APPROVE
SETTLE_LIST: READ APPROVE
STATEMENT: READ CREATE UPDATE
SYSTEM_DATA_DICT: READ CREATE UPDATE DELETE
SYSTEM_DATA_DOMAIN: READ CREATE UPDATE DELETE
SYSTEM_ROLE: READ CREATE UPDATE DELETE
SYSTEM_UPLOAD_TEMPLATE: READ CREATE UPDATE DELETE
SYSTEM_USER: READ CREATE UPDATE
TAX_INVOICE: READ CREATE UPDATE
TERM_SETTLE: READ CREATE UPDATE APPROVE
VAT_REPORT: READ EXECUTE
WITHDRAW: READ CREATE UPDATE
WITHHOLDING_TAX: READ EXECUTE
```
## 3. 산출물 요구
- 라이브 DB 대조 후 **갭 리포트**(없는 menu / 없는 perm / 없는 role grant / menu_path 불일치) 를 이 파일 §4 에 append
- 보강 마이그레이션 **V74__메뉴_권한_전수보강.sql** 1개로 통합(멱등: NOT EXISTS / ON CONFLICT). menu_path/component_path 불일치는 UPDATE 로 App.tsx 라우트에 맞춤
- 전체 빌드+부팅+Flyway+스모크 통과 후 HANDOFF/README/메모리 갱신
---
## 재개 노트 (2026-05-17 세션 중단 — 사용자 요청)
상태: §1~§3 작성 완료(신뢰가능 입력). **§4 갭리포트 미작성**, **V74 미생성**, 운영 DB schema V73, ga-api 8082 가동중이었음.
내일 재개 순서:
1. 죽은 팀 잔재 제거 후 `TeamCreate(ga-pro-team, pl)` 재생성
2. ga-api 8082 생존 확인(netstat), 죽었으면 재기동
3. Task #8(infra): 본 파일 §1·§2 ↔ 라이브 DB(8082) SQL 대조 → §4 "## 4. 갭 리포트" append
4. Task #9(dba): §4 기반 `V74__메뉴_권한_전수보강.sql` 멱등 작성(V72/V73 NOT EXISTS 패턴, menu_path는 §1 라우트로 UPDATE)
5. Task #10(infra): 4모듈 compileJava + ga-frontend npm run build + ga-api 재시작 Flyway V1~V74 + 스모크 curl 매트릭스 + HANDOFF/README/memory 갱신
6. PL 자율 전권, 승인 안 묻고 끝까지, 최종 결과만 사용자 보고
명백 갭(이미 식별, §4에서 DB 확인 후 V74 반영): 라우트↔menu_path 불일치 9건 + 메뉴 부재 2건(ENDORSE/COMPLAINT) + APPROVE/EXECUTE perm 미시드 의심 8군.
---
## 4. 갭 리포트 (2026-05-17 분석)
> 분석 방법: 운영 DB 192.168.0.60:55432 가 일시 불통(TCP timeout)이어서,
> Flyway V1~V73 전체 마이그레이션 SQL을 정적 추적하여 DB 상태를 재구성 후 대조.
> V73 적용 완료 기준. 직전 세션 통합검증(HANDOFF §3-7/3-8)에서 DB가 정상이었음을 확인한 상태.
### 4-1. GAP (a) — menu 행 자체 없음
**해당 없음 (0건)** — §2의 모든 menu_code가 DB에 존재함.
ENDORSE/COMPLAINT는 V54에서 삽입됨. 신규 메뉴 필요 없음.
### 4-2. GAP (b) — menu_permission 행 없음
**3건**:
| menu_code | perm_code | 원인 | V74 조치 |
|---|---|---|---|
| NOTICE | DELETE | V54에서 APPROVAL/NOTICE/WITHDRAW 에 READ/CREATE/UPDATE/APPROVE만 등록. DELETE 누락. Controller 에는 DELETE 있음. | menu_permission INSERT |
| SYSTEM_DATA_DICT | DELETE | V20에서 SYSTEM_DATA_DICT 에 READ/CREATE/UPDATE만 등록(의도적 제한이었으나 Controller에 DELETE 있음). | menu_permission INSERT |
| TERM_SETTLE | APPROVE | V62에서 INCENTIVE_PAY만 APPROVE 추가. TERM_SETTLE READ/CREATE/UPDATE만. Controller 에 APPROVE 있음. | menu_permission INSERT |
### 4-3. GAP (c) — role_menu_permission 없음 또는 is_granted!='Y'
**13건**:
| role_code | menu_code | perm_code | 원인 | V74 조치 |
|---|---|---|---|---|
| SUPER_ADMIN | NOTICE | DELETE | (b) perm 자체 없어 grant도 없음 | perm INSERT 후 grant INSERT |
| ADMIN | NOTICE | DELETE | 동일 | 동일 |
| SUPER_ADMIN | SYSTEM_DATA_DICT | DELETE | (b) perm 자체 없어 grant도 없음 | perm INSERT 후 grant INSERT |
| ADMIN | SYSTEM_DATA_DICT | DELETE | 동일 | 동일 |
| ADMIN | SYSTEM_ROLE | READ | V16에서 ADMIN은 parent=SYSTEM 메뉴 제외. SYSTEM_ROLE 은 GRP_SYSTEM 하위 → ADMIN 무권한. | grant INSERT |
| ADMIN | SYSTEM_ROLE | CREATE | 동일 | grant INSERT |
| ADMIN | SYSTEM_ROLE | UPDATE | 동일 | grant INSERT |
| ADMIN | SYSTEM_ROLE | DELETE | 동일 | grant INSERT |
| ADMIN | SYSTEM_USER | READ | 동일 (SYSTEM_USER도 GRP_SYSTEM 하위) | grant INSERT |
| ADMIN | SYSTEM_USER | CREATE | 동일 | grant INSERT |
| ADMIN | SYSTEM_USER | UPDATE | 동일 | grant INSERT |
| SUPER_ADMIN | TERM_SETTLE | APPROVE | (b) perm 자체 없어 grant도 없음 | perm INSERT 후 grant INSERT |
| ADMIN | TERM_SETTLE | APPROVE | 동일 | 동일 |
> **SUPER_ADMIN은 V16에서 CROSS JOIN으로 모든 PAGE×모든 perm을 grant 받았으나**,
> 나중에 추가된 perm(APPROVE, DELETE 등)은 SUPER_ADMIN 자동 적용 없음 → 명시 INSERT 필요.
### 4-4. GAP (route) — App.tsx route vs DB menu_path 불일치
**11건**:
| App.tsx route | DB menu_code | 현재 DB menu_path | 목표값 (V74 UPDATE) | 비고 |
|---|---|---|---|---|
| commission/reinstatement | REINSTATE_COMM | /commission/reinstate | /commission/reinstatement | UPDATE 필요 |
| commission/persistency | PERSIST_BONUS | /commission/persist | /commission/persistency | UPDATE 필요 |
| commission/override-ledger | OVERRIDE_LEDGER | /commission/override | /commission/override-ledger | UPDATE 필요 |
| agent/contracts | AGENT_CONTRACT | /org/agent-contract | /agent/contracts | UPDATE 필요 |
| agent/licenses | AGENT_LICENSE | /org/agent-license | /agent/licenses | UPDATE 필요 |
| agent/trainings | AGENT_TRAINING | /org/agent-training | /agent/trainings | UPDATE 필요 |
| contracts/endorsements | ENDORSE | /contract/endorsement | /contracts/endorsements | UPDATE 필요 |
| contracts/complaints | COMPLAINT | /contract/complaint | /contracts/complaints | UPDATE 필요 |
| system/approvals | APPROVAL | /system/approval | /system/approvals | UPDATE 필요 |
| system/notices | NOTICE | /system/notice | /system/notices | UPDATE 필요 |
| payments/withdraw | WITHDRAW | /system/withdraw | /payments/withdraw | UPDATE 필요 |
> component_path도 동일하게 App.tsx 라우트 기반으로 UPDATE 필요.
> (V54에서 등록된 경로가 App.tsx 경로와 다르게 설계됨)
### 4-5. 요약
| 갭 유형 | 건수 |
|---|---|
| (a) menu 행 없음 | 0 |
| (b) menu_permission 없음 | 3 |
| (c) role_menu_permission 없음 | 13 |
| route/menu_path 불일치 | 11 |
| **합계** | **27** |
V74 필요 SQL 작업:
- `menu_permission` INSERT (NOT EXISTS 조건): 3건
- `role_menu_permission` INSERT (ON CONFLICT DO NOTHING): 6건 (ADMIN/SYSTEM_* 7건은 의도적 제외 — 보안 경계)
- `menu` UPDATE menu_path/component_path: 11건
---
## 5. V74 상태 및 DB 복귀 시 재검증 절차 (2026-05-17)
### 5-1. 현재 상태
| 항목 | 상태 | 비고 |
|---|---|---|
| V74 SQL 작성 | 완료 | `ga-common/src/main/resources/db/migration/V74__메뉴_권한_전수보강.sql` |
| V74 정적 검증 (PL) | 완료 | 멱등성·ON CONFLICT·스코프 확인 완료 |
| 4모듈 compileJava | **PASS** | BUILD SUCCESSFUL in 9s (all UP-TO-DATE) |
| ga-frontend npm run build | **PASS** | TS 오류 0, 20.15s, 16888 모듈 변환 성공 |
| 운영 DB 접속 | **DEFERRED** | 192.168.0.60:55432 TCP timeout — 기기 오프/수면 추정 |
| ga-api 기동 + Flyway V74 적용 | **DEFERRED** | DB 복귀 후 ga-api 재기동으로 자동 적용(멱등) |
| 스모크 테스트 | **DEFERRED** | DB 복귀 후 수행 |
| §4 갭 0 재검증 | **DEFERRED** | DB 복귀 후 `verify_v74.py` 실행 |
### 5-2. DB 복귀 시 실행 절차
```
# 1. DB 접속 확인
python verify_v74.py # DB_UP 확인 후 Flyway 버전 출력
# 2. ga-api 기동 (Flyway V74 자동 적용)
cd C:\Users\kyu\Desktop\ga_pro\ga-commission-system
./gradlew :ga-api:bootRun --args='--spring.profiles.active=trading_ai' &
# 부팅 로그에서 "Flyway V74" 적용 성공 확인
# 3. 갭 재검증 (V74 적용 후)
python verify_v74.py
# 예상 결과: 20/20 PASS
# 4. 스모크 curl 매트릭스
curl -s -u admin:admin1234! http://localhost:8082/api/withholding-tax-reports | python -m json.tool
curl -s -u admin:admin1234! http://localhost:8082/api/vat-reports | python -m json.tool
curl -s -u admin:admin1234! http://localhost:8082/api/agent-annual-incomes | python -m json.tool
curl -s -u admin:admin1234! http://localhost:8082/api/accounting-entries | python -m json.tool
```
재검증 스크립트: `C:\Users\kyu\Desktop\ga_pro\ga-commission-system\verify_v74.py`
V74는 멱등(NOT EXISTS / ON CONFLICT DO NOTHING / WHERE menu_path != target)이므로 ga-api 재기동 시 안전하게 자동 적용됨.
+505
View File
@@ -0,0 +1,505 @@
# GA 수수료 정산 시스템 — 개발자 가이드
신입 개발자가 **5분 안에 첫 PR**을 보낼 수 있도록 작성한 온보딩 문서.
README.md 의 "실행 가이드"를 먼저 읽고 로컬 환경을 기동한 다음 이 문서로 넘어올 것.
---
## 1. 환경 설정
### 필수 설치
| 도구 | 버전 | 확인 명령 |
|------|------|-----------|
| JDK | 17 이상 | `java -version` |
| Node.js | 18 이상 | `node -v` |
| Docker Desktop | 최신 | `docker version` |
| IntelliJ IDEA | 2023+ 권장 | — |
### 선택: 로컬 DB/Redis (Docker 권장)
```bash
# Docker 로 PostgreSQL + Redis 한 번에 띄우기
docker compose up -d postgres redis
```
운영 DB(`192.168.0.60:55432/trading_ai/ga`)에 바로 붙을 경우 Docker 불필요.
### 백엔드 실행
```bash
# 프로젝트 루트에서
./gradlew :ga-api:bootRun --args='--spring.profiles.active=trading_ai'
# 로컬 DB 사용 시 (기본 프로파일)
./gradlew :ga-api:bootRun
```
### 프론트엔드 실행
```bash
cd ga-frontend
npm install
npm run dev # http://localhost:3000
```
`/api/*` 요청은 Vite 프록시로 `http://localhost:8080`에 자동 전달됩니다.
---
## 2. 프로젝트 구조
```
ga-commission-system/
├── ga-common/ # 공통 프레임워크
│ ├── annotation/ # @RequirePermission, @DataChangeLog, @ExcelColumn
│ ├── aop/ # PermissionAspect, DataChangeLogAspect, ApiLogAspect
│ ├── auth/ # JWT 필터/프로바이더
│ ├── code/ # 공통코드 서비스 (Redis 캐시)
│ ├── excel/ # SXSSF 다운로드 / SAX 업로드
│ └── db/migration/ # V1~V55 Flyway SQL (모든 마이그레이션은 여기)
├── ga-core/ # 도메인 코어 — VO, Mapper, XML
│ ├── vo/ # {도메인}VO + {도메인}Resp + {도메인}SaveReq + {도메인}SearchParam
│ ├── mapper/ # @Mapper 인터페이스 (패키지는 도메인별)
│ └── (resources)/mapper/ # MyBatis XML
├── ga-api/ # REST API — Service + Controller
│ ├── auth/ # 로그인/토큰 갱신
│ ├── controller/ # 도메인별 Controller
│ └── service/ # 도메인별 Service
├── ga-batch/ # Spring Batch — 월 정산 잡 (8 Step)
└── ga-frontend/ # React 18 + Vite + TypeScript
├── src/
│ ├── components/common/ # SearchForm, DataGrid, PermissionButton, ExcelExportButton, CodeSelect, CodeBadge
│ ├── components/biz/ # ApprovalProgress, AttachmentUpload, NotificationBell
│ ├── pages/ # 도메인별 페이지 (50+)
│ ├── hooks/ # usePermission, useCommonCodes
│ └── theme/tokens.ts # 디자인 토큰 (GRAY, RADIUS, SHADOW, COLOR_*)
└── nginx.conf # SPA + /api 리버스 프록시
```
---
## 3. 한 도메인 추가 — 표준 작업 흐름 (5분 컷)
아래 순서로 작성하면 메뉴 등록부터 화면까지 일관됩니다.
### Step 1. DB 마이그레이션 (dba)
`ga-common/src/main/resources/db/migration/V{N+1}__{도메인명}.sql` 생성.
```sql
-- 예: V56__회계전표.sql
CREATE TABLE IF NOT EXISTS ga.accounting_entry (
id BIGSERIAL PRIMARY KEY,
settle_month CHAR(6) NOT NULL,
entry_type VARCHAR(20) NOT NULL, -- DEBIT / CREDIT
amount NUMERIC(18,2) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 메뉴 등록 (menu 테이블)
INSERT INTO ga.menu (menu_code, parent_code, menu_name, url, sort_order, is_use)
VALUES ('ACCOUNTING_ENTRY', 'ACCOUNTING', '회계 전표', '/accounting/entries', 10, true)
ON CONFLICT (menu_code) DO NOTHING;
```
번호 규칙: 기존 최대 번호 + 1. 현재 마지막은 V55.
### Step 2. VO 작성 (core)
`ga-core/src/main/java/com/ga/core/vo/{도메인}/` 아래 4종 생성.
```java
// 검색 파라미터
@Data
public class AccountingEntrySearchParam extends SearchParam {
private String settleMonth;
private String entryType;
}
// 응답
@Data
public class AccountingEntryResp {
private Long id;
private String settleMonth;
private String entryType;
@JsonFormat(shape = JsonFormat.Shape.STRING) private BigDecimal amount;
private LocalDateTime createdAt;
}
// 저장 요청
@Data
public class AccountingEntrySaveReq {
@NotBlank private String settleMonth;
@NotBlank private String entryType;
@NotNull private BigDecimal amount;
}
// Enum (공통코드 기반 처리는 String으로, 타입 안전성이 필요할 때만 Enum 사용)
public enum EntryType {
DEBIT, CREDIT;
@JsonValue public String value() { return name(); }
}
```
### Step 3. Mapper Interface + XML (core)
```java
// ga-core/.../mapper/accounting/AccountingEntryMapper.java
@Mapper
public interface AccountingEntryMapper {
List<AccountingEntryResp> selectList(AccountingEntrySearchParam p);
AccountingEntryResp selectById(Long id);
int insert(AccountingEntrySaveReq req);
int update(AccountingEntrySaveReq req);
int delete(Long id);
}
```
XML은 `ga-core/src/main/resources/mapper/accounting/AccountingEntryMapper.xml`:
```xml
<mapper namespace="com.ga.core.mapper.accounting.AccountingEntryMapper">
<select id="selectList" resultType="com.ga.core.vo.accounting.AccountingEntryResp">
SELECT id, settle_month, entry_type, amount, created_at
FROM ga.accounting_entry
<where>
<if test="settleMonth != null">AND settle_month = #{settleMonth}</if>
<if test="entryType != null">AND entry_type = #{entryType}</if>
</where>
ORDER BY id DESC
</select>
</mapper>
```
### Step 4. Service (api)
```java
// ga-api/.../service/accounting/AccountingEntryService.java
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AccountingEntryService {
private final AccountingEntryMapper mapper;
public PageResponse<AccountingEntryResp> list(AccountingEntrySearchParam p) {
p.startPage(); // PageHelper 트리거
return PageResponse.of(mapper.selectList(p));
}
@Transactional
@DataChangeLog(target = "accounting_entry", action = "INSERT")
public void create(AccountingEntrySaveReq req) {
mapper.insert(req);
}
}
```
### Step 5. Controller (api)
```java
// ga-api/.../controller/accounting/AccountingEntryController.java
@RestController
@RequestMapping("/api/accounting-entries")
@RequiredArgsConstructor
public class AccountingEntryController {
private final AccountingEntryService service;
@GetMapping
@RequirePermission(menu = "ACCOUNTING_ENTRY", perm = "READ")
public ApiResponse<PageResponse<AccountingEntryResp>> list(AccountingEntrySearchParam p) {
return ApiResponse.ok(service.list(p));
}
@PostMapping
@RequirePermission(menu = "ACCOUNTING_ENTRY", perm = "CREATE")
public ApiResponse<Void> create(@RequestBody @Valid AccountingEntrySaveReq req) {
service.create(req);
return ApiResponse.ok();
}
}
```
URL 규칙: `/api/{리소스s}` — 복수형 + kebab-case. 예) `/api/accounting-entries`, `/api/agent-licenses`.
### Step 6. React 페이지 (frontend)
`ga-frontend/src/pages/accounting/AccountingEntries.tsx`:
```tsx
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import PageContainer from '@/components/common/PageContainer';
import SearchForm, { SearchCondition } from '@/components/common/SearchForm';
import DataGrid from '@/components/common/DataGrid';
import ExcelExportButton from '@/components/common/ExcelExportButton';
import PermissionButton from '@/components/common/PermissionButton';
import type { ColDef } from '@ag-grid-community/core';
interface Row { id: number; settleMonth: string; entryType: string; amount: string; }
const CONDITIONS: SearchCondition[] = [
{ type: 'month', name: 'settleMonth', label: '정산월' },
{ type: 'code', name: 'entryType', label: '유형', groupCode: 'ENTRY_TYPE' },
];
const COLUMNS: ColDef<Row>[] = [
{ field: 'settleMonth', headerName: '정산월', width: 120 },
{ field: 'entryType', headerName: '유형', width: 100 },
{ field: 'amount', headerName: '금액', width: 150, type: 'numericColumn' },
];
export default function AccountingEntries() {
const [params, setParams] = useState<Record<string, unknown>>({});
const { data, isLoading } = useQuery({
queryKey: ['accounting-entries', params],
queryFn: () => axios.get('/api/accounting-entries', { params }).then(r => r.data.data),
});
return (
<PageContainer title="회계 전표">
<SearchForm conditions={CONDITIONS} onSearch={setParams} />
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8, gap: 8 }}>
<ExcelExportButton url="/api/accounting-entries/excel" params={params} />
<PermissionButton menuCode="ACCOUNTING_ENTRY" permCode="CREATE" type="primary">
</PermissionButton>
</div>
<DataGrid rows={data?.list} columns={COLUMNS} loading={isLoading} />
</PageContainer>
);
}
```
라우터 등록은 `App.tsx``<Routes>` 블록에 `<Route path="/accounting/entries" element={<AccountingEntries />} />` 한 줄 추가.
---
## 4. 자주 쓰는 패턴
### CodeSelect — 공통코드 드롭다운
```tsx
import CodeSelect from '@/components/common/CodeSelect';
// groupCode 는 V9/V12/V55 에 등록된 공통코드 그룹 코드
<CodeSelect groupCode="SETTLE_STATUS" value={status} onChange={setStatus} />
```
### ExcelExportButton — 엑셀 다운로드
```tsx
<ExcelExportButton
url="/api/recruit-ledger/excel"
params={params} // 현재 검색 조건 그대로 전달
label="엑셀 다운로드"
/>
```
백엔드 엔드포인트는 `ExcelService.download(mapper::selectList, columns, response)` 패턴으로 구현.
### CodeBadge — 상태 배지
```tsx
import CodeBadge from '@/components/common/CodeBadge';
// status 값에 해당하는 공통코드 label + 색상 자동 매핑
<CodeBadge groupCode="DISPUTE_STATUS" value={row.status} />
```
### PermissionButton — 권한 게이트
```tsx
<PermissionButton menuCode="PERIOD_CLOSE" permCode="APPROVE">
</PermissionButton>
```
권한이 없으면 렌더링 자체를 하지 않습니다 (`return null`).
### AG Grid 서버사이드 페이징
`DataGrid` 컴포넌트는 `ClientSideRowModelModule`을 사용하고 페이징은 API에서 PageHelper 결과를 받아 컴포넌트 외부에서 처리합니다. 100만건 이상의 대용량 화면에서는 `ServerSideRowModelModule`로 교체하세요.
```tsx
// 대용량 — AG Grid ServerSide 직접 사용
const datasource = {
getRows: (params) => {
axios.get('/api/recruit-ledger', { params: { page: params.request.startRow / 100 + 1, size: 100 } })
.then(r => params.successCallback(r.data.data.list, r.data.data.total));
},
};
```
---
## 5. 디자인 토큰 사용법
모든 색상·간격·그림자는 `@/theme/tokens.ts` 에서 임포트합니다. Inline style 에 직접 색상 코드를 쓰지 마세요.
```tsx
import { GRAY, RADIUS, SHADOW, COLOR_PRIMARY, COLOR_ERROR } from '@/theme/tokens';
// 카드 스타일 예시
<div style={{
background: '#fff',
borderRadius: RADIUS.lg, // 16px
boxShadow: SHADOW.md,
border: `1px solid ${GRAY[200]}`,
padding: 24,
}}>
// 경고 텍스트
<span style={{ color: COLOR_ERROR }}> </span>
// 비활성 텍스트
<span style={{ color: GRAY[500] }}> </span>
```
---
## 6. 결재 워크플로우 패턴
결재가 필요한 도메인(withdraw_request, commission_dispute, settle_master 확정, incentive_payment 등)은 `approval_request` 테이블을 통해 처리합니다.
```java
// target_type 은 공통코드 APPROVAL_TARGET 그룹에 등록
// withdraw_request 결재 신청 예시 (Service)
ApprovalRequestSaveReq req = new ApprovalRequestSaveReq();
req.setTargetType("WITHDRAW"); // WITHDRAW / SETTLE / DISPUTE / INCENTIVE
req.setTargetId(withdrawId);
req.setApprovalLineCode("BASIC_3"); // approval_line.line_code
approvalRequestMapper.insert(req);
```
프론트에서는 `ApprovalProgress` 컴포넌트로 현재 결재 단계를 시각화합니다.
```tsx
import ApprovalProgress from '@/components/biz/ApprovalProgress';
<ApprovalProgress targetType="WITHDRAW" targetId={withdrawId} />
```
---
## 7. @RequirePermission / @DataChangeLog / @JsonValue Enum
### @RequirePermission
```java
@GetMapping("/export")
@RequirePermission(menu = "RECRUIT_LEDGER", perm = "EXPORT")
public void exportExcel(HttpServletResponse response) { ... }
```
menu 값은 `ga.menu.menu_code`와 일치해야 합니다. 불일치하면 403.
### @DataChangeLog
```java
@Transactional
@DataChangeLog(target = "agent", action = "UPDATE")
public void update(Long id, AgentSaveReq req) {
mapper.update(id, req);
}
// → data_change_log 에 before/after JSON 자동 저장 (AOP)
```
action 값: INSERT / UPDATE / DELETE / APPROVE / REJECT.
### @JsonValue Enum
```java
public enum CommissionType {
RECRUIT("모집"), MAINTAIN("유지"), COLLECTION("수금"),
OVERRIDE("오버라이드"), RENEWAL("갱신"), REINSTATE("부활"),
PERSIST("유지보수수당"), INCENTIVE("시책"), COMMISSION("위촉");
private final String label;
CommissionType(String label) { this.label = label; }
@JsonValue // JSON 직렬화 시 name() 반환
public String value() { return name(); }
public String label() { return label; }
}
```
---
## 8. AG Grid vs Ant Design Table 사용 기준
| 상황 | 컴포넌트 |
|------|----------|
| 일반 조회 목록 (< 5만건) | `DataGrid` (내부적으로 AG Grid) |
| 엑셀과 동일한 인라인 편집 필요 | AG Grid `editable: true` 직접 사용 |
| 단순 설정/마스터 테이블 (< 100행) | Ant Design `Table` 직접 사용 |
| 대용량 서버사이드 (> 10만건) | AG Grid `ServerSideRowModelModule` |
| KPI/집계 합계행 있는 화면 | `DataGrid``pinnedBottomRow` prop |
---
## 9. 트러블슈팅 FAQ
### Q. @RequirePermission 에서 403 이 발생함
menu 코드가 `ga.menu.menu_code` 에 없거나 해당 역할에 권한이 없는 경우입니다.
1. `SELECT * FROM ga.menu WHERE menu_code = 'FOO';` — 메뉴가 존재하는지 확인
2. `SELECT * FROM ga.role_menu_permission WHERE menu_code = 'FOO';` — 권한 매트릭스 확인
3. V54 메뉴 INSERT SQL 이 운영 DB 에 적용됐는지 확인 (ga-api 재시작 필요)
### Q. Redis 캐시와 DB 공통코드가 다름
`POST /api/codes/refresh` 를 호출하면 Redis 공통코드 캐시를 전체 갱신합니다.
또는 Redis CLI 에서 `DEL ga:codes:*` 로 수동 삭제 후 재조회.
### Q. MyBatis XML "No result found" / "column not found"
- resultType 의 필드명이 SQL 컬럼명과 다른 경우: XML 에 `<resultMap>` 또는 SQL 에 alias 사용
- `snake_case``camelCase` 자동 변환은 `MyBatisConfig``mapUnderscoreToCamelCase=true` 설정되어 있어 기본 동작
### Q. Flyway 마이그레이션 체크섬 에러
이미 적용된 SQL 파일을 수정하면 발생합니다. 수정이 필요하면 새 버전 파일(`V{N+1}__fix.sql`)을 추가하세요.
개발 환경 긴급 수정 시: `DELETE FROM ga.flyway_schema_history WHERE version = '55';` 후 재시작.
### Q. AG Grid 화면이 흰 화면으로 보임
`@ag-grid-community/styles/ag-grid.css``ag-theme-quartz.css` import 가 `DataGrid.tsx` 에 있습니다.
페이지에서 `DataGrid` 컴포넌트를 사용하면 자동으로 적용됩니다. 직접 `AgGridReact` 를 사용할 때는 수동으로 import 필요.
### Q. 프론트 401 "토큰이 만료되었습니다"
`localStorage.removeItem('accessToken')``/login` 으로 재로그인.
React Query 의 `onError` 인터셉터에서 401 시 자동 `/login` 리다이렉트가 설정되어 있습니다.
### Q. 빌드 시 "Cannot find symbol" — Mapper 메서드 누락
`ga-core` 의 Mapper 인터페이스에 메서드가 없으면 `ga-api` 서비스에서 컴파일 에러.
`ga-core` 의 해당 Mapper 와 XML 에 메서드를 먼저 추가하고 `ga-api` 를 빌드하세요.
### Q. 운영 DB 에서 P2~P4 API 가 "relation does not exist" 에러
운영 DB 에는 V17(또는 V22) 까지만 적용되어 있습니다.
`ga-api 를 재시작`하면 Flyway 가 V18~V55 를 자동 순차 적용합니다.
---
## 10. 코드 컨벤션
### VO 네이밍
| 클래스 | 용도 | 예시 |
|--------|------|------|
| `{도메인}VO` | DB row 1:1 매핑 (내부 전용) | `AgentVO` |
| `{도메인}Resp` | API 응답 | `AgentResp` |
| `{도메인}SaveReq` | 등록/수정 요청 (공용) | `AgentSaveReq` |
| `{도메인}SearchParam` | 검색 조건 + 페이징 | `AgentSearchParam` |
### Mapper XML 규칙
- namespace 는 Mapper 인터페이스 FQCN 과 정확히 일치
- resultType 은 항상 Resp 클래스 사용 (VO 직접 반환 금지)
- 동적 조건은 `<where>` + `<if>` 사용 (`1=1` AND 패턴 금지)
- 대량 INSERT 는 `<foreach>` batch 사용
### API URL 규칙
- `/api/{리소스s}` — 복수형 kebab-case
- 예: `/api/agent-contracts`, `/api/commission-disputes`, `/api/period-closes`
- 단건 조회: `GET /api/{리소스s}/{id}`
- 상태 변경: `POST /api/{리소스s}/{id}/approve` (동사 사용 허용)
+145
View File
@@ -0,0 +1,145 @@
# 도메인 GAP 분석 & P4 기획서
작성일: 2026-05-14
작성자: PL (자율 모드)
대상: 한국 GA(법인보험대리점) 수수료 정산 시스템
---
## 1. 현재까지의 도메인 커버리지 (P0~P3 + 부속)
| 영역 | 적용 항목 |
|---|---|
| **세무** (P0-1) | 사업소득 3.3% / 기타소득 8.8% / 지방세 10% / VAT 10% |
| **공제** (P0-2) | 월별 차감 마스터(agent_deduction) + 이체 명세 |
| **명세서** (P0-3) | commission_statement + Excel 출력 |
| **1200%룰** (P1-1) | regulatory_limit + 한도 검증 + 분급 큐 |
| **분급** (P1-2) | installment_plan 7년 (35/25/15/10/7/5/3) |
| **차등환수** (P1-3) | chargeback_grade 5단계 |
| **마감/정정/이의/보류** (P2-1~4) | period_close / settle_correction / commission_dispute / hold_reason_code |
| **시책·해촉·세금계산서·외부회신** (P2-5~8) | incentive_program/payment / termination_settlement / tax_invoice / carrier_reconciliation_out |
| **KPI** (P3) | mv_monthly/org/retention/cumulative + Service + 화면 |
---
## 2. 한국 GA 도메인에서 **여전히 빠진** 항목 (검토)
### 2-A. 영업/계약 라이프사이클 (필수)
-**청약(Proposal)** → 청약→인수심사→체결 단계. 청약 수당(소액) 처리 부재
-**인수심사 결과(Underwriting Result)** — 거절/조건부 승인/할증 결과 추적
-**계약 변경(Endorsement)** — 보험료/피보험자/수익자 변경 시 수수료 재계산 트리거
-**부활(Reinstatement)** — 실효된 계약 부활 시 환수 처리 + 수수료 재산정
-**계약 양도(Assignment)** — 설계사 간 계약 이관 → 수수료 재배분
### 2-B. 설계사 라이프사이클 (필수)
-**위촉계약(Agent Contract)** — 보험사-설계사 위촉계약서 + 위촉수수료
-**자격 관리(License)** — 자격증 만료/갱신 알림 (생보/손보/변액)
-**보수교육(Training)** — 의무 교육 시간 추적 (감독규정 12시간/년)
-**징계 이력(Discipline)** — 불완전판매 제재 → 환수 가중치
-**휴직(Suspension)** — 활동중지 기간 정산 제외
### 2-C. 회계/재무 (필수)
-**회계 전표(Accounting Entry)** — 수수료 지급액 자동 분개
-**출금 신청 워크플로우(Withdraw Request)** — 결재 → 펌뱅킹 → 결과
-**원천세 신고서(Tax Report)** — 분기 사업소득세 신고 자료 집계
-**부가세 신고 자료** — 매출/매입 집계 (세금계산서 기반)
-**결산(Period Settlement)** — period_close 와 별개의 월/분기/연 회계 마감
### 2-D. 컴플라이언스 (중요)
-**민원 관리(Complaint)** — 고객 민원 → 설계사 책임 추적 → 환수 트리거
-**불완전판매 모니터링** — 13M/25M 유지율 기반 이상 탐지
-**상품판매 자격 매트릭스** — 변액=변액자격필요, 매핑 검증
### 2-E. 시스템/UX (필수)
-**결재선/결재 요청(Approval)** — 정산 확정/이의/시책 결재 워크플로우
-**공지사항(Notice)** + **개인 알림(UserNotification)**
-**첨부파일(Attachment)** — 위촉계약서/민원자료 등 문서 저장
-**사용자 알림 채널** — 메일/SMS 발송 큐
### 2-F. 분석/리포트 보강 (있으면 좋음)
-**이상치 탐지** (Outlier KPI)
-**차월 예측** (예상 정산액)
-**연말 지급명세서** — 설계사 연 사업소득 세무 자료 PDF
---
## 3. 우선순위 결정 (PL 판단)
> 추가 검토 (2026-05-14): 사용자 지적 — **"오버라이드 / 수금수수료 같은 수수료 종류 정보가 많이 빠짐"**.
> P4-A 로 **수수료 종류 보강** 5개 도메인을 우선 배치하고, 운영 9 도메인을 P4-B 로 후위 배치.
### **P4-A — 수수료 종류 보강 (5 도메인)** ⭐ 사용자 지적 반영
| # | 도메인 | 마이그레이션 | 핵심 |
|---|---|---|---|
| P4-A-1 | **commission_type 코드 + 통합 마스터** | V40 | 모집/유지/수금/오버라이드/갱신/부활/유지보수수당/시책/위촉 9종 통합 코드 + commission_master 마스터 |
| P4-A-2 | **수금수수료 (collection_commission_rule + collection_commission_ledger)** | V41 | 보험료 수납 단위(월) 발생. 납입주기별 차등 + 원장 |
| P4-A-3 | **갱신/부활 수수료 (renewal_commission, reinstatement_commission)** | V42 | 자동차/실비 갱신 + 실효 부활 시 별도 수수료 룰·원장 |
| P4-A-4 | **유지보수수당 (persistency_bonus_rule + persistency_bonus)** | V43 | 13M/25M/37M 유지율 충족 1회 지급. 등급별 정액 또는 정률 |
| P4-A-5 | **오버라이드 보강 (override_ledger + override_summary)** | V44 | 기존 override_rule + 8 Step 결과를 **원장 단위로 추적**. 직급별/조직별 합계 + 검증 |
### **P4-B — 운영 필수 9 도메인** (P4-A 완료 후)
| # | 도메인 | 마이그레이션 | 핵심 |
|---|---|---|---|
| P4-B-1 | **위촉계약 (agent_contract)** | V45 | 보험사-설계사 위촉계약서 + 위촉수수료 + 효력 기간 |
| P4-B-2 | **자격 관리 (agent_license)** | V46 | 생보/손보/변액 자격증 + 만료일 + 갱신 이력 |
| P4-B-3 | **보수교육 (agent_training)** | V47 | 교육 이수 시간 + 의무 12시간/년 충족 여부 |
| P4-B-4 | **계약 변경 (contract_endorsement)** | V48 | 변경 유형(보험료/수익자/피보험자) + 수수료 재계산 트리거 |
| P4-B-5 | **민원 관리 (complaint)** | V49 | 고객 민원 + 설계사 책임 + 처리 상태 + 환수 연동 |
| P4-B-6 | **결재 워크플로우 (approval_line + approval_request)** | V50 | 결재선 마스터 + 다단 결재 요청/처리 |
| P4-B-7 | **공지사항 + 알림 (notice + user_notification)** | V51 | 시스템 공지 + 사용자별 알림 (읽음 여부) |
| P4-B-8 | **첨부파일 (attachment)** | V52 | 도메인 무관 공통 첨부 (소속/파일경로/타입) |
| P4-B-9 | **출금 신청 (withdraw_request)** | V53 | 결재 + 펌뱅킹 송신 + 결과 반영 워크플로우 |
메뉴 INSERT 는 **V54** 한 파일에서 P4-A + P4-B 14개 메뉴 한 번에 등록.
### **P5 — 회계/분석 보강** (별도 라운드)
- 회계 전표 자동 분개 / 원천세·부가세 신고 자료 / 결산 / 이상치·예측 KPI / 연말 지급명세서
### **P6 — 모바일/외부 연동** (제일 후속)
- 펌뱅킹 실연동 / 모바일 셀프 조회 / SMS/카카오 발송
이번 라운드는 **P4 만** 일괄 진행. P5/P6 는 다음 라운드에서.
---
## 4. P4 이행 계획 (Wave 방식, 이미 검증된 패턴)
| Wave | 담당 | 산출물 |
|---|---|---|
| Wave 1 | dba-architect | V40~V48 9개 + V49 메뉴 INSERT (총 10 파일) |
| Wave 2 | core-architect | P4 VO/Resp/SaveReq/SearchParam + Mapper(IF+XML) + Enum |
| Wave 3 | api-developer | Service + Controller + 결재 AOP (또는 Service 가드) |
| Wave 4 | frontend-developer | 화면 9개 (토스 톤 일관) + 라우터 + Route 등록 |
| Wave 5 | infra-reviewer | 빌드/통합 검증 + README/HANDOFF 갱신 + 메뉴 적용 |
병렬화: Wave 1↔3 의존, Wave 4 는 Wave 3 후, Wave 5 는 마지막. 단 #8 KPI 대시보드 진행 중이므로 frontend Wave 4 는 #8 완료 후.
---
## 5. 도메인 가정값 (P4 채택)
| 항목 | 표준값 | 근거 |
|---|---|---|
| 보수교육 의무 시간 | 12 시간/년 | 보험업법 시행령 제17조 |
| 자격증 갱신 주기 | 5 년 | 보험업감독규정 |
| 민원 환수 기준 | 13M 이내 민원 = 환수 100% | 일반 GA 사내 규정 가정 |
| 결재 최대 단계 | 3 단계 | 일반 운영 가정 |
| 첨부 최대 크기 | 50 MB | 시스템 기본값 |
| 출금 신청 마감 | 매월 25일 | 정산일 익월 5일 가정 |
가정값 → system_config / approval_line / training_requirement 등 외부화 가능 구조로 등록.
---
## 6. 일정 추정
| Wave | 예상 시간 |
|---|---|
| Wave 1 (dba) | 5~7분 |
| Wave 2 (core) | 8~12분 |
| Wave 3 (api) | 6~10분 |
| Wave 4 (frontend, #8 후) | 10~15분 |
| Wave 5 (infra) | 5~8분 |
| **합계** | **35~50분** |
배치/AOP 같은 추가 통합은 P5 라운드로 미룸.
+384
View File
@@ -0,0 +1,384 @@
# 보험 GA 수수료 정산 — 도메인 기본 지식 (신입사원 교육용)
> 이 문서는 **보험을 처음 접하는 신입사원/개발자**가 GA 수수료 정산 업무를 이해하기 위한 입문·교육 자료입니다.
> 코드 구조가 아니라 "이 업무가 무엇이고, 수수료를 어떻게 계산하는지"를 설명합니다.
> 표준 가정값은 한국 보험업감독규정 기준이며, 실제 회사 정책과 다르면 코드가 아니라
> 데이터(`system_config` / `regulatory_limit` / `installment_ratio` / `chargeback_grade`)만 바꾸면 됩니다.
---
## 목차
1. [한 문장 요약](#1-한-문장-요약)
2. [등장 인물과 핵심 개념](#2-등장-인물과-핵심-개념)
3. [보험 기초 — 보험을 처음 접한다면](#3-보험-기초--보험을-처음-접한다면)
4. [수수료의 종류 (이 도메인의 핵심)](#4-수수료의-종류-이-도메인의-핵심)
5. [규제 룰 — 왜 "그냥 곱하면" 안 되는가](#5-규제-룰--왜-그냥-곱하면-안-되는가)
6. [세금과 공제 — 실지급액은 어떻게 정해지나](#6-세금과-공제--실지급액은-어떻게-정해지나)
7. [수수료 계산 공식과 숫자 예시 (가장 중요)](#7-수수료-계산-공식과-숫자-예시-가장-중요)
8. [월 정산 파이프라인 (실제 배치 12단계)](#8-월-정산-파이프라인-실제-배치-12단계)
9. [정산의 상태(Status) 흐름](#9-정산의-상태status-흐름)
10. [정산 그 이후 — 회계·세무·지급](#10-정산-그-이후--회계세무지급)
11. [KPI / 분석](#11-kpi--분석)
12. [자주 헷갈리는 포인트 (FAQ)](#12-자주-헷갈리는-포인트-faq)
13. [용어 빠른 사전](#13-용어-빠른-사전)
---
## 1. 한 문장 요약
> **보험계약을 모집·관리한 설계사에게, 보험사가 준 수수료를 규정·세금·공제를 반영해 매월 정확히 나눠주고(정산), 그 결과를 명세서·회계장부·세무신고로 남기는 업무.**
---
## 2. 등장 인물과 핵심 개념
| 용어 | 영문/코드 | 설명 |
|---|---|---|
| **GA** | General Agency | 여러 보험사 상품을 함께 파는 **보험대리점**. 우리 시스템의 주체. |
| **보험사** | Carrier / Company | 상품을 만들고 GA에 수수료를 지급하는 원수사 (삼성생명·메리츠 등). |
| **설계사** | Agent | 실제로 계약을 모집·관리하는 사람. 정산의 **수령 대상**. |
| **조직** | Organization | 설계사가 속한 지점·본부 트리. 오버라이드(상위 직책 수당)의 기준. |
| **상품** | Product | 보험 상품. 상품별로 수수료율이 다름. |
| **계약** | Contract | 고객이 가입한 보험증권 1건. 정산의 **기준 단위**. |
| **수수료** | Commission | 보험사→GA→설계사로 흐르는 돈. 종류가 매우 다양 (4장 참고). |
| **원장** | Ledger | 계산의 최소 단위 기록 (계약×월×수수료종류 1줄). |
| **정산 마스터** | settle_master | 원장을 설계사×월로 합산한 지급 확정 단위. |
| **명세서** | Statement | 설계사에게 발급하는 "이번 달 얼마 받는지" 내역서. |
**돈의 흐름:**
```
[보험사] --(원수수료 지급)--> [GA] --(설계사 몫 정산)--> [설계사]
└─ 세금 원천징수 + 공제(가지급금 등) 차감 후 실지급
```
---
## 3. 보험 기초 — 보험을 처음 접한다면
수수료를 이해하려면 보험 자체의 기본 용어부터 알아야 합니다. (보험을 한 번도 안 다뤄봤다는 가정)
| 용어 | 쉬운 설명 |
|---|---|
| **보험계약** | 고객이 "매달 얼마 낼 테니, 사고/사망/만기 때 보장해줘"라고 보험사와 맺은 약속 (증권 1장 = 계약 1건). |
| **보험료 (Premium)** | 고객이 보험사에 내는 돈. 보통 **월납**(매달)·연납·일시납. 수수료 계산의 출발점. |
| **계약자/피보험자/수익자** | 돈 내는 사람 / 보장 대상인 사람 / 보험금 받는 사람. (셋이 같을 수도 다를 수도) |
| **보험종류** | 크게 **생명보험**(사망·종신·연금)과 **손해보험**(자동차·화재·실손). 종류마다 수수료율이 다름. |
| **회차 / 차년** | 계약이 몇 번째 해/달째 유지 중인지. **1차년(초년도)**의 모집수수료가 가장 큼. |
| **납입 / 미납 / 실효** | 보험료를 냈는지(납입) / 안 냈는지(미납) / 안 내서 계약이 끊긴 상태(실효). |
| **유지 / 해지 / 부활** | 계약이 살아있음(유지) / 고객이 끊음(해지) / 실효된 걸 되살림(부활). |
| **유지율 (Persistency)** | 가입한 계약이 일정 기간(13개월·25개월) 뒤에도 살아있는 비율. **영업 건전성의 핵심 지표.** |
| **청약 / 인수(언더라이팅)** | 고객이 가입 신청(청약) → 보험사가 위험 심사 후 받아들임(인수). |
| **위촉 / 해촉** | 설계사가 GA에 소속되는 것(위촉) / 소속이 끝나는 것(해촉). 해촉 시 계약이 후임에게 **승계**됨. |
> **왜 보험사가 GA에, GA가 설계사에게 돈을 주나?**
> 보험사는 직접 영업하는 대신 GA·설계사가 고객을 데려오면 그 대가로 **수수료**를 준다.
> 즉 수수료는 "계약을 따오고(모집) 잘 유지시킨(유지)" 대가다. 그래서 계약이 일찍 깨지면 **환수**(돌려받기)한다.
---
## 4. 수수료의 종류 (이 도메인의 핵심)
수수료는 "한 번에 다 주는 것"이 아니라 **계약의 생애주기**에 따라 여러 종류로 나뉜다.
| 종류 | 코드 | 언제 발생 | 누구에게 | 비고 |
|---|---|---|---|---|
| **모집수수료** | recruit | 계약 체결 시 (초년도) | **원 모집설계사** | 가장 큼. 보통 분급(5-2) 대상 |
| **유지수수료** | maintain | 계약 유지되는 매월/매년 | 현재 관리설계사 | 계약 승계 시 후임에게 귀속 전환 |
| **수금수수료** | collection | 보험료가 실제 납입될 때 | 관리설계사 | 납입 실적 기반 |
| **갱신수수료** | renewal | 계약 갱신 시 | 관리설계사 | 갱신형 상품 |
| **부활수수료** | reinstatement | 실효된 계약 부활 시 | 관리설계사 | |
| **유지보수당(지속수당)** | persistency bonus | 일정 유지율 달성 시 | 설계사 | 보너스 성격 |
| **오버라이드** | override | 하위 설계사 실적 발생 시 | **상위 직책자** | 조직 트리 기반 수당 |
| **시책·인센티브** | incentive | 캠페인/목표 달성 시 | 설계사 | 한시적 프로모션 |
| **수입수수료** | income | 보험사가 GA에 지급한 수수료 수입 원장 | GA 경영진/재무 | 수지차(=수입-지급) 집계용. 설계사 지급과 분리 관리 |
| **소개·이관수수료** | referral/transfer | 계약을 소개(REFERRAL)하거나 이관(TRANSFER)한 설계사에게 | 소개/이관 설계사 | 정률 또는 정액. 기준금액×율 또는 고정액 |
| **정착지원금** | settling support | 신입/경력 설계사 입사 후 약정 기간 | 신규 설계사 | 등급별 월 정액. 조기 해촉 시 잔여약정 비율로 환수 |
| **인정실적** | recognized performance | 계약 체결 시 (환산보험료 기준) | 설계사 | 환산보험료×인정율. 시상·등급 산정의 기초 지표 |
| **시상** | award | 목표 실적 달성 시 | 설계사 | 신인도입/법인/유지율/실적 시상. 정률 또는 정액 |
| **생보운영지원수수료** | life op support | 생보 계약 운영 실적 기준 | 설계사 | 실적×지급률. 환수 가능 |
| **지점장수당** | branch mgr allowance | 소속 조직 실적 발생 시 | 지점장 | 조직실적×수당율, cap(상한) 제한. 환수 가능 |
> **모집 vs 유지의 귀속 차이 (중요):** 설계사가 해촉·이관되면, 그 계약의 **유지수수료는 후임 설계사**에게 넘어가지만(계약 승계),
> **모집수수료는 원래 모집한 설계사**에게 그대로 남는다. (한국 고아계약 승계 표준)
---
## 5. 규제 룰 — 왜 "그냥 곱하면" 안 되는가
수수료는 법으로 한도와 지급 방식이 정해져 있다. 단순 계산이 아니라 아래 룰을 모두 통과해야 한다.
### 5-1. 1200% 룰 (보험업감독규정 제5-15조의5)
- 초년도(1차년)에 설계사에게 줄 수 있는 모집수수료 총액은 **연납보험료의 1200%**를 넘을 수 없다.
- 추가로 **1차년 한도 35%** 등 연차별 상한이 있다.
-`RegulatoryLimitChecker` 가 누적 지급액을 추적해 한도 초과분을 잘라낸다.
### 5-2. 분급 (Installment) — 나눠 지급
- 큰 모집수수료를 한 번에 주지 않고 **7년에 걸쳐 비율대로 분할** 지급.
- 표준 비율(가정): **35 / 25 / 15 / 10 / 7 / 5 / 3 (%)** — 합 100%.
-`InstallmentPlanGenerator` 가 회차별 지급 스케줄(`installment_plan`)을 만든다.
### 5-3. 차등 환수 (Chargeback) — 조기 해지 시 돌려받기
- 계약이 일찍 깨지면 이미 준 수수료의 일부를 **환수**한다. 유지 기간이 짧을수록 많이 환수.
- 표준 단계(가정):
| 유지 개월 | 환수율 |
|---|---|
| 1 ~ 12 | 100% |
| 13 ~ 24 | 50% |
| 25 ~ 36 | 25% |
| 37 ~ 84 | 10% |
| 85 ~ | 0% (환수 없음) |
-`ChargebackCalculator` 가 해지 시점의 유지 개월로 환수액을 계산.
---
## 6. 세금과 공제 — 실지급액은 어떻게 정해지나
설계사에게 주는 돈(gross)에서 **세금을 원천징수**하고 **공제**를 빼야 **실지급액(net)**이 된다.
### 6-1. 원천징수 세금 (한국 표준, `system_config` 외부화)
| 항목 | 세율 | 적용 |
|---|---|---|
| 사업소득 원천징수 | **3.3%** | 일반 설계사(사업소득자) |
| 기타소득 원천징수 | **8.8%** | 기타소득 유형 |
| 지방소득세 | 소득세의 **10%** | 소득세에 부가 |
| 부가가치세 | **10%** | 과세사업자 설계사가 세금계산서 발행 시 (GA가 설계사에 추가 지급) |
### 6-2. 공제 (Deduction)
- 가지급금(선지급) 회수, 대출 상환, 기타 차감 등을 매월 정산액에서 뺀다.
- `agent_deduction`(월별 차감 마스터) + `payment_deduction_detail`(이체 명세)로 관리.
### 6-3. 실지급액 계산 공식 (개념)
```
gross(총수수료)
− 원천세(소득세 + 지방소득세)
공제(가지급금 등)
(+ 부가세, 과세사업자인 경우)
= net(실지급액) → 설계사 계좌로 이체(펌뱅킹)
```
---
## 7. 수수료 계산 공식과 숫자 예시 (가장 중요)
> 이 장이 핵심입니다. 실제 코드(`CommissionCalculator`, `TaxCalculator`, `CalcOverrideStep`)가 쓰는 공식을 그대로 정리하고 숫자로 풀어봅니다.
### 7-1. 기본 공식 (모집·유지수수료 공통)
계약 1건의 수수료는 **세 번 곱하기**로 끝납니다.
```
① 보험사 지급액 = 보험료(premium) × 보험사요율(company_rate %)
② 설계사 수수료 = 보험사 지급액 × 지급률(payout_rate %)
③ 원천세 = 설계사 수수료 × 3.3%
─────────────────────────────────────────────────────
원장 기록 = 설계사 수수료 − 원천세
```
- **보험사요율(company_rate)**: 보험사가 이 상품에 주는 비율. **상품 × 회차(차년) × 기준일**로 결정.
- **지급률(payout_rate)**: GA가 받은 돈 중 설계사에게 주는 비율. **설계사 등급 × 보험종류 × 회차**로 결정.
→ 같은 계약이라도 **등급이 높은 설계사일수록 지급률이 높다.**
- 모집수수료와 유지수수료는 **공식이 같고**, 회차(`commission_year`)만 다릅니다. (모집=1차년, 유지=2차년 이후)
- 모든 금액은 `BigDecimal`, 반올림 `HALF_UP`, 소수점 2자리.
### 7-2. 숫자 예시 A — 모집수수료 1건
| 항목 | 값 | 계산 |
|---|---|---|
| 보험료 (연환산) | 1,000,000원 | (가정) |
| 보험사요율 | 60% | 상품·1차년 기준 |
| **① 보험사 지급액** | **600,000원** | 1,000,000 × 60% |
| 지급률 (설계사 등급) | 75% | 등급·보험종류·1차년 |
| **② 설계사 수수료** | **450,000원** | 600,000 × 75% |
| **③ 원천세 (3.3%)** | **14,850원** | 450,000 × 3.3% |
| **원장 실수령** | **435,150원** | 450,000 14,850 |
### 7-3. 숫자 예시 B — 분급(7년 분할) 적용 시
위 모집수수료 450,000원이 분급 대상이면, 한 번에 다 주지 않고 7년에 나눠 줍니다.
| 차년 | 비율 | 지급액 |
|---|---|---|
| 1차년 | 35% | 157,500원 |
| 2차년 | 25% | 112,500원 |
| 3차년 | 15% | 67,500원 |
| 4차년 | 10% | 45,000원 |
| 5차년 | 7% | 31,500원 |
| 6차년 | 5% | 22,500원 |
| 7차년 | 3% | 13,500원 |
| **합계** | **100%** | **450,000원** |
→ 즉 계약 직후 통장에 들어오는 건 450,000원이 아니라 **1차년분 157,500원(에서 세금 차감)** 입니다.
### 7-4. 숫자 예시 C — 차등 환수 (조기 해지)
이 계약이 **13개월차에 해지**되면, 유지 13~24개월 구간 환수율 **50%**가 적용됩니다.
```
환수액 = 기지급 모집수수료 × 환수율
= 450,000 × 50% = 225,000원 ← 다음 정산에서 (-)로 차감
```
→ 유지 12개월 이내였다면 환수율 100%(전액 환수), 85개월 이상이면 0%(환수 없음).
### 7-5. 숫자 예시 D — 오버라이드 (상위 직책 수당)
하위 설계사의 실적에 대해 **상위 직책자**가 받는 수당입니다.
```
오버라이드 = 하위 실적(base) × 오버라이드율(override_pct) (단, cap 초과 시 cap 금액으로 제한)
```
| 항목 | 값 |
|---|---|
| 하위 설계사 월 실적(base) | 2,000,000원 |
| 오버라이드율 | 4% |
| 오버라이드 | 80,000원 (= 2,000,000 × 4%) |
| (cap이 100,000원이면) | 80,000원 그대로 (cap 미만) |
### 7-6. 숫자 예시 E — 세금 상세 (과세사업자 설계사)
설계사 수수료 450,000원을 **사업소득 + 과세사업자**로 정산할 때 (`TaxCalculator` 기준):
| 항목 | 값 | 계산 |
|---|---|---|
| 설계사 수수료 (gross) | 450,000원 | |
| 소득세 (3.3%) | 14,850원 | 450,000 × 3.3% |
| 지방소득세 (소득세의 10%) | 1,485원 | 14,850 × 10% |
| 부가세 (10%, 과세사업자) | +45,000원 | 450,000 × 10% (추가 지급) |
| **실지급액 (net)** | **478,665원** | 450,000 14,850 1,485 + 45,000 |
> 여기에 가지급금 등 **공제**가 있으면 net에서 추가로 빠집니다.
> 비과세/면세 설계사는 부가세(+45,000) 항목이 없습니다.
### 7-7. P7 신규 수수료 계산 공식 (V88~V96, incar 갭 반영)
| 종류 | 공식 | 비고 |
|---|---|---|
| **수입수수료** | `income_amount = premium × company_rate` | 보험사→GA 수입 원장. 수지차 = Σincome Σpayout |
| **소개·이관수수료** | `commission_amount = base × rate` (RATE형) 또는 `fixed_amount` (FIXED형) | 계약 소개/이관 설계사에게 지급 |
| **정착지원금** | 지급: 등급별 `support_amount` (월 정액). 환수: `support_amount × (잔여약정개월 / 약정개월)` (조기 해촉 시) | `net = support_amount clawback_amount` |
| **인정실적(환산보험료)** | `converted_premium = premium × conversion_rate`. `recognized_amount = converted_premium × recognition_rate` | 시상/등급 산정 기초. 상품별 환산율은 `product_conversion_rate` |
| **시상** | `award_amount = base × award_rate` (RATE형) 또는 `award_fixed` (FIXED형) | `threshold_amount` 이상 실적 달성 시만 발동 |
| **생보운영지원수수료** | `support_amount = base × support_rate`. `net = support_amount clawback_amount` | 생보 실적 기준 지급 |
| **지점장수당** | `allowance_amount = LEAST(base × allowance_rate, cap_amount)` | cap=NULL이면 무제한. `net = allowance_amount clawback_amount` |
> 모든 금액 `NUMERIC(15,2)`, 율 `NUMERIC(7,4)`(0~1 소수 표현), 반올림 HALF_UP.
### 7-8. 한 장 정리
```
보험료 ──×보험사요율──▶ 보험사지급액 ──×지급률──▶ 설계사수수료(gross)
┌─ (분급) 7년 분할 ─┐ │
└─ (환수) 조기해지 차감 ┘
gross − 원천세(소득세+지방세) − 공제 (+부가세) = 실지급액(net) ──▶ 계좌이체
```
---
## 8. 월 정산 파이프라인 (실제 배치 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차 검증선.
---
## 9. 정산의 상태(Status) 흐름
정산 마스터는 함부로 다시 못 바꾸도록 상태로 잠근다.
```
DRAFT(계산됨) → CONFIRMED(확정) → PAID(지급완료)
└─ 재정산은 status NOT IN ('CONFIRMED','PAID') 인 것만 갱신 (확정분 보호)
```
- **마감(Period Close)**: 한 달을 닫으면 그 달은 더 이상 수정 불가. 회계 분개에 채번(journal_no)이 찍힌다.
- **정정(Correction)**: 마감 후 오류 발견 시 정정 전표로만 바로잡는다 (원본은 보존).
- **이의신청(Dispute)**: 설계사가 금액에 이의를 제기하는 창구.
---
## 10. 정산 그 이후 — 회계·세무·지급
| 영역 | 내용 | 산출물 |
|---|---|---|
| **회계 분개** | 수수료 지급을 차변/대변 전표로 기록 | `contract_accounting_entry`, 계정과목 `account_code` |
| **회계 결산** | 월/기간 마감 + 잔액 스냅샷 | `accounting_close`, `account_balance_snapshot` |
| **원천세 신고** | 분기별 원천징수세 신고 자료 | `withholding_tax_report` |
| **부가세 신고** | 분기별 부가세 신고 자료 | `vat_report` |
| **세금계산서** | 사업자 설계사 발행분 관리 | `tax_invoice` |
| **연말 지급명세서** | 연 사업소득 신고용 | `year_end_statement` |
| **실지급(이체)** | 펌뱅킹으로 설계사 계좌 이체 | `withdraw_request`, 펌뱅킹 어댑터(KFTC) |
---
## 11. KPI / 분석
- **월별 요약 / 조직별 요약**: 지급 총액, 인원, 평균 등 (Materialized View).
- **유지율(Retention)**: 13회차/25회차 유지율 — 보험 영업 건전성의 핵심 지표.
- **이상치 탐지(Anomaly)**: 유지율 급락 등 비정상 패턴 자동 경보.
- **예측(Forecast)**: 이동평균(SMA)으로 차월 수수료 예측.
---
## 12. 자주 헷갈리는 포인트 (FAQ)
1. **"수수료를 왜 한 번에 안 주나?"** → 1200% 룰·분급 규제 + 조기 해지 환수 리스크 때문. 7년 분할이 표준.
2. **"설계사가 그만두면 그 계약 수수료는?"** → 유지수수료는 후임(승계)에게, 모집수수료는 원 설계사에게.
3. **"gross인데 통장엔 적게 들어와요"** → 원천세(3.3%) + 공제(가지급금 등)가 빠진 net이 실지급액. (단, 과세사업자는 부가세가 더해짐)
4. **"확정한 달을 다시 돌렸는데 안 바뀌어요"** → 정상. CONFIRMED/PAID는 보호되고, 바로잡으려면 정정 전표를 쓴다.
5. **"같은 계약인데 설계사마다 수수료가 달라요"** → 지급률(payout_rate)이 **설계사 등급**에 따라 다르기 때문. (7-1 참고)
6. **"표준 세율/한도가 우리 회사랑 달라요"** → 코드 수정 불필요. `system_config`·`regulatory_limit`·`installment_ratio`·`chargeback_grade` 데이터만 변경.
---
## 13. 용어 빠른 사전
| 한글 | 영문 | 한 줄 |
|---|---|---|
| 보험료 | Premium | 고객이 내는 돈. 수수료 계산의 출발점 |
| 보험종류 | Insurance type | 생명/손해. 종류별 요율 다름 |
| 회차/차년 | Commission year | 계약 몇 년째인지. 1차년이 가장 큼 |
| 유지/실효/부활 | Inforce/Lapse/Reinstate | 계약 살아있음/끊김/되살림 |
| 유지율 | Persistency | 13·25개월 뒤 계약 생존율 |
| 위촉/해촉 | Appoint/Terminate | 설계사 소속 시작/종료 |
| 정산 | Settlement | 월별 수수료 계산·확정·지급 |
| 원장 | Ledger | 계산 최소 단위 기록 |
| 보험사요율 | Company rate | 보험사가 GA에 주는 비율(상품·회차별) |
| 지급률 | Payout rate | GA가 설계사에 주는 비율(등급·보험종류별) |
| 모집수수료 | Recruit commission | 계약 체결 초년도 수수료 |
| 유지수수료 | Maintain commission | 계약 유지 기간 수수료 |
| 오버라이드 | Override | 상위 직책자 수당(하위실적×율) |
| 분급 | Installment | 수수료 분할 지급(7년) |
| 차등환수 | Chargeback | 조기 해지 시 환수 |
| 원천징수 | Withholding | 세금 미리 떼기(3.3%) |
| 공제 | Deduction | 가지급금 등 차감 |
| 마감 | Period close | 월 정산 잠금 |
| 정정 | Correction | 마감 후 오류 전표 수정 |
| 이의신청 | Dispute | 설계사 금액 이의 |
| 승계 | Succession | 해촉/이관 시 후임 귀속 전환 |
| 펌뱅킹 | Firm banking | 대량 계좌이체(KFTC) |
---
*기준: 한국 보험업감독규정 표준 가정값. 시스템 schema v96 (2026-05-31 기준, P7 incar 갭 반영). 표준값은 모두 데이터로 외부화되어 코드 변경 없이 조정 가능.*
*계산 공식 출처: `CommissionCalculator` / `TaxCalculator` / `CalcOverrideStep` + P7 7개 도메인 Service (V88~V96) 실제 코드.*
+475 -48
View File
@@ -1,4 +1,4 @@
# Handoff Note — 2026-05-12 # Handoff Note — 최신 갱신 2026-05-29 (계약 승계 V86 §3-16) / Round 2 기준 2026-05-14
다음 세션에서 이어가기 위한 핸드오프 문서. Agent Teams 기반 자동 체인으로 진행 중인 작업의 현재 상태와 잔여 작업, 재개 절차를 기록한다. 다음 세션에서 이어가기 위한 핸드오프 문서. Agent Teams 기반 자동 체인으로 진행 중인 작업의 현재 상태와 잔여 작업, 재개 절차를 기록한다.
@@ -64,61 +64,488 @@
- P1-3-b(core) + P1-1-c TODO 교체(api) 2명 동시 - P1-3-b(core) + P1-1-c TODO 교체(api) 2명 동시
- 모듈이 다른 사람끼리 묶어 머지 충돌 없음 - 모듈이 다른 사람끼리 묶어 머지 충돌 없음
## 3. 잔여 작업 ## 3. Round 2 완료 사항 (2026-05-14)
### 3-1. P1 정산 통합 (보류 권장) ### 3-1. P2 — 8 도메인 (V29~V36, ~50 VO·Mapper, 18 Service+Controller)
- ga-batch의 8 Step 정산 잡에 TaxCalculator/DeductionService/RegulatoryLimitChecker/InstallmentService/ChargebackCalculator 통합 | # | 도메인 | 마이그레이션 | 상태 |
- batch-engineer 작업
- 운영 비즈니스 룰 의존이 커 **인프라만 완성하고 통합은 사용자 결정 후** 진행 권장
- 보류 가능 — 현 시점에 인프라/CRUD/API는 모두 동작
### 3-2. 도메인 P2 — 8개 항목 (Wave 방식)
| # | 도메인 | 마이그레이션 | 핵심 |
|---|---|---|---| |---|---|---|---|
| P2-1 | 마감 (Period Close) | V29 | period_close 테이블 + 마감 후 수정 차단 AOP | | P2-1 | 마감 (Period Close) | V29 | 완료 |
| P2-2 | 정정 (Correction) | V30 | settle_correction + 원본 참조 + 정정 사유 | | P2-2 | 정정 (Correction) | V30 | 완료 |
| P2-3 | 이의 신청 (Dispute) | V31 | commission_dispute + OPEN/IN_REVIEW/APPROVED/REJECTED | | P2-3 | 이의신청 (Dispute) | V31 | 완료 |
| P2-4 | 보류 사유 (Hold Reasons) | V32 | settle_master.hold_reason_code + HOLD_REASON 코드그룹 | | P2-4 | 보류사유 (Hold Reasons) | V32 | 완료 |
| P2-5 | 시책·인센티브 | V33 | incentive_program + incentive_payment | | P2-5 | 시책·인센티브 | V33 | 완료 |
| P2-6 | 해촉 정산 (Termination) | V34 | termination_settlement (agent_org_history 연계) | | P2-6 | 해촉정산 (Termination) | V34 | 완료 |
| P2-7 | 세금계산서 (Tax Invoice) | V35 | tax_invoice 발행 이력 (SUPPLY/PURCHASE) | | P2-7 | 세금계산서 (Tax Invoice) | V35 | 완료 |
| P2-8 | 외부 회신 | V36 | carrier_reconciliation_out (보험사 회신 인터페이스) | | P2-8 | 외부회신 | V36 | 완료 |
**Wave 디스패치 계획**: 화면: ga-frontend Wave 4 에서 P4-B 화면과 함께 작성됨.
- Wave 1 (dba): V29~V36 8개 마이그레이션 한 번에
- Wave 2 (core): 8개 도메인 VO/Mapper 한 번에
- Wave 3 (api): 8개 도메인 Service/Controller 한 번에
- 디스패치 24 → 3으로 축소
### 3-3. 도메인 P3 — KPI 대시보드 ### 3-2. P3 — KPI 대시보드 (V37~V39)
| 작업 | 담당 | - dba: mv_monthly_summary / mv_org_summary / mv_retention_rate / mv_cumulative (V37)
- dba: 리포트 메뉴 + KPI 메뉴 INSERT (V38, V39)
- core: KpiMapper.java + XML
- api: KpiService + KpiController (`/api/kpi/*`)
- frontend: KpiMonthlySummary / KpiOrgSummary / KpiRetention / KpiCumulative (4개 화면)
### 3-3. P4-A — 수수료 종류 보강 (V40~V44, 5 도메인)
| # | 도메인 | 마이그레이션 | 상태 |
|---|---|---|---|
| P4-A-1 | commission_type 코드 + commission_master 통합 | V40 | 완료 |
| P4-A-2 | 수금수수료 (collection_commission_rule/ledger) | V41 | 완료 |
| P4-A-3 | 갱신·부활수수료 (renewal/reinstatement) | V42 | 완료 |
| P4-A-4 | 유지보수수당 (persistency_bonus_rule/bonus) | V43 | 완료 |
| P4-A-5 | 오버라이드 원장 (override_ledger/summary) | V44 | 완료 |
### 3-4. P4-B — 운영 필수 9 도메인 (V45~V53)
| # | 도메인 | 마이그레이션 | 상태 |
|---|---|---|---|
| P4-B-1 | 위촉계약 (agent_contract) | V45 | 완료 |
| P4-B-2 | 자격관리 (agent_license) | V46 | 완료 |
| P4-B-3 | 보수교육 (agent_training) | V47 | 완료 |
| P4-B-4 | 계약변경 (contract_endorsement) | V48 | 완료 |
| P4-B-5 | 민원관리 (complaint) | V49 | 완료 |
| P4-B-6 | 결재워크플로우 (approval_line/request) | V50 | 완료 |
| P4-B-7 | 공지·알림 (notice/user_notification) | V51 | 완료 |
| P4-B-8 | 첨부파일 (attachment) | V52 | 완료 |
| P4-B-9 | 출금신청 (withdraw_request) | V53 | 완료 |
### 3-5. V54~V55 + 프론트
- V54: P4 메뉴 INSERT (P4-A + P4-B 14개 메뉴)
- V55: P4 공통코드 추가 (approval_type, complaint_type, license_type, withdraw_status 등)
- 프론트: ErrorBoundary 전역 적용 + 토스 디자인 시스템 토큰 (`RADIUS`, `GRAY`, `SHADOW`) 일관 적용
- 프론트 화면 추가: AgentContracts / AgentLicenses / AgentTrainings / Complaints / ContractEndorsements / CollectionCommission / CommissionMaster / OverrideLedger / PersistencyBonus / ReinstatementCommission / RenewalCommission / WithdrawRequest / Approvals / Notices (14개)
### 3-6. P1 정산 통합 (batch-engineer 완료)
- ga-batch 8 Step에 TaxCalculator / DeductionService / RegulatoryLimitChecker / InstallmentPlanGenerator / ChargebackCalculator 통합 완료
## 3-7. P5 Wave 1 — agent + contract 회계 진입점 (V63~V66)
| 항목 | 마이그레이션 | 상태 |
|---|---|---|
| agent_annual_income 테이블 | V63 | 완료 |
| contract_accounting_entry 테이블 | V64 | 완료 |
| P5-W1 테스트 데이터 + 메뉴 INSERT (ANNUAL_INCOME / ACCOUNTING_JOURNAL) | V65 | 완료 |
| account_code 마스터 + 4건 초기 데이터 | V66 | 완료 |
| rule 감사 컬럼 보강 (commission_rate/payout_rule/override_rule 등) | V70 | 완료 |
| payment 세무 컬럼 보강 (income_tax_amount/local_tax_amount/vat_amount/net_amount) | V71 | 완료 |
핵심 산출물:
- `agent_annual_income` (agentId+settleYear 복합PK, 연도별 사업소득 집계)
- `contract_accounting_entry` (분개 엔트리, debit/credit 계정코드 + journalNo 마감)
- `account_code` 마스터 (account_type DEBIT/CREDIT/BOTH 구분, 초기 4건: 8350/2530/1130/4110)
- `AgentAnnualIncomeService` + `AgentAnnualIncomeController` (`/api/agent-annual-incomes`)
- `ContractAccountingService` + `ContractAccountingController` (`/api/accounting-entries`)
- `AccountCodeService` + `AccountCodeController` (`/api/common/account-codes`)
- `YearEndIncomeAggregateStep` (Step 10, 12월 마감 시만 실행, settleMonth.endsWith("12") 조건)
- `AccountingEntryGenerateStep` (Step 9, 매월 정산 후 분개 생성, ledger UNION 기반 contract_id 확보, journal_no=NULL 미전기)
- 프론트 화면: `AgentAnnualIncomeList` (`/commission/annual-income`) + `AccountingJournalList` (`/commission/accounting-journal`)
통합 검증 결과 (2026-05-15):
- Flyway: V63/V64/V65/V66/V70/V71 적용 확인 (현재 ga schema V71)
- GET /api/agent-annual-incomes → 200 OK
- POST /api/agent-annual-incomes/regenerate?year=2025 → 200 OK, affected=0 (payment 데이터 없음, 정상)
- GET /api/accounting-entries → 200 OK
- GET /api/common/account-codes?accountType=DEBIT → 200 OK, 2 items
보정 사항 (통합 검증 중 발견):
- App.tsx 라우트와 V65 menu_path 모두 `/commission/annual-income`, `/commission/accounting-journal`로 일치
- ANNUAL_INCOME EXECUTE 권한 누락 → DB 직접 INSERT로 보정
- payment 세무 컬럼 미적용 (V23 체크섬 충돌 이력) → V71로 idempotent 보강
운영 DB 적용: ga-api 재시작 → Flyway V18~V71 일괄 자동 적용.
## 3-8. P5 Wave 2 — 원천세/부가세 분기신고 (V67~V69)
| 항목 | 마이그레이션 | 상태 |
|---|---|---|
| withholding_tax_report 테이블 (원천세 분기신고) | V67 | 완료 |
| vat_report 테이블 (부가세 분기신고) | V68 | 완료 |
| P5-W2 테스트 데이터 + 메뉴 INSERT (WITHHOLDING_TAX / VAT_REPORT) | V69 | 완료 |
| EXECUTE 권한 보강 (WITHHOLDING_TAX / VAT_REPORT) | V73 | 완료 |
핵심 산출물:
- `withholding_tax_report` (분기별 원천세 신고 집계: settleQuarter/agentId/totalIncome/totalWithheld/totalLocalTax)
- `vat_report` (분기별 부가세 신고 집계: settleQuarter/totalSales/totalPurchase/totalVatPayable/taxInvoiceCount)
- `WithholdingTaxReportMapper` + `WithholdingTaxReportMapper.xml` (selectList/selectDetailById/upsertQuarterlyAggregate/updateFileStatusToFiled)
- `VatReportMapper` + `VatReportMapper.xml` (selectList/selectDetailById/upsertQuarterlyAggregate/updateFileStatusToFiled)
- `WithholdingTaxReportService` + `WithholdingTaxReportController` (`/api/withholding-tax-reports`)
- `VatReportService` + `VatReportController` (`/api/vat-reports`)
- `WithholdingTaxQuarterlyStep` (batch Step 11, 분기 마지막 월 실행)
- `VatReportQuarterlyStep` (batch Step 12, 분기 마지막 월 실행)
- 프론트 API 모듈: `ga-frontend/src/api/withholdingTax.ts` + `ga-frontend/src/api/vat.ts`
통합 검증 결과 (2026-05-17):
- Flyway: 운영 DB ga schema V73 (V67/V68/V69/V73 모두 적용 확인)
- GET /api/withholding-tax-reports → 200 OK, list 반환 (집계 데이터 있음)
- GET /api/withholding-tax-reports/1 → 200 OK, 단건 상세 반환
- POST /api/withholding-tax-reports/regenerate {"settleQuarter":"2025-Q4"} → 200 OK, affected=0 (이미 존재, 정상)
- PUT /api/withholding-tax-reports/complete {"ids":[1]} → 200 OK
- GET /api/vat-reports → 200 OK, list 반환
- GET /api/vat-reports/1 → 200 OK, 단건 상세 반환
- POST /api/vat-reports/regenerate {"settleQuarter":"2025-Q4"} → 200 OK, affected=1
- PUT /api/vat-reports/complete {"ids":[1]} → 200 OK
보정 사항 (통합 검증 중 발견):
- frontend build 실패: ChargebackGradeList/InstallmentRatioList/DeductionList validator 파라미터 implicit any, CommissionDispute/IncentiveProgram/TaxInvoiceList parser 반환타입 불일치 → 명시적 타입 어노테이션으로 수정 (6개 파일)
- complete 엔드포인트 요청 바디: `{"ids":[id]}` 형식 (단일 reportId 아님)
운영 DB 최신 상태: ga schema V73 적용 완료.
## 3-11. P6 PoC — 외부 연동 어댑터 모듈 (V81~V84, 2026-05-24 라이브 적용)
P6 본 작업의 PoC가 완료되어 어댑터 추상화 + Mock 구현 + SDK 분기 + 운영 잔여 기능까지 들어감.
실제 KFTC/Toast SDK 통합 시 어댑터 구현체만 바꾸면 ga-api/ga-common 무변경.
| 항목 | 마이그레이션 | 상태 |
|---|---|---|
| 분급 비율 회차(월) 단위 등록 지원 | V81 | 완료 |
| withdraw_request 펌뱅킹 추적 컬럼 (transfer_tx_id/transfer_status/transfer_at) | V82 | 완료 |
| WITHDRAW_REQUESTS EXECUTE 권한 보강 (오기 — 0건 적용) | V83 | 완료(no-op) |
| WITHDRAW 메뉴 EXECUTE 권한 (V83 보정본) | V84 | 완료 |
핵심 산출물:
- **신규 모듈 `ga-external-adapter`** — 외부 SDK 의존성 격리, settings.gradle 등록
- ga-common.adapter.bank: `BankTransferAdapter` 인터페이스 + Request/Response/Status
- ga-common.adapter.message: `MessageAdapter` 인터페이스 + Channel(SMS/KAKAO)/Request/Response
- `MockBankTransferAdapter` / `MockMessageAdapter` (로그 + 계좌·전화 마스킹, 기본 활성)
- `KftcBankTransferAdapter` / `ToastMessageAdapter` 스켈레톤 (UnsupportedOperationException, SDK 통합 시 구현)
- `@ConditionalOnProperty` 프로파일 분기: `app.adapter.bank=mock|kftc`, `app.adapter.message=mock|toast`
- `ApprovalService.handleApprovalCallback(WITHDRAW)` — 최종 승인 시 펌뱅킹 어댑터 호출 → 성공 시 APPROVED→SENT 전이 + transfer_tx_id 영속화, 실패 시 APPROVED 유지 + fail_reason 기록
- `ApprovalService.sendStepNotification` — DB 알림 외 SMS 어댑터 호출 (UserMapper.selectFirstActiveContactByRoleCode 로 실제 결재자 phone 매핑, phone 미확보 시 SMS skip)
- `WithdrawBankRetryService` + `BankRetryController` (`POST /api/internal/bank-retry`) — 실패 transfer 재시도 배치 + `@EnableScheduling` + cron 옵션(기본 비활성) + `app.bank-retry.limit`
- `WithdrawRequestMapper.selectFailedTransfers` / `updateTransferResult` 추가
- `WithdrawRequestVO/Resp` + `WithdrawRequestMapper.xml` + 화면 `WithdrawRequest.tsx` 에 transfer_tx_id/transfer_status/transfer_at 노출
설정값(application.yml):
```yaml
app:
adapter:
bank: ${ADAPTER_BANK:mock} # mock | kftc
message: ${ADAPTER_MESSAGE:mock} # mock | toast
bank-retry:
limit: ${BANK_RETRY_LIMIT:50}
cron: ${BANK_RETRY_CRON:0 0 0 1 1 ?} # 사실상 비활성. 운영 활성화 예 "0 */10 * * * *"
```
통합 검증 결과 (2026-05-24):
- 5모듈 컴파일 BUILD SUCCESSFUL (ga-common/ga-core/ga-external-adapter/ga-api/ga-batch)
- 운영 DB v82~v84 적용 완료, ga-api 8082 가동
- POST /api/auth/login → 200 OK, accessToken 발급
- GET /api/withdraw-requests → 200 OK, 2건 모두 status=SENT / transfer_status=ACCEPTED / transferTxId="MOCK-…" 영속화
- POST /api/internal/bank-retry → 200 OK, data=0 (실패 건 없음 — 정상)
- GET /api/approvals/requests/mine → 200 OK, WITHDRAW APPROVED 이력 정상
- GET /api/forecast-kpi → 200 OK
- GET /api/year-end-statements → 200 OK
보정 사항:
- V83 작성 시 menu_code 오기로 0건 적용 → V84 로 정정 보강
## 4. 잔여 작업 (P6 실연동)
P5 전체 + P6 PoC(어댑터 모듈 + Mock 구현 + SDK 분기 + 재시도 배치 + DB 추적 컬럼) 완료.
잔여는 **외부 스펙 확정 후 적용** 가능한 항목 + 모바일.
### P6 실연동
| 항목 | 설명 | 선행 조건 |
|---|---|---|
| KFTC 펌뱅킹 실 SDK 통합 | `KftcBankTransferAdapter` 본체 구현 + 인증·엔드포인트 주입 | KFTC API 키·계약·테스트망 |
| Toast(NHN) SMS 실 SDK 통합 | `ToastMessageAdapter` 본체 구현 | 계정·App Key·발신번호·템플릿 코드 |
| 카카오 알림톡 실 SDK 통합 | 동일 (채널만 KAKAO) | 비즈 채널·템플릿 승인 |
| 펌뱅킹 SETTLED 동기화 | ACCEPTED→SETTLED 폴링 스케줄 (queryStatus 사용) | 실 SDK 통합 이후 |
| 펌뱅킹/메시지 호출 감사 이력 | `external_call_log` 테이블 + 영속화 + 조회 화면 | 독립 진행 가능 |
| 모바일 셀프 조회 PWA | 설계사 전용. 정산 명세서/출금 신청/공지 등 | 화면 우선순위 협의 |
## 3-12. P6 PoC — 외부연동 호출 감사 이력 (V85, 2026-05-24 라이브 적용)
P6 PoC 의 운영성 보강. 펌뱅킹/SMS·카카오 어댑터 모든 호출이 `external_call_log` 테이블에 AOP 로 자동 영속화. 외부 SDK 통합 후에도 그대로 재사용.
| 항목 | 위치 | 상태 |
|---|---|---|
| external_call_log 테이블 + 인덱스 + SYSTEM_EXTERNAL_CALL 메뉴/권한 | V85 | 완료 |
| ExternalCallLogVO / Resp / SearchParam | ga-core | 완료 |
| ExternalCallLogMapper(.java/.xml) — insertOne + selectList + selectById | ga-core | 완료 |
| ExternalCallLogService — recordCall @Transactional(REQUIRES_NEW) | ga-api | 완료 |
| ExternalCallLogController — GET /api/external-call-logs[/{id}] | ga-api | 완료 |
| ExternalCallLoggingAspect — BankTransferAdapter+/MessageAdapter+ 모든 메서드 @Around | ga-api/aop | 완료 |
| ExternalCallLogList.tsx + api/externalCallLog.ts + App.tsx 라우트 | ga-frontend | 완료 |
핵심 동작:
- 어댑터 호출 전/후·예외 모두 `external_call_log` INSERT (호출자 트랜잭션과 분리 — REQUIRES_NEW)
- request/response 는 PII 마스킹된 JSON 한 줄로 요약 (계좌·전화 뒤 4자리, 이름 첫글자만)
- 영속화 자체가 실패해도 비즈니스 흐름은 절대 깨지 않음 (warn 로그만)
- `target_ref_type/id` 자동 추출: WITHDRAW(BankTransferRequest.withdrawRequestId), MessageRequest.refType/refId
통합 검증 결과 (2026-05-24):
- Flyway: V85 자동 적용 → 운영 DB schema **v85**
- 5모듈 컴파일 BUILD SUCCESSFUL + ga-frontend TS 타입 검증 통과
- 라이브 시나리오: 신규 withdraw#3 → 결재 1단계 + 2단계 APPROVE → 최종 승인 → 펌뱅킹 호출
- withdraw#3 status=SENT, transferTxId=MOCK-…, transferStatus=ACCEPTED
- GET /api/external-call-logs → total=1, log#1: BANK/MockBankTransferAdapter/requestTransfer/success=true/durMs=3/target=WITHDRAW#3
- 상세: requestSummary 의 accountNoMasked="****0123" — PII 마스킹 정상 (평문 비노출)
## 4. 잔여 작업 (P6 실연동)
P5 전체 + P6 PoC(어댑터 모듈 + Mock 구현 + SDK 분기 + 재시도 배치 + DB 추적 컬럼 + **호출 감사 이력**) 완료.
잔여는 **외부 스펙 확정 후 적용** 가능한 항목 + 모바일.
### P6 실연동
| 항목 | 설명 | 선행 조건 |
|---|---|---|
| KFTC 펌뱅킹 실 SDK 통합 | `KftcBankTransferAdapter` 본체 구현 + 인증·엔드포인트 주입 | KFTC API 키·계약·테스트망 |
| Toast(NHN) SMS 실 SDK 통합 | `ToastMessageAdapter` 본체 구현 | 계정·App Key·발신번호·템플릿 코드 |
| 카카오 알림톡 실 SDK 통합 | 동일 (채널만 KAKAO) | 비즈 채널·템플릿 승인 |
| 펌뱅킹 SETTLED 동기화 | ACCEPTED→SETTLED 폴링 스케줄 (queryStatus 사용) | 실 SDK 통합 이후 |
| 모바일 셀프 조회 PWA | 설계사 전용. 정산 명세서/출금 신청/공지 등 | 화면 우선순위 협의 |
## 3-13. P6 — 모바일 셀프 조회 PWA 스캐폴드 (ga-mobile-pwa, 2026-05-24)
설계사 본인 정산명세서·출금·공지를 모바일에서 조회하는 PWA 신규 모듈. 데스크탑 admin UI(`ga-frontend`)와 별도. **백엔드 무변경** — 기존 `ga-api`(8082) 그대로 재사용.
### 구성
| 항목 | 위치 | 비고 |
|---|---|---|
| 신규 모듈 | `ga-mobile-pwa/` | Vite + React 18 + TS + antd-mobile@5 |
| PWA 인프라 | `vite-plugin-pwa` | manifest + service worker autoUpdate, `/api/**` NetworkFirst(5s/5min) |
| 인증 | `src/stores/authStore.ts` (zustand persist) | 토큰 키: `mobile.accessToken` (`ga-frontend` 와 분리) |
| API 클라이언트 | `src/api/{request,auth,statement,withdraw,notice}.ts` | axios + 401 자동 리다이렉트 |
| 화면 5종 | `src/pages/{Login,Home,StatementList,WithdrawList,NoticeList}.tsx` | NavBar + List/Card + PullToRefresh + TabBar |
| 라우팅 | `src/App.tsx` + `RequireAuth` 가드 + `MobileTabBar` | `/login` 외 모두 보호 |
### 화면 ↔ 백엔드 API
| 경로 | 백엔드 |
|---|---| |---|---|
| V37 Materialized View 4종 (월별/조직별/유지율/누계) | dba-architect | | `/login` | POST `/api/auth/login` + GET `/api/auth/me` |
| KPI VO/Mapper | core-architect | | `/` (홈) | 세 도메인 카운트 조회 |
| KpiService + KpiController | api-developer | | `/statement` | GET `/api/statements?agentId={me.agentId}` |
| KPI 대시보드 화면 (AG Grid 합계행, 차트) | frontend-developer | | `/withdraw` | GET `/api/withdraw-requests?agentId={me.agentId}` |
| `/notice` | GET `/api/notices/active` |
### 3-4. 마무리 통합 본인 데이터 필터: 클라이언트에서 `profile.agentId` 를 쿼리 파라미터로 전달. 본격 운영에는 **백엔드 `/api/mobile/me/*` 전용 endpoint** (토큰 → agentId 서버측 강제 필터) 권장 — 잔여 항목.
| 작업 | 담당 |
### 빌드 / 실행
```bash
cd ga-mobile-pwa
npm install
npm run dev # http://localhost:3001 (vite dev — /api → 8082 proxy)
npm run build # tsc --noEmit + vite build → dist/ (manifest + sw 자동 생성)
npm run preview # 3001 에서 production 번들 서빙
```
### 통합 검증 (2026-05-24)
- `npm install` 407 packages 성공
- `tsc --noEmit` PASS (`@types/node` 추가)
- `npm run build` PASS — `manifest.webmanifest` + `sw.js` + `workbox-*.js` 자동 생성, precache 8 entries (528 KiB), 번들 516KB(gzip 173KB)
- 라이브: vite dev :3001 LISTENING, ga-api :8082 정상
- API 응답 확인: `/api/statements` total=112, `/api/withdraw-requests` total=3, `/api/notices/active` 0건(정상), `/api/auth/me` 정상
### 잔여 (스캐폴드 이후)
- ~~백엔드 `/api/mobile/me/*` 전용 endpoint~~ → §3-14 에서 완료
- ~~본인 알림(`user_notification`) 표시~~ → §3-14 에서 완료
- 출금 신청 작성 폼 (현재 이력 조회만)
- 정산 명세서 상세 + PDF 다운로드
- 토큰 만료 자동 refresh
- 푸시 알림 (FCM 등) — 외부 채널 결정 후
- 실 아이콘 PNG (192/512/512 maskable)
- 번들 코드 스플리팅 (manualChunks)
## 3-14. P6 — 모바일 PWA 보안 강화: `/api/mobile/me/*` 전용 endpoint (2026-05-24)
PWA 스캐폴드에서 클라이언트가 `agentId` 를 쿼리 파라미터로 보내 본인 데이터를 필터하던 변조 가능 구조를 **서버측 강제 필터**로 교체. 토큰 → user → agentId 자동 추출.
### 백엔드 신규
| 위치 | 내용 |
|---|---| |---|---|
| ga-batch 8 Step에 도메인 로직 통합 (TaxCalculator/Deduction/Regulatory/Installment/Chargeback) | batch-engineer | | `ga-api/service/mobile/MobileMeService` | 토큰에서 userId 추출, `userMapper.selectById` 로 agentId 결정. 클라이언트 param 의 agentId/userId 는 **무시하고 덮어쓰기**. agentId 미연결 시 `BizException(FORBIDDEN)` |
| 통합 테스트 | infra-reviewer | | `ga-api/controller/mobile/MobileMeController` | `GET /api/mobile/me/{summary,statements,withdraw-requests,notifications}` + `POST /api/mobile/me/notifications/{id}/read`. 별도 메뉴 권한 없이 인증만 통과 |
| README/DEVELOPER_GUIDE 업데이트 | infra-reviewer |
## 4. 재개 절차 (다음 세션) ### PWA 측 전환
- `src/api/statement.ts` / `withdraw.ts``/api/mobile/me/*` 호출, `agentId` 인터페이스에서 제거
- `src/api/notification.ts` 신규 — list/markRead/summary
- `Home.tsx``notificationApi.summary()` 한 번으로 3 카운트 동시 표시, 안 읽음 뱃지
- `NoticeList.tsx``Tabs``내 알림` / `공지사항` 두 탭. 알림 클릭 시 markRead + summary 무효화
- `StatementList.tsx` / `WithdrawList.tsx` — agentId 의존 제거, E403(설계사 미연결) 시 ErrorBlock 안내
1. **Claude Code 시작 시 cwd 확인**: `C:\Users\kyu\Desktop\ga_pro` (현재 세션과 동일) ### 라이브 검증 (2026-05-24)
2. **Agent Teams 활성화 확인**: `.claude/settings.json` 이미 커밋되어 있음 (`418ff7c`) - ga-api 컴파일 BUILD SUCCESSFUL · 재기동 OK
3. **팀 재생성**: 새 세션은 팀이 없음. 다음 명령 흐름: - PWA `tsc --noEmit` PASS · `npm run build` PASS (precache 8 entries 538 KiB, gzip 176KB)
- PL 역할로 `TeamCreate(team_name="ga-pro-team", agent_type="pl", description="...")` 호출 - `GET /api/mobile/me/summary` (admin, agentId=null) → 200 OK, 세 카운트 모두 0
- 7명 팀원 spawn (common/core/api/batch/frontend/dba/infra) — 첫 세션과 동일 - `GET /api/mobile/me/statements` (admin) → **E403** 차단 (보안 강제 동작 확인)
4. **HANDOFF.md 읽기**: 본 문서 + 최근 git log로 컨텍스트 확보 - `GET /api/mobile/me/notifications` (admin) → 200 OK total=0
5. **권장 시작 지점**: P2 Wave 1 디스패치 (dba-architect에게 V29~V36 8개 마이그레이션)
6. **사용자 결정 필요 항목**:
- P1 정산 통합 진행 여부 (Wave 작업 전 또는 P3 후)
- P2-1 마감 도메인의 마감 후 수정 차단 AOP 구현 방식 (전역 AOP vs Service-level 가드)
## 5. PL 가이드 (다음 세션 유의사항) ## 4. 잔여 작업 (P6 실연동)
P5 전체 + P6 PoC + **P6 후속(2026-05-27)** 완료. 잔여는 외부 SDK 차단 항목만.
### 운영 DB 마이그레이션 상태
운영 DB `192.168.0.60:55432/trading_ai/ga` 현재 **V96 적용 완료** (P7 incar 갭 반영 7도메인).
ga-api 8082 / ga-frontend 3000 / ga-mobile-pwa 3001 모두 라이브.
단위/통합 테스트: **SqlSessionFactory 환경 문제 해결 완료 (2026-05-29, §3-17)**. `./gradlew test` → 112건 전부 PASS (ga-common 57 / ga-api 36 / ga-batch 19).
## 3-15. P6 후속 (2026-05-27, 커밋 `2c9bf71` + `dfce9e5` + 후속 1)
| 영역 | 결과 |
|---|---|
| PWA 출금 신청 작성 폼 | `POST /api/mobile/me/withdraw-requests` + settle-master 드롭다운, **net_amount 잔여 한도 검증 포함** |
| PWA 명세서 상세 + 엑셀 다운로드 | `GET /api/mobile/me/statements/{id}[/download]`, 본인 검증 + 기존 download 흐름 재사용 |
| PWA 토큰 자동 refresh | 401 → `/api/auth/refresh` 단일화 재시도, refreshToken localStorage 영속 |
| 펌뱅킹 SETTLED 동기화 폴러 | `WithdrawBankSettleService` + `POST /api/internal/bank-settle`, ACCEPTED→COMPLETED/APPROVED 전이 |
| 사용자 phone 등록 (admin) | `PUT /api/system/users/{id}/phone`, UserList 인라인 Modal |
| PWA 본인 프로필 | `Profile.tsx`(phone 자기수정 + 비번변경 + 로그아웃), TabBar 5탭 |
| PWA 출금 신청 취소 | `POST /api/mobile/me/withdraw-requests/{id}/cancel`, REQUESTED 만 SwipeAction |
| SETTLED/FAILED 본인 알림 자동 발행 | `user_notification` INSERT, PWA NoticeList 내알림 탭 즉시 확인 |
| PWA 알림 모두 읽음 | `POST /api/mobile/me/notifications/read-all`, NoticeList 헤더 버튼 |
| 출금 잔여 한도 검증 | `settle_master.net_amount - sum(활성 출금합계)` 초과 시 E411 |
**e2e 검증 (2026-05-27 23:40, agent01/userId=4/agentId=1)**:
- 로그인 → AGENT role → /api/mobile/me/summary statementCount=3 / withdrawRequestCount=3
- /api/mobile/me/settle-masters → 3건 응답 (admin은 E403이었음)
- POST /api/mobile/me/withdraw-requests → 성공 (requestId=4)
- /api/mobile/me/statements/1 → 200 (본인) · /statements/2 → **E403 (타인)** ← 본인 검증 강제
- PUT /api/mobile/me/profile/phone → 200, /api/auth/me 즉시 반영
- POST /api/internal/bank-settle → SETTLED 동기화 + user_notification 자동 발행 코드 경로 검증
- POST /api/auth/refresh → 새 accessToken 발급
**잔여 — 외부 SDK 차단**:
| 항목 | 차단 사유 |
|---|---|
| KftcBankTransferAdapter 본체 | API 키·계약·테스트망 |
| ToastMessageAdapter 본체 | App Key·발신번호·템플릿 코드 |
| 카카오 알림톡 본체 | 비즈채널·템플릿 승인 |
| 푸시 알림 (FCM 등) | 외부 채널 결정 |
## 3-18. P7 — incar 갭 반영 수수료 계산 7개 도메인 풀스택 (V88~V96 — 완료, 2026-05-31 라이브 적용)
incar CMS 갭 분석 결과 GA 시스템에 없던 수수료 계산 7개 도메인을 DB→Core→API→Frontend 전 레이어로 추가. 운영 DB schema **v96**.
| 도메인 | 마이그레이션 | 테이블 | API 경로 |
|---|---|---|---|
| 수입수수료+수지차 (INCOME_COMM) | V88 | income_commission_ledger, margin_summary | `/api/income-commissions/*` |
| 소개·이관수수료 (REFERRAL_COMM) | V89 | referral_commission_rule, referral_commission_ledger | `/api/referral-commissions/*` |
| 정착지원금 (SETTLING_SUPPORT) | V90 | settling_support_rule, settling_support_ledger | `/api/settling-supports/*` |
| 인정실적+환산보험료 (RECOGNIZED_PERF) | V91 | product_conversion_rate, recognized_performance_ledger | `/api/recognized-performances/*` |
| 시상 (AWARD) | V92 | award_rule, award_ledger | `/api/awards/*` |
| 생보운영지원수수료 (LIFE_OP_SUPPORT) | V93 | life_support_rule, life_support_ledger | `/api/life-supports/*` |
| 지점장수당 (BRANCH_MGR_ALLOWANCE) | V94 | branch_mgr_allowance_rule, branch_mgr_allowance_ledger | `/api/branch-allowances/*` |
| 메뉴 7개 | V95 | — | GRP_COMMISSION 하위 sort 80~140 |
| 공통코드 5그룹 | V96 | — | INCOME_STATUS/REFERRAL_TYPE/CALC_TYPE/SUPPORT_STATUS/AWARD_TYPE |
통합 검증 (2026-05-31):
- `./gradlew build -x test` BUILD SUCCESSFUL
- Flyway V88~V96 9건 모두 success=True, 운영 DB schema v96
- 신규 14테이블 전부 존재 확인, 신규 메뉴 7건 등록 확인
- 기존 smoke_allmenus.py 67엔드포인트: PASS=66 FAIL=0 SKIP=1(settle/summary 필수파라미터, 기존 동일)
- P7 신규 8엔드포인트(인정실적은 /rates+/ledgers 분리): PASS=8 FAIL=0
- 주의: `/api/recognized-performances/rules` 는 없음. D4는 `/rates`(환산율)와 `/ledgers`(원장) 두 경로로 분리됨.
## 3-16. 계약 승계 (contract_succession, V86 — 완료, 2026-05-29 라이브 적용)
설계사 해촉/이관 시 보험계약의 **유지수수료(maintain) 귀속 설계사를 승계월 기준 후임으로 전환**.
모집수수료(1회차)는 원 모집설계사 귀속 유지 — 한국 고아계약 승계 표준. 후임 설계사는 본인 등급의 payout 비율로 수령.
| 레이어 | 산출물 |
|---|---|
| DB (V86) | `contract_succession`(contract_id/from_agent_id/to_agent_id/succession_date/reason/status) + 인덱스 3 + 메뉴 `CONTRACT_SUCCESSION`(GRP_PRD, path `/contracts/successions`) + 권한 READ/CREATE/UPDATE/DELETE + 역할부여(SUPER_ADMIN·ADMIN 전체 / MANAGER READ) + 공통코드 SUCCESSION_REASON·SUCCESSION_STATUS + 테스트데이터 1건 |
| core | `ContractSuccession` VO/Resp/SaveReq/SearchParam + Mapper(.java/.xml). 핵심 `selectEffectiveSuccessions(settleMonth)` = 계약별 최근 ACTIVE 승계 1건(DISTINCT ON, `TO_CHAR(succession_date,'YYYYMM') <= settleMonth`) |
| api | `ContractSuccessionService/Controller` (`/api/contract-successions`) CRUD + `/{id}/cancel`. from==to 가드(E411) |
| batch | `CalcMaintainStep`: effective 승계 Map 사전로드(N+1 회피) → `effectiveAgentId = getOrDefault(contractId, c.agentId)``CommissionCalculator.calcMaintainLedger(c, month, year, effectiveAgentId)` 오버로드(후임 등급 payout + `led.setAgentId(effectiveAgentId)`). **AggregateStep 무변경** — ledger의 agent_id로 집계하므로 후임 귀속 자동 전파 |
| frontend | `api/succession.ts` + `pages/contracts/ContractSuccessions.tsx` + App.tsx 라우트 `contracts/successions` |
핵심 산출물 커밋: `bd5ff4e`.
**부수 버그 수정 (커밋 `4dbc688`, 승계 무관)**:
- `AgentDeductionMapper.updateStatusBatch` 가 인터페이스 선언만 있고 XML 구문 누락 → AggregateStep(Step8) 이 PENDING 차감 일괄 APPLIED 전이 시 `Invalid bound statement (not found)` 로 aggregate 전체 FAILED 되던 잠복 버그(P0-2부터 존재). `updateStatus` 동일 패턴 + foreach IN 으로 보강. e2e 도중 발견.
통합 검증 (2026-05-29):
- 5모듈 compileJava BUILD SUCCESSFUL + ga-frontend tsc 0 오류
- Flyway V86 자동 적용 → 운영 DB schema **v86**
- API CRUD + cancel + from==to 가드(E411) 라이브 PASS
- **배치 풀잡 e2e**: ga-batch 8081 기동, `POST /internal/jobs/monthly-settlement`(JWT 필요 — ga-api admin 토큰 재사용). 테스트 승계(계약59 agent39→agent2, 2026-01-01) 등록 후 202605 정산 실행:
- maintain_ledger 계약59 = **agent2(후임)** / 5520(후임 등급 재계산, 원래 agent39/19320)
- settle_master agent2 maintain = 142700 = ledger 합 / agent39 = 191100 = ledger 합
- 무결성 **ledger 총합 == settle_master maintain 총합 = 17,556,135 정확 일치**
- (202604는 전 행 status=CONFIRMED → upsert 불변 가드 `WHERE status NOT IN ('CONFIRMED','PAID')` 로 settle_master 미갱신 = 정상 동작)
- e2e 테스트 DB 부산물(승계 레코드 / 202605 정산데이터 / 회계분개 / 차감 전이 / batch_job_log)은 단일 트랜잭션으로 전부 롤백 완료, pre-e2e 상태 복원 검증.
- **주의**: AccountingEntryGenerateStep(Step8b)은 멱등이 아님 — 동일 월 재실행 시 분개 중복 INSERT(주석상 existsByPaymentId 중복방어 언급되나 코드 미구현). 동일 월 재정산 전 기존 분개 정리 필요.
## 3-17. 테스트 환경 — SqlSessionFactory 문제 해결 (2026-05-29, 완료)
증상: `./gradlew :ga-api:test` 시 Spring 컨텍스트 로딩 실패 — `Error creating bean 'externalCallLogMapper' ... Property 'sqlSessionFactory' or 'sqlSessionTemplate' are required`.
원인:
- `application-test.yml` 이 MybatisAutoConfiguration / DataSourceAutoConfiguration 을 제외 → 컨텍스트에 SqlSessionFactory 없음.
- 그러나 `MyBatisConfig``@MapperScan(basePackages = "com.ga")` 가 모든 @Mapper 의 MapperFactoryBean 을 생성, 이들은 SqlSessionFactory 를 요구.
- `AbstractControllerTest` 는 매퍼를 하나씩 `@MockBean` 으로 막아 회피했으나, 이후 추가된 매퍼(ExternalCallLog/Succession/AccountingEntry 등)가 목록에 누락 → 실제 MapperFactoryBean 생성 시도 → 깨짐. **매퍼 추가될 때마다 목록 갱신해야 하는 취약 구조.**
- (부수) `SettleControllerTest` 가 구버전 `hold(id, reason)` 2인자 stub 사용 → 현재 `hold(id, reason, reasonCode)` 3인자와 불일치로 testCompile 실패.
해결:
- `TestMyBatisConfig`(@TestConfiguration) 신규 — 미접속 `PGSimpleDataSource` + `SqlSessionFactory` 한 쌍 제공. 모든 MapperFactoryBean 이 생성되므로 매퍼 목록 갱신 불필요. 컨트롤러 테스트는 Service 를 mock 하므로 실제 쿼리 미실행(연결 안 함).
- `AbstractControllerTest` `@Import``TestMyBatisConfig` 추가.
- `SettleControllerTest` hold stub 3인자로 정정.
검증: `./gradlew test --rerun-tasks`**BUILD SUCCESSFUL, 112건 PASS** (ga-common 57 / ga-api 36 / ga-batch 19, 실패·에러 0). DB 무관(미접속 DataSource).
## 3-9. P5-W3 메뉴/권한 전수 정합 보강 (V74 — 완료, 2026-05-21 라이브 적용)
| 항목 | 상태 |
|---|---|
| AUDIT_P5W3.md §4 갭 리포트 작성 | 완료 (2026-05-17, infra) |
| V74 SQL 작성 | 완료 (2026-05-17, dba) |
| V74 정적검증 (PL) | 완료 (멱등·ON CONFLICT·스코프 확인) |
| 4모듈 compileJava | **PASS** (BUILD SUCCESSFUL) |
| ga-frontend npm run build | **PASS** (TS 오류 0) |
| 운영 DB 접속·Flyway V74 적용 | **완료** (2026-05-21, ga-api 재기동 시 자동 적용) |
| 스모크 검증 | **완료** (verify_v74.py 20/20 PASS) |
갭 요약 (총 27건):
- (a) menu 없음: 0건
- (b) menu_permission 없음: 3건 (NOTICE/DELETE, SYSTEM_DATA_DICT/DELETE, TERM_SETTLE/APPROVE)
- (c) grant 없음: 6건 (위 3 perm × SUPER_ADMIN+ADMIN, ADMIN/SYSTEM_* 7건은 보안상 의도적 제외)
- route/menu_path 불일치: 11건 (V74에서 UPDATE 처리)
갭 재검증 (V74 적용 후): `python verify_v74.py`**20/20 PASS** 확인 완료 (2026-05-21).
## 3-10. P5-W3 본론 — 결산 / 이상치·예측 / 연말명세서 (V75~V78, 2026-05-21 완료)
| 항목 | 마이그레이션 | 상태 |
|---|---|---|
| accounting_close + account_balance_snapshot (회계 결산) | V75 | 완료 |
| retention_anomaly_alert + forecast_kpi_monthly (이상치·예측 KPI) | V76 | 완료 |
| year_end_statement (연말 지급명세서) | V77 | 완료 |
| P5-W3 본론 메뉴 4개 + 권한 + 공통코드 7그룹 + 테스트데이터 | V78 | 완료 |
핵심 산출물:
- `AccountingCloseService/Controller` (`/api/accounting-closes`) — DRAFT 생성 / 마감 실행(분개 채번+잔액 스냅샷) / 재오픈
- `RetentionAnomalyAlertService/Controller` (`/api/retention-anomalies`) — 13M/25M 이상 탐지 / 검토 처리
- `ForecastKpiMonthlyService/Controller` (`/api/forecast-kpi`) — 이동평균(SMA3/6/NAIVE) 차월 예측
- `YearEndStatementService/Controller` (`/api/year-end-statements`) — 연 사업소득 명세서 재집계/발급/발송/엑셀
- 배치: BatchConfig 에 Step 11(WithholdingTaxQuarterly) / Step 12(VatReportQuarterly) 연결
- 프론트: `AccountingCloseList` / `YearEndStatementList` / `RetentionAnomalyList` / `ForecastKpiList` 4화면 + API 모듈 4개 + App.tsx 라우트 4개
- 공통: `usePermission`/`PermissionButton``EXECUTE` 권한 누락 보강 (canExecute)
통합 검증 (2026-05-21):
- Flyway: 운영 DB V74~V78 자동 적용 → 현재 **v78**
- 4모듈 compileJava + ga-frontend npm run build **PASS** (TS 오류 0)
- 스모크: 신규 4 + 회귀 4 = **GET 8/8 PASS**, 쓰기(regenerate/detect/draft) 4/4 PASS
- 보정: YearEndStatementMapper.xml 의 `a.agent_code` 미존재 컬럼 참조 제거 (agent 테이블에 agent_code 없음)
## 5. 재개 절차 (다음 세션)
1. **Claude Code 시작 시 cwd 확인**: `C:\Users\kyu\Desktop\ga_pro`
2. **HANDOFF.md 읽기**: 본 문서 §3-16(계약 승계) + §3-15(P6 후속) + 최근 git log
3. **운영 DB 상태**: **V96 적용 완료** (P7 incar 7도메인). ga-api 8082 가동. ga-frontend 3000 / ga-mobile-pwa 3001 은 필요 시 기동. origin/main 동기화 완료(HEAD `4dbc688`, 2026-05-29 push). P7 코드(V88~V96)는 로컬 커밋 아직 없음.
4. **재기동이 필요한 경우**:
- `cd ga-commission-system && nohup ./gradlew :ga-api:bootRun --args='--spring.profiles.active=trading_ai' > ../ga-api-bootrun.log 2>&1 & disown`
- `cd ga-commission-system/ga-frontend && npm run dev` (3000)
- `cd ga-commission-system/ga-mobile-pwa && npm run dev` (3001)
- 정산 배치: `nohup ./gradlew :ga-batch:bootRun --args='--spring.profiles.active=trading_ai' > ../ga-batch-bootrun.log 2>&1 &` (8081). 트리거 `POST :8081/internal/jobs/monthly-settlement {"settleMonth":"yyyyMM"}`**JWT 필요(ga-api `/api/auth/login` admin 토큰 재사용)**. 동기 실행.
5. **테스트 계정 / DB**: `admin`/`admin1234!`(SUPER_ADMIN), `agent01`/`agent1234!`(userId=4·agentId=1·AGENT). 로그인 필드명 `loginId`. DB: psql17 `192.168.0.60:55432/trading_ai` user `kyu`/pw `7895123`, `SET search_path TO ga`.
6. **사용자 결정 필요 항목 (외부 SDK 차단 사유)**:
- KFTC 펌뱅킹 API 계약·인증 정보 → `KftcBankTransferAdapter` 본체 구현
- NHN Toast / 카카오 비즈채널 계정 + 템플릿 코드 → `ToastMessageAdapter` 본체
- 푸시 알림 채널 (FCM 등) 결정 → PWA 통합
## 6. PL 가이드 (다음 세션 유의사항)
- **시간차 중복 메시지**: 팀원이 SendMessage로 같은 보고를 2~3번 보내는 경우 있음. 짧게 acknowledge만. - **시간차 중복 메시지**: 팀원이 SendMessage로 같은 보고를 2~3번 보내는 경우 있음. 짧게 acknowledge만.
- **idle 알림**: 팀원이 작업 끝낼 때마다 idle 알림 보냄. 정상. 액션 필요 없음. - **idle 알림**: 팀원이 작업 끝낼 때마다 idle 알림 보냄. 정상. 액션 필요 없음.
@@ -127,9 +554,9 @@
- **자동 정책**: - **자동 정책**:
- 각 단계 완료 후 자동 커밋 (push 안 함) - 각 단계 완료 후 자동 커밋 (push 안 함)
- 빌드/테스트 실패 시 담당 팀원에 재지시 - 빌드/테스트 실패 시 담당 팀원에 재지시
- 컴파일은 PL이 직접 검증, 테스트는 환경 문제(SqlSessionFactory)로 통과 안 되는 게 알려진 상태 - 컴파일은 PL이 직접 검증. 테스트는 SqlSessionFactory 환경 문제 해결 후 `./gradlew test` 112건 PASS (§3-17)
## 6. 표준 가정 (도메인 작업 진행 시 채택한 값) ## 7. 표준 가정 (도메인 작업 진행 시 채택한 값)
도메인 룰은 한국 보험업감독규정 표준 가정을 외부화 가능한 구조로 등록. 실제 회사 정책과 다르면 system_config / regulatory_limit / installment_ratio / chargeback_grade의 데이터만 수정하면 됨. 도메인 룰은 한국 보험업감독규정 표준 가정을 외부화 가능한 구조로 등록. 실제 회사 정책과 다르면 system_config / regulatory_limit / installment_ratio / chargeback_grade의 데이터만 수정하면 됨.
+163 -19
View File
@@ -40,9 +40,13 @@ ga-commission-system/
### 시나리오 A — 운영 DB(trading_ai/ga) 에 연결해서 실행 ⭐ 권장 ### 시나리오 A — 운영 DB(trading_ai/ga) 에 연결해서 실행 ⭐ 권장
이미 V1~V17 마이그레이션 + 테스트 데이터(보험사 5 / 상품 20 / 설계사 50 / 계약 200)가 V1~V17 마이그레이션 + 테스트 데이터(보험사 5 / 상품 20 / 설계사 50 / 계약 200)가
`192.168.0.60:55432/trading_ai/ga` 스키마에 적용되어 있습니다. `192.168.0.60:55432/trading_ai/ga` 스키마에 적용되어 있습니다.
> **운영 DB 상태 (2026-05-17 기준)**: V73 적용 완료. V74(메뉴/권한 전수보강) 작성 완료, 대기 중.
> ga-api 를 재시작하면 Flyway 가 V74 를 자동 적용합니다(멱등).
> DB 복귀 후 `python verify_v74.py` 로 보강 결과를 검증하세요.
#### 1) Backend (ga-api) 실행 #### 1) Backend (ga-api) 실행
```bash ```bash
@@ -107,6 +111,10 @@ PW : admin1234!
| 정산 결과 | `/settle` | 월 요약 + 확정/보류 | | 정산 결과 | `/settle` | 월 요약 + 확정/보류 |
| 정산 배치 | `/settle/batch` | 배치 실행 + 진행률 폴링 | | 정산 배치 | `/settle/batch` | 배치 실행 + 진행률 폴링 |
| 지급 관리 | `/payments` | 지급 상태 추적 | | 지급 관리 | `/payments` | 지급 상태 추적 |
| 연말 사업소득 명세서 | `/settle/annual-income` | 설계사별 연간 사업소득 집계 + 재집계 (P5-W1) |
| 분개장 | `/settle/accounting-journal` | 회계 자동분개 엔트리 + 마감 처리 (P5-W1) |
| 원천세 신고 | `/tax/withholding-tax` | 분기별 원천세 신고 집계 + 재집계 + 제출완료 처리 (P5-W2) |
| 부가세 신고 | `/tax/vat` | 분기별 부가세 신고 집계 + 재집계 + 제출완료 처리 (P5-W2) |
| 사용자 관리 | `/system/users` | 비번 초기화/잠금 해제 | | 사용자 관리 | `/system/users` | 비번 초기화/잠금 해제 |
| 역할/권한 | `/system/roles` | 권한 매트릭스 (메뉴 × 6권한) | | 역할/권한 | `/system/roles` | 권한 매트릭스 (메뉴 × 6권한) |
| 공통코드 | `/system/codes` | 그룹별 코드 조회 | | 공통코드 | `/system/codes` | 그룹별 코드 조회 |
@@ -119,7 +127,7 @@ PW : admin1234!
# 1) PostgreSQL + Redis 기동 (Docker) # 1) PostgreSQL + Redis 기동 (Docker)
docker compose up -d postgres redis docker compose up -d postgres redis
# 2) ga-api 실행 (Flyway 가 자동으로 V1~V17 마이그레이션) # 2) ga-api 실행 (Flyway 가 자동으로 V1~V55 마이그레이션)
./gradlew :ga-api:bootRun ./gradlew :ga-api:bootRun
# 기본 프로파일은 application.yml — localhost:5432/ga 에 연결 # 기본 프로파일은 application.yml — localhost:5432/ga 에 연결
@@ -219,23 +227,86 @@ UPDATE ga.users SET password = '$2b$10$...' WHERE login_id = 'admin';
## 📋 주요 기능 ## 📋 주요 기능
### 백엔드 ### 공통 프레임워크 (ga-common)
- **인증/권한**: JWT (HS256), 메뉴×권한 RBAC, BCrypt 비밀번호 - **인증/권한**: JWT (HS256 액세스 2h/리프레시 7d), 메뉴×6권한 RBAC, BCrypt 비밀번호
- **공통코드**: Redis 캐시, 시스템코드 잠금, REST CRUD - **공통코드**: Redis 캐시, 시스템코드 잠금, REST CRUD
- **메뉴**: 트리 구조, 사용자별 동적 메뉴 + 권한 매트릭스 - **메뉴**: 트리 구조, 사용자별 동적 메뉴 + 권한 매트릭스
- **엑셀**: SXSSF+Cursor 다운로드(100만건), SAX+배치 업로드(70만건) - **엑셀**: SXSSF+Cursor 다운로드(100만건), SAX+배치 업로드(70만건)
- **로그**: 로그인/API/데이터변경 자동 기록 (비동기) - **로그**: 로그인/API/데이터변경 자동 기록 (비동기 `@DataChangeLog` AOP)
- **암호화**: AES-256 CBC (`resident_no`, `account_no` 자동) - **암호화**: AES-256 CBC (`resident_no`, `account_no` TypeHandler 자동 적용)
- **업로드 템플릿**: 동적 컬럼 매핑 + 변환 규칙 관리
### 도메인 ### P0/P1 — 핵심 정산 도메인
- **조직**: 트리 구조, CTE 재귀, 인사 이력 - **세무 (P0-1)**: TaxCalculator 순수 도메인 — 사업소득 3.3% / 기타소득 8.8% / 지방세 10% / VAT 10%
- **계약**: 보험사/상품/계약 관리 - **공제 (P0-2)**: agent_deduction 월별 차감 마스터 + payment_deduction_detail 이체 명세
- **수수료 규정**: 보험사율, 지급율, 오버라이드, 환수, 예외코드 - **명세서 (P0-3)**: commission_statement 발급 마스터 + ExcelStatementGenerator (SXSSF)
- **원장**: 모집/유지/예외 (PageHelper + AG Grid) - **1200%룰 (P1-1)**: regulatory_limit 마스터 + RegulatoryLimitChecker (효력일 이력 추적)
- **정산**: 8 Step 배치 (수신→변환→대사→모집→유지→환수/예외→오버라이드→집계) - **분급 (P1-2)**: installment_plan 7년 (35/25/15/10/7/5/3%) + InstallmentPlanGenerator
- **지급**: 이체파일 생성, 상태 추적 - **차등환수 (P1-3)**: chargeback_grade 5단계 + ChargebackCalculator
### 정산 배치 8 Step ### P2 — 운영 필수 8 도메인 (V29~V36)
- **마감 (P2-1)**: period_close — 월 마감 + 마감 후 수정 차단 가드
- **정정 (P2-2)**: settle_correction — 원본 참조 정정 + 사유 코드
- **이의신청 (P2-3)**: commission_dispute — OPEN/IN_REVIEW/APPROVED/REJECTED 워크플로우
- **보류사유 (P2-4)**: settle_master.hold_reason_code + HOLD_REASON 코드그룹
- **시책·인센티브 (P2-5)**: incentive_program + incentive_payment
- **해촉정산 (P2-6)**: termination_settlement (agent_org_history 연계)
- **세금계산서 (P2-7)**: tax_invoice 발행 이력 (SUPPLY/PURCHASE)
- **외부회신 (P2-8)**: carrier_reconciliation_out — 보험사 회신 인터페이스
### P3 — KPI 대시보드 (V37~V39)
- **월별 KPI**: mv_monthly_summary Materialized View + AG Grid 합계행
- **조직별 KPI**: mv_org_summary — 조직 트리별 실적
- **유지율 KPI**: mv_retention_rate — 13M/25M 유지율 추적
- **누계 KPI**: mv_cumulative — 연초 누계 + 차트
### P4-A — 수수료 종류 보강 (V40~V44)
- **commission_master 통합 (P4-A-1)**: commission_type 9종 코드 통합 — 모집/유지/수금/오버라이드/갱신/부활/유지보수수당/시책/위촉
- **수금수수료 (P4-A-2)**: collection_commission_rule + collection_commission_ledger — 납입주기별 차등
- **갱신·부활수수료 (P4-A-3)**: renewal_commission_rule/ledger + reinstatement_commission_rule/ledger
- **유지보수수당 (P4-A-4)**: persistency_bonus_rule + persistency_bonus — 13M/25M/37M 유지율 1회 지급
- **오버라이드 원장 (P4-A-5)**: override_ledger + override_summary — 직급별/조직별 합계 + 검증
### P4-B — 운영 필수 9 도메인 (V45~V53)
- **위촉계약 (P4-B-1)**: agent_contract — 보험사별 위촉계약서 + 위촉수수료 + 효력 기간
- **자격관리 (P4-B-2)**: agent_license — 생보/손보/변액 자격증 + 만료일 + 갱신 이력
- **보수교육 (P4-B-3)**: agent_training — 의무 12시간/년 이수 추적
- **계약변경 (P4-B-4)**: contract_endorsement — 변경 유형 + 수수료 재계산 트리거
- **민원관리 (P4-B-5)**: complaint — 고객 민원 + 설계사 책임 + 환수 연동
- **결재워크플로우 (P4-B-6)**: approval_line + approval_request — 최대 3단계 다단 결재
- **공지·알림 (P4-B-7)**: notice + user_notification — 시스템 공지 + 읽음 여부
- **첨부파일 (P4-B-8)**: attachment — 도메인 무관 공통 첨부 (소속/경로/타입)
- **출금신청 (P4-B-9)**: withdraw_request — 결재 + 펌뱅킹 송신 + 결과 반영
### P5 Wave 2 — 원천세/부가세 분기신고 (V67~V69)
- **원천세 신고** (`/tax/withholding-tax`): 분기별 설계사 사업소득 원천세 신고 집계, 재집계 + 제출완료 처리
- `GET /api/withholding-tax-reports` — 목록 조회 (settleQuarter/agentId/orgId/fileStatus 필터)
- `GET /api/withholding-tax-reports/{id}` — 단건 상세
- `POST /api/withholding-tax-reports/regenerate {"settleQuarter":"YYYY-Qn"}` — 분기 재집계 (EXECUTE 권한)
- `PUT /api/withholding-tax-reports/complete {"ids":[...]}` — 제출완료 처리 (EXECUTE 권한)
- **부가세 신고** (`/tax/vat`): tax_invoice 기반 분기 매출/매입 VAT 집계, 재집계 + 제출완료 처리
- `GET /api/vat-reports` — 목록 조회 (settleQuarter/fileStatus 필터)
- `GET /api/vat-reports/{id}` — 단건 상세
- `POST /api/vat-reports/regenerate {"settleQuarter":"YYYY-Qn"}` — 분기 재집계 (EXECUTE 권한)
- `PUT /api/vat-reports/complete {"ids":[...]}` — 제출완료 처리 (EXECUTE 권한)
### P5 Wave 1 — 회계 진입점 (V63~V66)
- **연말 사업소득 명세서** (`/settle/annual-income`): 설계사별 귀속연도 사업소득 집계, 재집계 버튼
- `GET /api/agent-annual-incomes` — 목록 조회 (settleYear/agentId/orgId 필터)
- `GET /api/agent-annual-incomes/{agentId}/{settleYear}` — 단건 상세
- `POST /api/agent-annual-incomes/regenerate?year=YYYY` — 연도 전체 재집계 (EXECUTE 권한)
- **분개장** (`/settle/accounting-journal`): 수수료 지급 자동분개 엔트리, 마감(journalNo 채번) 기능
- `GET /api/accounting-entries` — 분개 목록 (settleMonth/journalNo/status 필터)
- `POST /api/accounting-entries/generate` — 정산월 기준 분개 자동생성 (settleMonth or paymentIds)
- `PUT /api/accounting-entries/close` — 분개 마감 처리 (entryIds + journalNo)
- **계정코드 마스터** (공통 드롭다운용)
- `GET /api/common/account-codes?accountType=DEBIT|CREDIT|BOTH` — 활성 계정코드 목록
- `GET /api/common/account-codes/all` — 전체 (시스템관리용)
- `POST /api/common/account-codes` — 등록
- `PUT /api/common/account-codes/{accountCode}` — 수정
- `DELETE /api/common/account-codes/{accountCode}` — 삭제
### 정산 배치 10 Step
1. **receiveData** — 보험사별 DataReceiver → raw_commission_data 적재 1. **receiveData** — 보험사별 DataReceiver → raw_commission_data 적재
2. **transformData** — MappingEngine 으로 raw → 표준 원장 (TRIM/UPPER/DATE/DIVIDE/CODE_MAP) 2. **transformData** — MappingEngine 으로 raw → 표준 원장 (TRIM/UPPER/DATE/DIVIDE/CODE_MAP)
3. **reconcile** — 보험사 통보액 vs 시스템 계산액 대사 3. **reconcile** — 보험사 통보액 vs 시스템 계산액 대사
@@ -244,6 +315,8 @@ UPDATE ga.users SET password = '$2b$10$...' WHERE login_id = 'admin';
6. **chargebackException** — 실효 계약 환수 자동생성 6. **chargebackException** — 실효 계약 환수 자동생성
7. **calcOverride** — 조직 트리 상향 탐색 → 상위 직급에 분배 7. **calcOverride** — 조직 트리 상향 탐색 → 상위 직급에 분배
8. **aggregate** — 설계사별 통합 집계 → settle_master UPSERT (3.3% 세금) 8. **aggregate** — 설계사별 통합 집계 → settle_master UPSERT (3.3% 세금)
9. **accountingEntryGenerate** — recruit_ledger+maintain_ledger → contract_accounting_entry INSERT
10. **yearEndIncomeAggregate** — 12월 마감 시만 실행: payment 합산 → agent_annual_income UPSERT
--- ---
@@ -305,6 +378,65 @@ public class FooController {
| V15 | 통계/리포트 뷰 (v_agent_monthly_summary 등) | | V15 | 통계/리포트 뷰 (v_agent_monthly_summary 등) |
| V16 | 관리자 계정 + 메뉴 상세 + 권한 매트릭스 | | V16 | 관리자 계정 + 메뉴 상세 + 권한 매트릭스 |
| V17 | 테스트 데이터 (보험사 5 / 상품 20 / 설계사 50 / 계약 200 + 수수료 규정) | | V17 | 테스트 데이터 (보험사 5 / 상품 20 / 설계사 50 / 계약 200 + 수수료 규정) |
| V18 | 메뉴 트리 구조 재정렬 (동적 사이드바용) |
| V19 | 업로드 템플릿 마스터 (upload_template / upload_column) |
| V20 | 데이터 사전 (data_domain / data_dictionary) |
| V21 | 프로필·이력서 도메인 (agent_profile) |
| V22 | 비즈니스 도메인 확장 (계약·정산 보조 컬럼) |
| V23 | 세무 처리 — agent.tax_type/business_no/vat_type, payment 세금 컬럼 |
| V24 | 공제차감 — agent_deduction + payment_deduction_detail |
| V25 | 수수료 명세서 — commission_statement |
| V26 | 규제한도 마스터 — regulatory_limit (1200%룰) |
| V27 | 분급 계획 — installment_ratio + installment_plan |
| V28 | 차등환수 룰 — chargeback_grade |
| V29 | 마감 마스터 — period_close |
| V30 | 정정 — settle_correction |
| V31 | 이의신청 — commission_dispute |
| V32 | 보류사유 코드 — HOLD_REASON 공통코드 그룹 |
| V33 | 시책·인센티브 — incentive_program + incentive_payment |
| V34 | 해촉정산 — termination_settlement |
| V35 | 세금계산서 — tax_invoice |
| V36 | 보험사 회신 인터페이스 — carrier_reconciliation_out |
| V37 | KPI Materialized View 4종 (월별/조직별/유지율/누계) |
| V38 | 리포트 메뉴 INSERT |
| V39 | KPI 메뉴 INSERT |
| V40 | 수수료종류 코드 + commission_master 통합 마스터 |
| V41 | 수금수수료 — collection_commission_rule + collection_commission_ledger |
| V42 | 갱신·부활수수료 — renewal/reinstatement commission rule+ledger |
| V43 | 유지보수수당 — persistency_bonus_rule + persistency_bonus |
| V44 | 오버라이드 원장 — override_ledger + override_summary |
| V45 | 위촉계약 — agent_contract |
| V46 | 자격관리 — agent_license |
| V47 | 보수교육 — agent_training + training_requirement |
| V48 | 계약변경 — contract_endorsement |
| V49 | 민원관리 — complaint |
| V50 | 결재 워크플로우 — approval_line + approval_line_step + approval_request + approval_history |
| V51 | 공지·알림 — notice + user_notification |
| V52 | 첨부파일 — attachment |
| V53 | 출금신청 — withdraw_request |
| V54 | P4 메뉴 INSERT (P4-A + P4-B 14개 메뉴) |
| V55 | P4 공통코드 추가 (approval_type, complaint_type, license_type 등) |
| V56 | agent 세무 컬럼 보강 (tax_type/business_no/vat_type — V23 체크섬 충돌 보정) |
| V57 | admin 계정 초기화 |
| V58 | 1200%룰 메뉴 권한 |
| V59 | 신규 메뉴 권한 |
| V60 | statement 메뉴 권한 |
| V61 | installment_plan 메뉴 권한 |
| V62 | 추가 메뉴 권한 |
| V63 | P5-W1: agent_annual_income 테이블 (설계사 연말 사업소득 집계) |
| V64 | P5-W1: contract_accounting_entry 테이블 (회계 분개 엔트리) |
| V65 | P5-W1: 테스트 데이터 + 메뉴 INSERT (ANNUAL_INCOME / ACCOUNTING_JOURNAL) |
| V66 | P5-W1: account_code 마스터 + 초기 4건 (8350/2530/1130/4110) |
| V67 | P5-W2: withholding_tax_report 테이블 (원천세 분기 신고) |
| V68 | P5-W2: vat_report 테이블 (부가세 분기 신고) |
| V69 | P5-W2: 테스트 데이터 + 메뉴 INSERT (원천세/부가세 신고) |
| V70 | rule 도메인 감사 컬럼 보강 (commission_rate/payout_rule/override_rule 등) |
| V71 | payment 세무 컬럼 보강 (income_tax_amount/local_tax_amount/vat_amount/net_amount) |
| V72 | P5-W1 EXECUTE 권한 보강 (ANNUAL_INCOME / ACCOUNTING_JOURNAL) |
| V73 | P5-W2 EXECUTE 권한 보강 (WITHHOLDING_TAX / VAT_REPORT) |
> 운영 DB (192.168.0.60) 현재 V73 까지 적용 완료.
> **ga-api 재시작 시** Flyway 가 신규 마이그레이션을 자동으로 순차 적용합니다.
--- ---
@@ -312,14 +444,26 @@ public class FooController {
- [x] 1단계 — 프로젝트 뼈대 + DB (V1~V17) - [x] 1단계 — 프로젝트 뼈대 + DB (V1~V17)
- [x] **ga-common** — 공통 프레임워크 일체 - [x] **ga-common** — 공통 프레임워크 일체
- [x] **ga-core**22개 테이블 VO/Mapper/XML - [x] **ga-core**65+ 테이블 VO/Mapper/XML (P0~P4 전체)
- [x] **ga-api** — Auth + 14개 도메인 컨트롤러 - [x] **ga-api** — Auth + 50+ 도메인 컨트롤러 (128개 Java 파일)
- [x] **ga-batch** — MonthlySettlementJob 8 Step (실제 동작 로직 포함) - [x] **ga-batch** — MonthlySettlementJob 8 Step (실제 동작 로직 포함)
- [x] **Docker** — docker-compose + multi-stage Dockerfile + nginx - [x] **Docker** — docker-compose + multi-stage Dockerfile + nginx
- [x] **ga-frontend**13개 페이지 (대시보드/설계사/계약/원장/정산/지급/배치/시스템관리) - [x] **ga-frontend**50+ 페이지 (대시보드/설계사/계약/원장/정산/지급/배치/KPI/수수료/시스템관리)
- [x] **운영 DB 적용** — 192.168.0.60:55432/trading_ai/ga (V1~V17 + 테스트 데이터) - [x] **DB 마이그레이션** — V1~V71 (71개 파일, ga-common/src/main/resources/db/migration)
- [ ] **단위/통합 테스트** — 미작성 - [x] **코드 품질** — Enum/DTO/MapStruct/readOnly 트랜잭션 일괄 정비
- [x] **P0 세무·공제·명세서** — TaxCalculator / DeductionService / ExcelStatementGenerator
- [x] **P1 1200%룰·분급·차등환수** — RegulatoryLimitChecker / InstallmentPlanGenerator / ChargebackCalculator
- [x] **P2 마감·정정·이의·보류·시책·해촉·세금계산서·외부회신** (8 도메인)
- [x] **P3 KPI 대시보드** — Materialized View 4종 + Service + 화면
- [x] **P4-A 수수료 종류 보강** — commission_master 통합 / 수금 / 갱신 / 부활 / 유지보수수당 / 오버라이드원장
- [x] **P4-B 운영 도메인** — 위촉계약 / 자격관리 / 보수교육 / 계약변경 / 민원 / 결재워크플로우 / 공지·알림 / 첨부파일 / 출금신청
- [x] **P5-W1 회계 진입점** — agent_annual_income / contract_accounting_entry / account_code (V63~V66, 통합검증 완료)
- [x] **P5-W2 원천세·부가세 분기신고** — withholding_tax_report / vat_report (V67~V69, 통합검증 완료 2026-05-17)
- [x] **운영 DB** — 192.168.0.60:55432/trading_ai/ga (V73 까지 적용 완료)
- [ ] **단위/통합 테스트** — 미작성 (SqlSessionFactory 환경 문제로 보류)
- [ ] **MappingEngine 정밀화** — 일부 변환 규칙은 단순화된 형태 - [ ] **MappingEngine 정밀화** — 일부 변환 규칙은 단순화된 형태
- [ ] **P5-W3 결산 / 이상치 / 연말 지급명세서** — 미착수
- [ ] **P6 외부 연동** — 펌뱅킹 실연동 / SMS·카카오 / 모바일 셀프 조회
--- ---
+1 -1
View File
@@ -46,7 +46,7 @@ services:
JWT_SECRET: ${JWT_SECRET:-please-change-this-jwt-secret-must-be-256-bits} JWT_SECRET: ${JWT_SECRET:-please-change-this-jwt-secret-must-be-256-bits}
ENCRYPT_KEY: ${ENCRYPT_KEY:-please-change-this-32-byte-key!} ENCRYPT_KEY: ${ENCRYPT_KEY:-please-change-this-32-byte-key!}
ports: ports:
- "8080:8080" - "8082:8082"
batch: batch:
build: build:
+168
View File
@@ -0,0 +1,168 @@
# DOMAIN_GAP_MOD1-8 — 수수료 갭 8종 신규모듈 권위 스펙
> 2026-06-06 갭분석 결과 "개념적으로 빠진" 수수료 모듈 8종. 업무 수치는 **데모 기본값**(운영 시 룰테이블 조정).
> 모든 레이어 에이전트는 이 문서를 단일 진실원으로 따른다. 테이블/컬럼/Enum/경로/메뉴코드명을 **그대로** 사용.
## 공통 규약 (기존 패턴 — V92/V95/V96 미러)
- PK `BIGSERIAL`, 금액 `NUMERIC(15,2)`, 율 `NUMERIC(7,4)`(0~1 소수), 정산월 `CHAR(6)`(YYYYMM).
- 감사: `created_at TIMESTAMP NOT NULL DEFAULT NOW()`, `created_by BIGINT`, `updated_at TIMESTAMP`.
- 모든 테이블 `COMMENT ON TABLE/COLUMN`, 인덱스 `idx_<약어>_...`.
- 메뉴: `menu`(parent=GRP_*, menu_type='PAGE', menu_level=2) + `menu_permission`(READ/CREATE/UPDATE/DELETE/EXPORT) + `role_menu_permission`(SUPER_ADMIN/ADMIN 전체, MANAGER READ/CREATE/UPDATE/EXPORT, AGENT READ). NOT EXISTS/ON CONFLICT 가드.
- 공통코드: `common_code_group` + `common_code`.
- core: `XxxVO`(전컬럼)/`XxxResp`(+조인명)/`XxxSaveReq`(입력)/`XxxSearchParam`(extends PageParam) + Mapper(.java/.xml, selectList/selectById/insert/update/delete + 도메인쿼리) + Enum(상태) + MapStruct(필요시). Mapper는 BaseMapper 상속 안 함(개별 정의, 기존 commission Mapper 미러).
- api: `XxxService`(@Service @Transactional(readOnly=true), 쓰기 @Transactional) + `XxxController`(@RestController @RequestMapping("/api/...") @RequirePermission, @DataChangeLog 쓰기, ApiResponse/PageResponse 반환). 기존 commission Controller 미러.
- frontend: `ga-frontend/src/pages/<area>/Xxx.tsx` + `api/<area>.ts` 함수 + `App.tsx` 라우트. 100건↑/셀편집/합계는 AG Grid, 단순 CRUD는 Ant Design DataTable.
- 마이그레이션 번호: MOD-1=V116, MOD-2=V117, MOD-3=V118, MOD-4=V119, MOD-5=V120, MOD-6=V121, MOD-7=V122, MOD-8=V123. (필요 시 모듈당 2파일 가능 — 예 V116_a 테이블 / 단일 파일에 메뉴·코드 포함 권장)
---
## MOD-1 — 슈퍼바이저/도입수수료 (recruiter/development commission) [V116]
설계사를 GA에 **도입(리크루팅)**한 상위 설계사에게 지급하는 별도 트랙(오버라이드와 무관, recruiter 관계 기반).
### DB V116
- `ALTER TABLE agent ADD COLUMN recruiter_agent_id BIGINT REFERENCES agent(agent_id);` (도입자, NULL 허용)
- `recruiter_commission_rule`(rule_id PK, recruiter_type VARCHAR(20) CHECK IN('NEW_RECRUIT','DEVELOPMENT','TRAINING'), calc_type VARCHAR(10) CHECK IN('RATE','FIXED'), commission_rate NUMERIC(7,4), fixed_amount NUMERIC(15,2), effective_from DATE NOT NULL, effective_to DATE, 감사컬럼). CHECK rate 0~1.
- `recruiter_commission_ledger`(ledger_id PK, recruiter_agent_id BIGINT NOT NULL REFERENCES agent, recruit_agent_id BIGINT NOT NULL REFERENCES agent, settle_month CHAR(6) NOT NULL, recruiter_type VARCHAR(20) NOT NULL, base_amount NUMERIC(15,2) NOT NULL, commission_amount NUMERIC(15,2) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'CALCULATED' CHECK IN('CALCULATED','CONFIRMED','PAID','CANCELLED'), 감사). idx (recruiter_agent_id, settle_month).
- 메뉴 `RECRUITER_COMM`('도입수수료', '/commission/recruiter', 'commission/RecruiterCommission', GRP_COMMISSION, sort 150) + 권한 + 공통코드 `RECRUITER_TYPE`(NEW_RECRUIT 신인도입/DEVELOPMENT 육성/TRAINING 교육).
- 공식(데모): commission = base_amount × rule.commission_rate (RATE) / fixed_amount (FIXED). base_amount=도입 설계사의 당월 모집수수료 합(데모 입력 허용).
### core/api
- `RecruiterCommissionRule*` + `RecruiterCommissionLedger*` VO/Resp/SaveReq/SearchParam, Mapper(.java/.xml: selectList/selectById/insert/update/delete + `aggregateByAgent(settleMonth)` = SUM(commission_amount) GROUP BY recruiter_agent_id). Enum `RecruiterType`.
- `RecruiterCommissionService`: rule CRUD + ledger list/detail/createLedger(룰 calc_type 권위, ReferralCommissionService 미러) + aggregate. Controller `/api/recruiter-commissions` (rules/ledgers).
- **정산 반영**: `AggregateStep`에 recruiterCommissionMapper.aggregateByAgent 추가 → other_commission_total 합산(기존 9종 패턴 동일, recruiter_agent_id 기준 agentId). (지급성이므로 gross 가산.)
### frontend
- `pages/commission/RecruiterCommission.tsx`(룰 탭 + 원장 탭, 등록 모달), `api/commission.ts`에 함수 추가, App.tsx 라우트.
---
## MOD-2 — 선지급/차익정산 (advance payment recovery + delta settlement) [V117]
신입/램프업 설계사에 미리 지급한 **선지급금**을 확정수수료로 우선 회수, 차액만 실지급.
### DB V117 (GRP_SETTLE)
- `advance_payment`(advance_id PK, agent_id BIGINT NOT NULL REFERENCES agent, advance_month CHAR(6) NOT NULL, principal_amount NUMERIC(15,2) NOT NULL CHECK>0, recovered_amount NUMERIC(15,2) NOT NULL DEFAULT 0, balance NUMERIC(15,2) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'OUTSTANDING' CHECK IN('OUTSTANDING','PARTIAL','CLEARED','WAIVED'), memo VARCHAR(200), 감사). CHECK balance>=0. idx(agent_id, status).
- `advance_recovery_ledger`(recovery_id PK, advance_id BIGINT NOT NULL REFERENCES advance_payment, settle_month CHAR(6) NOT NULL, recovery_amount NUMERIC(15,2) NOT NULL CHECK>0, 감사). idx(advance_id), idx(settle_month).
- 메뉴 `ADVANCE_PAYMENT`('선지급정산','/settle/advance-payment','settle/AdvancePayment', GRP_SETTLE, sort 적절) + 권한 + 공통코드 `ADVANCE_STATUS`(OUTSTANDING 미상환/PARTIAL 부분/CLEARED 완료/WAIVED 면제).
### core/api/batch
- `AdvancePayment*` + `AdvanceRecoveryLedger*` VO/Resp/SaveReq/SearchParam, Mapper. 도메인쿼리: `outstandingByAgent(agentId)`=SUM(balance) WHERE status IN('OUTSTANDING','PARTIAL'); aging은 advance_month 기준.
- `AdvancePaymentService`: 선지급 등록(balance=principal)/조회/aging 리포트/면제(waive). Controller `/api/advance-payments`.
- **정산 연계(AggregateStep)**: net(=tax후) 계산 직후, P9 환수상계와 **동일 위치/유사 패턴**으로 선지급 잔액 FIFO 우선상계. recovered_advance = min(payable, outstanding). 차익정산 delta = payable recovered_advance. payable 최종 = delta. advance_recovery_ledger 기록 + advance_payment.recovered/balance/status 갱신. **멱등**: 당월 recovery reverse 후 재적용(P9 reverseClawbackRecovery 패턴). 환수상계와 선지급상계 순서: 환수(채권) → 선지급. 둘 다 payable 차감.
- 주의: 기존 settle_master payable_amount 의미에 선지급 차감 반영. 새 컬럼 불필요(payable에 통합) — 단 advance_recovery_ledger로 추적.
### frontend
- `pages/settle/AdvancePayment.tsx`(선지급 목록+잔액/상태, 등록·면제 모달, 상계내역 Drawer), `api/settle.ts` 또는 `api/operation.ts` 함수.
---
## MOD-3 — 환수 분할상환 (installment clawback) [V118]
대액 환수채권을 N개월 분할 납부 계획으로.
### DB V118 (GRP_COMMISSION)
- `clawback_installment_plan`(plan_id PK, receivable_id BIGINT NOT NULL REFERENCES clawback_receivable(receivable_id), installment_no INT NOT NULL, installment_amount NUMERIC(15,2) NOT NULL CHECK>0, due_month CHAR(6) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'SCHEDULED' CHECK IN('SCHEDULED','RECOVERED','CANCELLED'), recovered_at TIMESTAMP, 감사). UNIQUE(receivable_id, installment_no). idx(due_month, status).
- 메뉴 `CLAWBACK_INSTALLMENT`('환수분할상환','/commission/clawback-installment','commission/ClawbackInstallment', GRP_COMMISSION, sort) + 권한. 공통코드 `CB_INSTALLMENT_STATUS`(SCHEDULED 예정/RECOVERED 회수/CANCELLED 취소).
### core/api
- `ClawbackInstallmentPlan*` VO/Resp/SaveReq/SearchParam + Mapper(selectByReceivable, insertBatch, updateStatus).
- `ClawbackInstallmentService`: receivable 1건 + 회차수 N → 균등분할(마지막 회차 잔차흡수, InstallmentPlanGenerator 잔차패턴) 계획 생성. due_month=기준월+회차. list/detail/generate/cancel. Controller `/api/clawback-installments`.
- 월별 상계 연계는 데모 수준(계획 생성·조회·상태전이). (AggregateStep 통합은 선택 — 미연계 시 문서화.)
### frontend
- `pages/commission/ClawbackInstallment.tsx`(채권 선택→분할 생성, 회차 목록), `api/commission.ts`.
---
## MOD-4 — 익월 부활 재지급 (lapsed reinstatement repayment) [V119]
실효(보류)된 계약이 익월 부활 시 보류했던 수수료를 make-good 재지급.
### DB V119 (GRP_COMMISSION)
- `lapsed_reinstatement_recovery`(recovery_id PK, contract_id BIGINT NOT NULL REFERENCES contract(contract_id), agent_id BIGINT NOT NULL REFERENCES agent, lapsed_month CHAR(6) NOT NULL, reinstated_month CHAR(6) NOT NULL, withheld_amount NUMERIC(15,2) NOT NULL CHECK>=0, repaid_amount NUMERIC(15,2) NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'PENDING' CHECK IN('PENDING','REPAID','CANCELLED'), 감사). idx(agent_id, reinstated_month), idx(contract_id).
- 메뉴 `LAPSED_REINSTATEMENT`('익월부활재지급','/commission/lapsed-reinstatement','commission/LapsedReinstatement', GRP_COMMISSION, sort) + 권한. 공통코드 `LAPSE_RECOVERY_STATUS`(PENDING 대기/REPAID 재지급완료/CANCELLED 취소).
### core/api
- `LapsedReinstatementRecovery*` VO/Resp/SaveReq/SearchParam + Mapper(selectList/selectById/insert/updateStatus + aggregateByAgent=SUM(repaid_amount) WHERE status='REPAID' GROUP BY agent_id).
- `LapsedReinstatementService`: 부활 등록(withheld_amount=보류액, 데모 입력)/repay(상태 REPAID, repaid=withheld)/cancel. Controller `/api/lapsed-reinstatements`.
- **정산 반영(선택)**: aggregateByAgent를 AggregateStep other_commission_total에 가산(REPAID만). 미연계 시 문서화.
### frontend
- `pages/commission/LapsedReinstatement.tsx`, `api/commission.ts`.
---
## MOD-5 — 정산대사 입수 (carrier reconciliation IN) [V120]
보험사 통보 정산자료 vs GA 원장 대사 차이 산출.
### DB V120 (GRP_SETTLE)
- `carrier_reconciliation_in`(recon_id PK, company_id BIGINT NOT NULL REFERENCES insurance_company(company_id), settle_month CHAR(6) NOT NULL, file_name VARCHAR(200), record_count INT NOT NULL DEFAULT 0, reported_total NUMERIC(15,2) NOT NULL DEFAULT 0, system_total NUMERIC(15,2) NOT NULL DEFAULT 0, variance_amount NUMERIC(15,2) NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'RECEIVED' CHECK IN('RECEIVED','MATCHED','VARIANCE','APPROVED'), 감사). UNIQUE(company_id, settle_month). idx(settle_month).
- `reconciliation_variance`(variance_id PK, recon_id BIGINT NOT NULL REFERENCES carrier_reconciliation_in, policy_no VARCHAR(50), expected_amount NUMERIC(15,2), reported_amount NUMERIC(15,2), variance_reason VARCHAR(100), 감사). idx(recon_id).
- 메뉴 `CARRIER_RECON_IN`('정산대사입수','/settle/carrier-recon-in','settle/CarrierReconIn', GRP_SETTLE, sort) + 권한. 공통코드 `RECON_IN_STATUS`(RECEIVED 입수/MATCHED 일치/VARIANCE 차이/APPROVED 승인).
- insurance_company PK 컬럼명 확인 후 사용(company_id 가정, 다르면 실제명).
### core/api
- `CarrierReconciliationIn*` + `ReconciliationVariance*` VO/Resp/SaveReq/SearchParam + Mapper.
- `CarrierReconciliationInService`: 대사 등록(reported_total 입력)/reconcile(system_total=settle_master 또는 income_commission 집계, variance=reportedsystem, status 자동 MATCHED/VARIANCE)/approve. Controller `/api/carrier-recon-in`.
### frontend
- `pages/settle/CarrierReconIn.tsx`(대사 목록 + 차이내역 Drawer), `api/settle.ts`/`operation.ts`.
---
## MOD-6 — 환수 시효/소멸 (statute of limitations) [V121]
오래된 미결 환수채권을 만료개월 경과 시 자동 대손(WRITTEN_OFF/EXPIRED).
### DB V121 (GRP_COMMISSION)
- `statute_config`(config_id PK, entity_type VARCHAR(30) NOT NULL UNIQUE CHECK IN('CLAWBACK_RECEIVABLE'), expiration_months INT NOT NULL CHECK>0, is_active CHAR(1) NOT NULL DEFAULT 'Y', 감사). 시드 1행: ('CLAWBACK_RECEIVABLE', 36, 'Y').
- 메뉴 `STATUTE_CONFIG`('환수시효관리','/commission/statute','commission/StatuteConfig', GRP_COMMISSION, sort) + 권한.
- (별도 status 코드 불필요 — 기존 clawback_receivable status에 WRITTEN_OFF 존재 가정. 없으면 사유코드만 추가.)
### core/api
- `StatuteConfig*` VO/Resp/SaveReq + Mapper. clawback_receivable에 만료대상 조회/대손처리 쿼리(`selectExpired(beforeMonth)`, `writeOffExpired`) 추가(기존 ClawbackReceivableMapper에).
- `StatuteService`: config CRUD + `expireOverdue(baseMonth)` = origin_settle_month <= baseMonthexpiration_months AND status='OUTSTANDING' → WRITTEN_OFF(reason EXPIRED) + 카운트. Controller `/api/statute` (config + POST expire).
### frontend
- `pages/commission/StatuteConfig.tsx`(설정 + 만료실행 버튼 + 결과), `api/commission.ts`.
---
## MOD-7 — 환수 감면/면제 (chargeback waiver/reduction) [V122]
재직 설계사 환수에 대한 감면 신청·승인 워크플로.
### DB V122 (GRP_COMMISSION)
- `chargeback_waiver_request`(request_id PK, contract_id BIGINT NOT NULL REFERENCES contract, agent_id BIGINT NOT NULL REFERENCES agent, settle_month CHAR(6) NOT NULL, original_chargeback NUMERIC(15,2) NOT NULL CHECK>=0, waiver_amount NUMERIC(15,2) NOT NULL CHECK>=0, reason VARCHAR(200), approval_status VARCHAR(20) NOT NULL DEFAULT 'REQUESTED' CHECK IN('REQUESTED','APPROVED','REJECTED'), approver_id BIGINT, approved_at TIMESTAMP, 감사). CHECK waiver_amount<=original_chargeback. idx(agent_id, settle_month).
- 메뉴 `CHARGEBACK_WAIVER`('환수감면신청','/commission/chargeback-waiver','commission/ChargebackWaiver', GRP_COMMISSION, sort) + 권한. 공통코드 `WAIVER_STATUS`(REQUESTED 신청/APPROVED 승인/REJECTED 반려).
### core/api
- `ChargebackWaiverRequest*` VO/Resp/SaveReq/SearchParam + Mapper.
- `ChargebackWaiverService`: 신청(create)/approve(승인 시 approval_status, approver/approved_at; 환수액 조정은 데모 수준 — 승인분 추적)/reject. waiver<=original 검증. Controller `/api/chargeback-waivers`.
### frontend
- `pages/commission/ChargebackWaiver.tsx`(신청 목록 + 승인/반려 액션), `api/commission.ts`.
---
## MOD-8 — 채널별 수수료 차등 (channel-specific commission) [V123]
판매채널(직판/방카/홈쇼핑/제휴)별 요율 차등. **하위호환 필수**(channel NULL = 기존 동작).
### DB V123
- `ALTER TABLE contract ADD COLUMN sales_channel VARCHAR(20);` (NULL 허용, NULL=DIRECT 취급)
- 공통코드 `SALES_CHANNEL`(DIRECT 직판/BANCASSURANCE 방카/HOME_SHOPPING 홈쇼핑/PARTNERSHIP 제휴).
- commission_rate / payout_rule 룰 테이블에 `sales_channel VARCHAR(20)` 컬럼 추가(NULL=모든채널). **실제 룰 테이블명·조회 SQL 확인 필수**(CommissionCalculator가 쓰는 rate 조회). 조회 우선순위: channel 일치 룰 우선, 없으면 channel IS NULL 룰 폴백.
- 메뉴 신규 불필요(계약 화면에서 노출). 단 계약 등록/조회에 sales_channel 추가.
### core/api/batch
- ContractVO/Resp/SaveReq에 salesChannel. ContractMapper.xml insert/update/select에 sales_channel.
- rate 조회 Mapper SQL에 channel 파라미터 추가(`AND (sales_channel = #{channel} OR sales_channel IS NULL) ORDER BY sales_channel NULLS LAST` 패턴으로 channel 우선). CommissionCalculator가 contract.salesChannel 전달.
- **하위호환 회귀 필수 검증**: channel NULL 계약은 기존과 동일 rate. 기존 CommissionCalculatorTest 통과 유지.
### frontend
- 계약 페이지(`pages/contracts/...` 또는 product)에서 sales_channel select 노출(공통코드 SALES_CHANNEL).
---
## 실행 순서 (5-Wave, 레이어별 병렬)
1. **DBA**: V116~V123 작성 (dba-architect). → SQL 파싱/리뷰.
2. **CORE**: 8모듈 VO/Mapper/Enum/MapStruct (core-architect). → `:ga-core:compileJava` GREEN.
3. **API+BATCH**: Service/Controller (api-developer) ‖ AggregateStep/배치 연계 (batch-engineer). → `:ga-api:compileJava :ga-batch:compileJava` GREEN.
4. **FRONTEND**: 화면+api+라우트 (frontend-developer). → `tsc` GREEN.
5. **INFRA/검증**: `./gradlew build`(test) GREEN + Flyway 적용 + 스모크 (PL + infra-reviewer).
- 각 Wave 후 PL이 빌드로 직접 검증. 컬럼/이름 불일치는 PL이 즉시 수정.
@@ -0,0 +1,46 @@
# P10 — 환수 보험사자료 업로드 (#3)
작성: PL, 2026-06-04. 사용자 "후속 갭 3종 진행" 중 첫 번째.
근거: incar `환수보험사자료 업로드`. GA는 LAPSE 계약 기반 자동환수(ChargebackExceptionStep)만 있고,
보험사가 통보한 환수자료(증권번호+환수금액)를 직접 인입하는 경로가 없음.
범위: 풀스택(DB→Core→API→Frontend). 신규 테이블 없음 — 기존 `chargeback`(V6) 재사용.
Flyway: **V112 최신 → V113**(메뉴/권한만). 금액 NUMERIC(15,2).
## 1. chargeback 테이블 (기존, 재사용)
cb_id PK / contract_id FK(nullable) / agent_id FK NN / policy_no / lapse_month INT /
cb_rate / cb_amount NN / paid_amount(0) / remain_amount NN / settle_month CHAR(6) NN /
installment_no / max_installments / status('NEW') / created_at.
## 2. 업로드 설계
- **1 업로드 = 1 settleMonth**(요청 파라미터). 파일 각 행 = 계약별 환수 통보.
- 엑셀 헤더(ChargebackUploadExcelVO, @ExcelColumn): `증권번호`(policyNo, order1) / `환수금액`(cbAmount, order2) / `실효월`(lapseMonth YYYYMM 문자열, order3, 선택) / `환수율`(cbRate, order4, 선택).
- **agent/contract 해소**: policy_no → contract 조회 → contract_id + agent_id. 미발견 행은 에러행으로 수집(스킵, 결과에 행번호+사유 반환). ContractMapper에 `selectByPolicyNo(policyNo)` 없으면 core가 추가(contract 테이블 policy_no 컬럼 확인).
- 생성 ChargebackVO: contractId/agentId(해소), policyNo, lapseMonth(YYYYMM→INT 변환, '202401'→202401 또는 경과월? **chargeback.lapse_month는 INT**: 기존 의미가 경과월(month elapsed)인지 실효연월인지 ChargebackExceptionStep 확인 후 동일 의미로 — 자동환수와 일관성. 불명확하면 실효연월 YYYYMM의 끝2자리(월)이 아니라 **경과월**로: contract_date~lapse_date 개월수 계산. 단순화를 위해 파일에 없으면 null 허용), cbRate, cbAmount, paidAmount=0, remainAmount=cbAmount, settleMonth(param), status='NEW'.
- **주의**: 자동환수(ChargebackExceptionStep)와 동일 (contract, settleMonth) 중복 생성 방지 — 동일 contract_id+settle_month NEW 존재 시 스킵 또는 갱신(중복 인입 방지). core에 `existsByContractAndMonth` 또는 service에서 selectByAgent로 확인. 최소: 같은 정산월 재업로드 시 중복 INSERT 경고만(운영자 책임). **권장: contract_id+settle_month UNIQUE 부분 보장 위해 service가 사전 조회 후 skip + 결과 리포트.**
- ExcelService.importLargeExcel(file, ChargebackUploadExcelVO.class, 500, consumer) 사용. consumer에서 해소+검증+chargebackMapper.insertBatch. 트랜잭션: @Transactional.
## 3. 조회(List) — 신규
chargeback 목록 화면이 현재 없음. 추가:
- core: ChargebackResp(+agentName/contractNo 조인) / ChargebackSearchParam(settleMonth/agentId/status/policyNo) / ChargebackMapper.selectList + countByCondition. (기존 ChargebackVO 유지)
- api: ChargebackService.list(PageHelper) / detail. 업로드는 ChargebackUploadService.upload(settleMonth, file)→ExcelImportResult-유사(성공/에러행).
## 4. API
- `POST /api/chargebacks/upload?settleMonth=YYYYMM` (multipart file) — @RequirePermission(CHARGEBACK_DATA, CREATE), @DataChangeLog. 반환: {successCount, errorCount, errors[]}.
- `GET /api/chargebacks` — 목록(@RequirePermission READ, PageResponse).
- `GET /api/chargebacks/export` — 엑셀(@Transactional).
컨트롤러 위치 `controller/settle/ChargebackController` 또는 기존 chargeback 패키지 확인.
## 5. DB (V113)
- 메뉴 `CHARGEBACK_DATA`(환수자료관리), 상위 GRP_SETTLE, path `/settle/chargeback-data`, component `settle/ChargebackData`. (V97 단계마감 등 GRP_SETTLE 패턴)
- 권한 READ/CREATE/EXPORT + 역할부여 SUPER_ADMIN·ADMIN 전체, MANAGER READ.
- 공통코드 불필요(chargeback status는 기존 코드 재사용 여부 확인 — 없으면 생략).
## 6. Frontend
- `src/api/chargeback.ts` — list/upload(FormData)/export.
- `src/pages/settle/ChargebackData.tsx` — 상단 정산월 선택 + 파일 업로드 버튼(업로드 결과 성공/에러행 표시) + AG Grid 목록(증권번호/설계사/환수금액/잔여/상태/정산월) + 엑셀. 기존 업로드 화면(UploadPage 등) 결과표시 패턴 참고.
- App.tsx 라우트 `settle/chargeback-data`.
## 7. 검증(PL)
build+test, Flyway V113, GET 목록 200, **샘플 엑셀 업로드 실호출**(정상행 INSERT + 미존재 증권번호 에러행 리포트) 라이브 확인 후 테스트 chargeback 롤백/삭제.
</content>
+62
View File
@@ -0,0 +1,62 @@
# P11 — 시상/신인시상 환수 (#1)
작성: PL, 2026-06-04. 후속 갭 3종 중 두 번째.
근거: incar `유지율시상환수`/`신인도입유치자시상환수`/`시상내역 조회`. GA award_ledger(V92)는
지급만 있고 환수(clawback) 처리가 없음. 정착지원금/생보운영지원/지점장수당은 이미
`clawback_amount`+`net_amount` 패턴 보유 → **award_ledger도 동일 패턴으로 통일**.
신인시상은 award_type='NEW_RECRUIT'로 이미 존재 → 별도 테이블 불필요. 모든 award_type에 환수 적용.
Flyway: **V113 최신 → V114**. 금액 NUMERIC(15,2), HALF_UP.
## 1. 현재 award_ledger (V92)
ledger_id / agent_id / settle_month / award_type(NEW_RECRUIT/CORPORATE/RETENTION/PERFORMANCE) /
base_amount / award_amount / status(CALCULATED/CONFIRMED/PAID/CANCELLED) / created_at / updated_at.
aggregateByAgent = SUM(award_amount) WHERE status!=CANCELLED.
**문제**: clawback_amount/net_amount 없음. settling/life/branch는 net=supportclawback, aggregate=SUM(net).
## 2. 스키마 변경 (V114) — settling_support_ledger 패턴 미러
```sql
ALTER TABLE award_ledger
ADD COLUMN clawback_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
ADD COLUMN net_amount NUMERIC(15,2) NOT NULL DEFAULT 0;
-- 기존 행 백필: 환수 없던 과거 = net = award
UPDATE award_ledger SET net_amount = award_amount WHERE net_amount = 0 AND clawback_amount = 0;
-- clawback 음수 방지 + 상태에 CLAWED_BACK 추가(settling 패턴과 통일)
ALTER TABLE award_ledger ADD CONSTRAINT chk_award_clawback CHECK (clawback_amount >= 0);
ALTER TABLE award_ledger DROP CONSTRAINT < status CHECK >; -- \d 로 확인 (예: award_ledger_status_check)
ALTER TABLE award_ledger ADD CONSTRAINT chk_award_status
CHECK (status IN ('CALCULATED','CONFIRMED','PAID','CANCELLED','CLAWED_BACK'));
```
> 기존 status CHECK 제약명은 `\d award_ledger` 또는 information_schema 로 정확히 확인 후 DROP. 추정명 사용 금지.
> net_amount는 음수 허용(환수>시상 시 음수 가능)이나, **§4 검증에서 clawback ≤ award_amount 강제**하므로 실제 net≥0. (지급성이므로 음수 차단 — settling 동일)
메뉴 변경 없음(award 화면 기존). 공통코드 AWARD_STATUS에 CLAWED_BACK 있으면 추가(없으면 생략).
## 3. Core
- AwardLedgerVO/Resp/SaveReq에 `clawbackAmount`, `netAmount` 추가.
- `AwardLedgerMapper.aggregateByAgent` XML: `SUM(award_amount)`**`SUM(net_amount)`** (net=awardclawback). status!=CANCELLED 유지.
- 신규 Mapper 메서드 `applyClawback(@Param ledgerId, @Param clawbackAmount, @Param netAmount, @Param status)` (+XML): clawback_amount/net_amount/status='CLAWED_BACK'/updated_at 갱신.
- createLedger 시 net_amount = award_amount clawback_amount 저장(insert XML에 두 컬럼 포함).
## 4. API (AwardService 확장)
- `createLedger`: clawback_amount(SaveReq, 기본0) 받아 **clawback ≤ award_amount 검증(초과 시 E411)**, net = award clawback 계산해 저장.
- **신규 액션** `applyClawback(ledgerId, AwardClawbackReq{clawbackAmount, reason})` @Transactional:
- 원장 조회(없으면 NOT_FOUND). 이미 CANCELLED면 E411.
- clawbackAmount > award_amount 면 E411("환수액이 시상액 초과").
- net = award_amount clawbackAmount. mapper.applyClawback(ledgerId, clawback, net, 'CLAWED_BACK').
- @DataChangeLog.
- Controller: `PUT /api/awards/ledgers/{id}/clawback` (@RequirePermission(menu=AWARD, action=UPDATE)). 기존 award 컨트롤러 경로 패턴 확인.
> **AggregateStep 무변경**: awardMapper.aggregateByAgent가 SUM(net_amount) 반환하므로 환수가 자동으로 other_commission 차감에 반영. net<0이면 P9 환수채권 이월에 자연 연계.
## 5. Frontend (기존 Award 화면 확장)
- award 원장 그리드에 환수액/실지급(net) 컬럼 추가.
- 행 액션 "환수처리"(PermissionButton UPDATE) → 모달(환수액 입력 + 사유) → PUT /clawback. 성공 시 invalidate.
- api 모듈에 applyClawback 추가. 기존 award api/페이지 파일명 확인 후 확장.
## 6. 검증(PL)
build+test, Flyway V114(award_ledger 컬럼/제약/백필), 라이브: award 원장 1건 생성(net=award) →
applyClawback(부분환수) → net 감소·status CLAWED_BACK 확인 → clawback>award E411 → aggregateByAgent
가 net 반영하는지 SQL 확인. 테스트 데이터 정리.
</content>
@@ -0,0 +1,65 @@
# P12 — 지급대상건 구성·수정 (#2)
작성: PL, 2026-06-04. 후속 갭 3종 중 마지막.
근거: incar `지급대상건구성`/`지급대상건수정`/`지급대상건조회`. GA는 settle_master를 건별
confirm/hold/release만 가능, **정산월 단위 지급대상 배치 구성(일괄 포함/제외 후 확정)** 레이어 부재.
범위: 풀스택. 기존 settle_master 위의 **구성/스냅샷 레이어**(settle_master 상태는 본 범위에서 변경 안 함 — 결합 최소화). Flyway: **V114 최신 → V115**. 금액 NUMERIC(15,2).
## 1. 개념
- 정산월의 지급대상(settle_master, payable_amount>0)을 모아 **배치(payment_batch)** 로 구성.
- 각 건(payment_batch_item)을 **포함/제외**(사유) 편집 → 합계 재계산.
- **확정(finalize)**: 배치 잠금(FINALIZED). 확정된 포함건 목록 = 지급대상 확정본(스냅샷).
- AccountingClose(V75 DRAFT/마감/재오픈) 패턴 준용. **settle_master.status는 본 범위에서 미변경**(향후 finalize→지급실행 연계는 별도).
## 2. 스키마 (V115)
### payment_batch (지급대상 배치)
| 컬럼 | 타입 | 설명 |
|---|---|---|
| batch_id | BIGSERIAL PK | |
| settle_month | CHAR(6) NN | |
| batch_name | VARCHAR(100) NN | |
| status | VARCHAR(20) NN DEFAULT 'DRAFT' | DRAFT/FINALIZED/CANCELLED |
| total_count | INT NN DEFAULT 0 | 포함건수 |
| total_payable | NUMERIC(15,2) NN DEFAULT 0 | 포함건 payable 합 |
| created_at/created_by/finalized_at/finalized_by/updated_at | | |
- idx(settle_month, status). CHECK status IN('DRAFT','FINALIZED','CANCELLED').
### payment_batch_item (지급대상건)
| 컬럼 | 타입 | 설명 |
|---|---|---|
| item_id | BIGSERIAL PK | |
| batch_id | BIGINT NN FK payment_batch | |
| settle_id | BIGINT NN FK settle_master | |
| agent_id | BIGINT NN FK agent | |
| payable_amount | NUMERIC(15,2) NN | settle_master.payable_amount 스냅샷 |
| included | BOOLEAN NN DEFAULT true | 포함여부 |
| exclude_reason | VARCHAR(300) | 제외사유 |
| created_at | | |
- UNIQUE(batch_id, settle_id). idx(batch_id).
### 메뉴/권한/코드
- 메뉴 `PAYMENT_TARGET`(지급대상관리), GRP_SETTLE, `/settle/payment-target`, `settle/PaymentTarget`.
- 권한 READ/CREATE/UPDATE/EXECUTE(확정) + 역할(SUPER_ADMIN·ADMIN 전체, MANAGER READ).
- 공통코드 PAYMENT_BATCH_STATUS(DRAFT=작성중/FINALIZED=확정/CANCELLED=취소).
## 3. API (PaymentBatchService, `/api/payment-batches`)
- `POST` create(settleMonth, batchName) @Transactional: payment_batch(DRAFT) INSERT → settle_master 조회(settle_month=, payable_amount>0, status != 'PAID') → 각 행 payment_batch_item(included=true, payable 스냅샷) INSERT → total_count/total_payable 집계 UPDATE. 동일월 DRAFT 중복 생성 허용(이름으로 구분).
- `GET` list(settleMonth/status 필터, PageResponse) / `GET /{id}` detail(배치 + items).
- `PUT /{id}/items/{itemId}` updateItem(included, excludeReason) @Transactional: DRAFT 배치만(FINALIZED/CANCELLED 시 E411). 항목 갱신 후 배치 total 재집계.
- `PUT /{id}/finalize` @Transactional, @RequirePermission EXECUTE: DRAFT→FINALIZED, finalized_at/by. 포함건 0이면 E411.
- `PUT /{id}/cancel`: DRAFT/FINALIZED→CANCELLED.
- `GET /{id}/export` 엑셀(@Transactional).
- @RequirePermission(PAYMENT_TARGET, *) + @DataChangeLog(쓰기).
총계 재집계: `total_count = COUNT(included=true)`, `total_payable = SUM(payable WHERE included)` — Mapper에 recalcTotals(batchId) 제공.
## 4. Frontend (PaymentTarget.tsx)
- 배치 목록(정산월/상태 필터) + "구성" 버튼(정산월+배치명 입력 → create).
- 배치 행 클릭 → 상세: 배치 헤더(상태/포함건수/합계) + items AG Grid(설계사/payable/포함체크/제외사유). DRAFT일 때 포함토글·사유 편집(PermissionButton UPDATE) → updateItem. 합계행.
- "확정" 버튼(PermissionButton EXECUTE, DRAFT만) → finalize. "취소" 버튼. 엑셀.
- React Query, 변경 후 invalidate.
## 5. 검증(PL)
build+test, Flyway V115, 라이브: create(특정월) → items 자동생성 확인 → updateItem 제외 → total 재집계 확인 → finalize → 잠금(이후 updateItem E411) → cancel. 테스트 데이터 정리.
</content>
+255
View File
@@ -0,0 +1,255 @@
# P7 — incar 갭 반영: 수수료 계산 7개 도메인
작성: PL (자율 모드), 2026-05-31
근거: incar CMS 116메뉴 vs ga-pro 96메뉴 갭 분석. 사용자 지시 "필요한 메뉴 적용 + 수수료 공식 다 적용".
범위: 🅰 수수료 계산 7개 도메인 풀스택 (DB→Core→API→Frontend→Infra).
기존 패턴 100% 준수 (template = 수금수수료 V41 / CollectionCommission*). 모든 금액 `NUMERIC(15,2)`, 율 `NUMERIC(7,4)`(소수, 0~1), 반올림 HALF_UP.
Flyway: **V87이 최신 → V88부터.** 각 도메인은 rule(+) ledger 2테이블 패턴. 메뉴는 GRP_COMMISSION 하위.
---
## 도메인별 설계
### D1. 수입수수료 + 수지차 (INCOME_COMM) — V88
보험사가 GA에 지급한 **수입** 측. 기존 원장(LEDGER_*)은 설계사 **지급** 측. 수지차 = 수입 지급.
**income_commission_ledger** (수입수수료 원장)
| 컬럼 | 타입 | 설명 |
|---|---|---|
| income_id | BIGSERIAL PK | |
| contract_id | BIGINT NN FK contract | |
| carrier_id | BIGINT NN FK insurance_company | 보험사 |
| settle_month | CHAR(6) NN | |
| premium_amount | NUMERIC(15,2) NN | 보험료 |
| company_rate | NUMERIC(7,4) NN | 보험사요율 |
| income_amount | NUMERIC(15,2) NN | **= premium_amount × company_rate** |
| commission_type | VARCHAR(20) NN | RECRUIT/MAINTAIN 등 |
| status | VARCHAR(20) NN DEFAULT 'CALCULATED' | CALCULATED/CONFIRMED/CANCELLED |
| created_at/updated_at | | |
idx: (carrier_id, settle_month), (contract_id), (status, settle_month)
**margin_summary** (수지차 집계 — recalc 액션으로 채움)
| 컬럼 | 타입 | 설명 |
|---|---|---|
| margin_id | BIGSERIAL PK | |
| settle_month | CHAR(6) NN | |
| agent_id | BIGINT FK agent (NULL이면 전체) | |
| income_total | NUMERIC(15,2) NN DEFAULT 0 | 수입 합 |
| payout_total | NUMERIC(15,2) NN DEFAULT 0 | 지급 합(settle_master 기준) |
| margin_amount | NUMERIC(15,2) NN DEFAULT 0 | **= income_total payout_total** |
| margin_rate | NUMERIC(7,4) | = margin/income (income>0) |
| created_at | | |
UNIQUE(settle_month, agent_id)
**공식**: income_amount = premium × company_rate. margin = Σincome Σpayout.
### D2. 소개·이관수수료 (REFERRAL_COMM) — V89
계약을 소개(REFERRAL)하거나 이관(TRANSFER)한 설계사에게 주는 수수료.
**referral_commission_rule**
| 컬럼 | 타입 | 설명 |
|---|---|---|
| rule_id | BIGSERIAL PK | |
| referral_type | VARCHAR(20) NN | REFERRAL/TRANSFER |
| calc_type | VARCHAR(10) NN | RATE/FIXED |
| commission_rate | NUMERIC(7,4) | calc_type=RATE 일 때 |
| fixed_amount | NUMERIC(15,2) | calc_type=FIXED 일 때 |
| effective_from DATE NN / effective_to DATE | | |
CHECK referral_type IN ('REFERRAL','TRANSFER'); CHECK calc_type IN ('RATE','FIXED')
**referral_commission_ledger**
| 컬럼 | 타입 | 설명 |
|---|---|---|
| ledger_id | BIGSERIAL PK | |
| contract_id | BIGINT NN FK | |
| referrer_agent_id | BIGINT NN FK agent | 소개/이관한 설계사 |
| settle_month | CHAR(6) NN | |
| referral_type | VARCHAR(20) NN | |
| base_amount | NUMERIC(15,2) NN | 기준금액(수수료/보험료) |
| commission_rate | NUMERIC(7,4) | |
| commission_amount | NUMERIC(15,2) NN | **= base×rate (RATE) 또는 fixed_amount (FIXED)** |
| status | VARCHAR(20) NN DEFAULT 'CALCULATED' | |
idx: (referrer_agent_id, settle_month),(contract_id)
### D3. 정착지원금 (SETTLING_SUPPORT) — V90
신입/경력 설계사 정착 지원금. 등급별 정액 지급 + 조기 해촉 시 환수.
**settling_support_rule**
| 컬럼 | 타입 | 설명 |
|---|---|---|
| rule_id BIGSERIAL PK | | |
| agent_grade | VARCHAR(20) NN | 설계사 등급 |
| support_amount | NUMERIC(15,2) NN | 월 정액 지원금 |
| duration_months | INT NN | 약정 지원 개월수 |
| clawback_months | INT NN | 이 개월 내 해촉 시 환수 대상 |
| effective_from DATE NN / effective_to DATE | | |
**settling_support_ledger**
| 컬럼 | 타입 | 설명 |
|---|---|---|
| ledger_id BIGSERIAL PK | | |
| agent_id BIGINT NN FK agent | | |
| settle_month CHAR(6) NN | | |
| support_amount NUMERIC(15,2) NN | 지급액 | |
| clawback_amount NUMERIC(15,2) NN DEFAULT 0 | 환수액 | |
| net_amount NUMERIC(15,2) NN | **= support_amount clawback_amount** | |
| status VARCHAR(20) NN DEFAULT 'CALCULATED' | PAID/CLAWED_BACK 포함 | |
idx: (agent_id, settle_month)
**공식**: 지급 = 등급별 support_amount. 환수 = 조기해촉시 support_amount × (잔여약정개월/약정개월) (clawback service 계산).
### D4. 인정실적 + 환산보험료 (RECOGNIZED_PERF) — V91
환산보험료 = 보험료 × 상품별환산율. 인정실적 = 환산보험료 × 인정율. 시상/등급의 기초.
**product_conversion_rate** (상품별 환산율)
| 컬럼 | 타입 | 설명 |
|---|---|---|
| rate_id BIGSERIAL PK | | |
| product_id BIGINT NN FK product | | |
| conversion_rate NUMERIC(7,4) NN | 환산율 (예: 0.5=50%) | |
| effective_from DATE NN / effective_to DATE | | |
UNIQUE(product_id, effective_from)
**recognized_performance_ledger**
| 컬럼 | 타입 | 설명 |
|---|---|---|
| ledger_id BIGSERIAL PK | | |
| agent_id BIGINT NN FK agent | | |
| contract_id BIGINT NN FK contract | | |
| settle_month CHAR(6) NN | | |
| premium_amount NUMERIC(15,2) NN | 보험료 | |
| conversion_rate NUMERIC(7,4) NN | 적용 환산율 | |
| converted_premium NUMERIC(15,2) NN | **= premium × conversion_rate** | |
| recognition_rate NUMERIC(7,4) NN | 인정율 | |
| recognized_amount NUMERIC(15,2) NN | **= converted_premium × recognition_rate** | |
| status VARCHAR(20) NN DEFAULT 'CALCULATED' | | |
idx: (agent_id, settle_month),(contract_id)
### D5. 시상 (AWARD) — V92
신인도입/법인/유지율 등 시상. 조건 충족 시 정액 또는 정률.
**award_rule**
| 컬럼 | 타입 | 설명 |
|---|---|---|
| rule_id BIGSERIAL PK | | |
| award_type VARCHAR(20) NN | NEW_RECRUIT/CORPORATE/RETENTION/PERFORMANCE | |
| calc_type VARCHAR(10) NN | RATE/FIXED | |
| threshold_amount NUMERIC(15,2) | 충족 기준 실적(이상) | |
| award_rate NUMERIC(7,4) | RATE | |
| award_fixed NUMERIC(15,2) | FIXED | |
| effective_from DATE NN / effective_to DATE | | |
CHECK award_type IN (...); CHECK calc_type IN ('RATE','FIXED')
**award_ledger**
| 컬럼 | 타입 | 설명 |
|---|---|---|
| ledger_id BIGSERIAL PK | | |
| agent_id BIGINT NN FK agent | | |
| settle_month CHAR(6) NN | | |
| award_type VARCHAR(20) NN | | |
| base_amount NUMERIC(15,2) NN | 기준 실적 | |
| award_amount NUMERIC(15,2) NN | **= base×award_rate (RATE) 또는 award_fixed (FIXED)** | |
| status VARCHAR(20) NN DEFAULT 'CALCULATED' | | |
idx: (agent_id, settle_month),(award_type, settle_month)
### D6. 생보운영지원수수료 (LIFE_OP_SUPPORT) — V93
생보 계약 운영지원 수수료. 실적 기준 지급률 + 환수.
**life_support_rule**
| 컬럼 | 타입 | 설명 |
|---|---|---|
| rule_id BIGSERIAL PK | | |
| agent_grade VARCHAR(20) NN | | |
| support_rate NUMERIC(7,4) NN | 지급률 | |
| effective_from DATE NN / effective_to DATE | | |
**life_support_ledger**
| 컬럼 | 타입 | 설명 |
|---|---|---|
| ledger_id BIGSERIAL PK | | |
| agent_id BIGINT NN FK agent | | |
| settle_month CHAR(6) NN | | |
| base_amount NUMERIC(15,2) NN | 생보 실적 | |
| support_rate NUMERIC(7,4) NN | | |
| support_amount NUMERIC(15,2) NN | **= base × support_rate** | |
| clawback_amount NUMERIC(15,2) NN DEFAULT 0 | 환수 | |
| net_amount NUMERIC(15,2) NN | = support clawback | |
| status VARCHAR(20) NN DEFAULT 'CALCULATED' | | |
idx: (agent_id, settle_month)
### D7. 지점장수당 (BRANCH_MGR_ALLOWANCE) — V94
지점장이 소속 조직 실적에 대해 받는 수당. cap 제한 + 환수.
**branch_mgr_allowance_rule**
| 컬럼 | 타입 | 설명 |
|---|---|---|
| rule_id BIGSERIAL PK | | |
| position_code VARCHAR(20) NN | 직책(지점장 등) | |
| allowance_rate NUMERIC(7,4) NN | 수당율 | |
| cap_amount NUMERIC(15,2) | 상한(NULL=무제한) | |
| effective_from DATE NN / effective_to DATE | | |
**branch_mgr_allowance_ledger**
| 컬럼 | 타입 | 설명 |
|---|---|---|
| ledger_id BIGSERIAL PK | | |
| manager_agent_id BIGINT NN FK agent | 지점장 | |
| org_id BIGINT NN FK organization | 대상 조직 | |
| settle_month CHAR(6) NN | | |
| base_amount NUMERIC(15,2) NN | 조직 실적 | |
| allowance_rate NUMERIC(7,4) NN | | |
| allowance_amount NUMERIC(15,2) NN | **= LEAST(base×rate, cap)** | |
| clawback_amount NUMERIC(15,2) NN DEFAULT 0 | | |
| status VARCHAR(20) NN DEFAULT 'CALCULATED' | | |
idx: (manager_agent_id, settle_month),(org_id, settle_month)
> **확정 FK (DB 실검증):** contract(contract_id) · agent(agent_id) · product(product_id) · organization(org_id) · **insurance_company(company_id)** · settle_master. 보험사 FK는 `carrier_id BIGINT REFERENCES insurance_company(company_id)`. 수지차 payout은 settle_master 집계로 산출(dba가 컬럼 확인).
---
## 메뉴 (V95) — GRP_COMMISSION 하위 7개 PAGE
| menu_code | menu_name | menu_path | component_path | sort |
|---|---|---|---|---|
| INCOME_COMM | 수입수수료/수지차 | /commission/income | commission/IncomeCommission | 80 |
| REFERRAL_COMM | 소개·이관수수료 | /commission/referral | commission/ReferralCommission | 90 |
| SETTLING_SUPPORT | 정착지원금 | /commission/settling-support | commission/SettlingSupport | 100 |
| RECOGNIZED_PERF | 인정실적 | /commission/recognized | commission/RecognizedPerformance | 110 |
| AWARD | 시상 | /commission/award | commission/Award | 120 |
| LIFE_OP_SUPPORT | 생보운영지원수수료 | /commission/life-support | commission/LifeSupport | 130 |
| BRANCH_MGR_ALLOWANCE | 지점장수당 | /commission/branch-allowance | commission/BranchAllowance | 140 |
권한: READ/CREATE/UPDATE/DELETE/EXPORT. 역할부여: SUPER_ADMIN/ADMIN 전체, MANAGER READ/CREATE/UPDATE/EXPORT, AGENT READ(INCOME_COMM 제외 — 본인 관련만). V54 패턴 그대로.
## 공통코드 (V96)
| group_code | 코드 |
|---|---|
| INCOME_STATUS | CALCULATED/CONFIRMED/CANCELLED |
| REFERRAL_TYPE | REFERRAL=소개/TRANSFER=이관 |
| CALC_TYPE | RATE=정률/FIXED=정액 |
| SUPPORT_STATUS | CALCULATED/PAID/CLAWED_BACK |
| AWARD_TYPE | NEW_RECRUIT=신인도입/CORPORATE=법인/RETENTION=유지율/PERFORMANCE=실적 |
CommissionType enum 확장: INCOME/REFERRAL/TRANSFER/SETTLING/RECOGNIZED/AWARD/LIFE_SUPPORT/BRANCH_ALLOWANCE 추가 + V40 COMMISSION_TYPE 공통코드 동기화.
---
## Wave 실행
| Wave | 담당 | 산출물 |
|---|---|---|
| 1 | dba-architect | V88~V96 (도메인 7 + 메뉴 + 공통코드). FK 테이블명 실검증. |
| 2 | core-architect | 14테이블 VO/Resp/SaveReq/SearchParam + Mapper IF+XML + CommissionType enum 확장 |
| 3 | api-developer | 7 Service + 7 Controller (@RequirePermission/@DataChangeLog, 공식 계산 Service 내장) |
| 4 | frontend-developer | 7 페이지 + api/commission.ts 확장 + App.tsx 라우트 |
| 5 | infra-reviewer | `./gradlew build` + 프론트 빌드 + smoke. README/HANDOFF/DOMAIN_KNOWLEDGE 갱신 |
---
## 완료 상태 (2026-05-31)
schema **v96** 적용(Flyway V88~V96 success), 전체 `./gradlew build` SUCCESSFUL, 프론트 빌드 OK, ga-api 8082 라이브. P7 14개 GET + 수지차 recalc POST 전수 호출 5xx 0건. 수입수수료(보험료×요율=6,000,000)·수지차(수입−지급) 공식 런타임 실측 검증 후 테스트데이터 정리.
**PL 통합검증에서 잡은 버그 3건(팀원 GET-only smoke가 놓침) — 수정 완료:**
1. 프론트 인정실적 경로 `/rules` → 백엔드 `/rates` 불일치 → `api/commission.ts` 수정.
2. 프론트 수지차 경로 `/margin` → 백엔드 `/margins` 불일치 → 수정.
3. 수지차 recalc 3중 결함: 컨트롤러 `@RequestBody`(프론트는 쿼리파라미터)→`@RequestParam`, 서비스 `payoutTotal=0` 하드코딩 → settle_master.net_amount 실집계(`sumPayoutByMonth`)+`sumIncomeByMonth` 신규, `selectByMonthAndAgent``#{agentId} IS NULL` Postgres typed-null 오류 → `<choose>`로 교체.
+169
View File
@@ -0,0 +1,169 @@
# P8 — incar 갭 반영: 운영/정산 프로세스 7개 도메인 (🅱)
작성: PL (자율 모드), 2026-05-31. 사용자 지시 "다 적용". P7(🅰 수수료계산 7)에 이어 🅱 운영 7개로 incar 갭 14개 완성.
범위: 풀스택(DB→Core→API→Frontend→Infra). 패턴 100% 준수(template = 수금수수료 V41 / CollectionCommission*, P7과 동일).
Flyway: **V96이 최신 → V97부터.** 메뉴는 적절한 상위그룹 하위. 금액 NUMERIC(15,2), 율 NUMERIC(7,4), HALF_UP.
**확정 FK**: agent(agent_id), contract(contract_id), organization(org_id), insurance_company(company_id), grade(grade_id), settle_master.
---
## 도메인별 설계
### D8. 단계마감 (STEP_CLOSE) — V97
월 정산을 단계별로 마감(수신→계산→검증→지급). 기존 PERIOD_CLOSE(단일 잠금) 보강.
**settlement_step_close**
| 컬럼 | 타입 | 설명 |
|---|---|---|
| step_close_id | BIGSERIAL PK | |
| settle_month | CHAR(6) NN | |
| step_code | VARCHAR(20) NN | RECEIVE/CALC/VERIFY/PAY |
| step_seq | INT NN | 단계 순서 1~4 |
| status | VARCHAR(20) NN DEFAULT 'OPEN' | OPEN/CLOSED |
| closed_at TIMESTAMP / closed_by BIGINT / created_at | | |
UNIQUE(settle_month, step_code); CHECK step_code IN(...); CHECK status IN('OPEN','CLOSED')
공식 없음(워크플로우). 액션: close(단계마감), reopen.
### D9. 본사정산 (HQ_SETTLE) — V98
GA 본사 자체의 수입/비용 정산 항목(설계사 지급과 별개의 GA 손익).
**hq_settle_item**
| 컬럼 | 타입 | 설명 |
|---|---|---|
| item_id | BIGSERIAL PK | |
| settle_month | CHAR(6) NN | |
| item_type | VARCHAR(20) NN | INCOME(수입)/EXPENSE(비용) |
| item_name | VARCHAR(100) NN | 항목명 |
| amount | NUMERIC(15,2) NN | 금액 |
| memo | VARCHAR(300) | |
| status | VARCHAR(20) NN DEFAULT 'REGISTERED' | REGISTERED/CONFIRMED |
| created_at/created_by/updated_at | | |
idx: (settle_month, item_type). CHECK item_type IN('INCOME','EXPENSE')
공식: 본사손익 = Σ(INCOME amount) Σ(EXPENSE amount). 조회 집계 엔드포인트 제공.
### D10. 등급평가 (GRADE_EVAL) — V99
설계사 실적 점수로 등급 산정. rule(점수구간→등급) + 평가원장.
**grade_eval_rule**
| 컬럼 | 타입 | 설명 |
|---|---|---|
| rule_id BIGSERIAL PK | | |
| grade_id | BIGINT NN FK grade | 부여 등급 |
| min_score | NUMERIC(7,2) NN | 이 점수 이상이면 해당 등급 |
| effective_from DATE NN / effective_to DATE | | |
**agent_grade_evaluation**
| 컬럼 | 타입 | 설명 |
|---|---|---|
| eval_id BIGSERIAL PK | | |
| agent_id BIGINT NN FK agent | | |
| eval_period CHAR(6) NN | 평가기준월 | |
| performance_score NUMERIC(7,2) NN | 실적점수 | |
| prev_grade_id BIGINT FK grade | 기존 등급 | |
| evaluated_grade_id BIGINT FK grade | 산정 등급 | |
| status VARCHAR(20) NN DEFAULT 'EVALUATED' | EVALUATED/APPLIED | |
idx: (agent_id, eval_period). UNIQUE(agent_id, eval_period)
공식: evaluated_grade = score 이상인 최대 min_score 룰의 grade. Service가 grade_eval_rule 조회해 결정.
### D11. 공동계약 (JOINT_CONTRACT) — V100
한 계약을 여러 설계사가 공동 모집 → 지분율(share_rate)로 수수료 배분.
**joint_contract**
| 컬럼 | 타입 | 설명 |
|---|---|---|
| joint_id BIGSERIAL PK | | |
| contract_id BIGINT NN FK contract | | |
| agent_id BIGINT NN FK agent | | |
| role VARCHAR(10) NN | LEAD/SUB | |
| share_rate NUMERIC(7,4) NN | 지분율(합=1) | |
| created_at/created_by | | |
UNIQUE(contract_id, agent_id); CHECK role IN('LEAD','SUB'); CHECK share_rate>=0 AND share_rate<=1
공식: 설계사몫 = 계약수수료 × share_rate. (검증: 계약별 share_rate 합=1 — Service 가드)
### D12. 보증보험 (GUARANTEE_INS) — V101
설계사가 가입하는 신원보증보험 가입현황 추적(공식 없음, 관리).
**agent_guarantee_insurance**
| 컬럼 | 타입 | 설명 |
|---|---|---|
| guarantee_id BIGSERIAL PK | | |
| agent_id BIGINT NN FK agent | | |
| policy_no VARCHAR(50) NN | 증권번호 | |
| carrier_id BIGINT FK insurance_company(company_id) | 보증보험사 | |
| guarantee_amount NUMERIC(15,2) NN | 보증금액 | |
| start_date DATE NN / end_date DATE NN | 보증기간 | |
| status VARCHAR(20) NN DEFAULT 'ACTIVE' | ACTIVE/EXPIRED/CANCELLED | |
idx: (agent_id, status),(end_date)
### D13. 적립금 (RESERVE_FUND) — V102
환수 대비 수수료 일부 적립 + 잔액 관리.
**agent_reserve_fund**
| 컬럼 | 타입 | 설명 |
|---|---|---|
| reserve_id BIGSERIAL PK | | |
| agent_id BIGINT NN FK agent | | |
| settle_month CHAR(6) NN | | |
| reserve_in NUMERIC(15,2) NN DEFAULT 0 | 적립액 | |
| reserve_out NUMERIC(15,2) NN DEFAULT 0 | 인출/사용액 | |
| balance NUMERIC(15,2) NN | **= 전월잔액 + reserve_in reserve_out** | |
| created_at | | |
UNIQUE(agent_id, settle_month). idx (agent_id, settle_month)
공식: balance = (직전월 balance) + reserve_in reserve_out. Service가 직전월 조회해 누적.
### D14. 수수료 시뮬레이터 (COMM_SIMULATOR) — V103
보험료/요율/지급률 입력 → 수수료·세금·실지급·분급·환수예상 산출. 시나리오 저장.
**commission_simulation**
| 컬럼 | 타입 | 설명 |
|---|---|---|
| sim_id BIGSERIAL PK | | |
| sim_name VARCHAR(100) NN | 시나리오명 | |
| premium_amount NUMERIC(15,2) NN | 입력 보험료 | |
| company_rate NUMERIC(7,4) NN | 보험사요율 | |
| payout_rate NUMERIC(7,4) NN | 지급률 | |
| commission_year INT NN | 회차 | |
| company_payment NUMERIC(15,2) NN | = 보험료×요율 | |
| agent_commission NUMERIC(15,2) NN | = 보험사지급액×지급률 | |
| withholding_tax NUMERIC(15,2) NN | = 설계사수수료×3.3% | |
| net_amount NUMERIC(15,2) NN | = 수수료−원천세 | |
| expected_chargeback NUMERIC(15,2) | 예상 환수(13개월 가정 50% 등) | |
| created_at/created_by | | |
공식(DOMAIN_KNOWLEDGE 7장과 동일): company_payment=premium×company_rate, agent_commission=company_payment×payout_rate, withholding_tax=agent_commission×0.033, net=agent_commissionwithholding_tax. Service의 simulate 액션이 입력만 받아 계산 후 저장/반환.
---
## 메뉴 (V104)
| menu_code | menu_name | 상위그룹 | menu_path | component_path |
|---|---|---|---|---|
| STEP_CLOSE | 단계마감 | GRP_SETTLE | /settle/step-close | settle/StepClose |
| HQ_SETTLE | 본사정산 | GRP_SETTLE | /settle/hq-settle | settle/HqSettle |
| GRADE_EVAL | 등급평가 | GRP_ORG | /org/grade-eval | org/GradeEvaluation |
| JOINT_CONTRACT | 공동계약 | GRP_PRD | /contracts/joint | contracts/JointContract |
| GUARANTEE_INS | 보증보험 | GRP_ORG | /org/guarantee | org/GuaranteeInsurance |
| RESERVE_FUND | 적립금 | GRP_COMMISSION | /commission/reserve | commission/ReserveFund |
| COMM_SIMULATOR | 수수료 시뮬레이터 | GRP_COMMISSION | /commission/simulator | commission/CommissionSimulator |
권한 READ/CREATE/UPDATE/DELETE/EXPORT(STEP_CLOSE는 READ/UPDATE/APPROVE). 역할부여 V54/V95 패턴.
## 공통코드 (V105)
| group_code | 코드 |
|---|---|
| STEP_CODE | RECEIVE=수신마감/CALC=계산마감/VERIFY=검증마감/PAY=지급마감 |
| STEP_STATUS | OPEN=열림/CLOSED=마감 |
| HQ_ITEM_TYPE | INCOME=수입/EXPENSE=비용 |
| JOINT_ROLE | LEAD=주설계사/SUB=공동설계사 |
| GUARANTEE_STATUS | ACTIVE=유효/EXPIRED=만료/CANCELLED=해지 |
| GRADE_EVAL_STATUS | EVALUATED=평가완료/APPLIED=반영완료 |
---
## Wave 실행 (P7과 동일 팀/패턴)
1 dba: V97~V105 · 2 core: VO/Mapper/Enum · 3 api: Service/Controller(공식·집계 내장) · 4 front: 7페이지+commission/settle/org api+App.tsx · 5 infra: build+smoke
**PL 검증**: GET뿐 아니라 액션 POST(close/reopen/simulate/recalc balance/등급산정)까지 실호출로 5xx 0 확인 — P7에서 GET-only smoke가 버그를 놓친 교훈 반영.
---
## 완료 상태 (2026-05-31)
schema **v105** 적용(Flyway V97~V105 success), 전체 `./gradlew build` SUCCESSFUL, 프론트 빌드 OK, ga-api 8082 라이브. 8테이블·7메뉴 등록. GET 9 + 액션 POST 5(simulate/hq-settles/step-closes/close/reserve upsert) **전부 200, 5xx 0건**. 시뮬레이터 공식 실측: 보험료 1,000,000×0.6=600,000 → ×0.75=450,000 → 원천세 14,850 → net 435,150 → 예상환수 225,000 ✓. 테스트데이터 정리 완료. (P8은 P7과 달리 액션까지 PL이 직접 호출 검증 → 숨은 버그 0.)
+168
View File
@@ -0,0 +1,168 @@
# P9 — 환수채권 이월/미수금 관리 (Clawback Receivable Carry-forward)
작성: PL, 2026-06-04. 사용자 지시 "수수료 빠진 업무 찾아서 기획·설계 후 적용".
근거: incar CMS 메뉴(`환수이월관리`/`환수계약리스트`/`환수리스트`/`위촉중환수`) ↔ GA 부재 + 코드 갭
(`AggregateStep.java:197-199` — net<0 시 경고 로그만, 미회수 환수 채권화·차월상계·대손 생애주기 전무).
범위: 풀스택(DB→Core→API→Batch→Frontend). 패턴 100% 준수(template = 정착지원금 V90 / SettlingSupport*).
Flyway: **V110이 최신 → V111부터.** 금액 NUMERIC(15,2), HALF_UP.
**확정 FK**: agent(agent_id), settle_master(agent_id, settle_month).
---
## 1. 업무 정의 (표준 한국 GA 관행 — 데이터로 외부화)
조기해지 차등환수(chargeback)·공제가 당월 지급수수료(gross)를 초과하면 `net < 0`. 이 미회수분은 **설계사가 GA에 갚아야 할 미수채권(환수채권)** 이다. 표준 처리:
1. **이월(carryover)**: 당월 net<0 → 음수분(|net|)을 환수채권으로 신규 등록, 당월 실지급(payable)=0.
2. **상계 회수(recovery)**: 차월 이후 net>0이면 미결 채권을 **FIFO(오래된 origin월 우선)** 로 상계 회수. payable = net 회수액.
3. **대손/직접상환(write-off / direct settle)**: 장기 미회수 채권은 관리자가 대손(WRITTEN_OFF) 또는 외부 직접상환(CLEARED) 처리.
표준 가정(회사 정책 다르면 데이터/룰만 수정, 코드 불변):
- 상계 우선순위 = origin_settle_month ASC (FIFO).
- 회수는 당월 양수 net 한도 내에서만(설계사를 음수로 만들지 않음).
- 이자·연체료 없음(필요 시 후속).
---
## 2. 스키마 (V111)
### 2-1. clawback_receivable (환수채권 마스터)
| 컬럼 | 타입 | 설명 |
|---|---|---|
| receivable_id | BIGSERIAL PK | |
| agent_id | BIGINT NN FK agent | |
| origin_settle_month | CHAR(6) NN | 미수 발생 정산월 |
| original_amount | NUMERIC(15,2) NN | 발생액(양수, =당월 |net|) |
| recovered_amount | NUMERIC(15,2) NN DEFAULT 0 | 누적 상계 회수액 |
| balance | NUMERIC(15,2) NN | = original_amount recovered_amount |
| status | VARCHAR(20) NN DEFAULT 'OUTSTANDING' | OUTSTANDING/RECOVERING/CLEARED/WRITTEN_OFF |
| last_recovery_month | CHAR(6) | 최근 회수 정산월 |
| writeoff_reason | VARCHAR(300) | 대손/직접상환 사유 |
| written_off_at | TIMESTAMP | |
| written_off_by | BIGINT | |
| created_at | TIMESTAMP NN DEFAULT now() | |
| created_by | BIGINT | |
| updated_at | TIMESTAMP | |
- UNIQUE(agent_id, origin_settle_month) — 설계사·발생월당 1건(배치 upsert 멱등 키)
- CHECK status IN('OUTSTANDING','RECOVERING','CLEARED','WRITTEN_OFF')
- CHECK original_amount >= 0, balance >= 0
- idx: (agent_id, status), (status), (origin_settle_month)
### 2-2. clawback_recovery (상계 회수 이력)
| 컬럼 | 타입 | 설명 |
|---|---|---|
| recovery_id | BIGSERIAL PK | |
| receivable_id | BIGINT NN FK clawback_receivable | |
| settle_month | CHAR(6) NN | 회수(상계) 정산월 |
| recovered_amount | NUMERIC(15,2) NN | 이 월 이 채권에서 회수한 액 |
| created_at | TIMESTAMP NN DEFAULT now() | |
- idx: (receivable_id), (settle_month)
- **멱등 키**: 배치 재실행 시 동일 settle_month 회수분을 reverse(삭제+balance 복원) 후 재적용.
### 2-3. settle_master 컬럼 추가 (기존 의미 불변)
```sql
ALTER TABLE settle_master
ADD COLUMN clawback_recovered NUMERIC(15,2) NOT NULL DEFAULT 0, -- 당월 채권 상계 회수액
ADD COLUMN clawback_carryover NUMERIC(15,2) NOT NULL DEFAULT 0, -- 당월 발생→이월된 미수액
ADD COLUMN payable_amount NUMERIC(15,2) NOT NULL DEFAULT 0; -- 실지급(이체대상) = max(0,net) recovered
```
- `net_amount`(=gross−소득세−지방세+부가세−공제) 의미·계산 **불변**(무결성 Σ원장==Σsettle_master 보존).
- 실제 이체/출금 가용액은 `payable_amount` 사용(후속: withdraw 한도 검증을 payable_amount 기준으로 전환은 본 P9 범위 외, 문서화만).
### 2-4. 메뉴 / 권한 / 공통코드
- 메뉴 `CLAWBACK_RECEIVABLE` (환수채권관리), 상위 GRP_COMMISSION, path `/commission/clawback-receivable`, component `commission/ClawbackReceivable`.
- 권한 READ/UPDATE/EXPORT + 역할부여(SUPER_ADMIN·ADMIN 전체, MANAGER READ) — V54/V95 패턴.
- 공통코드 `CLAWBACK_RCV_STATUS`: OUTSTANDING=미회수/RECOVERING=회수중/CLEARED=상환완료/WRITTEN_OFF=대손.
---
## 3. 배치 통합 (AggregateStep, Step 8) — **핵심**
설계사 루프에서 `rawNet = tax.netAmount() deductionTotal` 계산 직후(현 196행), settle_master.upsert **이전**에 삽입:
```
priorBalance = receivableMapper.outstandingBalance(agentId, settleMonth)
// SUM(balance) WHERE agent_id=? AND status IN('OUTSTANDING','RECOVERING')
// AND origin_settle_month < settleMonth
recovered = 0; carryover = 0; payable = 0
if (rawNet.signum() >= 0) {
recovered = min(rawNet, priorBalance) // 양수 net에서 기존 채권 FIFO 상계
payable = rawNet - recovered
} else {
payable = 0
carryover = rawNet.negate() // 음수분 신규 채권화
}
vo.setNetAmount(rawNet) // 불변
vo.setClawbackRecovered(recovered)
vo.setClawbackCarryover(carryover)
vo.setPayableAmount(payable)
settleMapper.upsert(vo)
// settle_master upsert 가드(status NOT IN CONFIRMED/PAID)와 동일 행만 채권 부수효과 적용
if (settle_master 행이 갱신가능 상태였다면) {
clawbackService.applyCarryoverAndRecovery(agentId, settleMonth, carryover, recovered)
}
```
**applyCarryoverAndRecovery (배치 멱등 보장)** — ga-batch 내 헬퍼 또는 Mapper 직접:
1. **reverse**: 이 settleMonth의 기존 clawback_recovery 행 조회 → 각 receivable.balance += recovered_amount, recovered_amount -= , status 재계산(CLEARED→RECOVERING/OUTSTANDING) → recovery 행 삭제. (재실행 안전)
2. **carryover**: carryover>0 → upsert clawback_receivable (agent_id, origin=settleMonth) {original_amount=carryover, balance=carryover, recovered_amount=0, status=OUTSTANDING}. (ON CONFLICT 갱신)
3. **recovery**: recovered>0 → outstanding 채권 origin_month ASC FIFO 순회:
- take = min(recovered_remaining, rcv.balance); rcv.recovered_amount += take; rcv.balance -= take;
status = balance==0 ? CLEARED : RECOVERING; last_recovery_month=settleMonth;
INSERT clawback_recovery(receivable_id, settleMonth, take); recovered_remaining -= take;
- recovered_remaining==0 종료.
> reverse를 먼저 하므로 동일 월 재정산 시 회수 중복 없음. carryover는 UNIQUE upsert로 멱등.
> **주의**: priorBalance 조회는 `origin_settle_month < settleMonth` 로 당월 발생분 자기상계 방지.
배치 Step 순서: AggregateStep(8) 내부에서 처리(별도 Step 불필요). 회계분개(8b)·세금은 net_amount 기준 불변.
---
## 4. API (ga-api)
`ClawbackReceivableService` + `ClawbackReceivableController` (`/api/clawback-receivables`):
- `GET /api/clawback-receivables` — 목록(agentId/status/originMonth 필터, PageHelper, @RequirePermission READ)
- `GET /api/clawback-receivables/{id}` — 상세 + 회수이력(clawback_recovery 리스트)
- `GET /api/clawback-receivables/summary` — 설계사별 미결 잔액 집계(status OUTSTANDING/RECOVERING)
- `PUT /api/clawback-receivables/{id}/write-off` — 대손/직접상환 처리(@RequirePermission UPDATE, @DataChangeLog).
body {action: WRITTEN_OFF|CLEARED, reason}. balance→0, status 전이, writeoff_* 기록. **OUTSTANDING/RECOVERING만 허용**(CLEARED/WRITTEN_OFF 재처리 E411).
- `GET /api/clawback-receivables/export` — 엑셀(ExcelService, @Transactional).
- 표준 ApiResponse/PageResponse, MapStruct, BaseMapper 상속.
회수(recovery)는 **배치만** 생성(수동 회수 엔드포인트 없음 — 정산 상계로만 발생).
---
## 5. Frontend (ga-frontend)
- `src/api/clawbackReceivable.ts` — list/detail/summary/writeOff/export.
- `src/pages/commission/ClawbackReceivable.tsx` — AG Grid(목록 100건+ / status 컬러칩 / 합계행: original/recovered/balance) + 행 클릭 상세 Drawer(회수이력 타임라인) + 대손/상환 처리 버튼(PermissionButton UPDATE) + 엑셀 export.
- `App.tsx` 라우트 `commission/clawback-receivable` (lazy).
---
## 6. 검증 (PL 직접)
1. `./gradlew build`(test 포함) SUCCESSFUL, ga-frontend `npm run build` PASS.
2. Flyway V111 success, schema v111. 테이블 2개·컬럼 3개·메뉴 1·권한·공통코드 확인.
3. GET 3종 + write-off PUT 실호출 5xx 0.
4. **배치 e2e (핵심)**: 단일 트랜잭션 롤백 시나리오로
- (a) 특정 설계사에 chargeback>gross 인 net<0 상황 구성 → 202611 정산 → clawback_carryover 발생 + clawback_receivable OUTSTANDING 1건 + payable=0 확인.
- (b) 익월 202612 정산(양수 net) → clawback_recovered 상계 + receivable balance 감소/CLEARED + clawback_recovery 행 + payable=netrecovered 확인.
- (c) 동일월 재실행 → 회수 중복 없음(reverse 멱등) 확인.
- 부산물 전부 롤백, pre-e2e 복원.
---
## 7. 후속 갭 (본 P9 범위 외, 기록)
- 시상/인정실적 환수 ledger (incar `인정실적수수료환수`/`신인도입유치자시상환수`).
- 지급대상건 구성·수정(수동 포함/제외 워크플로우, incar `지급대상건구성/수정/조회`).
- 환수 보험사자료 업로드(incar `환수보험사자료 업로드` — 보험사 통보 기반 환수 인입).
- withdraw 가용한도 검증을 net_amount→payable_amount 로 전환.
</content>
</invoke>
@@ -0,0 +1,182 @@
# 수수료 계산 공식 전체 레퍼런스 (코드 기준)
> 모든 수수료 종류의 **실제 구현 계산식**을 정리한 문서입니다.
> 출처는 각 `*Service` / `*Calculator` / Step 코드이며, 2026-06 수수료 감사 수정분이 반영돼 있습니다.
> 공통 규칙: 모든 금액 `BigDecimal`, 반올림 `HALF_UP`. 금액 `NUMERIC(15,2)`(소수 2자리),
> 율 `NUMERIC(7,4)`. 원천세·환수 등 일부 집계는 원단위(scale 0).
---
## 0. 가장 기본 — 모집/유지 "세 번 곱하기"
`CommissionCalculator` (모집·유지 공통, 회차만 다름)
```
① 보험사지급액 = 보험료(premium) × 보험사요율(company_rate %) / 100
② 설계사수수료 = 보험사지급액 × 지급률(payout_rate %) / 100 ← gross(agent_amount)
③ 원장 원천세 = 설계사수수료 × 3.3% ← tax_amount
```
- `company_rate`: 상품 × 회차(commission_year) × 기준일로 조회
- `payout_rate` : 설계사등급 × 보험종류 × 회차로 조회 (등급 높을수록 ↑)
- 율이 NULL이면 0 폴백 + **경보 로그**(과소지급 추적). 금액 pct는 scale 2.
- **숫자 예시**: 보험료 1,000,000 × 60% = 600,000 → × 75% = 450,000 → 세금 14,850
| 종류 | 코드 | 회차(commission_year) | 귀속 |
|---|---|---|---|
| **모집수수료** | recruit | 1 (초년도) | 원 모집설계사 (승계돼도 잔류) |
| **유지수수료** | maintain | 2~ (`monthsElapsed/12 + 1`) | 현재 관리설계사 (승계 시 후임) |
---
## 1. 설계사 지급성 수수료 (settle_master gross 에 합산되는 9종 + 본 2종)
| # | 종류 | 코드 | 계산 공식 | 비고 |
|---|---|---|---|---|
| 1 | 모집수수료 | recruit | `premium × company_rate × payout_rate` | §0 |
| 2 | 유지수수료 | maintain | 〃 (회차만 다름) | §0 |
| 3 | **수금수수료** | collection | `commission = 보험료(premium_amount) × 수수료율(commission_rate)` | `CollectionCommissionService:89` |
| 4 | **갱신수수료** | renewal | `commission = req.commissionAmount` (룰의 rateValue/fixedAmount 참조, 원장은 입력액) | `RenewalCommissionService:91` |
| 5 | **부활수수료** | reinstatement | `net = commissionAmount + chargebackReversal` (부활 시 기환수액 일부 복원) | `ReinstatementCommissionService:88,100` |
| 6 | **지속수당(유지보너스)** | persistency_bonus | 유지율 ≥ `retention_rate_threshold` 일 때 `bonus_value` (RATE형 base×율 / FIXED형 정액) | 룰: 등급별 임계유지율 |
| 7 | **소개·이관수수료** | referral | RATE형 `base × rate` / FIXED형 `fixed_amount`. (RATE는 룰 rate 우선, 없으면 req) | `ReferralCommissionService:103,110,114` |
| 8 | **정착지원금** | settling_support | `net = support_amount clawback`. **환수액 ≤ 지원금 검증**(net 음수 차단) | `SettlingSupportService` |
| 9 | **시상** | award | `threshold_amount 이상`일 때만: RATE `base × award_rate` / FIXED `award_fixed` | `AwardService` (threshold 게이트) |
| 10 | **생보운영지원수수료** | life_support | `support = base × support_rate`, `net = support clawback`. **환수 ≤ support 검증** | `LifeSupportService` |
| 11 | **지점장수당** | branch_allowance | `allowance = min(base × allowance_rate, cap)`. cap=NULL이면 무제한. `net = allowance clawback` | `BranchAllowanceService:85-93` |
> settle_master 집계 시 9종(3~11, 모집·유지 제외)은 `other_commission_total` 로 합산돼 gross 에 더해진다.
---
## 2. 조직·실적 기반
| 종류 | 코드 | 계산 공식 | 비고 |
|---|---|---|---|
| **오버라이드** | override | `amount = min(base × override_pct, cap)`. **base = 하위설계사 모집+유지 합** (룰에 `insurance_type_cond` 있으면 해당 보험종류만 집계) | `CalcOverrideStep`. cap NULL=무제한. 조직트리 상향 탐색해 가장 가까운 `to_grade` 1명에 귀속 |
| **인정실적(환산보험료)** | recognized | `converted = premium × conversion_rate`, `recognized = converted × recognition_rate` | `RecognizedPerformanceService:85,89`. 시상·등급 산정 기초 |
| **수입수수료** | income | `income = premium × company_rate`. 수지차 `margin = Σincome Σpayout`, `marginRate = margin / Σincome` | `IncomeCommissionService:47,83,86`. 보험사→GA 수입 원장 |
| **등급평가** | grade_eval | `performance_score``selectActiveRuleByScore` → 매칭 룰의 `gradeId`로 등급 결정 (없으면 기존등급 유지) | `GradeEvaluationService:95-97` |
| **공동계약 지분** | joint_contract | 계약별 `Σ share_rate ≤ 1.0` 검증 (초과 시 E411). 각 설계사 몫 = 수수료 × 본인 share_rate | `JointContractService` |
| **준비금** | reserve_fund | `balance = (직전월 balance or 0) + reserve_in reserve_out` | `ReserveFundService:50` |
| **수수료 시뮬레이션** | simulation | `사수 = premium×company_rate×payout_rate`, `원천세 = 사수×3.3%`, `net = 사수−원천세`, `예상환수 = 사수×기본환수율(or 입력)` | `CommissionSimulationService` (조회 전용 미리보기) |
---
## 3. 횡단(cross-cutting) 규제·정산 계산
### 3-1. 1200% 룰 (RegulatoryLimitChecker) — **단위 환산 + clip+이연**
```
한도(KRW) = 연환산보험료 × 한도율(%) / 100 ← unit=PERCENT 일 때 (1200%, 1차년 35%)
위반조건 = (기지급 누적 + 추가분) > 한도(KRW)
```
- 1차년 한도 초과 시: **한도잔여까지만 당월 지급(clip)**, 초과분만 분급(7년)으로 **이연**, 세액 재계산.
전액 이연되면 당월 원장 미생성. (`CalcRecruitStep`)
### 3-2. 분급 (InstallmentPlanGenerator) — 7년 분할
```
회차별 지급 = 총수수료 × 회차비율(%) / 100 (표준 35/25/15/10/7/5/3)
```
- 비율 합이 정확히 100%이면 **마지막 회차가 반올림 잔차를 흡수**(분급 합 = 원금 보장).
### 3-3. 차등환수 (ChargebackCalculator) — 조기해지
```
경과월 = MONTHS_BETWEEN(계약일, 해지일)
환수액 = 기지급 모집수수료 × 환수율(경과월 구간)
```
| 경과월 | 환수율 |
|---|---|
| 0 ~ 12 | 100% |
| 13 ~ 24 | 50% |
| 25 ~ 36 | 25% |
| 37 ~ 84 | 10% |
| 85 ~ | 0% |
- (0개월 즉시해지도 100% 구간에 포함 — V109 보정)
### 3-4. 세금 (TaxCalculator / BatchTaxCalculator)
```
소득세 = gross × 소득세율(사업 3.3% / 기타 8.8%) (scale 0, 원단위)
지방세 = 소득세 × 10% (scale 0)
부가세 = gross × 10% (과세사업자 TAXABLE 만, 그 외 0)
net = gross − 소득세 − 지방세 + 부가세
```
- **gross ≤ 0(환수>지급)이면 세금 0, net = gross** (음수 원천세가 신고에 전파되는 것 차단)
### 3-5. 집계 → 실지급 (AggregateStep, settle_master)
```
gross = 모집 + 유지 + 오버라이드 + 예외(+) − 환수 − 예외() + 기타9종(other_commission_total)
tax = 소득세 + 지방세
net = gross − 소득세 − 지방세 + 부가세 − 공제(deduction)
```
- net < 0(공제>지급)이면 경보 로그(차월 이월 검토 대상).
- 무결성: 8단계 후 `Σ원장 == Σsettle_master`.
### 3-6. 회계 분개 (AccountingEntryGenerateStep) — **원천세 예수금 분리**
```
(차) 8350 지급수수료 gross
(대) 2530 미지급금 net (= gross 원천세)
(대) 2540 예수금 원천세(tax_amount) ← 원천세>0 일 때 leg 분리
```
- 차변합(gross) = 대변합(net + 원천세) 균형.
---
## 3-7. 2026-06 감사 보완 4종 (V124~V126)
설계사 지급성 수수료 종류는 사실상 완비된 상태에서, 감사로 발견한 **실제 결함·규제 갭**만 보완.
### 3-7-A. 지급보류(HOLD) 지급배치 제외 — **버그 수정**
```
지급배치 대상 = settle_master WHERE payable_amount>0 AND status NOT IN ('PAID','HOLD')
```
- 종전엔 `status != 'PAID'` 만 검사 → 운영자가 지급보류(HOLD)한 설계사도 펌뱅킹 이체에 포함되던 결함.
(`PaymentBatchItemMapper.insertFromSettleMaster`)
### 3-7-B. 환수 원천세 역분개 — 교차월 환수 시 기납부 원천세 보정
```
gross<0(상계되지 않은 환수분)인 월:
역분개 기준액 = |gross|
소득세 역분개 = −(|gross| × 소득세율) ← withholding_tax_adjustment (음수)
지방세 역분개 = −(소득세 역분개액 × 10%)
분기 원천세신고 = Σpayment(원천세) + Σadjustment(역분개, 음수) ← 과대신고 해소
```
- gross≥0(당월 환수가 양수분으로 상계)인 달은 당월 과세에 이미 반영 → **역분개 미발생**(이중계상 없음).
- settle_master gross/net/carryover/payable 흐름은 **불변**(역분개는 신고보정용 별도 레코드).
(`AggregateStep` gross<0 분기, `WithholdingTaxReportMapper.upsertQuarterlyAggregate`)
### 3-7-C. 환수채권 연체이자 — 미수채권 이월 시 월 단리 적립
```
당월 적립이자 = 채권잔액(balance) × 연체이자율(%) / 1200 (월 단리, scale 0)
적립조건 = origin_settle_month ≤ 당월 (거치기간+1)
balance += 적립이자 (→ 기존 FIFO 상계가 이자까지 회수)
```
- 이자율 `CLAWBACK_OVERDUE_INTEREST_RATE`(기본 0=미적립 안전폴백), 거치기간 `CLAWBACK_INTEREST_GRACE_MONTHS`(기본 0).
- 멱등: `clawback_interest` 이력으로 당월 적립분 reverse 후 재적립. (`AggregateStep.reverseAndAccrueClawbackInterest`)
### 3-7-D. 사업소득 간이지급명세서(월별) — 국세청 제출 대응
```
간이지급명세서 = commission_statement(MONTHLY) ⋈ agent
(소득자·주민/사업자번호·지급액·소득세·지방소득세·차인지급액)
```
- 신규 데이터테이블 없음(읽기전용 리포트). 조회 + 엑셀 export. (`ReportMapper.selectSimplifiedPayment`)
---
## 4. 실지급 흐름 한 장 요약
```
보험료 ─×보험사요율─▶ 보험사지급액 ─×지급률─▶ 설계사수수료(gross)
┌─ 1200%룰: 1차년 한도 clip + 초과분 7년 분급 이연 ─┐
└─ 조기해지: 차등환수(−) / 부활: 환수복원(+) ┘
gross − 소득세 − 지방세 (+부가세) − 공제 = net(실지급) ──▶ 펌뱅킹 이체
회계분개: (차)지급수수료 / (대)미지급금 net + 예수금 원천세
```
---
*기준 코드: CommissionCalculator, TaxCalculator, ChargebackCalculator, InstallmentPlanGenerator,
RegulatoryLimitChecker, CalcRecruit/Maintain/OverrideStep, AggregateStep, AccountingEntryGenerateStep,
P7/P8 12개 commission Service. schema v126. 2026-06 수수료 감사 수정 + 갭보완 4종(§3-7) 반영.*
@@ -0,0 +1,168 @@
# E2E 시나리오 — 보험이 어떻게 들어와서 수수료가 어떻게 나가는가
> 이 문서는 **계약 1건이 인입되어 설계사 통장에 돈이 꽂힐 때까지**의 전체 순서를
> "등장인물 + 숫자"로 따라가는 시나리오입니다.
> 각 단계는 실제 계산 코드(`CommissionCalculator`, `RegulatoryLimitChecker`,
> `InstallmentPlanGenerator`, `ChargebackCalculator`, `TaxCalculator`)와 1:1로 대응하며,
> 이 시나리오를 그대로 검증하는 단위테스트가 `SettlementScenarioTest` 입니다.
---
## 0. 등장인물 (이 시나리오의 테스트 데이터)
| 역할 | 식별자 | 속성 |
|---|---|---|
| 원 모집설계사 **김설계** | `agentId=10`, `gradeId=5` | 사업소득(BUSINESS_INCOME) · 과세사업자(TAXABLE) |
| 후임 승계설계사 **박승계** | `agentId=20`, `gradeId=3` | (계약 승계 단계에서 등장) |
| 상위 직책자 **이지점** | `agentId=30` | 오버라이드 수령 |
| 상품 **무배당 종신보험** | `productId=1`, `WHOLE_LIFE`, 카테고리 `LIFE` | |
| 계약 | `contractId=100`, `policyNo=POL-0001` | 연납보험료 **1,000,000원**, 계약일 **2024-01-15** |
기준 룰(테스트에서 Mock으로 주입하는 데이터):
| 룰 | 값 |
|---|---|
| 보험사요율(company_rate) 1차년 | 60% |
| 보험사요율 2차년 | 10% |
| 지급률(payout_rate) 등급5·WHOLE_LIFE·1차년 | 75% |
| 지급률 등급5·2차년 | 50% |
| 지급률 등급3(후임)·2차년 | 40% |
| 분급 비율(7년) | 35 / 25 / 15 / 10 / 7 / 5 / 3 (%) |
| 차등환수 13~24개월 구간 | 50% |
| 1200% 한도(TOTAL_RATIO) | 12,000,000원 (연납 1,000,000 × 1200%) |
| 원천세 | 사업소득 3.3% · 지방세 = 소득세 10% · 부가세 10%(과세사업자) |
---
## 1. 한눈에 보는 순서
```
[보험사] 실적데이터 인입
│ (Step1 receive → Step2 transform → Step3 reconcile)
① 모집수수료 원장 계산 (Step4 calcRecruit) ← premium × company_rate × payout_rate
② 1200% 룰 한도 체크 (RegulatoryLimitChecker) ← 누적이 한도를 넘으면 잘라냄
③ 분급(7년) 계획 생성 (InstallmentPlanGenerator) ← 큰 모집수수료를 7년에 나눠 지급
④ 유지수수료 원장 계산 (Step5 calcMaintain) ← 2차년 이후 매년
│ └ ⑤ 계약 승계 시 후임에게 귀속 전환 (모집은 원설계사 유지)
⑥ 오버라이드 계산 (Step7 calcOverride) ← 하위실적 × 율 (cap 제한)
⑦ 조기 해지 환수 (Step6 chargeback) ← 유지개월 짧을수록 많이 환수
⑧ 설계사×월 집계 (Step8 aggregate) ← gross 합산
│ └ 세금 원천징수 + 공제 차감 → net(실지급액)
[설계사 통장] ← 펌뱅킹 이체(KFTC)
```
---
## 2. 단계별 숫자 (시나리오 본문)
### ① 모집수수료 원장 계산 — 보험이 "들어오는" 첫 돈
계약이 인입되면 설계사 김설계(등급5)의 모집수수료 원장 1줄이 생긴다.
```
① 보험사지급액 = 보험료 1,000,000 × 보험사요율 60% = 600,000
② 설계사수수료 = 600,000 × 지급률 75% = 450,000 ← gross
③ 원장 원천세 = 450,000 × 3.3% = 14,850
```
→ 원장: `agent_id=10`, `commission_year=1`, `agent_amount=450,000`, `tax_amount=14,850`
### ② 1200% 룰 한도 체크
초년도 모집수수료 누적이 연납보험료의 1200%(=12,000,000)를 넘으면 안 된다.
- 현재 누적 0 + 450,000 = 450,000 ≤ 12,000,000 → **통과**
- 만약 추가분이 한도를 넘기면 `ViolationResult.violated` 로 막고 초과분을 잘라낸다.
### ③ 분급(7년 분할) 계획 생성
모집수수료 450,000을 한 번에 안 주고 7년에 나눈다.
| 차년 | 비율 | 지급액 |
|---|---|---|
| 1 | 35% | 157,500 |
| 2 | 25% | 112,500 |
| 3 | 15% | 67,500 |
| 4 | 10% | 45,000 |
| 5 | 7% | 31,500 |
| 6 | 5% | 22,500 |
| 7 | 3% | 13,500 |
| **합** | **100%** | **450,000** |
> **무결성 규칙:** 분급액의 **합은 원수수료와 정확히 같아야 한다.**
> 금액이 딱 나눠떨어지지 않을 때(예: 435,150원) 회차별 반올림 잔차가 생기는데,
> 이때는 **마지막 회차가 잔차를 흡수**해서 합계를 원금에 맞춘다. (→ §4 보완)
### ④ 유지수수료 (2차년)
계약이 유지되면 2차년부터 유지수수료가 매년 발생한다.
```
보험사지급액 = 1,000,000 × 10%(2차년 요율) = 100,000
설계사수수료 = 100,000 × 50%(등급5 2차년) = 50,000
```
### ⑤ 계약 승계 (김설계 해촉 → 박승계 후임)
김설계가 해촉되면 그 계약의 **유지수수료만** 후임 박승계(등급3)에게 넘어간다.
```
유지수수료(후임) = 100,000 × 40%(등급3 2차년) = 40,000 → 원장 agent_id = 20(박승계)
```
- **모집수수료는 원 모집설계사 김설계(10)에게 그대로 남는다.** (고아계약 승계 표준)
### ⑥ 오버라이드 (상위 직책자 이지점)
하위 김설계의 실적에 대해 상위 이지점이 받는 수당.
```
오버라이드 = 하위실적 2,000,000 × 4% = 80,000 (cap 100,000 미만이므로 그대로)
```
### ⑦ 조기 해지 환수 — 13개월차 해지
계약이 13개월차(2024-01-15 → 2025-02-15)에 깨지면 13~24개월 구간 환수율 50%.
```
환수액 = 기지급 모집수수료 450,000 × 50% = 225,000 ← 다음 정산에서 (−) 차감
```
### ⑧ 집계 → 세금 → 실지급액(net)
김설계 한 달치를 settle_master로 집계하고 세금·공제를 반영한다.
과세사업자(TAXABLE) gross 450,000 기준:
```
소득세 = 450,000 × 3.3% = 14,850
지방세 = 14,850 × 10% = 1,485
부가세 = 450,000 × 10% = 45,000 (과세사업자만 +)
실지급 = 450,000 14,850 1,485 + 45,000 = 478,665
```
- 여기에 가지급금 공제 100,000이 있으면 net = 478,665 100,000 = **378,665**
- 비과세(NONE) 설계사는 부가세가 없어 net = 450,000 14,850 1,485 = **433,665**
---
## 3. 시나리오 ↔ 코드 ↔ 테스트 대응표
| 단계 | 코드 | 테스트 메서드 |
|---|---|---|
| ① 모집수수료 | `CommissionCalculator.calcRecruitLedger` | `s1_보험인입_모집수수료_원장계산` |
| ② 1200% 룰 | `BatchRegulatoryLimitChecker.checkContractTotal` | `s2_규제_1200퍼센트_한도_통과및위반` |
| ③ 분급 7년 | `BatchInstallmentPlanGenerator.generate` | `s3_분급_7년_분할_합계100퍼센트` |
| ③ 분급 잔차 | 〃 | `s3b_분급_라운딩_잔차_마지막회차_흡수` |
| ④ 유지수수료 | `CommissionCalculator.calcMaintainLedger` | `s4_유지수수료_2차년` |
| ⑤ 계약 승계 | `CommissionCalculator.calcMaintainLedger(effectiveAgentId)` | `s5_계약승계_유지수수료_후임귀속` |
| ⑦ 차등 환수 | `BatchChargebackCalculator.calculate` | `s6_조기해지_차등환수_13개월_50퍼센트` |
| ⑧ 세금/실지급 | `BatchTaxCalculator.calculate` | `s7_세금_실지급액_과세사업자` / `s8_세금_비과세` |
---
## 4. 이 시나리오 작성 중 발견·보완한 사항
**분급 계획 합계 불일치(반올림 잔차) — 수정함.**
- 현상: 분급액을 회차별로 `총액 × 비율 / 100` 으로 각각 반올림하면,
원금이 7로 나눠떨어지지 않을 때 분급 합계가 원금과 1~수원 어긋났다.
(예: 435,150원 → 회차 합 435,153원, +3원 누수)
- 영향: 분급 총합 ≠ 원 모집수수료 → 정산 무결성(합계 일치) 위반, 과/미지급.
- 수정: **비율 합이 정확히 100%인 정규 스케줄**일 때 마지막 회차가
`원금 직전 회차 합` 으로 잔차를 흡수하도록 보정.
(비율 합이 100% 미만인 부분지급 스케줄은 기존 동작 유지 → 설계 의도 보존)
- 대상: `InstallmentPlanGenerator`(ga-api), `BatchInstallmentPlanGenerator`(ga-batch) 양쪽.
---
*기준: schema v108, DOMAIN_KNOWLEDGE.md 7장 공식. 모든 금액 BigDecimal HALF_UP.*
@@ -0,0 +1,470 @@
# 신입사원 완전 기초 교육 — 보험과 수수료, 0부터 시작하기
> 대상: **보험을 한 번도 다뤄본 적 없는 신입사원/개발자.**
> 목표: 이 문서 하나만 끝까지 읽으면 "우리 회사가 무슨 일을 하고, 수수료가 어떻게 계산돼서 설계사 통장에 꽂히는지" 머릿속에 그림이 그려지게 하는 것.
> 읽는 법: 위에서 아래로 순서대로. 어려운 용어는 나올 때마다 바로 쉬운 말로 풀어 씁니다. 숫자 예시는 직접 손으로 따라 계산해 보세요.
> 더 깊은 코드/규정 레벨은 [`DOMAIN_KNOWLEDGE.md`](DOMAIN_KNOWLEDGE.md) 참고.
---
## 목차
1. [보험이 뭔가요? (진짜 0단계)](#1-보험이-뭔가요-진짜-0단계)
2. [등장인물 4명 — 누가 누구에게 돈을 주나](#2-등장인물-4명--누가-누구에게-돈을-주나)
3. [보험 기초 용어 25개 — 이것만 알면 대화가 된다](#3-보험-기초-용어-25개--이것만-알면-대화가-된다)
4. [수수료가 뭐고 왜 종류가 많은가](#4-수수료가-뭐고-왜-종류가-많은가)
5. [수수료 종류 한눈에 (우리 시스템이 다루는 전부)](#5-수수료-종류-한눈에-우리-시스템이-다루는-전부)
6. [수수료 계산의 핵심 공식 — "세 번 곱하기"](#6-수수료-계산의-핵심-공식--세-번-곱하기)
- 6-A. [수수료 종류별 계산식 — 하나씩 풀어보기](#6-a-수수료-종류별-계산식--하나씩-풀어보기--전부)
- 6-B. [기타수수료 — 예외 가감과 그 밖의 정산](#6-b-기타수수료--예외-가감과-그-밖의-정산--전부)
7. [숫자로 끝까지 따라가기 (한 계약의 일생)](#7-숫자로-끝까지-따라가기-한-계약의-일생)
8. [그냥 곱하면 안 되는 이유 — 규제 3종](#8-그냥-곱하면-안-되는-이유--규제-3종)
9. [세금과 공제 — 통장에 적게 들어오는 이유](#9-세금과-공제--통장에-적게-들어오는-이유)
10. [한 달이 흘러가는 과정 — 월 정산 파이프라인](#10-한-달이-흘러가는-과정--월-정산-파이프라인)
11. [수입 vs 지급, 그리고 수지차 — 회사는 어디서 남기나](#11-수입-vs-지급-그리고-수지차--회사는-어디서-남기나)
12. [자주 나오는 질문 (FAQ)](#12-자주-나오는-질문-faq)
13. [용어 빠른 사전](#13-용어-빠른-사전)
---
## 1. 보험이 뭔가요? (진짜 0단계)
**보험 = 여럿이 조금씩 돈을 모아뒀다가, 사고를 당한 사람에게 몰아주는 약속.**
예를 들어 100명이 매달 1만 원씩 모읍니다(월 100만 원). 그 중 한 명이 큰 병에 걸리면, 모인 돈에서 큰 금액을 그 사람에게 줍니다. 나머지 99명은 "내가 안 걸려서 다행이다" 하고 넘어갑니다. 이 "돈 모으고 → 사고나면 주기"를 전문으로 운영하는 회사가 **보험사**입니다.
- 고객이 매달 내는 돈 = **보험료(premium)**
- 사고/사망/만기 때 받는 돈 = **보험금**
- 이 약속을 적은 계약서 한 장 = **보험계약** (증권 1장 = 계약 1건)
> 우리 회사는 보험사가 아닙니다. 우리는 **보험을 파는 일(영업)을 대행**하고, 그 대가로 받은 돈을 **영업한 사람들에게 정확히 나눠주는 회사**입니다. 그게 다음 장에서 나오는 GA입니다.
---
## 2. 등장인물 4명 — 누가 누구에게 돈을 주나
| 누구 | 한 줄 설명 | 비유 |
|---|---|---|
| **고객** | 보험에 가입하고 보험료를 내는 사람 | 물건을 사는 손님 |
| **보험사 (Carrier)** | 보험 상품을 만들고, 팔아준 대가로 수수료를 주는 회사 (삼성생명, 메리츠 등) | 물건을 만든 제조사 |
| **GA (우리 회사)** | 여러 보험사 상품을 한데 모아 파는 대형 보험대리점 | 여러 브랜드를 파는 백화점 |
| **설계사 (Agent)** | 실제로 고객을 만나 계약을 따오는 사람 | 백화점 매장 직원 |
**돈의 흐름 (제일 중요한 그림):**
```
[고객] ──보험료──▶ [보험사] ──수수료──▶ [GA(우리)] ──정산──▶ [설계사]
└ 세금 떼고, 공제 빼고 통장으로
```
- 보험사는 직접 영업하는 대신, GA·설계사가 고객을 데려와 주면 그 대가로 **수수료**를 줍니다.
- GA는 보험사한테 받은 수수료(=**수입수수료**)에서, 영업한 설계사 몫(=**지급수수료**)을 떼어 줍니다.
- **우리 시스템이 하는 일 = 이 "설계사 몫을 정확히 계산해서 나눠주는 것(정산)".**
---
## 3. 보험 기초 용어 25개 — 이것만 알면 대화가 된다
| 용어 | 쉬운 설명 |
|---|---|
| 보험계약 | 고객이 "매달 낼 테니 사고 때 보장해줘"라고 맺은 약속. 증권 1장 = 1건. |
| 보험료(premium) | 고객이 내는 돈. **모든 수수료 계산의 출발점.** 월납/연납/일시납. |
| 보험금 | 사고·만기 때 고객이 받는 돈. (수수료와는 다른 개념!) |
| 계약자 / 피보험자 / 수익자 | 돈 내는 사람 / 보장 대상 / 보험금 받는 사람. (셋이 같을 수도 다를 수도) |
| 생명보험 | 사람의 사망·생존·연금 관련 보험. (종신, 연금보험 등) |
| 손해보험 | 자동차·화재·실손 등 재산·사고 관련 보험. |
| 회차 / 차년(commission year) | 계약이 몇 년째인지. **1차년(초년도)** 수수료가 가장 큼. |
| 납입 / 미납 / 실효 | 보험료를 냈다 / 안 냈다 / 안 내서 계약이 끊겼다. |
| 유지 / 해지 / 부활 | 계약 살아있음 / 고객이 끊음 / 끊긴 걸 되살림. |
| 유지율(persistency) | 가입한 계약이 13개월·25개월 뒤에도 살아있는 비율. **영업 건전성의 핵심 지표.** |
| 청약 / 인수(언더라이팅) | 고객이 가입 신청 → 보험사가 위험 심사 후 받아들임. |
| 위촉 / 해촉 | 설계사가 GA에 소속됨 / 소속이 끝남. |
| 승계 | 설계사가 해촉되면 그가 관리하던 계약을 후임에게 넘기는 것. |
| 모집 | 설계사가 새 계약을 따오는 행위. |
| 환산보험료 | 상품마다 조건이 달라 비교가 어렵기 때문에, 정해진 "환산율"을 곱해 **공통 기준으로 환산한 보험료**. 실적 비교·시상의 기준. |
| 인정실적 | 회사가 "이건 실적으로 인정한다"고 집계한 환산보험료 합. 등급·시상의 근거. |
| 등급 | 설계사 레벨. 등급이 높을수록 지급률(받는 비율)이 높음. |
| 오버라이드 | 부하 설계사 실적에 대해 상위 직책자가 받는 수당. |
| 시상 | 목표 달성/우수자에게 주는 상금 성격의 수당. |
| 시책(인센티브) | 한시적 캠페인 보너스. |
| 환수(chargeback) | 계약이 일찍 깨지면 이미 준 수수료를 일부 돌려받는 것. |
| 분급 | 큰 수수료를 한 번에 안 주고 여러 해에 나눠 주는 것. |
| 정산 | 매월 수수료를 계산·확정·지급하는 업무 전체. |
| 원장(ledger) | 계산의 최소 단위 기록 (계약 × 월 × 수수료종류 1줄). |
| 명세서 | 설계사에게 "이번 달 얼마 받는지" 보여주는 내역서. |
---
## 4. 수수료가 뭐고 왜 종류가 많은가
**수수료 = 계약을 따오고(모집) 잘 유지시킨(유지) 대가로 보험사→GA→설계사로 흐르는 돈.**
왜 종류가 많을까요? 계약은 **태어나서(체결) → 유지되다가 → 갱신·부활도 하고 → 깨지기도(해지) 합니다.** 이 생애주기 단계마다, 또 영업을 도운 방식마다 다른 이름의 수수료가 붙기 때문입니다.
핵심 직관 하나만 기억하세요:
> **"초년도(1차년) 모집수수료가 제일 크고, 그 다음부터는 유지·관리 대가로 잘게 나뉜다."**
---
## 5. 수수료 종류 한눈에 (우리 시스템이 다루는 전부)
우리 시스템은 아래 수수료/수당을 모두 계산합니다. (★ = incar 벤치마킹으로 새로 추가한 것 — P7/P8)
### 5-1. 계약 생애주기 기반
| 종류 | 언제 | 누구에게 | 공식 한 줄 |
|---|---|---|---|
| 모집수수료 | 계약 체결(초년도) | 원 모집설계사 | 보험사지급액 × 지급률 |
| 유지수수료 | 유지되는 매월/매년 | 현재 관리설계사(승계 시 후임) | 보험사지급액 × 지급률 |
| 수금수수료 | 보험료가 실제 납입될 때 | 관리설계사 | 납입보험료 × 수금율 |
| 갱신수수료 | 갱신형 상품 갱신 시 | 관리설계사 | 갱신보험료 × 갱신율 |
| 부활수수료 | 실효 계약 부활 시 | 관리설계사 | 부활보험료 × 부활율 |
### 5-2. 성과·조직 기반
| 종류 | 의미 | 공식 한 줄 |
|---|---|---|
| 오버라이드 | 하위 실적에 대한 상위 직책 수당 | 하위실적 × 오버라이드율 (cap 제한) |
| 유지보수수당(지속수당) | 일정 유지율 달성 보너스 | 등급별 정액 또는 정률 |
| 시책(인센티브) | 캠페인/목표 달성 프로모션 | 캠페인 규칙대로 |
| ★ 시상(Award) | 신인도입/법인/유지율 등 시상 | 충족 시 실적×율 또는 정액 |
| ★ 지점장수당 | 지점장이 소속 조직 실적에 받는 수당 | min(조직실적 × 수당율, 한도) |
### 5-3. 소개·이관·지원 (★ 신규)
| 종류 | 의미 | 공식 한 줄 |
|---|---|---|
| ★ 소개수수료 | 계약을 소개해 준 설계사에게 | 기준금액 × 소개율 (또는 정액) |
| ★ 이관수수료 | 계약을 다른 설계사에게 이관할 때 | 기준금액 × 이관율 |
| ★ 정착지원금 | 신입/경력 설계사 정착 지원금(조기해촉 시 환수) | 등급별 정액 − 환수 |
| ★ 생보운영지원수수료 | 생보 운영지원 명목 수수료 | 실적 × 지급률 − 환수 |
### 5-4. 회사(GA) 레벨 (★ 신규)
| 종류 | 의미 | 공식 한 줄 |
|---|---|---|
| ★ 수입수수료 | 보험사가 GA에 준 돈(수입 측) | 보험료 × 보험사요율 |
| ★ 수지차 | GA가 남긴 마진 | 수입 합 − 지급 합 |
| ★ 인정실적 | 시상·등급의 기준이 되는 실적 | 환산보험료 × 인정율 |
| ★ 본사정산 | GA 본사 자체 수입/비용 정산 | Σ수입 − Σ비용 |
### 5-5. 보조 관리 (★ 신규, 공식보다 관리 중심)
| 항목 | 의미 |
|---|---|
| ★ 환산율(상품별) | 보험료를 공통 기준으로 환산하는 비율 |
| ★ 등급평가 | 실적 점수로 설계사 등급 산정 |
| ★ 공동계약 | 한 계약을 여러 설계사가 공동 모집, 지분율로 배분 |
| ★ 보증보험 | 설계사 신원보증보험 가입 관리 |
| ★ 적립금 | 환수 대비 수수료 일부 적립·잔액 관리 |
| ★ 단계마감 | 정산을 수신→계산→검증→지급 단계로 마감 |
| ★ 수수료 시뮬레이터 | 보험료/요율 입력 → 수수료·세금·실지급 미리 계산 |
> **모집 vs 유지의 귀속 차이(자주 묻는 함정):** 설계사가 해촉·이관되면 그 계약의 **유지수수료는 후임**에게 넘어가지만, **모집수수료는 원래 모집한 설계사**에게 그대로 남습니다.
---
## 6. 수수료 계산의 핵심 공식 — "세 번 곱하기"
대부분의 수수료는 **곱하기 세 번**이면 끝납니다. 이것만 외우면 80%는 이해한 겁니다.
```
① 보험사 지급액 = 보험료(premium) × 보험사요율(company_rate)
② 설계사 수수료 = 보험사 지급액 × 지급률(payout_rate)
③ 원천세 = 설계사 수수료 × 3.3%
──────────────────────────────────────────────
설계사 손에 = 설계사 수수료 − 원천세 (− 공제 + 부가세)
```
두 가지 율의 뜻:
- **보험사요율(company_rate)**: 보험사가 이 상품에 주기로 한 비율. **상품 × 회차(차년)**로 정해짐.
- **지급률(payout_rate)**: GA가 받은 돈 중 설계사에게 주는 비율. **설계사 등급 × 보험종류 × 회차**로 정해짐. → 같은 계약이라도 **등급 높은 설계사가 더 많이** 받음.
> 모집수수료와 유지수수료는 **공식이 똑같고**, 회차(commission_year)만 다릅니다. (모집 = 1차년, 유지 = 2차년 이후)
---
## 6-A. 수수료 종류별 계산식 — 하나씩 풀어보기 (★ 전부)
5장 표의 "한 줄 공식"을 **실제 계산 방식 + 작은 숫자 예시**로 풀었습니다.
대부분 **"무엇 × 율"** 한 줄이고, 일부만 환수·한도·조건이 붙습니다.
(율은 코드에서 % 또는 소수로 들어오며, 모든 금액은 반올림 후 원 단위로 떨어집니다.)
### 계약 생애주기 기반
| 수수료 | 계산식 | 작은 예시 |
|---|---|---|
| **모집수수료** | 보험료 × 보험사요율 × 지급률 (세 번 곱하기, 1차년) | 100만 ×60% ×75% = **450,000** |
| **유지수수료** | 〃 (회차만 2차년 이후, 율이 더 낮음) | 100만 ×10% ×50% = **50,000** |
| **수금수수료** | 납입된 보험료 × 수금율 | 월납 100,000 ×5% = **5,000** |
| **갱신수수료** | 갱신보험료 × 갱신율 (실무는 확정 금액 입력) | 갱신 80만 ×3% = **24,000** |
| **부활수수료** | 부활 시 수수료 + **환수복원**(깎였던 환수 일부 되돌림) | 수수료 30,000 + 복원 10,000 = net **40,000** |
> **수금수수료가 왜 따로?** 계약만 따왔다고 끝이 아니라 **고객이 실제로 보험료를 내야** 돈이 들어옵니다. 그 "낸 보험료"에 비례해 주는 게 수금수수료입니다.
### 성과·조직 기반
| 수수료 | 계산식 | 작은 예시 |
|---|---|---|
| **오버라이드** | min(하위 설계사 실적 × 오버라이드율, **한도**) · 한도 없으면 무제한 | 하위 200만 ×4% = **80,000** (한도 10만 미만이라 그대로) |
| **지속수당** | 유지율이 **기준 이상**일 때만 → 실적×율 또는 등급별 정액 | 13회차 유지율 85% ≥ 기준 → 정액 **100,000** |
| **시상(Award)** | 실적이 **기준(threshold) 이상**일 때만 → 실적×시상율 또는 정액 | 기준 100만 **미달이면 0**, 충족 시 정액 **200,000** |
| **지점장수당** | min(조직 실적 × 수당율, 한도) − 환수 | 조직 1,000만 ×1% = 100,000, 한도 8만 → **80,000** |
> **오버라이드 핵심:** "내 밑의 설계사가 잘 팔면 그 일부를 팀장/지점장이 받는다." 단, 룰에 보험종류 조건(예: 생보만)이 있으면 **그 종류 실적만** 곱합니다. 한도(cap)를 넘으면 한도까지만.
> **시상·지속수당 핵심:** 기준(threshold·유지율)을 **넘어야만** 나옵니다. 못 넘으면 0원.
### 소개·이관·지원
| 수수료 | 계산식 | 작은 예시 |
|---|---|---|
| **소개수수료** | 기준금액 × 소개율 **또는** 정액 (룰이 RATE냐 FIXED냐) | 기준 100만 ×2% = **20,000** |
| **이관수수료** | 기준금액 × 이관율 | 기준 100만 ×1% = **10,000** |
| **정착지원금** | 등급별 월 정액 지급. 조기 해촉 시 **환수 = 정액 × (잔여약정개월 / 약정개월)**. net = 지급 − 환수 | 지급 50만, 환수 8만 → net **420,000** (환수는 지급액을 넘지 못함) |
| **생보운영지원수수료** | 생보 실적 × 지급률 − 환수 | 실적 200만 ×3% = 60,000 환수 0 = **60,000** |
> **정착지원금 환수:** 신입에게 "정착하라"고 미리 준 돈이라, 약정 기간을 못 채우고 나가면 **남은 기간만큼 비례로 토해냅니다.** 단, 환수액이 받은 지원금보다 클 수는 없습니다(net이 마이너스가 되지 않음).
### 회사(GA) 레벨
| 항목 | 계산식 | 작은 예시 |
|---|---|---|
| **수입수수료** | 보험료 × 보험사요율 (보험사 → GA로 들어온 돈) | 100만 ×60% = **600,000** |
| **수지차(마진)** | Σ수입 − Σ지급. 마진율 = 수지차 ÷ 수입 | 수입 600,000 지급 450,000 = **150,000** (마진율 25%) |
| **인정실적** | 환산보험료(= 보험료 × 환산율) × 인정율 | 100만 ×환산 100% ×인정 90% = **900,000** |
| **공동계약 배분** | 그 계약 수수료 × 본인 지분율 (지분 합은 100% 이하) | 450,000 ×지분 60% = **270,000** |
| **적립금 잔액** | 전월 잔액 + 이번 적립 사용 | 100,000 + 20,000 5,000 = **115,000** |
| **등급평가** | 실적 점수 → 점수 구간 룰 → 해당 등급 부여 | 점수 820 → "1등급" 구간 → 등급 상향 |
| **수수료 시뮬레이터** | 보험료·요율 입력 → 위 ①②③ + 세금·예상환수 미리 계산 | 입력만 하면 실지급까지 미리보기 |
> **수입 vs 지급, 수지차:** 보험사가 GA에 준 돈(**수입**)에서 설계사에게 나간 돈(**지급**)을 빼면 GA가 남긴 마진(**수지차**)입니다. 회사가 돈을 버는 핵심 지표예요. (자세히는 11장)
### 한 줄 정리
> **"무엇 × 율"이 기본.** 여기에 ① **조건**(시상·지속수당은 기준 넘어야), ② **한도**(오버라이드·지점장수당은 cap까지만), ③ **환수**(정착·생보지원은 토해내는 분 차감)만 더 붙는다고 보면 됩니다.
---
## 6-B. 기타수수료 — 예외 가감과 그 밖의 정산 (★ 전부)
위 "공식이 있는" 수수료 말고도, 매월 정산액에 **그때그때 더하거나 빼는 항목**들이 있습니다.
이걸 통틀어 **예외(가감) 항목**이라 부르고, "기타수수료"의 핵심입니다.
### 6-B-1. 예외 가감 (Exception) — 더하는 것(PLUS) / 빼는 것(MINUS)
공식으로 안 나오는 1회성·수동 항목입니다. **방향(direction)**이 PLUS면 지급에 더하고, MINUS면 빼냅니다.
| 코드 | 이름 | 방향 | 의미 |
|---|---|---|---|
| ADV | 선지급 | ➕ PLUS | 나중에 받을 걸 미리 줌 |
| PERF | 실적격려금 | ➕ PLUS | 실적 잘 낸 격려금 |
| AWARD | 시상금 | PLUS | 시상 인센티브 |
| SUPPORT | 운영지원금 | PLUS | 운영 보조 |
| REFUND | 환급금 | PLUS | 자동 환급(auto) |
| ETC | 기타 | PLUS | 그 외 잡다한 지급 |
| PEN | 제재금 | MINUS | 징계·벌과금 |
| PRECOL | 선차감 | MINUS | 전월 못 뗀 공제분 |
| LOAN | 대여금공제 | ➖ MINUS | 빌려준 돈 자동 회수 |
| TAX_ADJ | 세금조정 | MINUS | 세금 보정 |
- **계산:** 집계할 때 `+ 예외PLUS합 예외MINUS합` 으로 반영됩니다.
- `auto_calc_yn='Y'`(REFUND·LOAN·TAX_ADJ)는 **자동 생성**, 나머지는 **수동 입력 + 승인** 후 반영됩니다.
- `tax_included`(세금 포함 여부)에 따라 과세 대상인지 갈립니다.
- **예시:** 이번 달 수수료 450,000 + 실적격려금(PLUS) 100,000 대여금공제(MINUS) 50,000 = **500,000** (세전)
> **예외 항목이 왜 필요?** 모든 돈이 공식으로 딱 떨어지지 않습니다. "이번 달만 격려금 준다", "지난달 빠뜨린 공제 뗀다" 같은 **1회성 가감**을 처리하는 창구가 예외 항목입니다.
### 6-B-2. 시책·인센티브 (Incentive)
캠페인/목표 프로그램입니다. 프로그램 규칙대로 **기준실적(base) × 달성률** 또는 **확정 인센티브액**을 지급합니다.
- 예시: "이달 신계약 5건 이상 시 30만 원" → 달성하면 incentive_amount **300,000**
### 6-B-3. 해촉정산 (설계사가 그만둘 때 마지막 계산)
설계사가 해촉(퇴사)되면 그 시점에 한 번 **몰아서 정산**합니다.
```
최종 지급액 = 미지급분(미지급 모집 + 분급 잔액 + 미지급 시책)
− 환수 예정액 (+ 면제분 제외)
공제
```
- 미지급 분급(아직 안 준 7년치 잔액)과 환수 예정(곧 토해낼 것)을 **상계**해서 최종 한 번에 정리합니다.
- 예시: 미지급 200,000 환수예정 80,000 공제 20,000 = 최종 **100,000**
### 6-B-4. 본사정산 (회사 자체 손익)
설계사 지급과 **별개로**, GA 본사의 수입·비용을 집계합니다.
```
본사 손익(profit) = 수입(INCOME) 합 비용(EXPENSE) 합
```
- 설계사에게 나가는 돈이 아니라 **회사가 얼마 벌었나**를 보는 정산입니다. (수지차와 비슷한 회사 관점)
### 6-B-5. 보증보험 (수수료 아님 — 관리 항목)
설계사가 횡령·사고 대비로 드는 **신원보증보험**입니다. 돈을 주는 게 아니라 **보증금액(guarantee_amount)**을 관리만 합니다.
- 계산이 아니라 "이 설계사가 얼마짜리 보증보험에 가입돼 있나"를 기록하는 항목입니다.
### 한 줄 정리
> 공식 수수료(6-A)가 **본 메뉴**라면, 기타수수료는 **양념**입니다.
> ➕ 더하는 것(선지급·격려금·시상금·시책), ➖ 빼는 것(제재금·대여금·선차감), 그리고 **해촉 때 몰아서 정산**, **본사 손익**, **보증보험 관리**까지가 전부입니다.
---
## 7. 숫자로 끝까지 따라가기 (한 계약의 일생)
연환산 보험료 **100만 원**짜리 계약 하나가 태어나서 깨질 때까지 따라가 봅시다.
### (A) 모집수수료 — 계약이 태어났다
| 항목 | 값 | 계산 |
|---|---|---|
| 보험료(연환산) | 1,000,000원 | (가정) |
| 보험사요율 | 60% | 상품·1차년 |
| **① 보험사 지급액** | **600,000원** | 1,000,000 × 60% |
| 지급률(이 설계사 등급) | 75% | 등급·1차년 |
| **② 설계사 수수료** | **450,000원** | 600,000 × 75% |
| **③ 원천세(3.3%)** | **14,850원** | 450,000 × 3.3% |
| **원장에 남는 실수령** | **435,150원** | 450,000 14,850 |
### (B) 분급 — 한 번에 안 주고 7년에 나눠 준다
큰 모집수수료(450,000원)는 규제상 7년에 걸쳐 비율대로 나눠 줍니다(35/25/15/10/7/5/3%).
| 차년 | 비율 | 지급액 |
|---|---|---|
| 1차년 | 35% | 157,500원 |
| 2차년 | 25% | 112,500원 |
| 3차년 | 15% | 67,500원 |
| … | … | … |
| **합계** | **100%** | **450,000원** |
→ 계약 직후 통장에 들어오는 건 450,000원이 아니라 **1차년분 157,500원(에서 세금 차감)**.
### (C) 환수 — 고객이 13개월 만에 해지했다
유지 13~24개월 구간 환수율 **50%** 적용:
```
환수액 = 기지급 모집수수료 × 환수율 = 450,000 × 50% = 225,000원 ← 다음 정산에서 (−)로 차감
```
- 12개월 이내면 100%(전액 환수), 85개월 이상이면 0%(환수 없음).
### (D) 오버라이드 — 이 설계사의 팀장이 받는 수당
```
오버라이드 = 하위 실적 × 오버라이드율 = 2,000,000 × 4% = 80,000원 (cap 넘으면 cap까지만)
```
### (E) 수지차 — 회사는 이 계약으로 얼마 남겼나 (★신규)
```
수입수수료(보험사가 GA에 준 돈) = 1,000,000 × 60% = 600,000원
지급수수료(GA가 설계사에 준 돈) = 450,000원
수지차(GA 마진) = 600,000 450,000 = 150,000원
```
이 한 흐름이 머리에 들어왔다면, 나머지 수수료 종류는 "기준금액 × 율" 변형일 뿐입니다.
---
## 8. 그냥 곱하면 안 되는 이유 — 규제 3종
수수료는 법으로 한도·방식이 정해져 있어, 단순 곱셈 결과를 그대로 줄 수 없습니다.
1. **1200% 룰** (보험업감독규정): 초년도 모집수수료 총액은 **연납보험료의 1200%**를 못 넘음. 시스템이 누적 지급액을 추적해 초과분을 잘라냄.
2. **분급**: 큰 수수료를 7년에 나눠 지급(위 7-B).
3. **차등 환수**: 조기 해지 시 유지기간이 짧을수록 많이 환수(위 7-C).
> 회사마다 세율·한도가 달라도 **코드는 안 고칩니다.** 전부 데이터(`system_config`, `regulatory_limit`, `installment_ratio`, `chargeback_grade`)로 빼놨기 때문에 값만 바꾸면 됩니다.
---
## 9. 세금과 공제 — 통장에 적게 들어오는 이유
설계사에게 주는 돈(gross)에서 **세금을 먼저 떼고(원천징수)**, **공제를 빼면** 실제 입금액(net)이 됩니다.
| 항목 | 율 | 설명 |
|---|---|---|
| 사업소득 원천징수 | 3.3% | 일반 설계사(사업소득자) |
| 기타소득 원천징수 | 8.8% | 기타소득 유형 |
| 지방소득세 | 소득세의 10% | 소득세에 부가 |
| 부가가치세 | 10% | 과세사업자가 세금계산서 발행 시 GA가 추가 지급(+) |
**공제(deduction)**: 미리 준 돈(가지급금) 회수, 대출 상환, **적립금** 등을 그 달 정산액에서 차감.
```
gross − 원천세(소득세+지방세) − 공제 (+부가세, 과세사업자) = net(실지급액) → 계좌이체(펌뱅킹)
```
**예시(과세사업자, 수수료 450,000원):** 소득세 14,850 + 지방세 1,485 빼고, 부가세 45,000 더하면 → **실지급 478,665원.**
---
## 10. 한 달이 흘러가는 과정 — 월 정산 파이프라인
매월 배치가 아래 순서로 돈다고 생각하면 됩니다.
```
보험사 데이터 수신 → 표준포맷 변환 → 대사(검증) →
모집수수료 계산 → 유지수수료 계산(승계 반영) → 환수·예외 →
오버라이드 계산 → 집계(설계사×월, 세금·공제 반영) →
회계 분개 → (분기/연말이면) 세무신고 자료
```
마감은 **단계마감(★신규)**으로 수신→계산→검증→지급 단계별로 잠급니다. 한 달을 닫으면(마감) 더는 못 고치고, 오류는 **정정 전표**로만 바로잡습니다(원본 보존). 설계사가 금액에 이의를 제기하면 **이의신청**으로 받습니다.
**상태 흐름:** `DRAFT(계산됨) → CONFIRMED(확정) → PAID(지급완료)`. 확정·지급된 건은 재정산해도 안 바뀝니다(보호).
---
## 11. 수입 vs 지급, 그리고 수지차 — 회사는 어디서 남기나 (★ 신규 핵심)
신입이 가장 헷갈리는 지점입니다. 두 방향을 구분하세요.
| 방향 | 누가 → 누구 | 시스템 용어 |
|---|---|---|
| **수입(income)** | 보험사 → GA | 수입수수료 |
| **지급(payout)** | GA → 설계사 | 모집/유지/… 수수료, 정산 |
```
수지차(마진) = Σ수입수수료 − Σ지급수수료
```
- 수지차가 (+)면 GA가 남긴 것, (−)면 그달 지급이 수입보다 많았던 것(분급·시상 등으로 일시적으로 발생 가능).
- 여기에 **본사정산**(GA 자체 수입/비용)까지 합치면 회사 전체 손익이 보입니다.
- 우리 시스템의 `수입수수료/수지차` 화면에서 **재계산 버튼**을 누르면 그달 수입 합과 지급 합(settle_master 기준)을 모아 수지차를 계산합니다.
---
## 12. 자주 나오는 질문 (FAQ)
1. **"수수료를 왜 한 번에 안 주죠?"** → 1200% 룰·분급 규제 + 조기해지 환수 리스크 때문. 7년 분할이 표준.
2. **"설계사가 그만두면 그 계약 수수료는?"** → 유지수수료는 후임(승계)에게, 모집수수료는 원 설계사에게.
3. **"gross인데 통장엔 적게 들어와요."** → 원천세(3.3%) + 공제를 뺀 net이 실지급액. (과세사업자는 부가세가 더해짐)
4. **"같은 계약인데 설계사마다 수수료가 달라요."** → 지급률이 **설계사 등급**에 따라 다르기 때문.
5. **"수입수수료랑 지급수수료가 헷갈려요."** → 수입 = 보험사가 우리에게 준 돈, 지급 = 우리가 설계사에게 준 돈. 차이가 수지차(마진).
6. **"환산보험료는 왜 필요해요?"** → 상품마다 보험료 조건이 달라 그대로 비교하면 불공평. 환산율을 곱해 공통 기준으로 맞춘 게 환산보험료고, 그 합이 인정실적이 되어 시상·등급의 잣대가 됩니다.
7. **"표준 세율/한도가 우리 회사랑 달라요."** → 코드 수정 불필요. 데이터(`system_config` 등)만 변경.
---
## 13. 용어 빠른 사전
| 한글 | 영문 | 한 줄 |
|---|---|---|
| 보험료 | Premium | 고객이 내는 돈. 계산의 출발점 |
| 보험금 | Benefit | 사고·만기 때 고객이 받는 돈 |
| 회차/차년 | Commission year | 계약 몇 년째. 1차년이 가장 큼 |
| 유지율 | Persistency | 13·25개월 뒤 계약 생존율 |
| 위촉/해촉 | Appoint/Terminate | 설계사 소속 시작/종료 |
| 승계 | Succession | 해촉 시 후임 귀속 전환 |
| 보험사요율 | Company rate | 보험사가 GA에 주는 비율 |
| 지급률 | Payout rate | GA가 설계사에 주는 비율(등급별) |
| 모집수수료 | Recruit | 체결 초년도 수수료(제일 큼) |
| 유지수수료 | Maintain | 유지 기간 수수료 |
| 수금수수료 | Collection | 보험료 납입 시 |
| 갱신/부활수수료 | Renewal/Reinstatement | 갱신·부활 시 |
| 오버라이드 | Override | 상위 직책 수당 |
| 시상 | Award | 목표 달성 상금 |
| 소개/이관수수료 | Referral/Transfer | 소개·이관 대가 |
| 정착지원금 | Settling support | 신입 정착 지원금 |
| 수입수수료 | Income commission | 보험사가 GA에 준 돈 |
| 수지차 | Margin | 수입 지급 (GA 마진) |
| 환산보험료 | Converted premium | 공통 기준 환산 보험료 |
| 인정실적 | Recognized performance | 시상·등급 기준 실적 |
| 본사정산 | HQ settlement | GA 자체 수입/비용 정산 |
| 적립금 | Reserve fund | 환수 대비 적립·잔액 |
| 분급 | Installment | 수수료 7년 분할 |
| 차등환수 | Chargeback | 조기 해지 환수 |
| 원천징수 | Withholding | 세금 미리 떼기(3.3%) |
| 공제 | Deduction | 가지급금 등 차감 |
| 단계마감 | Step close | 단계별 정산 잠금 |
| 정산 | Settlement | 월별 계산·확정·지급 |
| 원장 | Ledger | 계산 최소 단위 기록 |
| 명세서 | Statement | 설계사 지급 내역서 |
| 펌뱅킹 | Firm banking | 대량 계좌이체 |
---
*기준: 한국 보험업감독규정 표준 가정값. 시스템 schema (incar 갭 반영 P7+P8 14개 도메인 포함). 표준값은 데이터로 외부화되어 코드 변경 없이 조정 가능.*
*더 깊은 코드/배치/규정 레벨은 [`DOMAIN_KNOWLEDGE.md`](DOMAIN_KNOWLEDGE.md), [`DOMAIN_GAP_P7.md`](DOMAIN_GAP_P7.md), [`DOMAIN_GAP_P8.md`](DOMAIN_GAP_P8.md) 참고.*
+74
View File
@@ -0,0 +1,74 @@
# 외부연동 활성화 가이드 (KFTC 펌뱅킹 / NHN Toast / FCM 푸시)
세 외부연동 어댑터는 **코드 구현 완료** 상태이며, 기본은 모두 **Mock** 으로 동작한다.
운영 활성화는 아래 환경변수(또는 application.yml)만 주입하면 된다. 키가 비어 있으면 어댑터는 **fail-fast**(IllegalStateException) 하여 잘못된 적용을 즉시 알린다.
> 공통 전환 스위치: `app.adapter.{bank|message|push}` = `mock`(기본) / `kftc` / `toast` / `fcm`
> 어댑터별 구현체는 `ga-external-adapter` 모듈, 모든 외부호출은 `external_call_log` 에 AOP 로 자동 감사된다.
---
## 1. KFTC 오픈뱅킹 입금이체 (펌뱅킹)
- 구현체: `KftcBankTransferAdapter` (입금이체 `POST /v2.0/transfer/deposit/acnt_num` + 이체결과조회)
- 활성화: `ADAPTER_BANK=kftc`
| 환경변수 | 설명 | 예시 |
|---|---|---|
| `KFTC_BASE_URL` | API 베이스 (운영/테스트베드) | `https://openapi.openbanking.or.kr` |
| `KFTC_ACCESS_TOKEN` | 이용기관 2-legged Access Token (주기 재발급) | (발급 토큰) |
| `KFTC_CNTR_ACCOUNT_NUM` | 이용기관 약정 출금계좌 번호 | (약정계좌) |
| `KFTC_CNTR_ACCOUNT_TYPE` | 약정계좌 구분 | `N` |
| `KFTC_WD_PRINT` | 출금계좌 인자내역 | `수수료정산` |
| `KFTC_NAME_CHECK` | 수취인 실명확인 옵션 | `off` |
**사전 조건(비개발)**: 금융결제원 이용기관 계약, 약정 출금계좌 등록, Access Token 발급 체계.
**검증**: 테스트베드에서 출금 1건 → `withdraw_request.transfer_status` 전이 + `external_call_log` 확인.
---
## 2. NHN Cloud(Toast) SMS / 카카오 알림톡
- 구현체: `ToastMessageAdapter` (SMS v3.0 + 알림톡 v2.3, `MessageChannel` 분기)
- 활성화: `ADAPTER_MESSAGE=toast`
| 환경변수 | 설명 |
|---|---|
| `TOAST_SMS_APP_KEY` / `TOAST_SMS_SECRET_KEY` | SMS 프로젝트 AppKey / Secret Key |
| `TOAST_SENDER_NO` | 사전 등록 발신번호 |
| `TOAST_ALIMTALK_APP_KEY` / `TOAST_ALIMTALK_SECRET_KEY` | 알림톡 AppKey / Secret Key |
| `TOAST_SENDER_KEY` | 카카오 비즈메시지 발신프로필 SenderKey |
**사전 조건(비개발)**: NHN Cloud 프로젝트 + SMS/알림톡 상품 활성화, 발신번호 등록, 알림톡 **템플릿 등록**(발송 시 `templateCode` 필수).
**검증**: 본인 번호로 SMS 1건 발송 → `header.isSuccessful=true` + `external_call_log` 확인.
---
## 3. FCM 푸시 (모바일 PWA)
- 서버 구현체: `FcmPushAdapter` (HTTP v1 `POST /v1/projects/{projectId}/messages:send`)
- 활성화(서버): `ADAPTER_PUSH=fcm`
| 환경변수(서버) | 설명 |
|---|---|
| `FCM_PROJECT_ID` | Firebase 프로젝트 ID |
| `FCM_ACCESS_TOKEN` | 서비스계정 OAuth2 토큰(scope `firebase.messaging`, 주기 재발급) |
**PWA(클라이언트) 활성화**`ga-mobile-pwa`:
1. Firebase 콘솔 → 웹 앱 등록 → 설정값을 `.env``VITE_FCM_*` 로 주입
(`VITE_FCM_API_KEY`, `VITE_FCM_PROJECT_ID`, `VITE_FCM_APP_ID`, `VITE_FCM_SENDER_ID`, `VITE_FCM_VAPID_KEY`)
2. `npm i firebase``src/push/initPush.ts` 의 TODO 블록(firebase/messaging `getToken`) 활성화
3. `public/firebase-messaging-sw.js` 서비스워커 배치
4. 로그인 시 `initPush()` 가 토큰 발급 → `POST /api/mobile/me/device-tokens` 자동 등록
**동작 흐름(구현 완료)**: 알림 발생(예: 출금 SETTLED) → `PushDispatchService``user_device_token` 의 활성 토큰 조회 → `PushAdapter.send`. 토큰 미등록/Mock 이면 no-op.
**검증**: PWA 로그인 → device-token 등록 확인(`user_device_token`) → 출금 SETTLED 트리거 → 푸시 수신 + `external_call_log(call_type=PUSH)` 확인.
---
## 요약 — 남은 것은 "키 주입"뿐
| 연동 | 코드 | 활성화에 필요한 것(비개발) |
|---|---|---|
| KFTC 입금이체 | ✅ 완료 | 계약 + 약정계좌 + Access Token |
| Toast SMS/알림톡 | ✅ 완료 | AppKey/SecretKey + 발신번호 + 알림톡 템플릿 |
| FCM 푸시 | ✅ 완료(서버) / 스캐폴드(PWA) | Firebase 프로젝트 + `firebase` SDK + VAPID 키 |
+1
View File
@@ -10,5 +10,6 @@ jar.enabled = false
dependencies { dependencies {
implementation project(':ga-core') implementation project(':ga-core')
implementation project(':ga-external-adapter')
testImplementation 'org.springframework.security:spring-security-test' testImplementation 'org.springframework.security:spring-security-test'
} }
@@ -2,12 +2,16 @@ package com.ga.api;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.scheduling.annotation.EnableScheduling;
/** /**
* GA 수수료 정산 API 서버. * GA 수수료 정산 API 서버.
* 컴포넌트 스캔: com.ga 패키지 (common/core/api 모두 포함) * 컴포넌트 스캔: com.ga 패키지 (common/core/api 모두 포함)
*/ */
@SpringBootApplication(scanBasePackages = {"com.ga"}) @SpringBootApplication(scanBasePackages = {"com.ga"})
@EnableCaching
@EnableScheduling
public class GaApiApplication { public class GaApiApplication {
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(GaApiApplication.class, args); SpringApplication.run(GaApiApplication.class, args);
@@ -0,0 +1,206 @@
package com.ga.api.aop;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ga.api.service.external.ExternalCallLogService;
import com.ga.common.adapter.bank.BankTransferRequest;
import com.ga.common.adapter.bank.BankTransferResponse;
import com.ga.common.adapter.message.MessageRequest;
import com.ga.common.adapter.message.MessageResponse;
import com.ga.common.util.SecurityUtil;
import com.ga.core.vo.external.ExternalCallLogVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 외부 연동 어댑터 호출 감사 이력 자동 영속화 (V85 external_call_log).
*
* <p>대상: {@link com.ga.common.adapter.bank.BankTransferAdapter},
* {@link com.ga.common.adapter.message.MessageAdapter} 모든 메서드.
*
* <p>정책:
* <ul>
* <li>호출 전/후 (정상·예외) 모두 영속화. 예외는 그대로 propagate.
* <li>request/response 는 마스킹된 JSON 요약만 저장 (계좌·전화 뒤 4자리만).
* <li>영속화는 별도 트랜잭션({@link com.ga.api.service.external.ExternalCallLogService}#recordCall = REQUIRES_NEW).
* <li>영속화 자체가 실패해도 비즈니스 흐름을 깨지 않는다 (warn 로그만).
* </ul>
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class ExternalCallLoggingAspect {
private final ExternalCallLogService logService;
private final ObjectMapper om;
@Around("execution(* com.ga.common.adapter.bank.BankTransferAdapter+.*(..))")
public Object aroundBank(ProceedingJoinPoint jp) throws Throwable {
return around(jp, "BANK");
}
@Around("execution(* com.ga.common.adapter.message.MessageAdapter+.*(..))")
public Object aroundMessage(ProceedingJoinPoint jp) throws Throwable {
return around(jp, "MESSAGE");
}
@Around("execution(* com.ga.common.adapter.push.PushAdapter+.*(..))")
public Object aroundPush(ProceedingJoinPoint jp) throws Throwable {
return around(jp, "PUSH");
}
private Object around(ProceedingJoinPoint jp, String callType) throws Throwable {
long start = System.currentTimeMillis();
ExternalCallLogVO vo = new ExternalCallLogVO();
vo.setCallType(callType);
vo.setAdapterClass(simpleName(jp.getTarget()));
vo.setMethodName(jp.getSignature().getName());
vo.setCalledAt(LocalDateTime.now());
try {
vo.setCreatedBy(SecurityUtil.getCurrentUserId());
} catch (Exception ignore) {
// 스케줄러/배치 컨텍스트에서는 인증 정보 없을 수 있음
}
applyRequest(vo, jp.getArgs());
try {
Object result = jp.proceed();
vo.setSuccess(true);
applyResponse(vo, result);
return result;
} catch (Throwable t) {
vo.setSuccess(false);
vo.setErrorClass(t.getClass().getName());
vo.setErrorMessage(truncate(t.getMessage(), 4000));
throw t;
} finally {
vo.setDurationMs((int) (System.currentTimeMillis() - start));
try {
logService.recordCall(vo);
} catch (Exception persistEx) {
log.warn("[external_call_log] persist failed: {}", persistEx.getMessage());
}
}
}
private void applyRequest(ExternalCallLogVO vo, Object[] args) {
if (args == null || args.length == 0) return;
Object a0 = args[0];
if (a0 instanceof BankTransferRequest btr) {
vo.setTargetRefType("WITHDRAW");
vo.setTargetRefId(btr.getWithdrawRequestId());
Map<String, Object> m = new LinkedHashMap<>();
m.put("withdrawRequestId", btr.getWithdrawRequestId());
m.put("bankCode", btr.getBankCode());
m.put("accountNoMasked", maskAccount(btr.getAccountNo()));
m.put("holder", maskName(btr.getAccountHolderName()));
m.put("amount", btr.getAmount());
m.put("memo", btr.getMemo());
vo.setRequestSummary(toJson(m));
} else if (a0 instanceof MessageRequest mr) {
vo.setTargetRefType(mr.getRefType());
vo.setTargetRefId(mr.getRefId());
Map<String, Object> m = new LinkedHashMap<>();
m.put("channel", mr.getChannel());
m.put("phoneMasked", maskPhone(mr.getPhoneNumber()));
m.put("title", mr.getTitle());
m.put("templateCode", mr.getTemplateCode());
m.put("refUserId", mr.getRefUserId());
m.put("contentPreview", truncate(mr.getContent(), 80));
vo.setRequestSummary(toJson(m));
} else if (a0 instanceof com.ga.common.adapter.push.PushRequest pr) {
vo.setTargetRefType(pr.getRefType());
vo.setTargetRefId(pr.getRefId());
Map<String, Object> m = new LinkedHashMap<>();
m.put("tokenMasked", maskToken(pr.getDeviceToken()));
m.put("title", pr.getTitle());
m.put("refUserId", pr.getRefUserId());
m.put("bodyPreview", truncate(pr.getBody(), 80));
vo.setRequestSummary(toJson(m));
} else if (a0 instanceof String s) {
// BankTransferAdapter.queryStatus(txId)
vo.setRequestSummary("{\"arg\":\"" + safeJsonString(s) + "\"}");
}
}
private void applyResponse(ExternalCallLogVO vo, Object result) {
if (result instanceof BankTransferResponse btr) {
vo.setResultCode(btr.getResultCode());
vo.setResultMessage(truncate(btr.getResultMessage(), 1000));
Map<String, Object> m = new LinkedHashMap<>();
m.put("txId", btr.getTxId());
m.put("status", btr.getStatus());
m.put("processedAt", btr.getProcessedAt());
vo.setResponseSummary(toJson(m));
} else if (result instanceof MessageResponse mr) {
vo.setResultCode(mr.getResultCode());
vo.setResultMessage(truncate(mr.getResultMessage(), 1000));
Map<String, Object> m = new LinkedHashMap<>();
m.put("messageId", mr.getMessageId());
m.put("sent", mr.isSent());
m.put("sentAt", mr.getSentAt());
vo.setResponseSummary(toJson(m));
} else if (result instanceof com.ga.common.adapter.push.PushResponse pr) {
vo.setResultCode(pr.getResultCode());
vo.setResultMessage(truncate(pr.getResultMessage(), 1000));
Map<String, Object> m = new LinkedHashMap<>();
m.put("messageId", pr.getMessageId());
m.put("sent", pr.isSent());
m.put("sentAt", pr.getSentAt());
vo.setResponseSummary(toJson(m));
}
}
private String simpleName(Object o) {
return o == null ? null : o.getClass().getSimpleName();
}
private String toJson(Object o) {
try {
return om.writeValueAsString(o);
} catch (Exception e) {
return "{}";
}
}
private String maskAccount(String accountNo) {
if (accountNo == null) return null;
String d = accountNo.replaceAll("[^0-9]", "");
if (d.length() <= 4) return "****";
return "****" + d.substring(d.length() - 4);
}
private String maskPhone(String phone) {
if (phone == null) return null;
String d = phone.replaceAll("[^0-9]", "");
if (d.length() <= 4) return "****";
return "***-****-" + d.substring(d.length() - 4);
}
private String maskName(String name) {
if (name == null || name.isEmpty()) return null;
return name.charAt(0) + "*".repeat(Math.max(0, name.length() - 1));
}
private String maskToken(String token) {
if (token == null) return null;
return token.length() <= 8 ? "****" : token.substring(0, 8) + "****";
}
private String truncate(String s, int max) {
if (s == null) return null;
return s.length() <= max ? s : s.substring(0, max);
}
private String safeJsonString(String s) {
return s == null ? "" : s.replace("\\", "\\\\").replace("\"", "\\\"");
}
}
@@ -0,0 +1,83 @@
package com.ga.api.controller.accounting;
import com.ga.api.service.accounting.AccountingCloseService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.accounting.AccountBalanceSnapshotVO;
import com.ga.core.vo.accounting.AccountingCloseResp;
import com.ga.core.vo.accounting.AccountingCloseSaveReq;
import com.ga.core.vo.accounting.AccountingCloseSearchParam;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 회계 결산(accounting_close) 컨트롤러.
*
* <p>"결산"은 일정 기간(월/분기/연)의 회계 분개를 마감하고 계정과목별 잔액을 확정 저장하는 작업입니다.
* 흐름: DRAFT 생성(createDraft) → 마감 실행(close, 미전기 분개에 전표번호 부여 + 잔액 스냅샷 생성) → 필요 시 재오픈(reopen).
*
* <p>상태: DRAFT / REOPENED 에서만 마감 가능. 잔액 스냅샷(snapshots)은 계정과목별 차변·대변 합계입니다.
* 권한(menu=ACCOUNTING_CLOSE): 조회 READ, DRAFT생성 CREATE, 마감 EXECUTE, 재오픈 APPROVE.
*/
@Tag(name = "회계 결산")
@RestController
@RequestMapping("/api/accounting-closes")
@RequiredArgsConstructor
public class AccountingCloseController {
private final AccountingCloseService service;
@GetMapping
@Operation(summary = "회계 결산 목록")
@RequirePermission(menu = "ACCOUNTING_CLOSE", perm = "READ")
public ApiResponse<PageResponse<AccountingCloseResp>> list(@ModelAttribute AccountingCloseSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{id}")
@Operation(summary = "회계 결산 상세")
@RequirePermission(menu = "ACCOUNTING_CLOSE", perm = "READ")
public ApiResponse<AccountingCloseResp> detail(@PathVariable Long id) {
return ApiResponse.ok(service.detail(id));
}
@GetMapping("/{id}/snapshots")
@Operation(summary = "계정과목별 잔액 스냅샷")
@RequirePermission(menu = "ACCOUNTING_CLOSE", perm = "READ")
public ApiResponse<List<AccountBalanceSnapshotVO>> snapshots(@PathVariable Long id) {
return ApiResponse.ok(service.snapshots(id));
}
@PostMapping("/draft")
@Operation(summary = "결산 DRAFT 생성")
@RequirePermission(menu = "ACCOUNTING_CLOSE", perm = "CREATE")
@DataChangeLog(menu = "ACCOUNTING_CLOSE", table = "accounting_close")
public ApiResponse<Long> createDraft(@Valid @RequestBody AccountingCloseSaveReq req) {
return ApiResponse.ok(service.createDraft(req));
}
@PostMapping("/{id}/close")
@Operation(summary = "결산 마감 실행")
@RequirePermission(menu = "ACCOUNTING_CLOSE", perm = "EXECUTE")
@DataChangeLog(menu = "ACCOUNTING_CLOSE", table = "accounting_close")
public ApiResponse<AccountingCloseResp> close(@PathVariable Long id) {
return ApiResponse.ok(service.close(id));
}
@PostMapping("/{id}/reopen")
@Operation(summary = "결산 재오픈")
@RequirePermission(menu = "ACCOUNTING_CLOSE", perm = "APPROVE")
@DataChangeLog(menu = "ACCOUNTING_CLOSE", table = "accounting_close")
public ApiResponse<Void> reopen(@PathVariable Long id, @RequestBody Map<String, String> body) {
service.reopen(id, body.get("reopenReason"));
return ApiResponse.ok();
}
}
@@ -0,0 +1,19 @@
package com.ga.api.controller.accounting;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.List;
/**
* 분개 마감 요청 DTO.
* entryIds: 마감할 분개 ID 목록(필수, 1건 이상). journalNo: 부여할 전표번호(필수).
*/
@Data
public class CloseJournalReq {
@NotEmpty
private List<Long> entryIds;
@NotBlank
private String journalNo;
}
@@ -0,0 +1,57 @@
package com.ga.api.controller.accounting;
import com.ga.api.service.accounting.ContractAccountingService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.accounting.ContractAccountingEntryResp;
import com.ga.core.vo.accounting.ContractAccountingEntrySearchParam;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 계약 회계 분개(contract_accounting_entry) 컨트롤러.
*
* <p>"분개"는 회계의 기본 기록 단위로, 거래 하나를 차변(빌린 곳)/대변(빌려준 곳) 두 계정으로 적는 것입니다.
* 여기서는 수수료 지급(payment) 건을 회계 분개로 자동 변환합니다.
* (차변: 8350 지급수수료 / 대변: 2530 미지급금)
*
* <p>generate: paymentIds 또는 settleMonth 기준으로 분개를 일괄 생성. close: 분개에 전표번호를 붙여 마감 처리.
* 권한(menu=ACCOUNTING_JOURNAL): 조회 READ, 분개생성 CREATE, 마감 UPDATE.
*/
@Tag(name = "계약 회계 분개")
@RestController
@RequestMapping("/api/accounting-entries")
@RequiredArgsConstructor
public class ContractAccountingController {
private final ContractAccountingService service;
@GetMapping
@Operation(summary = "분개 엔트리 목록 조회")
@RequirePermission(menu = "ACCOUNTING_JOURNAL", perm = "READ")
public ApiResponse<PageResponse<ContractAccountingEntryResp>> list(
@ModelAttribute ContractAccountingEntrySearchParam param) {
return ApiResponse.ok(service.list(param));
}
@PostMapping("/generate")
@Operation(summary = "자동 분개 생성 (paymentIds 또는 settleMonth)")
@RequirePermission(menu = "ACCOUNTING_JOURNAL", perm = "CREATE")
@DataChangeLog(menu = "ACCOUNTING_JOURNAL", table = "contract_accounting_entry")
public ApiResponse<Integer> generate(@RequestBody GenerateEntriesReq req) {
return ApiResponse.ok(service.generateFromPayments(req));
}
@PutMapping("/close")
@Operation(summary = "분개 마감 처리")
@RequirePermission(menu = "ACCOUNTING_JOURNAL", perm = "UPDATE")
@DataChangeLog(menu = "ACCOUNTING_JOURNAL", table = "contract_accounting_entry")
public ApiResponse<Integer> close(@Valid @RequestBody CloseJournalReq req) {
return ApiResponse.ok(service.closeJournal(req));
}
}
@@ -0,0 +1,15 @@
package com.ga.api.controller.accounting;
import lombok.Data;
import java.util.List;
/**
* 자동 분개 생성 요청 DTO.
* paymentIds 가 있으면 해당 지급건들로, 없으면 settleMonth(정산월) 전체 지급건으로 분개를 생성합니다.
*/
@Data
public class GenerateEntriesReq {
private List<Long> paymentIds;
private String settleMonth;
}
@@ -0,0 +1,23 @@
package com.ga.api.controller.approval;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 결재 단계 진행(승인/반려) 요청 DTO.
*
* <p>결재자(approverId)가 현재 단계에 대해 action(APPROVE=승인 / REJECT=반려)을 처리할 때 전달된다.
* comment는 선택 입력이다.</p>
*/
@Data
public class AdvanceReq {
/** 결재 처리자(user_id). */
@NotNull
private Long approverId;
/** APPROVE / REJECT */
@NotBlank
private String action;
/** 결재 코멘트(선택). */
private String comment;
}
@@ -0,0 +1,154 @@
package com.ga.api.controller.approval;
import com.ga.api.service.approval.ApprovalService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.approval.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 전자결재 관리 API.
*
* <p>두 개념을 다룬다.
* <ul>
* <li>결재선(line): 결재 단계(step) 시퀀스의 템플릿. 각 단계는 결재 권한 역할(roleCode)로 정의된다.</li>
* <li>결재 요청(request): 특정 대상(출금/정산/이의신청/시책 등 targetType)에 대한 상신 건.
* 결재선을 따라 단계별로 진행(advance)되며, 최종 승인 시 대상 도메인에 후처리 콜백이 일어난다.</li>
* </ul>
* 메뉴 APPROVAL 권한으로 보호되며, 단계 진행만 APPROVE 권한을 요구한다.</p>
*/
@Tag(name = "결재 관리")
@RestController
@RequestMapping("/api/approvals")
@RequiredArgsConstructor
public class ApprovalController {
private final ApprovalService service;
// ── 결재선 ─────────────────────────────────────────────────────────────────
/** 결재선 목록 조회(페이징). READ 권한 필요. */
@GetMapping("/lines")
@RequirePermission(menu = "APPROVAL", perm = "READ")
public ApiResponse<PageResponse<ApprovalLineResp>> listLines(
@ModelAttribute ApprovalLineSearchParam param) {
return ApiResponse.ok(service.listLines(param));
}
/** 결재선 단건 상세 조회. READ 권한 필요. */
@GetMapping("/lines/{lineId}")
@RequirePermission(menu = "APPROVAL", perm = "READ")
public ApiResponse<ApprovalLineResp> detailLine(@PathVariable Long lineId) {
return ApiResponse.ok(service.detailLine(lineId));
}
/** 특정 결재선의 단계(step) 목록 조회. READ 권한 필요. */
@GetMapping("/lines/{lineId}/steps")
@RequirePermission(menu = "APPROVAL", perm = "READ")
public ApiResponse<List<ApprovalLineStepResp>> stepsOfLine(@PathVariable Long lineId) {
return ApiResponse.ok(service.stepsOfLine(lineId));
}
/** 결재선 신규 등록. CREATE 권한 필요. */
@PostMapping("/lines")
@RequirePermission(menu = "APPROVAL", perm = "CREATE")
@DataChangeLog(menu = "APPROVAL", table = "approval_line")
public ApiResponse<Long> createLine(@Valid @RequestBody ApprovalLineSaveReq req) {
return ApiResponse.ok(service.createLine(req));
}
/** 결재선 수정. UPDATE 권한 필요. */
@PutMapping("/lines/{lineId}")
@RequirePermission(menu = "APPROVAL", perm = "UPDATE")
@DataChangeLog(menu = "APPROVAL", table = "approval_line")
public ApiResponse<Void> updateLine(@PathVariable Long lineId,
@Valid @RequestBody ApprovalLineSaveReq req) {
service.updateLine(lineId, req);
return ApiResponse.ok();
}
/** 결재선에 단계(step) 추가. 경로의 lineId를 요청에 주입한다. UPDATE 권한 필요. */
@PostMapping("/lines/{lineId}/steps")
@RequirePermission(menu = "APPROVAL", perm = "UPDATE")
@DataChangeLog(menu = "APPROVAL", table = "approval_line_step")
public ApiResponse<Void> addStep(@PathVariable Long lineId,
@Valid @RequestBody ApprovalLineStepSaveReq req) {
req.setLineId(lineId);
service.addStep(req);
return ApiResponse.ok();
}
// ── 결재 요청 ──────────────────────────────────────────────────────────────
/** 결재 요청 전체 목록 조회(페이징). READ 권한 필요. */
@GetMapping("/requests")
@RequirePermission(menu = "APPROVAL", perm = "READ")
public ApiResponse<PageResponse<ApprovalRequestResp>> listRequests(
@ModelAttribute ApprovalRequestSearchParam param) {
return ApiResponse.ok(service.listRequests(param));
}
/** 내가 상신한 결재 요청 목록. 현재 로그인 사용자로 필터된다. READ 권한 필요. */
@GetMapping("/requests/mine")
@RequirePermission(menu = "APPROVAL", perm = "READ")
public ApiResponse<PageResponse<ApprovalRequestResp>> myRequests(
@ModelAttribute ApprovalRequestSearchParam param) {
return ApiResponse.ok(service.myRequests(param));
}
/** 진행 중(PENDING) 결재 요청 목록. READ 권한 필요. */
@GetMapping("/requests/pending")
@RequirePermission(menu = "APPROVAL", perm = "READ")
public ApiResponse<PageResponse<ApprovalRequestResp>> pendingRequests(
@ModelAttribute ApprovalRequestSearchParam param) {
return ApiResponse.ok(service.pendingRequests(param));
}
/** 결재 요청 단건 상세 조회. READ 권한 필요. */
@GetMapping("/requests/{requestId}")
@RequirePermission(menu = "APPROVAL", perm = "READ")
public ApiResponse<ApprovalRequestResp> detailRequest(@PathVariable Long requestId) {
return ApiResponse.ok(service.detailRequest(requestId));
}
/** 결재 요청의 단계별 처리 이력 조회. READ 권한 필요. */
@GetMapping("/requests/{requestId}/history")
@RequirePermission(menu = "APPROVAL", perm = "READ")
public ApiResponse<List<ApprovalHistoryResp>> historyOfRequest(@PathVariable Long requestId) {
return ApiResponse.ok(service.historyOfRequest(requestId));
}
/** 결재 상신(요청 생성). PENDING 상태로 시작하며 1단계 결재자에게 알림이 발송된다. CREATE 권한 필요. */
@PostMapping("/requests")
@RequirePermission(menu = "APPROVAL", perm = "CREATE")
@DataChangeLog(menu = "APPROVAL", table = "approval_request")
public ApiResponse<Long> createRequest(@Valid @RequestBody ApprovalRequestSaveReq req) {
return ApiResponse.ok(service.createRequest(req));
}
/** 결재 단계 진행(승인/반려). 마지막 단계 승인 시 대상 도메인에 후처리 콜백이 일어난다. APPROVE 권한 필요. */
@PostMapping("/requests/{requestId}/advance")
@RequirePermission(menu = "APPROVAL", perm = "APPROVE")
@DataChangeLog(menu = "APPROVAL", table = "approval_request")
public ApiResponse<Void> advance(@PathVariable Long requestId,
@Valid @RequestBody AdvanceReq req) {
service.advance(requestId, req.getApproverId(), req.getAction(), req.getComment());
return ApiResponse.ok();
}
/** 결재 요청 취소. 진행 중(PENDING)인 건만 취소 가능. UPDATE 권한 필요. */
@PostMapping("/requests/{requestId}/cancel")
@RequirePermission(menu = "APPROVAL", perm = "UPDATE")
@DataChangeLog(menu = "APPROVAL", table = "approval_request")
public ApiResponse<Void> cancel(@PathVariable Long requestId) {
service.cancel(requestId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,100 @@
package com.ga.api.controller.attachment;
import com.ga.api.service.attachment.AttachmentService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.attachment.AttachmentResp;
import com.ga.core.vo.attachment.AttachmentSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.util.MimeTypeUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
/**
* 첨부파일 관리 API.
*
* <p>각종 도메인(refType + refId 조합)에 파일을 첨부·다운로드·삭제한다.
* refType은 "어떤 종류의 대상인지"(예: 계약/민원 등), refId는 그 대상의 PK다.
* 파일은 서버 디스크에 저장되고, 메타 정보(파일명/경로/크기/MIME)는 DB로 관리된다.</p>
*
* <p>메뉴 ATTACHMENT 권한으로 보호된다.</p>
*/
@Tag(name = "첨부파일")
@RestController
@RequestMapping("/api/attachments")
@RequiredArgsConstructor
public class AttachmentController {
private final AttachmentService service;
/** 첨부파일 메타 목록 조회(페이징). READ 권한 필요. */
@GetMapping
@RequirePermission(menu = "ATTACHMENT", perm = "READ")
public ApiResponse<PageResponse<AttachmentResp>> list(@ModelAttribute AttachmentSearchParam param) {
return ApiResponse.ok(service.list(param));
}
/** 특정 대상(refType+refId)에 달린 첨부파일 목록 조회. READ 권한 필요. */
@GetMapping("/by-ref")
@RequirePermission(menu = "ATTACHMENT", perm = "READ")
public ApiResponse<List<AttachmentResp>> listByRef(@RequestParam String refType,
@RequestParam Long refId) {
return ApiResponse.ok(service.listByRef(refType, refId));
}
/** 첨부파일 메타 단건 조회. READ 권한 필요. */
@GetMapping("/{attachmentId}/meta")
@RequirePermission(menu = "ATTACHMENT", perm = "READ")
public ApiResponse<AttachmentResp> detail(@PathVariable Long attachmentId) {
return ApiResponse.ok(service.detail(attachmentId));
}
/** 파일 업로드(multipart). 대상(refType+refId)에 연결해 저장한다. CREATE 권한 필요. */
@PostMapping(consumes = "multipart/form-data")
@RequirePermission(menu = "ATTACHMENT", perm = "CREATE")
@DataChangeLog(menu = "ATTACHMENT", table = "attachment")
public ApiResponse<Long> upload(@RequestParam("file") MultipartFile file,
@RequestParam String refType,
@RequestParam Long refId) {
return ApiResponse.ok(service.upload(file, refType, refId));
}
/** 파일 다운로드. 메타의 MIME/파일명으로 응답 헤더를 구성하고 파일 스트림을 내려준다. READ 권한 필요. */
@GetMapping("/{attachmentId}/download")
@RequirePermission(menu = "ATTACHMENT", perm = "READ")
public void download(@PathVariable Long attachmentId, HttpServletResponse response) throws IOException {
AttachmentResp meta = service.detail(attachmentId);
Path filePath = service.resolveFilePath(attachmentId);
response.setContentType(meta.getMimeType() != null ? meta.getMimeType() : MimeTypeUtils.APPLICATION_OCTET_STREAM_VALUE);
String encodedName = URLEncoder.encode(meta.getFileName(), StandardCharsets.UTF_8).replace("+", "%20");
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedName);
response.setContentLengthLong(meta.getFileSize() != null ? meta.getFileSize() : 0L);
try (OutputStream out = response.getOutputStream()) {
Files.copy(filePath, out);
}
}
/** 첨부파일 삭제(메타 + 실제 파일). DELETE 권한 필요. */
@DeleteMapping("/{attachmentId}")
@RequirePermission(menu = "ATTACHMENT", perm = "DELETE")
@DataChangeLog(menu = "ATTACHMENT", table = "attachment")
public ApiResponse<Void> delete(@PathVariable Long attachmentId) {
service.delete(attachmentId);
return ApiResponse.ok();
}
}
@@ -50,12 +50,14 @@ public class BatchTriggerController {
return ApiResponse.ok(log.getJobId()); return ApiResponse.ok(log.getJobId());
} }
/** 특정 배치 잡의 최신 실행 상태 조회(기본 월정산). READ 권한 필요. */
@GetMapping("/status") @GetMapping("/status")
@RequirePermission(menu = "BATCH_RUN", perm = "READ") @RequirePermission(menu = "BATCH_RUN", perm = "READ")
public ApiResponse<BatchJobLogVO> latest(@RequestParam(defaultValue = "MONTHLY_SETTLEMENT") String jobName) { public ApiResponse<BatchJobLogVO> latest(@RequestParam(defaultValue = "MONTHLY_SETTLEMENT") String jobName) {
return ApiResponse.ok(jobLogMapper.selectLatest(jobName)); return ApiResponse.ok(jobLogMapper.selectLatest(jobName));
} }
/** 배치 실행 이력 목록 조회(잡명/정산월 필터). READ 권한 필요. */
@GetMapping("/jobs") @GetMapping("/jobs")
@RequirePermission(menu = "BATCH_RUN", perm = "READ") @RequirePermission(menu = "BATCH_RUN", perm = "READ")
public ApiResponse<List<BatchJobLogVO>> list(@RequestParam(required = false) String jobName, public ApiResponse<List<BatchJobLogVO>> list(@RequestParam(required = false) String jobName,
@@ -63,6 +65,7 @@ public class BatchTriggerController {
return ApiResponse.ok(jobLogMapper.selectList(jobName, settleMonth)); return ApiResponse.ok(jobLogMapper.selectList(jobName, settleMonth));
} }
/** 배치 실행 이력 단건 상세 조회. READ 권한 필요. */
@GetMapping("/jobs/{jobId}") @GetMapping("/jobs/{jobId}")
@RequirePermission(menu = "BATCH_RUN", perm = "READ") @RequirePermission(menu = "BATCH_RUN", perm = "READ")
public ApiResponse<BatchJobLogVO> detail(@PathVariable Long jobId) { public ApiResponse<BatchJobLogVO> detail(@PathVariable Long jobId) {
@@ -7,18 +7,27 @@ import lombok.Data;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
/**
* 차등환수 금액 계산(시뮬레이션) 요청 DTO.
* 차등환수란 고객이 계약을 조기 해지하면 이미 지급한 수수료의 일부를 경과기간에 따라 차등 회수하는 것이다.
* (계약일~해지일 경과개월수가 짧을수록 환수율이 높다)
*/
@Data @Data
public class ChargebackCalculateReq { public class ChargebackCalculateReq {
/** 계약 체결일. 경과개월수 계산의 시작점. */
@NotNull @NotNull
private LocalDate contractDate; private LocalDate contractDate;
/** 해지일. 계약일과의 차이로 경과개월수를 구한다. */
@NotNull @NotNull
private LocalDate terminationDate; private LocalDate terminationDate;
/** 당초 지급했던 수수료 원금(양수). 여기에 환수율을 곱해 환수액을 산출한다. */
@NotNull @NotNull
@Positive @Positive
private BigDecimal originalCommission; private BigDecimal originalCommission;
/** 상품 분류. 분류별로 적용 환수율 구간(chargeback_grade)이 다르다. */
private String productCategory; private String productCategory;
} }
@@ -15,6 +15,12 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
/**
* 차등환수 등급(chargeback_grade) 관리 REST 컨트롤러.
* 환수 등급표는 "경과개월 구간별 환수율(%)"을 정의하는 기준표이며, 조기해지 시 환수액 계산에 쓰인다.
* - gradeService : 환수 등급표 CRUD + 비활성화
* - calculator : 실제 계약 정보로 환수액을 계산(시뮬레이션)
*/
@Tag(name = "환수 등급 관리") @Tag(name = "환수 등급 관리")
@RestController @RestController
@RequestMapping("/api/chargeback-grades") @RequestMapping("/api/chargeback-grades")
@@ -24,18 +30,21 @@ public class ChargebackGradeController {
private final ChargebackGradeService gradeService; private final ChargebackGradeService gradeService;
private final ChargebackCalculator calculator; private final ChargebackCalculator calculator;
/** 환수 등급표 목록 페이징 조회. */
@GetMapping @GetMapping
@RequirePermission(menu = "CHARGEBACK_GRADE", perm = "READ") @RequirePermission(menu = "CHARGEBACK_GRADE", perm = "READ")
public ApiResponse<PageResponse<ChargebackGradeResp>> list(ChargebackGradeSearchParam param) { public ApiResponse<PageResponse<ChargebackGradeResp>> list(ChargebackGradeSearchParam param) {
return ApiResponse.ok(gradeService.list(param)); return ApiResponse.ok(gradeService.list(param));
} }
/** 환수 등급 단건 상세 조회. */
@GetMapping("/{gradeId}") @GetMapping("/{gradeId}")
@RequirePermission(menu = "CHARGEBACK_GRADE", perm = "READ") @RequirePermission(menu = "CHARGEBACK_GRADE", perm = "READ")
public ApiResponse<ChargebackGradeResp> detail(@PathVariable Long gradeId) { public ApiResponse<ChargebackGradeResp> detail(@PathVariable Long gradeId) {
return ApiResponse.ok(gradeService.detail(gradeId)); return ApiResponse.ok(gradeService.detail(gradeId));
} }
/** 환수 등급 신규 등록. */
@PostMapping @PostMapping
@RequirePermission(menu = "CHARGEBACK_GRADE", perm = "CREATE") @RequirePermission(menu = "CHARGEBACK_GRADE", perm = "CREATE")
@DataChangeLog(menu = "CHARGEBACK_GRADE", table = "chargeback_grade") @DataChangeLog(menu = "CHARGEBACK_GRADE", table = "chargeback_grade")
@@ -44,6 +53,7 @@ public class ChargebackGradeController {
return ApiResponse.ok(); return ApiResponse.ok();
} }
/** 환수 등급 수정. */
@PutMapping("/{gradeId}") @PutMapping("/{gradeId}")
@RequirePermission(menu = "CHARGEBACK_GRADE", perm = "UPDATE") @RequirePermission(menu = "CHARGEBACK_GRADE", perm = "UPDATE")
@DataChangeLog(menu = "CHARGEBACK_GRADE", table = "chargeback_grade") @DataChangeLog(menu = "CHARGEBACK_GRADE", table = "chargeback_grade")
@@ -53,6 +63,7 @@ public class ChargebackGradeController {
return ApiResponse.ok(); return ApiResponse.ok();
} }
/** 환수 등급 비활성화(사용 중지). 비활성 등급은 환수액 계산에서 제외된다. */
@PutMapping("/{gradeId}/deactivate") @PutMapping("/{gradeId}/deactivate")
@RequirePermission(menu = "CHARGEBACK_GRADE", perm = "UPDATE") @RequirePermission(menu = "CHARGEBACK_GRADE", perm = "UPDATE")
@DataChangeLog(menu = "CHARGEBACK_GRADE", table = "chargeback_grade") @DataChangeLog(menu = "CHARGEBACK_GRADE", table = "chargeback_grade")
@@ -61,6 +72,7 @@ public class ChargebackGradeController {
return ApiResponse.ok(); return ApiResponse.ok();
} }
/** 계약일·해지일·원수수료를 입력받아 환수액을 계산(시뮬레이션)한다. */
@PostMapping("/calculate") @PostMapping("/calculate")
@RequirePermission(menu = "CHARGEBACK_GRADE", perm = "READ") @RequirePermission(menu = "CHARGEBACK_GRADE", perm = "READ")
public ApiResponse<ChargebackResult> calculate(@Valid @RequestBody ChargebackCalculateReq req) { public ApiResponse<ChargebackResult> calculate(@Valid @RequestBody ChargebackCalculateReq req) {
@@ -0,0 +1,65 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.AdvancePaymentService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.commission.AdvancePaymentResp;
import com.ga.core.vo.commission.AdvancePaymentSaveReq;
import com.ga.core.vo.commission.AdvancePaymentSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 선지급정산 API.
*
* 선지급(advance payment): 아직 확정되지 않은 미래 수수료를 설계사에게 미리 지급(가불)하는 것.
* 이후 실제 수수료가 발생하면 그 금액에서 차감(상계)하여 회수한다.
* - principalAmount(선지급 원금) / recoveredAmount(회수 누계) / balance(미상환 잔액)
* - status: OUTSTANDING(미상환) → PARTIAL(일부회수) → CLEARED(완전회수) 또는 WAIVED(면제)
*
* 권한: @RequirePermission(menu="ADVANCE_PAYMENT"), perm=READ/CREATE/UPDATE. 변경은 @DataChangeLog 로 감사 기록.
*/
@Tag(name = "선지급정산")
@RestController
@RequestMapping("/api/advance-payments")
@RequiredArgsConstructor
public class AdvancePaymentController {
private final AdvancePaymentService service;
/** 선지급 목록 조회(검색 + 페이징). 권한: ADVANCE_PAYMENT READ. */
@GetMapping
@RequirePermission(menu = "ADVANCE_PAYMENT", perm = "READ")
public ApiResponse<PageResponse<AdvancePaymentResp>> list(
@ModelAttribute AdvancePaymentSearchParam param) {
return ApiResponse.ok(service.list(param));
}
/** 선지급 단건 상세 조회. 권한: ADVANCE_PAYMENT READ. */
@GetMapping("/{advanceId}")
@RequirePermission(menu = "ADVANCE_PAYMENT", perm = "READ")
public ApiResponse<AdvancePaymentResp> detail(@PathVariable Long advanceId) {
return ApiResponse.ok(service.detail(advanceId));
}
/** 선지급 신규 등록. 등록 시 잔액=원금, 상태=OUTSTANDING. 응답: 생성된 advanceId. 권한: ADVANCE_PAYMENT CREATE. */
@PostMapping
@RequirePermission(menu = "ADVANCE_PAYMENT", perm = "CREATE")
@DataChangeLog(menu = "ADVANCE_PAYMENT", table = "advance_payment")
public ApiResponse<Long> create(@Valid @RequestBody AdvancePaymentSaveReq req) {
return ApiResponse.ok(service.create(req));
}
/** 선지급 면제 처리. 남은 잔액 회수를 포기(상태=WAIVED, 잔액=0). 권한: ADVANCE_PAYMENT UPDATE. */
@PostMapping("/{advanceId}/waive")
@RequirePermission(menu = "ADVANCE_PAYMENT", perm = "UPDATE")
@DataChangeLog(menu = "ADVANCE_PAYMENT", table = "advance_payment")
public ApiResponse<Void> waive(@PathVariable Long advanceId) {
service.waive(advanceId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,25 @@
package com.ga.api.controller.commission;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
/**
* 시상(award) 원장 환수 처리 요청 바디 (PUT /api/awards/ledgers/{id}/clawback).
*
* 환수(clawback): 이미 지급한 시상금을 일정 사유로 되돌려 받는 것.
* clawbackAmount(환수 금액, 0 이상 필수) + reason(환수 사유).
*/
@Data
public class AwardClawbackReq {
/** 환수 금액. 0 이상이어야 하며(@DecimalMin("0")) 필수값(@NotNull). */
@NotNull
@DecimalMin("0")
private BigDecimal clawbackAmount;
/** 환수 사유(선택). */
private String reason;
}
@@ -0,0 +1,109 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.AwardService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.commission.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 시상(award) API.
*
* 시상: 일정 실적 조건을 달성한 설계사에게 지급하는 포상금/인센티브.
* - Rule(시상 규칙): 어떤 조건일 때 얼마를 지급하는지 정의(생성/수정/삭제 가능).
* - Ledger(시상 원장): 규칙에 따라 실제 산출/지급된 시상금 내역. clawback(환수)로 회수 가능.
*
* 권한: @RequirePermission(menu="AWARD"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 로 감사 기록.
*/
@Tag(name = "시상")
@RestController
@RequestMapping("/api/awards")
@RequiredArgsConstructor
public class AwardController {
private final AwardService service;
// ── Rule (시상 규칙) ────────────────────────────────────────────────────
/** 시상 규칙 목록 조회. 권한: AWARD READ. */
@GetMapping("/rules")
@RequirePermission(menu = "AWARD", perm = "READ")
public ApiResponse<PageResponse<AwardRuleResp>> listRules(
@ModelAttribute AwardRuleSearchParam param) {
return ApiResponse.ok(service.listRules(param));
}
/** 시상 규칙 단건 상세. 권한: AWARD READ. */
@GetMapping("/rules/{ruleId}")
@RequirePermission(menu = "AWARD", perm = "READ")
public ApiResponse<AwardRuleResp> detailRule(@PathVariable Long ruleId) {
return ApiResponse.ok(service.detailRule(ruleId));
}
/** 시상 규칙 신규 등록. 응답: 생성된 ruleId. 권한: AWARD CREATE. */
@PostMapping("/rules")
@RequirePermission(menu = "AWARD", perm = "CREATE")
@DataChangeLog(menu = "AWARD", table = "award_rule")
public ApiResponse<Long> createRule(@Valid @RequestBody AwardRuleSaveReq req) {
return ApiResponse.ok(service.createRule(req));
}
/** 시상 규칙 수정. 권한: AWARD UPDATE. */
@PutMapping("/rules/{ruleId}")
@RequirePermission(menu = "AWARD", perm = "UPDATE")
@DataChangeLog(menu = "AWARD", table = "award_rule")
public ApiResponse<Void> updateRule(@PathVariable Long ruleId,
@Valid @RequestBody AwardRuleSaveReq req) {
service.updateRule(ruleId, req);
return ApiResponse.ok();
}
/** 시상 규칙 삭제. 권한: AWARD DELETE. */
@DeleteMapping("/rules/{ruleId}")
@RequirePermission(menu = "AWARD", perm = "DELETE")
@DataChangeLog(menu = "AWARD", table = "award_rule")
public ApiResponse<Void> deleteRule(@PathVariable Long ruleId) {
service.deleteRule(ruleId);
return ApiResponse.ok();
}
// ── Ledger (시상 원장) ──────────────────────────────────────────────────
/** 시상 원장 목록 조회. 권한: AWARD READ. */
@GetMapping("/ledgers")
@RequirePermission(menu = "AWARD", perm = "READ")
public ApiResponse<PageResponse<AwardLedgerResp>> listLedgers(
@ModelAttribute AwardLedgerSearchParam param) {
return ApiResponse.ok(service.listLedgers(param));
}
/** 시상 원장 단건 상세. 권한: AWARD READ. */
@GetMapping("/ledgers/{ledgerId}")
@RequirePermission(menu = "AWARD", perm = "READ")
public ApiResponse<AwardLedgerResp> detailLedger(@PathVariable Long ledgerId) {
return ApiResponse.ok(service.detailLedger(ledgerId));
}
/** 시상 원장 신규 등록(시상금 산출/지급 기록). 응답: 생성된 ledgerId. 권한: AWARD CREATE. */
@PostMapping("/ledgers")
@RequirePermission(menu = "AWARD", perm = "CREATE")
@DataChangeLog(menu = "AWARD", table = "award_ledger")
public ApiResponse<Long> createLedger(@Valid @RequestBody AwardLedgerSaveReq req) {
return ApiResponse.ok(service.createLedger(req));
}
/** 시상금 환수(clawback) 적용. 이미 지급한 시상금을 일부/전부 회수한다. 권한: AWARD UPDATE. */
@PutMapping("/ledgers/{ledgerId}/clawback")
@RequirePermission(menu = "AWARD", perm = "UPDATE")
@DataChangeLog(menu = "AWARD", table = "award_ledger")
public ApiResponse<Void> applyClawback(@PathVariable Long ledgerId,
@Valid @RequestBody AwardClawbackReq req) {
service.applyClawback(ledgerId, req);
return ApiResponse.ok();
}
}
@@ -0,0 +1,98 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.BranchAllowanceService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.commission.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 지점장수당 API.
*
* 지점장수당(branch manager allowance): 지점에 소속된 설계사들의 실적 합계에 따라
* 지점장에게 추가로 지급하는 수당. 상한(cap)이 적용될 수 있다.
* - Rule(수당 규칙): 지점 실적 대비 지급률/상한 정의. - Ledger(수당 원장): 실제 산출/지급 내역.
*
* 권한: @RequirePermission(menu="BRANCH_MGR_ALLOWANCE"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 기록.
*/
@Tag(name = "지점장수당")
@RestController
@RequestMapping("/api/branch-allowances")
@RequiredArgsConstructor
public class BranchAllowanceController {
private final BranchAllowanceService service;
// ── Rule (수당 규칙) ────────────────────────────────────────────────────
/** 지점장수당 규칙 목록 조회. 권한: BRANCH_MGR_ALLOWANCE READ. */
@GetMapping("/rules")
@RequirePermission(menu = "BRANCH_MGR_ALLOWANCE", perm = "READ")
public ApiResponse<PageResponse<BranchMgrAllowanceRuleResp>> listRules(
@ModelAttribute BranchMgrAllowanceRuleSearchParam param) {
return ApiResponse.ok(service.listRules(param));
}
@GetMapping("/rules/{ruleId}")
@RequirePermission(menu = "BRANCH_MGR_ALLOWANCE", perm = "READ")
public ApiResponse<BranchMgrAllowanceRuleResp> detailRule(@PathVariable Long ruleId) {
return ApiResponse.ok(service.detailRule(ruleId));
}
/** 지점장수당 규칙 신규 등록. 응답: ruleId. 권한: BRANCH_MGR_ALLOWANCE CREATE. */
@PostMapping("/rules")
@RequirePermission(menu = "BRANCH_MGR_ALLOWANCE", perm = "CREATE")
@DataChangeLog(menu = "BRANCH_MGR_ALLOWANCE", table = "branch_mgr_allowance_rule")
public ApiResponse<Long> createRule(@Valid @RequestBody BranchMgrAllowanceRuleSaveReq req) {
return ApiResponse.ok(service.createRule(req));
}
/** 지점장수당 규칙 수정. 권한: BRANCH_MGR_ALLOWANCE UPDATE. */
@PutMapping("/rules/{ruleId}")
@RequirePermission(menu = "BRANCH_MGR_ALLOWANCE", perm = "UPDATE")
@DataChangeLog(menu = "BRANCH_MGR_ALLOWANCE", table = "branch_mgr_allowance_rule")
public ApiResponse<Void> updateRule(@PathVariable Long ruleId,
@Valid @RequestBody BranchMgrAllowanceRuleSaveReq req) {
service.updateRule(ruleId, req);
return ApiResponse.ok();
}
/** 지점장수당 규칙 삭제. 권한: BRANCH_MGR_ALLOWANCE DELETE. */
@DeleteMapping("/rules/{ruleId}")
@RequirePermission(menu = "BRANCH_MGR_ALLOWANCE", perm = "DELETE")
@DataChangeLog(menu = "BRANCH_MGR_ALLOWANCE", table = "branch_mgr_allowance_rule")
public ApiResponse<Void> deleteRule(@PathVariable Long ruleId) {
service.deleteRule(ruleId);
return ApiResponse.ok();
}
// ── Ledger (수당 원장) ──────────────────────────────────────────────────
/** 지점장수당 원장 목록 조회. 권한: BRANCH_MGR_ALLOWANCE READ. */
@GetMapping("/ledgers")
@RequirePermission(menu = "BRANCH_MGR_ALLOWANCE", perm = "READ")
public ApiResponse<PageResponse<BranchMgrAllowanceLedgerResp>> listLedgers(
@ModelAttribute BranchMgrAllowanceLedgerSearchParam param) {
return ApiResponse.ok(service.listLedgers(param));
}
/** 지점장수당 원장 단건 상세. 권한: BRANCH_MGR_ALLOWANCE READ. */
@GetMapping("/ledgers/{ledgerId}")
@RequirePermission(menu = "BRANCH_MGR_ALLOWANCE", perm = "READ")
public ApiResponse<BranchMgrAllowanceLedgerResp> detailLedger(@PathVariable Long ledgerId) {
return ApiResponse.ok(service.detailLedger(ledgerId));
}
/** 지점장수당 원장 신규 등록(수당 산출/지급 기록). 응답: ledgerId. 권한: BRANCH_MGR_ALLOWANCE CREATE. */
@PostMapping("/ledgers")
@RequirePermission(menu = "BRANCH_MGR_ALLOWANCE", perm = "CREATE")
@DataChangeLog(menu = "BRANCH_MGR_ALLOWANCE", table = "branch_mgr_allowance_ledger")
public ApiResponse<Long> createLedger(@Valid @RequestBody BranchMgrAllowanceLedgerSaveReq req) {
return ApiResponse.ok(service.createLedger(req));
}
}
@@ -0,0 +1,73 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.CarrierReconciliationInService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.commission.CarrierReconciliationInResp;
import com.ga.core.vo.commission.CarrierReconciliationInSaveReq;
import com.ga.core.vo.commission.CarrierReconciliationInSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 정산대사(입수) API.
*
* 정산대사(reconciliation): 보험사로부터 받은 정산 명세(입수 자료)와 우리 시스템이 계산한 금액을
* 서로 맞춰 보아(대사) 차이를 확인/확정하는 작업.
* - reconcile: 대사 실행(차이 비교) - approve: 대사 결과 승인(확정)
*
* 권한: @RequirePermission(menu="CARRIER_RECON_IN"), perm=READ/CREATE/UPDATE. 변경은 @DataChangeLog 기록.
*/
@Tag(name = "정산대사입수")
@RestController
@RequestMapping("/api/carrier-recon-in")
@RequiredArgsConstructor
public class CarrierReconciliationInController {
private final CarrierReconciliationInService service;
/** 대사 입수 자료 목록 조회. 권한: CARRIER_RECON_IN READ. */
@GetMapping
@RequirePermission(menu = "CARRIER_RECON_IN", perm = "READ")
public ApiResponse<PageResponse<CarrierReconciliationInResp>> list(
@ModelAttribute CarrierReconciliationInSearchParam param) {
return ApiResponse.ok(service.list(param));
}
/** 대사 입수 단건 상세. 권한: CARRIER_RECON_IN READ. */
@GetMapping("/{reconId}")
@RequirePermission(menu = "CARRIER_RECON_IN", perm = "READ")
public ApiResponse<CarrierReconciliationInResp> detail(@PathVariable Long reconId) {
return ApiResponse.ok(service.detail(reconId));
}
/** 대사 입수 자료 신규 등록. 응답: reconId. 권한: CARRIER_RECON_IN CREATE. */
@PostMapping
@RequirePermission(menu = "CARRIER_RECON_IN", perm = "CREATE")
@DataChangeLog(menu = "CARRIER_RECON_IN", table = "carrier_reconciliation_in")
public ApiResponse<Long> create(@Valid @RequestBody CarrierReconciliationInSaveReq req) {
return ApiResponse.ok(service.create(req));
}
/** 대사 실행. 보험사 명세와 시스템 계산액을 비교해 차이를 계산한다. 권한: CARRIER_RECON_IN UPDATE. */
@PostMapping("/{reconId}/reconcile")
@RequirePermission(menu = "CARRIER_RECON_IN", perm = "UPDATE")
@DataChangeLog(menu = "CARRIER_RECON_IN", table = "carrier_reconciliation_in")
public ApiResponse<Void> reconcile(@PathVariable Long reconId) {
service.reconcile(reconId);
return ApiResponse.ok();
}
/** 대사 결과 승인(확정). 권한: CARRIER_RECON_IN UPDATE. */
@PostMapping("/{reconId}/approve")
@RequirePermission(menu = "CARRIER_RECON_IN", perm = "UPDATE")
@DataChangeLog(menu = "CARRIER_RECON_IN", table = "carrier_reconciliation_in")
public ApiResponse<Void> approve(@PathVariable Long reconId) {
service.approve(reconId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,73 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.ChargebackWaiverService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.commission.ChargebackWaiverRequestResp;
import com.ga.core.vo.commission.ChargebackWaiverRequestSaveReq;
import com.ga.core.vo.commission.ChargebackWaiverRequestSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 환수감면신청 API.
*
* 환수(chargeback): 계약 해지/실효 등으로 이미 지급한 수수료를 설계사로부터 다시 회수하는 것.
* 환수감면(waiver): 특별한 사유가 인정되면 그 환수 금액을 줄이거나 면제해 달라고 신청하는 것.
* - approve: 감면 신청 승인 - reject: 감면 신청 반려
*
* 권한: @RequirePermission(menu="CHARGEBACK_WAIVER"), perm=READ/CREATE/UPDATE. 변경은 @DataChangeLog 기록.
*/
@Tag(name = "환수감면신청")
@RestController
@RequestMapping("/api/chargeback-waivers")
@RequiredArgsConstructor
public class ChargebackWaiverController {
private final ChargebackWaiverService service;
/** 환수감면 신청 목록 조회. 권한: CHARGEBACK_WAIVER READ. */
@GetMapping
@RequirePermission(menu = "CHARGEBACK_WAIVER", perm = "READ")
public ApiResponse<PageResponse<ChargebackWaiverRequestResp>> list(
@ModelAttribute ChargebackWaiverRequestSearchParam param) {
return ApiResponse.ok(service.list(param));
}
/** 환수감면 신청 단건 상세. 권한: CHARGEBACK_WAIVER READ. */
@GetMapping("/{requestId}")
@RequirePermission(menu = "CHARGEBACK_WAIVER", perm = "READ")
public ApiResponse<ChargebackWaiverRequestResp> detail(@PathVariable Long requestId) {
return ApiResponse.ok(service.detail(requestId));
}
/** 환수감면 신청 등록. 응답: requestId. 권한: CHARGEBACK_WAIVER CREATE. */
@PostMapping
@RequirePermission(menu = "CHARGEBACK_WAIVER", perm = "CREATE")
@DataChangeLog(menu = "CHARGEBACK_WAIVER", table = "chargeback_waiver_request")
public ApiResponse<Long> create(@Valid @RequestBody ChargebackWaiverRequestSaveReq req) {
return ApiResponse.ok(service.create(req));
}
/** 환수감면 신청 승인. 권한: CHARGEBACK_WAIVER UPDATE. */
@PostMapping("/{requestId}/approve")
@RequirePermission(menu = "CHARGEBACK_WAIVER", perm = "UPDATE")
@DataChangeLog(menu = "CHARGEBACK_WAIVER", table = "chargeback_waiver_request")
public ApiResponse<Void> approve(@PathVariable Long requestId) {
service.approve(requestId);
return ApiResponse.ok();
}
/** 환수감면 신청 반려. 권한: CHARGEBACK_WAIVER UPDATE. */
@PostMapping("/{requestId}/reject")
@RequirePermission(menu = "CHARGEBACK_WAIVER", perm = "UPDATE")
@DataChangeLog(menu = "CHARGEBACK_WAIVER", table = "chargeback_waiver_request")
public ApiResponse<Void> reject(@PathVariable Long requestId) {
service.reject(requestId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,83 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.ClawbackInstallmentService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.commission.ClawbackInstallmentPlanResp;
import com.ga.core.vo.commission.ClawbackInstallmentPlanSearchParam;
import com.ga.core.vo.commission.ClawbackInstallmentPlanVO;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 환수분할상환 API.
*
* 환수채권(받아야 할 환수 금액)을 한 번에 못 갚을 때, 여러 달에 나눠 갚도록(분할상환) 계획을 만든다.
* - generate: 환수채권(receivableId) 하나를 N개월(count)로 나눠 baseMonth 부터 매월 상환 계획을 생성
* - cancel: 특정 회차 계획 취소
*
* 권한: @RequirePermission(menu="CLAWBACK_INSTALLMENT"), perm=READ/CREATE/UPDATE. 변경은 @DataChangeLog 기록.
*/
@Tag(name = "환수분할상환")
@RestController
@RequestMapping("/api/clawback-installments")
@RequiredArgsConstructor
public class ClawbackInstallmentController {
private final ClawbackInstallmentService service;
/** 분할상환 계획 목록 조회. 권한: CLAWBACK_INSTALLMENT READ. */
@GetMapping
@RequirePermission(menu = "CLAWBACK_INSTALLMENT", perm = "READ")
public ApiResponse<PageResponse<ClawbackInstallmentPlanResp>> list(
@ModelAttribute ClawbackInstallmentPlanSearchParam param) {
return ApiResponse.ok(service.list(param));
}
/** 분할상환 계획 단건 상세. 권한: CLAWBACK_INSTALLMENT READ. */
@GetMapping("/{planId}")
@RequirePermission(menu = "CLAWBACK_INSTALLMENT", perm = "READ")
public ApiResponse<ClawbackInstallmentPlanResp> detail(@PathVariable Long planId) {
return ApiResponse.ok(service.detail(planId));
}
/** 특정 환수채권에 묶인 분할상환 회차 전체 조회. 권한: CLAWBACK_INSTALLMENT READ. */
@GetMapping("/by-receivable/{receivableId}")
@RequirePermission(menu = "CLAWBACK_INSTALLMENT", perm = "READ")
public ApiResponse<List<ClawbackInstallmentPlanVO>> listByReceivable(
@PathVariable Long receivableId) {
return ApiResponse.ok(service.listByReceivable(receivableId));
}
/**
* 분할상환 계획 생성. 환수채권을 N개월로 균등 분할해 baseMonth 부터 회차를 만든다.
* Request body: { "count": N(분할 개월 수), "baseMonth": "YYYYMM"(첫 회차 월) }
* 권한: CLAWBACK_INSTALLMENT CREATE.
*/
@PostMapping("/generate/{receivableId}")
@RequirePermission(menu = "CLAWBACK_INSTALLMENT", perm = "CREATE")
@DataChangeLog(menu = "CLAWBACK_INSTALLMENT", table = "clawback_installment_plan")
public ApiResponse<Void> generate(@PathVariable Long receivableId,
@RequestBody Map<String, Object> body) {
// body 의 count/baseMonth 를 꺼내 서비스로 전달 (JSON 값은 문자열로 변환 후 파싱)
int count = Integer.parseInt(body.get("count").toString());
String baseMonth = body.get("baseMonth").toString();
service.generate(receivableId, count, baseMonth);
return ApiResponse.ok();
}
/** 분할상환 회차 취소. 권한: CLAWBACK_INSTALLMENT UPDATE. */
@PostMapping("/{planId}/cancel")
@RequirePermission(menu = "CLAWBACK_INSTALLMENT", perm = "UPDATE")
@DataChangeLog(menu = "CLAWBACK_INSTALLMENT", table = "clawback_installment_plan")
public ApiResponse<Void> cancel(@PathVariable Long planId) {
service.cancel(planId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,79 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.ClawbackReceivableService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.common.util.SecurityUtil;
import com.ga.core.vo.commission.ClawbackReceivableResp;
import com.ga.core.vo.commission.ClawbackReceivableSaveReq;
import com.ga.core.vo.commission.ClawbackReceivableSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 환수채권 관리 API.
*
* 환수채권(clawback receivable): 환수가 확정되어 설계사로부터 "받아야 할 돈"(미수금).
* 발생액(original) 중 회수액(recovered)을 빼면 잔액(balance)이 남는다.
* - write-off(대손상각): 회수가 불가능하다고 판단된 잔액을 손실로 털어내는 처리.
* - summary: 상태/설계사별 집계, export: 엑셀 다운로드.
*
* 권한: @RequirePermission(menu="CLAWBACK_RECEIVABLE"), perm=READ/UPDATE/EXPORT. 변경은 @DataChangeLog 기록.
*/
@Tag(name = "환수채권 관리")
@RestController
@RequestMapping("/api/clawback-receivables")
@RequiredArgsConstructor
public class ClawbackReceivableController {
private final ClawbackReceivableService service;
/** 환수채권 목록 조회(검색 + 페이징). 권한: CLAWBACK_RECEIVABLE READ. */
@GetMapping
@RequirePermission(menu = "CLAWBACK_RECEIVABLE", perm = "READ")
public ApiResponse<PageResponse<ClawbackReceivableResp>> list(
@ModelAttribute ClawbackReceivableSearchParam param) {
return ApiResponse.ok(service.list(param));
}
/** 환수채권 집계 요약(상태/그룹별 합계). 권한: CLAWBACK_RECEIVABLE READ. */
@GetMapping("/summary")
@RequirePermission(menu = "CLAWBACK_RECEIVABLE", perm = "READ")
public ApiResponse<List<Map<String, Object>>> summary(
@ModelAttribute ClawbackReceivableSearchParam param) {
return ApiResponse.ok(service.summary(param));
}
/** 환수채권 단건 상세. 권한: CLAWBACK_RECEIVABLE READ. */
@GetMapping("/{id}")
@RequirePermission(menu = "CLAWBACK_RECEIVABLE", perm = "READ")
public ApiResponse<ClawbackReceivableResp> detail(@PathVariable Long id) {
return ApiResponse.ok(service.detail(id));
}
/** 대손상각(write-off) 처리. 잔액을 손실 처리한다. 처리자(현재 로그인 사용자 ID)를 함께 기록. 권한: CLAWBACK_RECEIVABLE UPDATE. */
@PutMapping("/{id}/write-off")
@RequirePermission(menu = "CLAWBACK_RECEIVABLE", perm = "UPDATE")
@DataChangeLog(menu = "CLAWBACK_RECEIVABLE", table = "clawback_receivable")
public ApiResponse<Void> writeOff(@PathVariable Long id,
@Valid @RequestBody ClawbackReceivableSaveReq req) {
service.writeOff(id, req, SecurityUtil.getCurrentUserId());
return ApiResponse.ok();
}
/** 환수채권 목록 엑셀 다운로드. 응답 스트림(response)에 직접 파일을 쓴다. 권한: CLAWBACK_RECEIVABLE EXPORT. */
@GetMapping("/export")
@RequirePermission(menu = "CLAWBACK_RECEIVABLE", perm = "EXPORT")
public void export(@ModelAttribute ClawbackReceivableSearchParam param,
HttpServletResponse response) {
service.export(param, response);
}
}
@@ -0,0 +1,98 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.CollectionCommissionService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.commission.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 수금수수료 API.
*
* 수금수수료(collection commission): 설계사가 보험료를 정상적으로 수금(납입 유도/관리)한 것에 대해
* 지급하는 수수료. 규칙(Rule)에 따라 산출되어 원장(Ledger)에 기록된다.
*
* 권한: @RequirePermission(menu="COLLECT_COMM"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 기록.
*/
@Tag(name = "수금수수료")
@RestController
@RequestMapping("/api/collection-commissions")
@RequiredArgsConstructor
public class CollectionCommissionController {
private final CollectionCommissionService service;
// ── Rule (수금수수료 규칙) ──────────────────────────────────────────────
/** 수금수수료 규칙 목록 조회. 권한: COLLECT_COMM READ. */
@GetMapping("/rules")
@RequirePermission(menu = "COLLECT_COMM", perm = "READ")
public ApiResponse<PageResponse<CollectionCommissionRuleResp>> listRules(
@ModelAttribute CollectionCommissionRuleSearchParam param) {
return ApiResponse.ok(service.listRules(param));
}
/** 수금수수료 규칙 단건 상세. 권한: COLLECT_COMM READ. */
@GetMapping("/rules/{ruleId}")
@RequirePermission(menu = "COLLECT_COMM", perm = "READ")
public ApiResponse<CollectionCommissionRuleResp> detailRule(@PathVariable Long ruleId) {
return ApiResponse.ok(service.detailRule(ruleId));
}
/** 수금수수료 규칙 신규 등록. 응답: ruleId. 권한: COLLECT_COMM CREATE. */
@PostMapping("/rules")
@RequirePermission(menu = "COLLECT_COMM", perm = "CREATE")
@DataChangeLog(menu = "COLLECT_COMM", table = "collection_commission_rule")
public ApiResponse<Long> createRule(@Valid @RequestBody CollectionCommissionRuleSaveReq req) {
return ApiResponse.ok(service.createRule(req));
}
/** 수금수수료 규칙 수정. 권한: COLLECT_COMM UPDATE. */
@PutMapping("/rules/{ruleId}")
@RequirePermission(menu = "COLLECT_COMM", perm = "UPDATE")
@DataChangeLog(menu = "COLLECT_COMM", table = "collection_commission_rule")
public ApiResponse<Void> updateRule(@PathVariable Long ruleId,
@Valid @RequestBody CollectionCommissionRuleSaveReq req) {
service.updateRule(ruleId, req);
return ApiResponse.ok();
}
/** 수금수수료 규칙 삭제. 권한: COLLECT_COMM DELETE. */
@DeleteMapping("/rules/{ruleId}")
@RequirePermission(menu = "COLLECT_COMM", perm = "DELETE")
@DataChangeLog(menu = "COLLECT_COMM", table = "collection_commission_rule")
public ApiResponse<Void> deleteRule(@PathVariable Long ruleId) {
service.deleteRule(ruleId);
return ApiResponse.ok();
}
// ── Ledger (수금수수료 원장) ────────────────────────────────────────────
/** 수금수수료 원장 목록 조회. 권한: COLLECT_COMM READ. */
@GetMapping("/ledgers")
@RequirePermission(menu = "COLLECT_COMM", perm = "READ")
public ApiResponse<PageResponse<CollectionCommissionLedgerResp>> listLedgers(
@ModelAttribute CollectionCommissionLedgerSearchParam param) {
return ApiResponse.ok(service.listLedgers(param));
}
/** 수금수수료 원장 단건 상세. 권한: COLLECT_COMM READ. */
@GetMapping("/ledgers/{ledgerId}")
@RequirePermission(menu = "COLLECT_COMM", perm = "READ")
public ApiResponse<CollectionCommissionLedgerResp> detailLedger(@PathVariable Long ledgerId) {
return ApiResponse.ok(service.detailLedger(ledgerId));
}
/** 수금수수료 원장 신규 등록. 응답: ledgerId. 권한: COLLECT_COMM CREATE. */
@PostMapping("/ledgers")
@RequirePermission(menu = "COLLECT_COMM", perm = "CREATE")
@DataChangeLog(menu = "COLLECT_COMM", table = "collection_commission_ledger")
public ApiResponse<Long> createLedger(@Valid @RequestBody CollectionCommissionLedgerSaveReq req) {
return ApiResponse.ok(service.createLedger(req));
}
}
@@ -0,0 +1,81 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.CommissionMasterService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.commission.CommissionMasterResp;
import com.ga.core.vo.commission.CommissionMasterSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 수수료 마스터 API.
*
* 수수료 마스터(commission master): 설계사·정산월 단위로 각종 수수료(도입/소개/유지/시상 등)를
* 한데 모아 최종 지급액을 관리하는 핵심 원장. 여러 세부 원장이 이 마스터로 집계된다.
* - by-agent: 설계사+정산월의 수수료들, aggregated: 정산월 전체 집계, status: 정산 상태 변경(확정/지급 등).
*
* 권한: @RequirePermission(menu="COMM_MASTER"), perm=READ/UPDATE/DELETE. 변경은 @DataChangeLog 기록.
*/
@Tag(name = "수수료 마스터")
@RestController
@RequestMapping("/api/commissions")
@RequiredArgsConstructor
public class CommissionMasterController {
private final CommissionMasterService service;
/** 수수료 마스터 목록 조회(검색 + 페이징). 권한: COMM_MASTER READ. */
@GetMapping
@RequirePermission(menu = "COMM_MASTER", perm = "READ")
public ApiResponse<PageResponse<CommissionMasterResp>> list(@ModelAttribute CommissionMasterSearchParam param) {
return ApiResponse.ok(service.list(param));
}
/** 수수료 마스터 단건 상세. 권한: COMM_MASTER READ. */
@GetMapping("/{masterId}")
@RequirePermission(menu = "COMM_MASTER", perm = "READ")
public ApiResponse<CommissionMasterResp> detail(@PathVariable Long masterId) {
return ApiResponse.ok(service.detail(masterId));
}
/** 특정 설계사(agentId)의 특정 정산월(settleMonth) 수수료 내역 조회. 권한: COMM_MASTER READ. */
@GetMapping("/by-agent")
@RequirePermission(menu = "COMM_MASTER", perm = "READ")
public ApiResponse<List<CommissionMasterResp>> byAgent(@RequestParam Long agentId,
@RequestParam String settleMonth) {
return ApiResponse.ok(service.listByAgentAndMonth(agentId, settleMonth));
}
/** 정산월 단위 전체 집계 조회. 권한: COMM_MASTER READ. */
@GetMapping("/aggregated")
@RequirePermission(menu = "COMM_MASTER", perm = "READ")
public ApiResponse<List<CommissionMasterResp>> aggregated(@RequestParam String settleMonth) {
return ApiResponse.ok(service.aggregatedByMonth(settleMonth));
}
/** 정산 상태 변경(예: 확정/지급 등). body의 "status" 값으로 갱신. 권한: COMM_MASTER UPDATE. */
@PutMapping("/{masterId}/status")
@RequirePermission(menu = "COMM_MASTER", perm = "UPDATE")
@DataChangeLog(menu = "COMM_MASTER", table = "commission_master")
public ApiResponse<Void> updateStatus(@PathVariable Long masterId,
@RequestBody Map<String, String> body) {
service.updateStatus(masterId, body.get("status"));
return ApiResponse.ok();
}
/** 수수료 마스터 삭제. 권한: COMM_MASTER DELETE. */
@DeleteMapping("/{masterId}")
@RequirePermission(menu = "COMM_MASTER", perm = "DELETE")
@DataChangeLog(menu = "COMM_MASTER", table = "commission_master")
public ApiResponse<Void> delete(@PathVariable Long masterId) {
service.delete(masterId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,54 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.CommissionSimulationService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.commission.CommissionSimulationResp;
import com.ga.core.vo.commission.CommissionSimulationSaveReq;
import com.ga.core.vo.commission.CommissionSimulationSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 수수료 시뮬레이터 API.
*
* 시뮬레이션(simulation): 실제 정산을 확정하기 전에 "이 조건이면 수수료가 얼마 나올까"를 미리 계산해 보는 기능.
* 결과는 저장되어 이력으로 조회할 수 있다(실제 지급에는 영향 없음).
*
* 권한: @RequirePermission(menu="COMM_SIMULATOR"), perm=READ/CREATE.
*/
@Tag(name = "수수료 시뮬레이터")
@RestController
@RequestMapping("/api/commission-simulations")
@RequiredArgsConstructor
public class CommissionSimulationController {
private final CommissionSimulationService service;
/** 시뮬레이션 이력 목록 조회. 권한: COMM_SIMULATOR READ. */
@GetMapping
@RequirePermission(menu = "COMM_SIMULATOR", perm = "READ")
public ApiResponse<PageResponse<CommissionSimulationResp>> list(
@ModelAttribute CommissionSimulationSearchParam param) {
return ApiResponse.ok(service.list(param));
}
/** 시뮬레이션 단건 상세. 권한: COMM_SIMULATOR READ. */
@GetMapping("/{simId}")
@RequirePermission(menu = "COMM_SIMULATOR", perm = "READ")
public ApiResponse<CommissionSimulationResp> detail(@PathVariable Long simId) {
return ApiResponse.ok(service.detail(simId));
}
/** 시뮬레이션 실행. 요청 조건으로 예상 수수료를 계산해 결과를 반환(및 이력 저장). 권한: COMM_SIMULATOR CREATE. */
@PostMapping("/simulate")
@RequirePermission(menu = "COMM_SIMULATOR", perm = "CREATE")
@DataChangeLog(menu = "COMM_SIMULATOR", table = "commission_simulation")
public ApiResponse<CommissionSimulationResp> simulate(@Valid @RequestBody CommissionSimulationSaveReq req) {
return ApiResponse.ok(service.simulate(req));
}
}
@@ -0,0 +1,121 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.GradeEvaluationService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.commission.AgentGradeEvaluationResp;
import com.ga.core.vo.commission.AgentGradeEvaluationSaveReq;
import com.ga.core.vo.commission.AgentGradeEvaluationSearchParam;
import com.ga.core.vo.commission.GradeEvalRuleResp;
import com.ga.core.vo.commission.GradeEvalRuleSaveReq;
import com.ga.core.vo.commission.GradeEvalRuleSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 등급평가 API.
*
* 등급평가(grade evaluation): 설계사의 실적 등을 기준 규칙(Rule)에 따라 평가해 등급을 매기는 기능.
* 등급은 수수료율·시상 등에 영향을 준다.
* - Rule(평가 규칙) CRUD - Evaluation(평가 결과): 생성 후 apply(적용)하면 실제 설계사 등급에 반영된다.
*
* 권한: @RequirePermission(menu="GRADE_EVAL"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 기록.
*/
@Tag(name = "등급평가")
@RestController
@RequestMapping("/api/grade-evaluations")
@RequiredArgsConstructor
public class GradeEvaluationController {
private final GradeEvaluationService service;
// ── Rule (평가 규칙) ────────────────────────────────────────────────────
/** 등급평가 규칙 목록 조회. 권한: GRADE_EVAL READ. */
@GetMapping("/rules")
@RequirePermission(menu = "GRADE_EVAL", perm = "READ")
public ApiResponse<PageResponse<GradeEvalRuleResp>> listRules(@ModelAttribute GradeEvalRuleSearchParam param) {
return ApiResponse.ok(service.listRules(param));
}
/** 등급평가 규칙 단건 상세. 권한: GRADE_EVAL READ. */
@GetMapping("/rules/{ruleId}")
@RequirePermission(menu = "GRADE_EVAL", perm = "READ")
public ApiResponse<GradeEvalRuleResp> detailRule(@PathVariable Long ruleId) {
return ApiResponse.ok(service.detailRule(ruleId));
}
/** 등급평가 규칙 신규 등록. 응답: ruleId. 권한: GRADE_EVAL CREATE. */
@PostMapping("/rules")
@RequirePermission(menu = "GRADE_EVAL", perm = "CREATE")
@DataChangeLog(menu = "GRADE_EVAL", table = "grade_eval_rule")
public ApiResponse<Long> createRule(@Valid @RequestBody GradeEvalRuleSaveReq req) {
return ApiResponse.ok(service.createRule(req));
}
/** 등급평가 규칙 수정. 권한: GRADE_EVAL UPDATE. */
@PutMapping("/rules/{ruleId}")
@RequirePermission(menu = "GRADE_EVAL", perm = "UPDATE")
@DataChangeLog(menu = "GRADE_EVAL", table = "grade_eval_rule")
public ApiResponse<Void> updateRule(@PathVariable Long ruleId,
@Valid @RequestBody GradeEvalRuleSaveReq req) {
service.updateRule(ruleId, req);
return ApiResponse.ok();
}
/** 등급평가 규칙 삭제. 권한: GRADE_EVAL DELETE. */
@DeleteMapping("/rules/{ruleId}")
@RequirePermission(menu = "GRADE_EVAL", perm = "DELETE")
@DataChangeLog(menu = "GRADE_EVAL", table = "grade_eval_rule")
public ApiResponse<Void> deleteRule(@PathVariable Long ruleId) {
service.deleteRule(ruleId);
return ApiResponse.ok();
}
// ── Evaluation (평가 결과) ──────────────────────────────────────────────
/** 등급평가 결과 목록 조회. 권한: GRADE_EVAL READ. */
@GetMapping
@RequirePermission(menu = "GRADE_EVAL", perm = "READ")
public ApiResponse<PageResponse<AgentGradeEvaluationResp>> listEvals(
@ModelAttribute AgentGradeEvaluationSearchParam param) {
return ApiResponse.ok(service.listEvals(param));
}
/** 등급평가 결과 단건 상세. 권한: GRADE_EVAL READ. */
@GetMapping("/{evalId}")
@RequirePermission(menu = "GRADE_EVAL", perm = "READ")
public ApiResponse<AgentGradeEvaluationResp> detailEval(@PathVariable Long evalId) {
return ApiResponse.ok(service.detailEval(evalId));
}
/** 등급평가 결과 신규 생성(아직 미적용 상태). 응답: evalId. 권한: GRADE_EVAL CREATE. */
@PostMapping
@RequirePermission(menu = "GRADE_EVAL", perm = "CREATE")
@DataChangeLog(menu = "GRADE_EVAL", table = "agent_grade_evaluation")
public ApiResponse<Long> createEval(@Valid @RequestBody AgentGradeEvaluationSaveReq req) {
return ApiResponse.ok(service.createEval(req));
}
/** 등급평가 결과 적용. 평가된 등급을 실제 설계사 등급에 반영한다. 권한: GRADE_EVAL UPDATE. */
@PostMapping("/{evalId}/apply")
@RequirePermission(menu = "GRADE_EVAL", perm = "UPDATE")
@DataChangeLog(menu = "GRADE_EVAL", table = "agent_grade_evaluation")
public ApiResponse<Void> applyEval(@PathVariable Long evalId) {
service.applyEval(evalId);
return ApiResponse.ok();
}
/** 등급평가 결과 삭제. 권한: GRADE_EVAL DELETE. */
@DeleteMapping("/{evalId}")
@RequirePermission(menu = "GRADE_EVAL", perm = "DELETE")
@DataChangeLog(menu = "GRADE_EVAL", table = "agent_grade_evaluation")
public ApiResponse<Void> deleteEval(@PathVariable Long evalId) {
service.deleteEval(evalId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,73 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.GuaranteeInsuranceService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.commission.AgentGuaranteeInsuranceResp;
import com.ga.core.vo.commission.AgentGuaranteeInsuranceSaveReq;
import com.ga.core.vo.commission.AgentGuaranteeInsuranceSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 보증보험 API.
*
* 보증보험(guarantee insurance): 설계사가 선지급금 등을 갚지 못할 경우에 대비해 가입하는 보험.
* 설계사별 가입 정보(보험사/증권번호/보장한도/기간 등)를 관리한다.
*
* 권한: @RequirePermission(menu="GUARANTEE_INS"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 기록.
*/
@Tag(name = "보증보험")
@RestController
@RequestMapping("/api/guarantee-insurances")
@RequiredArgsConstructor
public class GuaranteeInsuranceController {
private final GuaranteeInsuranceService service;
/** 보증보험 가입 목록 조회. 권한: GUARANTEE_INS READ. */
@GetMapping
@RequirePermission(menu = "GUARANTEE_INS", perm = "READ")
public ApiResponse<PageResponse<AgentGuaranteeInsuranceResp>> list(
@ModelAttribute AgentGuaranteeInsuranceSearchParam param) {
return ApiResponse.ok(service.list(param));
}
/** 보증보험 단건 상세. 권한: GUARANTEE_INS READ. */
@GetMapping("/{guaranteeId}")
@RequirePermission(menu = "GUARANTEE_INS", perm = "READ")
public ApiResponse<AgentGuaranteeInsuranceResp> detail(@PathVariable Long guaranteeId) {
return ApiResponse.ok(service.detail(guaranteeId));
}
/** 보증보험 가입 등록. 응답: guaranteeId. 권한: GUARANTEE_INS CREATE. */
@PostMapping
@RequirePermission(menu = "GUARANTEE_INS", perm = "CREATE")
@DataChangeLog(menu = "GUARANTEE_INS", table = "agent_guarantee_insurance")
public ApiResponse<Long> create(@Valid @RequestBody AgentGuaranteeInsuranceSaveReq req) {
return ApiResponse.ok(service.create(req));
}
/** 보증보험 정보 수정. 권한: GUARANTEE_INS UPDATE. */
@PutMapping("/{guaranteeId}")
@RequirePermission(menu = "GUARANTEE_INS", perm = "UPDATE")
@DataChangeLog(menu = "GUARANTEE_INS", table = "agent_guarantee_insurance")
public ApiResponse<Void> update(@PathVariable Long guaranteeId,
@Valid @RequestBody AgentGuaranteeInsuranceSaveReq req) {
service.update(guaranteeId, req);
return ApiResponse.ok();
}
/** 보증보험 삭제. 권한: GUARANTEE_INS DELETE. */
@DeleteMapping("/{guaranteeId}")
@RequirePermission(menu = "GUARANTEE_INS", perm = "DELETE")
@DataChangeLog(menu = "GUARANTEE_INS", table = "agent_guarantee_insurance")
public ApiResponse<Void> delete(@PathVariable Long guaranteeId) {
service.delete(guaranteeId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,80 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.HqSettleService;
import com.ga.api.service.commission.HqSettleService.HqSummaryResp;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.commission.HqSettleItemResp;
import com.ga.core.vo.commission.HqSettleItemSaveReq;
import com.ga.core.vo.commission.HqSettleItemSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 본사정산 API.
*
* 본사정산(HQ settle): 본사 차원에서 발생하는 정산 항목(item)을 정산월 단위로 관리하고 합계(summary)를 낸다.
* 설계사 지급과 별개로 본사가 직접 가감하는 비용/수입 항목 등을 다룬다.
*
* 권한: @RequirePermission(menu="HQ_SETTLE"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 기록.
*/
@Tag(name = "본사정산")
@RestController
@RequestMapping("/api/hq-settles")
@RequiredArgsConstructor
public class HqSettleController {
private final HqSettleService service;
/** 본사정산 항목 목록 조회. 권한: HQ_SETTLE READ. */
@GetMapping
@RequirePermission(menu = "HQ_SETTLE", perm = "READ")
public ApiResponse<PageResponse<HqSettleItemResp>> list(@ModelAttribute HqSettleItemSearchParam param) {
return ApiResponse.ok(service.list(param));
}
/** 특정 정산월의 본사정산 합계 요약. 권한: HQ_SETTLE READ. */
@GetMapping("/summary")
@RequirePermission(menu = "HQ_SETTLE", perm = "READ")
public ApiResponse<HqSummaryResp> summary(@RequestParam String settleMonth) {
return ApiResponse.ok(service.summary(settleMonth));
}
/** 본사정산 항목 단건 상세. 권한: HQ_SETTLE READ. */
@GetMapping("/{itemId}")
@RequirePermission(menu = "HQ_SETTLE", perm = "READ")
public ApiResponse<HqSettleItemResp> detail(@PathVariable Long itemId) {
return ApiResponse.ok(service.detail(itemId));
}
/** 본사정산 항목 신규 등록. 응답: itemId. 권한: HQ_SETTLE CREATE. */
@PostMapping
@RequirePermission(menu = "HQ_SETTLE", perm = "CREATE")
@DataChangeLog(menu = "HQ_SETTLE", table = "hq_settle_item")
public ApiResponse<Long> create(@Valid @RequestBody HqSettleItemSaveReq req) {
return ApiResponse.ok(service.create(req));
}
/** 본사정산 항목 수정. 권한: HQ_SETTLE UPDATE. */
@PutMapping("/{itemId}")
@RequirePermission(menu = "HQ_SETTLE", perm = "UPDATE")
@DataChangeLog(menu = "HQ_SETTLE", table = "hq_settle_item")
public ApiResponse<Void> update(@PathVariable Long itemId,
@Valid @RequestBody HqSettleItemSaveReq req) {
service.update(itemId, req);
return ApiResponse.ok();
}
/** 본사정산 항목 삭제. 권한: HQ_SETTLE DELETE. */
@DeleteMapping("/{itemId}")
@RequirePermission(menu = "HQ_SETTLE", perm = "DELETE")
@DataChangeLog(menu = "HQ_SETTLE", table = "hq_settle_item")
public ApiResponse<Void> delete(@PathVariable Long itemId) {
service.delete(itemId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,86 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.IncomeCommissionService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.commission.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 수입수수료/수지차 API.
*
* - 수입수수료(income commission): GA가 보험사로부터 받는 수수료. "보험료 x 보험사 요율"로 계산되며,
* 설계사에게 지급하는 수수료(지출)의 재원이 된다.
* - 수지차(margin): 같은 정산월의 수입수수료 합계에서 설계사 지급액 합계를 뺀 GA의 마진.
* 양수면 흑자, 음수면 적자를 뜻한다.
*
* 모든 엔드포인트는 @RequirePermission(menu="INCOME_COMM")로 보호되며, perm 은 READ(조회)/CREATE(생성) 권한을 의미한다.
* 데이터 변경(POST)에는 @DataChangeLog 로 변경 이력(감사 로그)이 자동 기록된다.
*/
@Tag(name = "수입수수료/수지차")
@RestController
@RequestMapping("/api/income-commissions")
@RequiredArgsConstructor
public class IncomeCommissionController {
private final IncomeCommissionService service;
// ── Income Ledger (수입수수료 원장) ───────────────────────────────────────
/** 수입수수료 원장 목록 조회(검색조건 + 페이징). 권한: INCOME_COMM READ. */
@GetMapping("/ledgers")
@RequirePermission(menu = "INCOME_COMM", perm = "READ")
public ApiResponse<PageResponse<IncomeCommissionLedgerResp>> listLedgers(
@ModelAttribute IncomeCommissionLedgerSearchParam param) {
return ApiResponse.ok(service.listLedgers(param));
}
/** 수입수수료 원장 단건 상세 조회. 권한: INCOME_COMM READ. */
@GetMapping("/ledgers/{incomeId}")
@RequirePermission(menu = "INCOME_COMM", perm = "READ")
public ApiResponse<IncomeCommissionLedgerResp> detailLedger(@PathVariable Long incomeId) {
return ApiResponse.ok(service.detailLedger(incomeId));
}
/** 수입수수료 원장 신규 등록. 등록 시 "보험료 x 보험사요율"로 수입액이 계산된다. 응답: 생성된 incomeId. 권한: INCOME_COMM CREATE. */
@PostMapping("/ledgers")
@RequirePermission(menu = "INCOME_COMM", perm = "CREATE")
@DataChangeLog(menu = "INCOME_COMM", table = "income_commission_ledger")
public ApiResponse<Long> createLedger(@Valid @RequestBody IncomeCommissionLedgerSaveReq req) {
return ApiResponse.ok(service.createLedger(req));
}
// ── Margin Summary (수지차 집계) ──────────────────────────────────────────
/** 수지차 집계 목록 조회. 권한: INCOME_COMM READ. */
@GetMapping("/margins")
@RequirePermission(menu = "INCOME_COMM", perm = "READ")
public ApiResponse<PageResponse<MarginSummaryResp>> listMargins(
@ModelAttribute MarginSummarySearchParam param) {
return ApiResponse.ok(service.listMargins(param));
}
/** 수지차 집계 단건 상세 조회. 권한: INCOME_COMM READ. */
@GetMapping("/margins/{marginId}")
@RequirePermission(menu = "INCOME_COMM", perm = "READ")
public ApiResponse<MarginSummaryResp> detailMargin(@PathVariable Long marginId) {
return ApiResponse.ok(service.detailMargin(marginId));
}
/**
* 수지차 재계산. 정산월(settleMonth) 기준으로 수입수수료 합 - 설계사 지급액 합을 다시 산출해 저장한다.
* agentId 가 주어지면 해당 설계사 지급분만 좁혀 집계한다(미지정 시 GA 월 전체). 응답: marginId. 권한: INCOME_COMM CREATE.
*/
@PostMapping("/margin/recalc")
@RequirePermission(menu = "INCOME_COMM", perm = "CREATE")
@DataChangeLog(menu = "INCOME_COMM", table = "margin_summary")
public ApiResponse<Long> recalcMargin(@RequestParam String settleMonth,
@RequestParam(required = false) Long agentId) {
return ApiResponse.ok(service.recalcMargin(settleMonth, agentId));
}
}
@@ -0,0 +1,72 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.JointContractService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.commission.JointContractResp;
import com.ga.core.vo.commission.JointContractSaveReq;
import com.ga.core.vo.commission.JointContractSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 공동계약 API.
*
* 공동계약(joint contract): 한 계약을 두 명 이상의 설계사가 함께 모집한 경우, 그 수수료를
* 약정 비율(지분)대로 나눠 갖도록 관리하는 기능. 비율 합이 100%가 되도록 등록한다.
*
* 권한: @RequirePermission(menu="JOINT_CONTRACT"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 기록.
*/
@Tag(name = "공동계약")
@RestController
@RequestMapping("/api/joint-contracts")
@RequiredArgsConstructor
public class JointContractController {
private final JointContractService service;
/** 공동계약 목록 조회. 권한: JOINT_CONTRACT READ. */
@GetMapping
@RequirePermission(menu = "JOINT_CONTRACT", perm = "READ")
public ApiResponse<PageResponse<JointContractResp>> list(@ModelAttribute JointContractSearchParam param) {
return ApiResponse.ok(service.list(param));
}
/** 공동계약 단건 상세. 권한: JOINT_CONTRACT READ. */
@GetMapping("/{jointId}")
@RequirePermission(menu = "JOINT_CONTRACT", perm = "READ")
public ApiResponse<JointContractResp> detail(@PathVariable Long jointId) {
return ApiResponse.ok(service.detail(jointId));
}
/** 공동계약 신규 등록(설계사별 지분 비율 포함). 응답: jointId. 권한: JOINT_CONTRACT CREATE. */
@PostMapping
@RequirePermission(menu = "JOINT_CONTRACT", perm = "CREATE")
@DataChangeLog(menu = "JOINT_CONTRACT", table = "joint_contract")
public ApiResponse<Long> create(@Valid @RequestBody JointContractSaveReq req) {
return ApiResponse.ok(service.create(req));
}
/** 공동계약 수정(지분 비율 변경 등). 권한: JOINT_CONTRACT UPDATE. */
@PutMapping("/{jointId}")
@RequirePermission(menu = "JOINT_CONTRACT", perm = "UPDATE")
@DataChangeLog(menu = "JOINT_CONTRACT", table = "joint_contract")
public ApiResponse<Void> update(@PathVariable Long jointId,
@Valid @RequestBody JointContractSaveReq req) {
service.update(jointId, req);
return ApiResponse.ok();
}
/** 공동계약 삭제. 권한: JOINT_CONTRACT DELETE. */
@DeleteMapping("/{jointId}")
@RequirePermission(menu = "JOINT_CONTRACT", perm = "DELETE")
@DataChangeLog(menu = "JOINT_CONTRACT", table = "joint_contract")
public ApiResponse<Void> delete(@PathVariable Long jointId) {
service.delete(jointId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,74 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.LapsedReinstatementService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.commission.LapsedReinstatementRecoveryResp;
import com.ga.core.vo.commission.LapsedReinstatementRecoverySaveReq;
import com.ga.core.vo.commission.LapsedReinstatementRecoverySearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 익월부활재지급 API.
*
* 실효(lapsed): 보험료 미납 등으로 계약 효력이 일시 정지된 상태. 실효가 나면 그 달에 지급했던 수수료를 환수한다.
* 부활(reinstatement): 실효된 계약이 다시 살아나는 것. 다음 달(익월)에 부활하면 앞서 환수했던 수수료를
* 다시 지급(재지급)해 준다. 이 도메인은 그 "환수 → 익월 부활 시 재지급(회수)" 흐름을 관리한다.
* - repay: 부활에 따른 재지급(회수) 확정 - cancel: 재지급 취소
*
* 권한: @RequirePermission(menu="LAPSED_REINSTATEMENT"), perm=READ/CREATE/UPDATE. 변경은 @DataChangeLog 기록.
*/
@Tag(name = "익월부활재지급")
@RestController
@RequestMapping("/api/lapsed-reinstatements")
@RequiredArgsConstructor
public class LapsedReinstatementController {
private final LapsedReinstatementService service;
/** 익월부활 재지급 대상 목록 조회. 권한: LAPSED_REINSTATEMENT READ. */
@GetMapping
@RequirePermission(menu = "LAPSED_REINSTATEMENT", perm = "READ")
public ApiResponse<PageResponse<LapsedReinstatementRecoveryResp>> list(
@ModelAttribute LapsedReinstatementRecoverySearchParam param) {
return ApiResponse.ok(service.list(param));
}
/** 재지급 건 단건 상세. 권한: LAPSED_REINSTATEMENT READ. */
@GetMapping("/{recoveryId}")
@RequirePermission(menu = "LAPSED_REINSTATEMENT", perm = "READ")
public ApiResponse<LapsedReinstatementRecoveryResp> detail(@PathVariable Long recoveryId) {
return ApiResponse.ok(service.detail(recoveryId));
}
/** 재지급 건 신규 등록. 응답: recoveryId. 권한: LAPSED_REINSTATEMENT CREATE. */
@PostMapping
@RequirePermission(menu = "LAPSED_REINSTATEMENT", perm = "CREATE")
@DataChangeLog(menu = "LAPSED_REINSTATEMENT", table = "lapsed_reinstatement_recovery")
public ApiResponse<Long> create(@Valid @RequestBody LapsedReinstatementRecoverySaveReq req) {
return ApiResponse.ok(service.create(req));
}
/** 부활에 따른 재지급(회수) 확정 처리. 권한: LAPSED_REINSTATEMENT UPDATE. */
@PostMapping("/{recoveryId}/repay")
@RequirePermission(menu = "LAPSED_REINSTATEMENT", perm = "UPDATE")
@DataChangeLog(menu = "LAPSED_REINSTATEMENT", table = "lapsed_reinstatement_recovery")
public ApiResponse<Void> repay(@PathVariable Long recoveryId) {
service.repay(recoveryId);
return ApiResponse.ok();
}
/** 재지급 취소 처리. 권한: LAPSED_REINSTATEMENT UPDATE. */
@PostMapping("/{recoveryId}/cancel")
@RequirePermission(menu = "LAPSED_REINSTATEMENT", perm = "UPDATE")
@DataChangeLog(menu = "LAPSED_REINSTATEMENT", table = "lapsed_reinstatement_recovery")
public ApiResponse<Void> cancel(@PathVariable Long recoveryId) {
service.cancel(recoveryId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,99 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.LifeSupportService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.commission.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 생보운영지원수수료 API.
*
* 생명보험사 운영지원수수료(life operation support): 생보 상품 운영/관리 활동에 대해 지급하는 지원성 수수료.
* 규칙(Rule)에 정의된 기준에 따라 산출되어 원장(Ledger)에 기록된다.
*
* 구조는 "규칙(Rule) CRUD + 원장(Ledger) 조회/등록"으로, 다른 수수료 도메인과 동일한 패턴이다.
* 권한: @RequirePermission(menu="LIFE_OP_SUPPORT"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 기록.
*/
@Tag(name = "생보운영지원수수료")
@RestController
@RequestMapping("/api/life-supports")
@RequiredArgsConstructor
public class LifeSupportController {
private final LifeSupportService service;
// ── Rule (지원수수료 규칙) ──────────────────────────────────────────────
/** 규칙 목록 조회. 권한: LIFE_OP_SUPPORT READ. */
@GetMapping("/rules")
@RequirePermission(menu = "LIFE_OP_SUPPORT", perm = "READ")
public ApiResponse<PageResponse<LifeSupportRuleResp>> listRules(
@ModelAttribute LifeSupportRuleSearchParam param) {
return ApiResponse.ok(service.listRules(param));
}
/** 규칙 단건 상세. 권한: LIFE_OP_SUPPORT READ. */
@GetMapping("/rules/{ruleId}")
@RequirePermission(menu = "LIFE_OP_SUPPORT", perm = "READ")
public ApiResponse<LifeSupportRuleResp> detailRule(@PathVariable Long ruleId) {
return ApiResponse.ok(service.detailRule(ruleId));
}
/** 규칙 신규 등록. 응답: ruleId. 권한: LIFE_OP_SUPPORT CREATE. */
@PostMapping("/rules")
@RequirePermission(menu = "LIFE_OP_SUPPORT", perm = "CREATE")
@DataChangeLog(menu = "LIFE_OP_SUPPORT", table = "life_support_rule")
public ApiResponse<Long> createRule(@Valid @RequestBody LifeSupportRuleSaveReq req) {
return ApiResponse.ok(service.createRule(req));
}
/** 규칙 수정. 권한: LIFE_OP_SUPPORT UPDATE. */
@PutMapping("/rules/{ruleId}")
@RequirePermission(menu = "LIFE_OP_SUPPORT", perm = "UPDATE")
@DataChangeLog(menu = "LIFE_OP_SUPPORT", table = "life_support_rule")
public ApiResponse<Void> updateRule(@PathVariable Long ruleId,
@Valid @RequestBody LifeSupportRuleSaveReq req) {
service.updateRule(ruleId, req);
return ApiResponse.ok();
}
/** 규칙 삭제. 권한: LIFE_OP_SUPPORT DELETE. */
@DeleteMapping("/rules/{ruleId}")
@RequirePermission(menu = "LIFE_OP_SUPPORT", perm = "DELETE")
@DataChangeLog(menu = "LIFE_OP_SUPPORT", table = "life_support_rule")
public ApiResponse<Void> deleteRule(@PathVariable Long ruleId) {
service.deleteRule(ruleId);
return ApiResponse.ok();
}
// ── Ledger (지원수수료 원장) ────────────────────────────────────────────
/** 원장 목록 조회. 권한: LIFE_OP_SUPPORT READ. */
@GetMapping("/ledgers")
@RequirePermission(menu = "LIFE_OP_SUPPORT", perm = "READ")
public ApiResponse<PageResponse<LifeSupportLedgerResp>> listLedgers(
@ModelAttribute LifeSupportLedgerSearchParam param) {
return ApiResponse.ok(service.listLedgers(param));
}
/** 원장 단건 상세. 권한: LIFE_OP_SUPPORT READ. */
@GetMapping("/ledgers/{ledgerId}")
@RequirePermission(menu = "LIFE_OP_SUPPORT", perm = "READ")
public ApiResponse<LifeSupportLedgerResp> detailLedger(@PathVariable Long ledgerId) {
return ApiResponse.ok(service.detailLedger(ledgerId));
}
/** 원장 신규 등록(지원수수료 산출/지급 기록). 응답: ledgerId. 권한: LIFE_OP_SUPPORT CREATE. */
@PostMapping("/ledgers")
@RequirePermission(menu = "LIFE_OP_SUPPORT", perm = "CREATE")
@DataChangeLog(menu = "LIFE_OP_SUPPORT", table = "life_support_ledger")
public ApiResponse<Long> createLedger(@Valid @RequestBody LifeSupportLedgerSaveReq req) {
return ApiResponse.ok(service.createLedger(req));
}
}
@@ -0,0 +1,60 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.OverrideLedgerService;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.commission.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 오버라이드 원장 API.
*
* 오버라이드(override): 상위 직급자(예: 팀장/지점장)가 자기 산하 하위 설계사의 실적에 대해
* 일정 비율로 추가로 받는 수수료. "하위가 판 만큼 상위에게도 얹어주는" 구조라 override 라 부른다.
* 이 원장은 누가(fromAgent) 누구에게(toAgentId) 얼마를 넘겨주는지를 기록한다.
* - summary: 수령자(상위자) 기준 집계.
*
* 권한: @RequirePermission(menu="OVERRIDE_LEDGER"), perm=READ. (조회 전용 - 생성/배치는 별도 계산 로직에서 처리)
*/
@Tag(name = "오버라이드 원장")
@RestController
@RequestMapping("/api/override-ledgers")
@RequiredArgsConstructor
public class OverrideLedgerController {
private final OverrideLedgerService service;
/** 오버라이드 원장 목록 조회. 권한: OVERRIDE_LEDGER READ. */
@GetMapping
@RequirePermission(menu = "OVERRIDE_LEDGER", perm = "READ")
public ApiResponse<PageResponse<OverrideLedgerResp>> list(@ModelAttribute OverrideLedgerSearchParam param) {
return ApiResponse.ok(service.list(param));
}
/** 오버라이드 원장 단건 상세. 권한: OVERRIDE_LEDGER READ. */
@GetMapping("/{ledgerId}")
@RequirePermission(menu = "OVERRIDE_LEDGER", perm = "READ")
public ApiResponse<OverrideLedgerResp> detail(@PathVariable Long ledgerId) {
return ApiResponse.ok(service.detail(ledgerId));
}
/** 오버라이드 집계 목록(수령자별 합계 등). 권한: OVERRIDE_LEDGER READ. */
@GetMapping("/summary")
@RequirePermission(menu = "OVERRIDE_LEDGER", perm = "READ")
public ApiResponse<List<OverrideSummaryResp>> summaryList(@ModelAttribute OverrideSummarySearchParam param) {
return ApiResponse.ok(service.summaryList(param));
}
/** 특정 수령자(toAgentId)의 특정 정산월(settleMonth) 오버라이드 합계. 권한: OVERRIDE_LEDGER READ. */
@GetMapping("/summary/by-agent")
@RequirePermission(menu = "OVERRIDE_LEDGER", perm = "READ")
public ApiResponse<OverrideSummaryResp> summaryByAgent(@RequestParam Long toAgentId,
@RequestParam String settleMonth) {
return ApiResponse.ok(service.summaryByAgentAndMonth(toAgentId, settleMonth));
}
}
@@ -0,0 +1,96 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.PersistencyBonusService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.commission.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 유지보너스(유지보수수당) API.
*
* 유지율 보너스(persistency bonus): 계약이 일정 기간(예: 13회차/25회차)까지 해지 없이 잘 "유지"되면
* 그 유지 성과에 대해 설계사에게 지급하는 보너스. 유지율이 높을수록 보너스가 커진다.
* - Rule(보너스 규칙) CRUD - Bonus(보너스 산출 내역): upsert(있으면 갱신, 없으면 생성)로 등록.
*
* 권한: @RequirePermission(menu="PERSIST_BONUS"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 기록.
*/
@Tag(name = "유지보수수당")
@RestController
@RequestMapping("/api/persistency-bonus")
@RequiredArgsConstructor
public class PersistencyBonusController {
private final PersistencyBonusService service;
/** 유지보너스 규칙 목록 조회. 권한: PERSIST_BONUS READ. */
@GetMapping("/rules")
@RequirePermission(menu = "PERSIST_BONUS", perm = "READ")
public ApiResponse<PageResponse<PersistencyBonusRuleResp>> listRules(
@ModelAttribute PersistencyBonusRuleSearchParam param) {
return ApiResponse.ok(service.listRules(param));
}
/** 유지보너스 규칙 단건 상세. 권한: PERSIST_BONUS READ. */
@GetMapping("/rules/{ruleId}")
@RequirePermission(menu = "PERSIST_BONUS", perm = "READ")
public ApiResponse<PersistencyBonusRuleResp> detailRule(@PathVariable Long ruleId) {
return ApiResponse.ok(service.detailRule(ruleId));
}
/** 유지보너스 규칙 신규 등록. 응답: ruleId. 권한: PERSIST_BONUS CREATE. */
@PostMapping("/rules")
@RequirePermission(menu = "PERSIST_BONUS", perm = "CREATE")
@DataChangeLog(menu = "PERSIST_BONUS", table = "persistency_bonus_rule")
public ApiResponse<Long> createRule(@Valid @RequestBody PersistencyBonusRuleSaveReq req) {
return ApiResponse.ok(service.createRule(req));
}
/** 유지보너스 규칙 수정. 권한: PERSIST_BONUS UPDATE. */
@PutMapping("/rules/{ruleId}")
@RequirePermission(menu = "PERSIST_BONUS", perm = "UPDATE")
@DataChangeLog(menu = "PERSIST_BONUS", table = "persistency_bonus_rule")
public ApiResponse<Void> updateRule(@PathVariable Long ruleId,
@Valid @RequestBody PersistencyBonusRuleSaveReq req) {
service.updateRule(ruleId, req);
return ApiResponse.ok();
}
/** 유지보너스 규칙 삭제. 권한: PERSIST_BONUS DELETE. */
@DeleteMapping("/rules/{ruleId}")
@RequirePermission(menu = "PERSIST_BONUS", perm = "DELETE")
@DataChangeLog(menu = "PERSIST_BONUS", table = "persistency_bonus_rule")
public ApiResponse<Void> deleteRule(@PathVariable Long ruleId) {
service.deleteRule(ruleId);
return ApiResponse.ok();
}
/** 유지보너스 산출 내역 목록 조회. 권한: PERSIST_BONUS READ. */
@GetMapping("/bonuses")
@RequirePermission(menu = "PERSIST_BONUS", perm = "READ")
public ApiResponse<PageResponse<PersistencyBonusResp>> listBonuses(
@ModelAttribute PersistencyBonusSearchParam param) {
return ApiResponse.ok(service.listBonuses(param));
}
/** 유지보너스 산출 내역 단건 상세. 권한: PERSIST_BONUS READ. */
@GetMapping("/bonuses/{bonusId}")
@RequirePermission(menu = "PERSIST_BONUS", perm = "READ")
public ApiResponse<PersistencyBonusResp> detailBonus(@PathVariable Long bonusId) {
return ApiResponse.ok(service.detailBonus(bonusId));
}
/** 유지보너스 산출 내역 upsert(동일 키가 있으면 갱신, 없으면 신규 생성). 권한: PERSIST_BONUS CREATE. */
@PostMapping("/bonuses")
@RequirePermission(menu = "PERSIST_BONUS", perm = "CREATE")
@DataChangeLog(menu = "PERSIST_BONUS", table = "persistency_bonus")
public ApiResponse<Void> upsertBonus(@Valid @RequestBody PersistencyBonusSaveReq req) {
service.upsertBonus(req);
return ApiResponse.ok();
}
}
@@ -0,0 +1,100 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.RecognizedPerformanceService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.commission.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 인정실적 API.
*
* 인정실적(recognized performance): 실제 보험료를 그대로 실적으로 보지 않고, 상품 종류에 따른
* 환산율(product conversion rate)을 곱해 "회사가 인정해 주는 실적"으로 환산한 값.
* (예: 보장성 100%, 저축성 30% 등. 같은 보험료라도 상품에 따라 인정실적이 달라진다.)
* - rates(환산율 규칙) CRUD - ledgers(인정실적 원장) 조회/등록.
*
* 권한: @RequirePermission(menu="RECOGNIZED_PERF"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 기록.
*/
@Tag(name = "인정실적")
@RestController
@RequestMapping("/api/recognized-performances")
@RequiredArgsConstructor
public class RecognizedPerformanceController {
private final RecognizedPerformanceService service;
// ── Product Conversion Rate (상품 환산율 규칙) ──────────────────────────
/** 상품 환산율 목록 조회. 권한: RECOGNIZED_PERF READ. */
@GetMapping("/rates")
@RequirePermission(menu = "RECOGNIZED_PERF", perm = "READ")
public ApiResponse<PageResponse<ProductConversionRateResp>> listRates(
@ModelAttribute ProductConversionRateSearchParam param) {
return ApiResponse.ok(service.listRates(param));
}
/** 상품 환산율 단건 상세. 권한: RECOGNIZED_PERF READ. */
@GetMapping("/rates/{rateId}")
@RequirePermission(menu = "RECOGNIZED_PERF", perm = "READ")
public ApiResponse<ProductConversionRateResp> detailRate(@PathVariable Long rateId) {
return ApiResponse.ok(service.detailRate(rateId));
}
/** 상품 환산율 신규 등록. 응답: rateId. 권한: RECOGNIZED_PERF CREATE. */
@PostMapping("/rates")
@RequirePermission(menu = "RECOGNIZED_PERF", perm = "CREATE")
@DataChangeLog(menu = "RECOGNIZED_PERF", table = "product_conversion_rate")
public ApiResponse<Long> createRate(@Valid @RequestBody ProductConversionRateSaveReq req) {
return ApiResponse.ok(service.createRate(req));
}
/** 상품 환산율 수정. 권한: RECOGNIZED_PERF UPDATE. */
@PutMapping("/rates/{rateId}")
@RequirePermission(menu = "RECOGNIZED_PERF", perm = "UPDATE")
@DataChangeLog(menu = "RECOGNIZED_PERF", table = "product_conversion_rate")
public ApiResponse<Void> updateRate(@PathVariable Long rateId,
@Valid @RequestBody ProductConversionRateSaveReq req) {
service.updateRate(rateId, req);
return ApiResponse.ok();
}
/** 상품 환산율 삭제. 권한: RECOGNIZED_PERF DELETE. */
@DeleteMapping("/rates/{rateId}")
@RequirePermission(menu = "RECOGNIZED_PERF", perm = "DELETE")
@DataChangeLog(menu = "RECOGNIZED_PERF", table = "product_conversion_rate")
public ApiResponse<Void> deleteRate(@PathVariable Long rateId) {
service.deleteRate(rateId);
return ApiResponse.ok();
}
// ── Ledger (인정실적 원장) ──────────────────────────────────────────────
/** 인정실적 원장 목록 조회. 권한: RECOGNIZED_PERF READ. */
@GetMapping("/ledgers")
@RequirePermission(menu = "RECOGNIZED_PERF", perm = "READ")
public ApiResponse<PageResponse<RecognizedPerformanceLedgerResp>> listLedgers(
@ModelAttribute RecognizedPerformanceLedgerSearchParam param) {
return ApiResponse.ok(service.listLedgers(param));
}
/** 인정실적 원장 단건 상세. 권한: RECOGNIZED_PERF READ. */
@GetMapping("/ledgers/{ledgerId}")
@RequirePermission(menu = "RECOGNIZED_PERF", perm = "READ")
public ApiResponse<RecognizedPerformanceLedgerResp> detailLedger(@PathVariable Long ledgerId) {
return ApiResponse.ok(service.detailLedger(ledgerId));
}
/** 인정실적 원장 신규 등록(환산율 적용해 인정실적 산출). 응답: ledgerId. 권한: RECOGNIZED_PERF CREATE. */
@PostMapping("/ledgers")
@RequirePermission(menu = "RECOGNIZED_PERF", perm = "CREATE")
@DataChangeLog(menu = "RECOGNIZED_PERF", table = "recognized_performance_ledger")
public ApiResponse<Long> createLedger(@Valid @RequestBody RecognizedPerformanceLedgerSaveReq req) {
return ApiResponse.ok(service.createLedger(req));
}
}
@@ -0,0 +1,99 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.RecruiterCommissionService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.commission.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 도입수수료 API.
*
* 도입수수료(recruiter commission): 새 설계사를 회사에 데려온(도입/리크루팅) 사람에게 지급하는 수수료.
* 도입한 설계사가 실적을 내면, 그 실적에 연동해 도입자에게도 일정 수수료가 발생한다.
* - Rule(도입수수료 규칙) CRUD - Ledger(도입수수료 원장) 조회/등록.
*
* 권한: @RequirePermission(menu="RECRUITER_COMM"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 기록.
*/
@Tag(name = "도입수수료")
@RestController
@RequestMapping("/api/recruiter-commissions")
@RequiredArgsConstructor
public class RecruiterCommissionController {
private final RecruiterCommissionService service;
// ── Rule (도입수수료 규칙) ──────────────────────────────────────────────
/** 도입수수료 규칙 목록 조회. 권한: RECRUITER_COMM READ. */
@GetMapping("/rules")
@RequirePermission(menu = "RECRUITER_COMM", perm = "READ")
public ApiResponse<PageResponse<RecruiterCommissionRuleResp>> listRules(
@ModelAttribute RecruiterCommissionRuleSearchParam param) {
return ApiResponse.ok(service.listRules(param));
}
/** 도입수수료 규칙 단건 상세. 권한: RECRUITER_COMM READ. */
@GetMapping("/rules/{ruleId}")
@RequirePermission(menu = "RECRUITER_COMM", perm = "READ")
public ApiResponse<RecruiterCommissionRuleResp> detailRule(@PathVariable Long ruleId) {
return ApiResponse.ok(service.detailRule(ruleId));
}
/** 도입수수료 규칙 신규 등록. 응답: ruleId. 권한: RECRUITER_COMM CREATE. */
@PostMapping("/rules")
@RequirePermission(menu = "RECRUITER_COMM", perm = "CREATE")
@DataChangeLog(menu = "RECRUITER_COMM", table = "recruiter_commission_rule")
public ApiResponse<Long> createRule(@Valid @RequestBody RecruiterCommissionRuleSaveReq req) {
return ApiResponse.ok(service.createRule(req));
}
/** 도입수수료 규칙 수정. 권한: RECRUITER_COMM UPDATE. */
@PutMapping("/rules/{ruleId}")
@RequirePermission(menu = "RECRUITER_COMM", perm = "UPDATE")
@DataChangeLog(menu = "RECRUITER_COMM", table = "recruiter_commission_rule")
public ApiResponse<Void> updateRule(@PathVariable Long ruleId,
@Valid @RequestBody RecruiterCommissionRuleSaveReq req) {
service.updateRule(ruleId, req);
return ApiResponse.ok();
}
/** 도입수수료 규칙 삭제. 권한: RECRUITER_COMM DELETE. */
@DeleteMapping("/rules/{ruleId}")
@RequirePermission(menu = "RECRUITER_COMM", perm = "DELETE")
@DataChangeLog(menu = "RECRUITER_COMM", table = "recruiter_commission_rule")
public ApiResponse<Void> deleteRule(@PathVariable Long ruleId) {
service.deleteRule(ruleId);
return ApiResponse.ok();
}
// ── Ledger (도입수수료 원장) ────────────────────────────────────────────
/** 도입수수료 원장 목록 조회. 권한: RECRUITER_COMM READ. */
@GetMapping("/ledgers")
@RequirePermission(menu = "RECRUITER_COMM", perm = "READ")
public ApiResponse<PageResponse<RecruiterCommissionLedgerResp>> listLedgers(
@ModelAttribute RecruiterCommissionLedgerSearchParam param) {
return ApiResponse.ok(service.listLedgers(param));
}
/** 도입수수료 원장 단건 상세. 권한: RECRUITER_COMM READ. */
@GetMapping("/ledgers/{ledgerId}")
@RequirePermission(menu = "RECRUITER_COMM", perm = "READ")
public ApiResponse<RecruiterCommissionLedgerResp> detailLedger(@PathVariable Long ledgerId) {
return ApiResponse.ok(service.detailLedger(ledgerId));
}
/** 도입수수료 원장 신규 등록. 응답: ledgerId. 권한: RECRUITER_COMM CREATE. */
@PostMapping("/ledgers")
@RequirePermission(menu = "RECRUITER_COMM", perm = "CREATE")
@DataChangeLog(menu = "RECRUITER_COMM", table = "recruiter_commission_ledger")
public ApiResponse<Long> createLedger(@Valid @RequestBody RecruiterCommissionLedgerSaveReq req) {
return ApiResponse.ok(service.createLedger(req));
}
}
@@ -0,0 +1,99 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.ReferralCommissionService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.commission.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 소개·이관수수료 API.
*
* 소개수수료(referral commission): 고객이나 계약을 다른 설계사에게 "소개·이관"해 준 사람에게 지급하는 수수료.
* 소개 받은 설계사가 계약을 성사시키면, 소개해 준 사람에게 규칙(Rule)에 따라 정액(FIXED) 또는 정률(RATE)로 지급한다.
* - Rule(소개수수료 규칙) CRUD - Ledger(소개수수료 원장) 조회/등록.
*
* 권한: @RequirePermission(menu="REFERRAL_COMM"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 기록.
*/
@Tag(name = "소개·이관수수료")
@RestController
@RequestMapping("/api/referral-commissions")
@RequiredArgsConstructor
public class ReferralCommissionController {
private final ReferralCommissionService service;
// ── Rule (소개수수료 규칙) ──────────────────────────────────────────────
/** 소개수수료 규칙 목록 조회. 권한: REFERRAL_COMM READ. */
@GetMapping("/rules")
@RequirePermission(menu = "REFERRAL_COMM", perm = "READ")
public ApiResponse<PageResponse<ReferralCommissionRuleResp>> listRules(
@ModelAttribute ReferralCommissionRuleSearchParam param) {
return ApiResponse.ok(service.listRules(param));
}
/** 소개수수료 규칙 단건 상세. 권한: REFERRAL_COMM READ. */
@GetMapping("/rules/{ruleId}")
@RequirePermission(menu = "REFERRAL_COMM", perm = "READ")
public ApiResponse<ReferralCommissionRuleResp> detailRule(@PathVariable Long ruleId) {
return ApiResponse.ok(service.detailRule(ruleId));
}
/** 소개수수료 규칙 신규 등록. 응답: ruleId. 권한: REFERRAL_COMM CREATE. */
@PostMapping("/rules")
@RequirePermission(menu = "REFERRAL_COMM", perm = "CREATE")
@DataChangeLog(menu = "REFERRAL_COMM", table = "referral_commission_rule")
public ApiResponse<Long> createRule(@Valid @RequestBody ReferralCommissionRuleSaveReq req) {
return ApiResponse.ok(service.createRule(req));
}
/** 소개수수료 규칙 수정. 권한: REFERRAL_COMM UPDATE. */
@PutMapping("/rules/{ruleId}")
@RequirePermission(menu = "REFERRAL_COMM", perm = "UPDATE")
@DataChangeLog(menu = "REFERRAL_COMM", table = "referral_commission_rule")
public ApiResponse<Void> updateRule(@PathVariable Long ruleId,
@Valid @RequestBody ReferralCommissionRuleSaveReq req) {
service.updateRule(ruleId, req);
return ApiResponse.ok();
}
/** 소개수수료 규칙 삭제. 권한: REFERRAL_COMM DELETE. */
@DeleteMapping("/rules/{ruleId}")
@RequirePermission(menu = "REFERRAL_COMM", perm = "DELETE")
@DataChangeLog(menu = "REFERRAL_COMM", table = "referral_commission_rule")
public ApiResponse<Void> deleteRule(@PathVariable Long ruleId) {
service.deleteRule(ruleId);
return ApiResponse.ok();
}
// ── Ledger (소개수수료 원장) ────────────────────────────────────────────
/** 소개수수료 원장 목록 조회. 권한: REFERRAL_COMM READ. */
@GetMapping("/ledgers")
@RequirePermission(menu = "REFERRAL_COMM", perm = "READ")
public ApiResponse<PageResponse<ReferralCommissionLedgerResp>> listLedgers(
@ModelAttribute ReferralCommissionLedgerSearchParam param) {
return ApiResponse.ok(service.listLedgers(param));
}
/** 소개수수료 원장 단건 상세. 권한: REFERRAL_COMM READ. */
@GetMapping("/ledgers/{ledgerId}")
@RequirePermission(menu = "REFERRAL_COMM", perm = "READ")
public ApiResponse<ReferralCommissionLedgerResp> detailLedger(@PathVariable Long ledgerId) {
return ApiResponse.ok(service.detailLedger(ledgerId));
}
/** 소개수수료 원장 신규 등록. 응답: ledgerId. 권한: REFERRAL_COMM CREATE. */
@PostMapping("/ledgers")
@RequirePermission(menu = "REFERRAL_COMM", perm = "CREATE")
@DataChangeLog(menu = "REFERRAL_COMM", table = "referral_commission_ledger")
public ApiResponse<Long> createLedger(@Valid @RequestBody ReferralCommissionLedgerSaveReq req) {
return ApiResponse.ok(service.createLedger(req));
}
}
@@ -0,0 +1,95 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.ReinstatementCommissionService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.commission.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 부활수수료 API.
*
* 부활(reinstatement): 실효(보험료 미납 등으로 효력 정지)됐던 계약이 밀린 보험료 납입 등으로 다시 살아나는 것.
* 부활수수료: 계약을 부활시킨 설계사에게 규칙(Rule)에 따라 지급하는 수수료.
* - Rule(부활수수료 규칙) CRUD - Ledger(부활수수료 원장) 조회/등록.
*
* 권한: @RequirePermission(menu="REINSTATE_COMM"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 기록.
*/
@Tag(name = "부활수수료")
@RestController
@RequestMapping("/api/reinstatement-commissions")
@RequiredArgsConstructor
public class ReinstatementCommissionController {
private final ReinstatementCommissionService service;
/** 부활수수료 규칙 목록 조회. 권한: REINSTATE_COMM READ. */
@GetMapping("/rules")
@RequirePermission(menu = "REINSTATE_COMM", perm = "READ")
public ApiResponse<PageResponse<ReinstatementCommissionRuleResp>> listRules(
@ModelAttribute ReinstatementCommissionRuleSearchParam param) {
return ApiResponse.ok(service.listRules(param));
}
/** 부활수수료 규칙 단건 상세. 권한: REINSTATE_COMM READ. */
@GetMapping("/rules/{ruleId}")
@RequirePermission(menu = "REINSTATE_COMM", perm = "READ")
public ApiResponse<ReinstatementCommissionRuleResp> detailRule(@PathVariable Long ruleId) {
return ApiResponse.ok(service.detailRule(ruleId));
}
/** 부활수수료 규칙 신규 등록. 응답: ruleId. 권한: REINSTATE_COMM CREATE. */
@PostMapping("/rules")
@RequirePermission(menu = "REINSTATE_COMM", perm = "CREATE")
@DataChangeLog(menu = "REINSTATE_COMM", table = "reinstatement_commission_rule")
public ApiResponse<Long> createRule(@Valid @RequestBody ReinstatementCommissionRuleSaveReq req) {
return ApiResponse.ok(service.createRule(req));
}
/** 부활수수료 규칙 수정. 권한: REINSTATE_COMM UPDATE. */
@PutMapping("/rules/{ruleId}")
@RequirePermission(menu = "REINSTATE_COMM", perm = "UPDATE")
@DataChangeLog(menu = "REINSTATE_COMM", table = "reinstatement_commission_rule")
public ApiResponse<Void> updateRule(@PathVariable Long ruleId,
@Valid @RequestBody ReinstatementCommissionRuleSaveReq req) {
service.updateRule(ruleId, req);
return ApiResponse.ok();
}
/** 부활수수료 규칙 삭제. 권한: REINSTATE_COMM DELETE. */
@DeleteMapping("/rules/{ruleId}")
@RequirePermission(menu = "REINSTATE_COMM", perm = "DELETE")
@DataChangeLog(menu = "REINSTATE_COMM", table = "reinstatement_commission_rule")
public ApiResponse<Void> deleteRule(@PathVariable Long ruleId) {
service.deleteRule(ruleId);
return ApiResponse.ok();
}
/** 부활수수료 원장 목록 조회. 권한: REINSTATE_COMM READ. */
@GetMapping("/ledgers")
@RequirePermission(menu = "REINSTATE_COMM", perm = "READ")
public ApiResponse<PageResponse<ReinstatementCommissionLedgerResp>> listLedgers(
@ModelAttribute ReinstatementCommissionLedgerSearchParam param) {
return ApiResponse.ok(service.listLedgers(param));
}
/** 부활수수료 원장 단건 상세. 권한: REINSTATE_COMM READ. */
@GetMapping("/ledgers/{ledgerId}")
@RequirePermission(menu = "REINSTATE_COMM", perm = "READ")
public ApiResponse<ReinstatementCommissionLedgerResp> detailLedger(@PathVariable Long ledgerId) {
return ApiResponse.ok(service.detailLedger(ledgerId));
}
/** 부활수수료 원장 신규 등록. 응답: ledgerId. 권한: REINSTATE_COMM CREATE. */
@PostMapping("/ledgers")
@RequirePermission(menu = "REINSTATE_COMM", perm = "CREATE")
@DataChangeLog(menu = "REINSTATE_COMM", table = "reinstatement_commission_ledger")
public ApiResponse<Long> createLedger(@Valid @RequestBody ReinstatementCommissionLedgerSaveReq req) {
return ApiResponse.ok(service.createLedger(req));
}
}
@@ -0,0 +1,95 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.RenewalCommissionService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.commission.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 갱신수수료 API.
*
* 갱신(renewal): 보험 기간이 끝난 계약을 같은 조건으로 다시 이어가는 것(예: 1년 갱신형 보험).
* 갱신수수료: 계약이 갱신될 때 그 유지·관리 대가로 설계사에게 지급하는 수수료.
* - Rule(갱신수수료 규칙) CRUD - Ledger(갱신수수료 원장) 조회/등록.
*
* 권한: @RequirePermission(menu="RENEWAL_COMM"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 기록.
*/
@Tag(name = "갱신수수료")
@RestController
@RequestMapping("/api/renewal-commissions")
@RequiredArgsConstructor
public class RenewalCommissionController {
private final RenewalCommissionService service;
/** 갱신수수료 규칙 목록 조회. 권한: RENEWAL_COMM READ. */
@GetMapping("/rules")
@RequirePermission(menu = "RENEWAL_COMM", perm = "READ")
public ApiResponse<PageResponse<RenewalCommissionRuleResp>> listRules(
@ModelAttribute RenewalCommissionRuleSearchParam param) {
return ApiResponse.ok(service.listRules(param));
}
/** 갱신수수료 규칙 단건 상세. 권한: RENEWAL_COMM READ. */
@GetMapping("/rules/{ruleId}")
@RequirePermission(menu = "RENEWAL_COMM", perm = "READ")
public ApiResponse<RenewalCommissionRuleResp> detailRule(@PathVariable Long ruleId) {
return ApiResponse.ok(service.detailRule(ruleId));
}
/** 갱신수수료 규칙 신규 등록. 응답: ruleId. 권한: RENEWAL_COMM CREATE. */
@PostMapping("/rules")
@RequirePermission(menu = "RENEWAL_COMM", perm = "CREATE")
@DataChangeLog(menu = "RENEWAL_COMM", table = "renewal_commission_rule")
public ApiResponse<Long> createRule(@Valid @RequestBody RenewalCommissionRuleSaveReq req) {
return ApiResponse.ok(service.createRule(req));
}
/** 갱신수수료 규칙 수정. 권한: RENEWAL_COMM UPDATE. */
@PutMapping("/rules/{ruleId}")
@RequirePermission(menu = "RENEWAL_COMM", perm = "UPDATE")
@DataChangeLog(menu = "RENEWAL_COMM", table = "renewal_commission_rule")
public ApiResponse<Void> updateRule(@PathVariable Long ruleId,
@Valid @RequestBody RenewalCommissionRuleSaveReq req) {
service.updateRule(ruleId, req);
return ApiResponse.ok();
}
/** 갱신수수료 규칙 삭제. 권한: RENEWAL_COMM DELETE. */
@DeleteMapping("/rules/{ruleId}")
@RequirePermission(menu = "RENEWAL_COMM", perm = "DELETE")
@DataChangeLog(menu = "RENEWAL_COMM", table = "renewal_commission_rule")
public ApiResponse<Void> deleteRule(@PathVariable Long ruleId) {
service.deleteRule(ruleId);
return ApiResponse.ok();
}
/** 갱신수수료 원장 목록 조회. 권한: RENEWAL_COMM READ. */
@GetMapping("/ledgers")
@RequirePermission(menu = "RENEWAL_COMM", perm = "READ")
public ApiResponse<PageResponse<RenewalCommissionLedgerResp>> listLedgers(
@ModelAttribute RenewalCommissionLedgerSearchParam param) {
return ApiResponse.ok(service.listLedgers(param));
}
/** 갱신수수료 원장 단건 상세. 권한: RENEWAL_COMM READ. */
@GetMapping("/ledgers/{ledgerId}")
@RequirePermission(menu = "RENEWAL_COMM", perm = "READ")
public ApiResponse<RenewalCommissionLedgerResp> detailLedger(@PathVariable Long ledgerId) {
return ApiResponse.ok(service.detailLedger(ledgerId));
}
/** 갱신수수료 원장 신규 등록. 응답: ledgerId. 권한: RENEWAL_COMM CREATE. */
@PostMapping("/ledgers")
@RequirePermission(menu = "RENEWAL_COMM", perm = "CREATE")
@DataChangeLog(menu = "RENEWAL_COMM", table = "renewal_commission_ledger")
public ApiResponse<Long> createLedger(@Valid @RequestBody RenewalCommissionLedgerSaveReq req) {
return ApiResponse.ok(service.createLedger(req));
}
}
@@ -0,0 +1,53 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.ReserveFundService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.commission.AgentReserveFundResp;
import com.ga.core.vo.commission.AgentReserveFundSaveReq;
import com.ga.core.vo.commission.AgentReserveFundSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 적립금 API.
*
* 적립금(reserve fund): 향후 발생할 수 있는 환수 등에 대비해, 설계사 수수료 중 일부를 떼어 모아 두는 돈.
* 환수가 생기면 이 적립금에서 우선 충당할 수 있다. 설계사별 적립 내역을 관리한다.
*
* 권한: @RequirePermission(menu="RESERVE_FUND"), perm=READ/CREATE. 변경은 @DataChangeLog 기록.
*/
@Tag(name = "적립금")
@RestController
@RequestMapping("/api/reserve-funds")
@RequiredArgsConstructor
public class ReserveFundController {
private final ReserveFundService service;
/** 적립금 내역 목록 조회. 권한: RESERVE_FUND READ. */
@GetMapping
@RequirePermission(menu = "RESERVE_FUND", perm = "READ")
public ApiResponse<PageResponse<AgentReserveFundResp>> list(@ModelAttribute AgentReserveFundSearchParam param) {
return ApiResponse.ok(service.list(param));
}
/** 적립금 단건 상세. 권한: RESERVE_FUND READ. */
@GetMapping("/{reserveId}")
@RequirePermission(menu = "RESERVE_FUND", perm = "READ")
public ApiResponse<AgentReserveFundResp> detail(@PathVariable Long reserveId) {
return ApiResponse.ok(service.detail(reserveId));
}
/** 적립금 내역 신규 등록(적립/사용 기록). 응답: reserveId. 권한: RESERVE_FUND CREATE. */
@PostMapping
@RequirePermission(menu = "RESERVE_FUND", perm = "CREATE")
@DataChangeLog(menu = "RESERVE_FUND", table = "agent_reserve_fund")
public ApiResponse<Long> create(@Valid @RequestBody AgentReserveFundSaveReq req) {
return ApiResponse.ok(service.create(req));
}
}
@@ -0,0 +1,99 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.SettlingSupportService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.commission.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 정착지원금 API.
*
* 정착지원금(settling support): 새로 들어온 설계사가 안정적으로 자리 잡을 때까지 일정 기간 지급하는 지원금.
* 보통 실적이 충분치 않은 초기 기간 동안 최소 수입을 보전해 준다(조건 미달 시 환수될 수도 있음).
* - Rule(지원금 규칙) CRUD - Ledger(지원금 원장) 조회/등록.
*
* 권한: @RequirePermission(menu="SETTLING_SUPPORT"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 기록.
*/
@Tag(name = "정착지원금")
@RestController
@RequestMapping("/api/settling-supports")
@RequiredArgsConstructor
public class SettlingSupportController {
private final SettlingSupportService service;
// ── Rule (정착지원금 규칙) ──────────────────────────────────────────────
/** 정착지원금 규칙 목록 조회. 권한: SETTLING_SUPPORT READ. */
@GetMapping("/rules")
@RequirePermission(menu = "SETTLING_SUPPORT", perm = "READ")
public ApiResponse<PageResponse<SettlingSupportRuleResp>> listRules(
@ModelAttribute SettlingSupportRuleSearchParam param) {
return ApiResponse.ok(service.listRules(param));
}
/** 정착지원금 규칙 단건 상세. 권한: SETTLING_SUPPORT READ. */
@GetMapping("/rules/{ruleId}")
@RequirePermission(menu = "SETTLING_SUPPORT", perm = "READ")
public ApiResponse<SettlingSupportRuleResp> detailRule(@PathVariable Long ruleId) {
return ApiResponse.ok(service.detailRule(ruleId));
}
/** 정착지원금 규칙 신규 등록. 응답: ruleId. 권한: SETTLING_SUPPORT CREATE. */
@PostMapping("/rules")
@RequirePermission(menu = "SETTLING_SUPPORT", perm = "CREATE")
@DataChangeLog(menu = "SETTLING_SUPPORT", table = "settling_support_rule")
public ApiResponse<Long> createRule(@Valid @RequestBody SettlingSupportRuleSaveReq req) {
return ApiResponse.ok(service.createRule(req));
}
/** 정착지원금 규칙 수정. 권한: SETTLING_SUPPORT UPDATE. */
@PutMapping("/rules/{ruleId}")
@RequirePermission(menu = "SETTLING_SUPPORT", perm = "UPDATE")
@DataChangeLog(menu = "SETTLING_SUPPORT", table = "settling_support_rule")
public ApiResponse<Void> updateRule(@PathVariable Long ruleId,
@Valid @RequestBody SettlingSupportRuleSaveReq req) {
service.updateRule(ruleId, req);
return ApiResponse.ok();
}
/** 정착지원금 규칙 삭제. 권한: SETTLING_SUPPORT DELETE. */
@DeleteMapping("/rules/{ruleId}")
@RequirePermission(menu = "SETTLING_SUPPORT", perm = "DELETE")
@DataChangeLog(menu = "SETTLING_SUPPORT", table = "settling_support_rule")
public ApiResponse<Void> deleteRule(@PathVariable Long ruleId) {
service.deleteRule(ruleId);
return ApiResponse.ok();
}
// ── Ledger (정착지원금 원장) ────────────────────────────────────────────
/** 정착지원금 원장 목록 조회. 권한: SETTLING_SUPPORT READ. */
@GetMapping("/ledgers")
@RequirePermission(menu = "SETTLING_SUPPORT", perm = "READ")
public ApiResponse<PageResponse<SettlingSupportLedgerResp>> listLedgers(
@ModelAttribute SettlingSupportLedgerSearchParam param) {
return ApiResponse.ok(service.listLedgers(param));
}
/** 정착지원금 원장 단건 상세. 권한: SETTLING_SUPPORT READ. */
@GetMapping("/ledgers/{ledgerId}")
@RequirePermission(menu = "SETTLING_SUPPORT", perm = "READ")
public ApiResponse<SettlingSupportLedgerResp> detailLedger(@PathVariable Long ledgerId) {
return ApiResponse.ok(service.detailLedger(ledgerId));
}
/** 정착지원금 원장 신규 등록. 응답: ledgerId. 권한: SETTLING_SUPPORT CREATE. */
@PostMapping("/ledgers")
@RequirePermission(menu = "SETTLING_SUPPORT", perm = "CREATE")
@DataChangeLog(menu = "SETTLING_SUPPORT", table = "settling_support_ledger")
public ApiResponse<Long> createLedger(@Valid @RequestBody SettlingSupportLedgerSaveReq req) {
return ApiResponse.ok(service.createLedger(req));
}
}
@@ -0,0 +1,83 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.StatuteService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.core.vo.commission.StatuteConfigResp;
import com.ga.core.vo.commission.StatuteConfigSaveReq;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 환수시효관리 API.
*
* 시효(statute of limitations): 환수채권을 받아낼 수 있는 법적 기한. 기한이 지나면(시효 만료) 더 이상 회수할 수 없어
* 손실(대손)로 처리해야 한다. 이 도메인은 시효 기준 설정(config)과 만료 건 일괄 정리를 담당한다.
*
* 권한: @RequirePermission(menu="STATUTE_CONFIG"), perm=READ/CREATE/UPDATE. 변경은 @DataChangeLog 기록.
*/
@Tag(name = "환수시효관리")
@RestController
@RequestMapping("/api/statute")
@RequiredArgsConstructor
public class StatuteController {
private final StatuteService service;
/** 시효 설정 전체 목록 조회. 권한: STATUTE_CONFIG READ. */
@GetMapping
@RequirePermission(menu = "STATUTE_CONFIG", perm = "READ")
public ApiResponse<List<StatuteConfigResp>> list() {
return ApiResponse.ok(service.listConfigs());
}
/** 시효 설정 단건 상세. 권한: STATUTE_CONFIG READ. */
@GetMapping("/{configId}")
@RequirePermission(menu = "STATUTE_CONFIG", perm = "READ")
public ApiResponse<StatuteConfigResp> detail(@PathVariable Long configId) {
return ApiResponse.ok(service.detailConfig(configId));
}
/** 시효 설정 신규 등록. 응답: configId. 권한: STATUTE_CONFIG CREATE. */
@PostMapping
@RequirePermission(menu = "STATUTE_CONFIG", perm = "CREATE")
@DataChangeLog(menu = "STATUTE_CONFIG", table = "statute_config")
public ApiResponse<Long> create(@Valid @RequestBody StatuteConfigSaveReq req) {
return ApiResponse.ok(service.createConfig(req));
}
/** 시효 설정 수정. 권한: STATUTE_CONFIG UPDATE. */
@PutMapping("/{configId}")
@RequirePermission(menu = "STATUTE_CONFIG", perm = "UPDATE")
@DataChangeLog(menu = "STATUTE_CONFIG", table = "statute_config")
public ApiResponse<Void> update(@PathVariable Long configId,
@Valid @RequestBody StatuteConfigSaveReq req) {
service.updateConfig(configId, req);
return ApiResponse.ok();
}
/**
* 만료 대상 환수채권 일괄 대손 처리. 기준월(baseMonth)까지 시효가 지난 환수채권을 한꺼번에 손실 처리하고
* 처리 건수를 반환한다. baseMonth 미지정 시 현재 정산월을 사용한다.
* Request body: { "baseMonth": "YYYYMM" }
* 권한: STATUTE_CONFIG UPDATE.
*/
@PostMapping("/expire")
@RequirePermission(menu = "STATUTE_CONFIG", perm = "UPDATE")
@DataChangeLog(menu = "STATUTE_CONFIG", table = "clawback_receivable")
public ApiResponse<Integer> expire(@RequestBody Map<String, String> body) {
// baseMonth 가 비어 있으면 현재 정산월로 대체
String baseMonth = body.get("baseMonth");
if (baseMonth == null || baseMonth.isBlank()) {
baseMonth = com.ga.common.util.DateUtil.getSettleMonth();
}
return ApiResponse.ok(service.expireOverdue(baseMonth));
}
}
@@ -0,0 +1,81 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.StepCloseService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.commission.StepCloseResp;
import com.ga.core.vo.commission.StepCloseSaveReq;
import com.ga.core.vo.commission.StepCloseSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 단계마감 API.
*
* 단계마감(step close): 월 정산을 여러 단계로 나눠, 각 단계가 끝나면 "마감"해서 더 이상 수정 못 하도록 잠그는 것.
* 마감(close) 후 수정이 필요하면 다시 reopen(마감 해제)할 수 있다. 마감/해제는 승인(APPROVE) 권한이 필요하다.
* - close: 단계 마감(잠금) - reopen: 마감 해제(다시 열기)
*
* 권한: @RequirePermission(menu="STEP_CLOSE"), perm=READ/UPDATE/APPROVE. 변경은 @DataChangeLog 기록.
*/
@Tag(name = "단계마감")
@RestController
@RequestMapping("/api/step-closes")
@RequiredArgsConstructor
public class StepCloseController {
private final StepCloseService service;
/** 단계마감 목록 조회. 권한: STEP_CLOSE READ. */
@GetMapping
@RequirePermission(menu = "STEP_CLOSE", perm = "READ")
public ApiResponse<PageResponse<StepCloseResp>> list(@ModelAttribute StepCloseSearchParam param) {
return ApiResponse.ok(service.list(param));
}
/** 특정 정산월(settleMonth)의 단계마감 현황 조회. 권한: STEP_CLOSE READ. */
@GetMapping("/by-month")
@RequirePermission(menu = "STEP_CLOSE", perm = "READ")
public ApiResponse<List<StepCloseResp>> listByMonth(@RequestParam String settleMonth) {
return ApiResponse.ok(service.listByMonth(settleMonth));
}
/** 단계마감 단건 상세. 권한: STEP_CLOSE READ. */
@GetMapping("/{stepCloseId}")
@RequirePermission(menu = "STEP_CLOSE", perm = "READ")
public ApiResponse<StepCloseResp> detail(@PathVariable Long stepCloseId) {
return ApiResponse.ok(service.detail(stepCloseId));
}
/** 단계마감 항목 신규 생성(아직 미마감 상태). 응답: stepCloseId. 권한: STEP_CLOSE UPDATE. */
@PostMapping
@RequirePermission(menu = "STEP_CLOSE", perm = "UPDATE")
@DataChangeLog(menu = "STEP_CLOSE", table = "settlement_step_close")
public ApiResponse<Long> create(@Valid @RequestBody StepCloseSaveReq req) {
return ApiResponse.ok(service.create(req));
}
/** 단계 마감 확정(해당 단계 잠금). 승인 권한 필요. 권한: STEP_CLOSE APPROVE. */
@PostMapping("/{stepCloseId}/close")
@RequirePermission(menu = "STEP_CLOSE", perm = "APPROVE")
@DataChangeLog(menu = "STEP_CLOSE", table = "settlement_step_close")
public ApiResponse<Void> close(@PathVariable Long stepCloseId) {
service.close(stepCloseId);
return ApiResponse.ok();
}
/** 단계 마감 해제(다시 수정 가능 상태로). 승인 권한 필요. 권한: STEP_CLOSE APPROVE. */
@PostMapping("/{stepCloseId}/reopen")
@RequirePermission(menu = "STEP_CLOSE", perm = "APPROVE")
@DataChangeLog(menu = "STEP_CLOSE", table = "settlement_step_close")
public ApiResponse<Void> reopen(@PathVariable Long stepCloseId) {
service.reopen(stepCloseId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,85 @@
package com.ga.api.controller.complaint;
import com.ga.api.service.complaint.ComplaintService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.complaint.ComplaintResp;
import com.ga.core.vo.complaint.ComplaintSaveReq;
import com.ga.core.vo.complaint.ComplaintSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 민원 관리 API.
*
* <p>고객 민원을 접수·처리하는 도메인이다. 민원은 특정 보험계약(contractId)·담당 설계사(agentId)와 연결되며,
* 상태는 OPEN(접수) → CLOSED(처리완료)로 전이된다. 환수 유발(chargebackTriggered=true) 민원은
* 처리 후 정산 배치(P5)가 실제 수수료 환수(chargeback)를 생성한다(여기서는 플래그만 관리).</p>
*
* <p>메뉴 COMPLAINT 권한으로 보호된다.</p>
*/
@Tag(name = "민원 관리")
@RestController
@RequestMapping("/api/complaints")
@RequiredArgsConstructor
public class ComplaintController {
private final ComplaintService service;
/** 민원 목록 조회(페이징). READ 권한 필요. */
@GetMapping
@RequirePermission(menu = "COMPLAINT", perm = "READ")
public ApiResponse<PageResponse<ComplaintResp>> list(@ModelAttribute ComplaintSearchParam param) {
return ApiResponse.ok(service.list(param));
}
/** 민원 단건 상세 조회. READ 권한 필요. */
@GetMapping("/{complaintId}")
@RequirePermission(menu = "COMPLAINT", perm = "READ")
public ApiResponse<ComplaintResp> detail(@PathVariable Long complaintId) {
return ApiResponse.ok(service.detail(complaintId));
}
/** 민원 신규 접수. 등록 시 OPEN 상태로 시작한다. CREATE 권한 필요. */
@PostMapping
@RequirePermission(menu = "COMPLAINT", perm = "CREATE")
@DataChangeLog(menu = "COMPLAINT", table = "complaint")
public ApiResponse<Long> create(@Valid @RequestBody ComplaintSaveReq req) {
return ApiResponse.ok(service.create(req));
}
/** 민원 내용 수정. 상태는 변경하지 않는다(처리완료는 별도 엔드포인트). UPDATE 권한 필요. */
@PutMapping("/{complaintId}")
@RequirePermission(menu = "COMPLAINT", perm = "UPDATE")
@DataChangeLog(menu = "COMPLAINT", table = "complaint")
public ApiResponse<Void> update(@PathVariable Long complaintId,
@Valid @RequestBody ComplaintSaveReq req) {
service.update(complaintId, req);
return ApiResponse.ok();
}
/** 민원 처리완료(status=CLOSED). 처리결과(resolution)를 기록하며 이미 완료된 건은 거부된다. UPDATE 권한 필요. */
@PostMapping("/{complaintId}/close")
@RequirePermission(menu = "COMPLAINT", perm = "UPDATE")
@DataChangeLog(menu = "COMPLAINT", table = "complaint")
public ApiResponse<Void> close(@PathVariable Long complaintId,
@RequestBody Map<String, String> body) {
service.close(complaintId, body.get("resolution"));
return ApiResponse.ok();
}
/** 민원 레코드 삭제. DELETE 권한 필요. */
@DeleteMapping("/{complaintId}")
@RequirePermission(menu = "COMPLAINT", perm = "DELETE")
@DataChangeLog(menu = "COMPLAINT", table = "complaint")
public ApiResponse<Void> delete(@PathVariable Long complaintId) {
service.delete(complaintId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,86 @@
package com.ga.api.controller.contract;
import com.ga.api.service.contract.AgentContractService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.contract.AgentContractResp;
import com.ga.core.vo.contract.AgentContractSaveReq;
import com.ga.core.vo.contract.AgentContractSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 위촉계약 관리 API.
*
* <p>위촉계약(agent_contract)은 설계사와 보험사(carrier) 사이의 모집 위탁 계약이다.
* 어떤 설계사가 어떤 보험사의 상품을 팔 수 있는지를 정의하며, 유효기간(effectiveFrom~To)과
* 정착지원금(onboardingBonusAmount) 등을 포함한다. 상태는 ACTIVE(유효)에서 TERMINATED(해촉)로 전이된다.</p>
*
* <p>주의: 여기서 말하는 계약은 "설계사 위촉계약"이며, 고객이 가입한 "보험계약"과는 다르다.
* 메뉴 AGENT_CONTRACT 권한으로 보호된다.</p>
*/
@Tag(name = "위촉계약 관리")
@RestController
@RequestMapping("/api/agent-contracts")
@RequiredArgsConstructor
public class AgentContractController {
private final AgentContractService service;
/** 위촉계약 목록 조회(페이징). READ 권한 필요. */
@GetMapping
@RequirePermission(menu = "AGENT_CONTRACT", perm = "READ")
public ApiResponse<PageResponse<AgentContractResp>> list(@ModelAttribute AgentContractSearchParam param) {
return ApiResponse.ok(service.list(param));
}
/** 위촉계약 단건 상세 조회. READ 권한 필요. */
@GetMapping("/{contractId}")
@RequirePermission(menu = "AGENT_CONTRACT", perm = "READ")
public ApiResponse<AgentContractResp> detail(@PathVariable Long contractId) {
return ApiResponse.ok(service.detail(contractId));
}
/** 위촉계약 신규 등록. 등록 시 ACTIVE 상태로 시작한다. CREATE 권한 필요. */
@PostMapping
@RequirePermission(menu = "AGENT_CONTRACT", perm = "CREATE")
@DataChangeLog(menu = "AGENT_CONTRACT", table = "agent_contract")
public ApiResponse<Long> create(@Valid @RequestBody AgentContractSaveReq req) {
return ApiResponse.ok(service.create(req));
}
/** 위촉계약 수정. 상태는 변경하지 않는다(해촉은 별도 엔드포인트). UPDATE 권한 필요. */
@PutMapping("/{contractId}")
@RequirePermission(menu = "AGENT_CONTRACT", perm = "UPDATE")
@DataChangeLog(menu = "AGENT_CONTRACT", table = "agent_contract")
public ApiResponse<Void> update(@PathVariable Long contractId,
@Valid @RequestBody AgentContractSaveReq req) {
service.update(contractId, req);
return ApiResponse.ok();
}
/** 위촉계약 해촉(status=TERMINATED). 사유(reason)를 함께 기록하며 이미 해촉된 건은 거부된다. UPDATE 권한 필요. */
@PostMapping("/{contractId}/terminate")
@RequirePermission(menu = "AGENT_CONTRACT", perm = "UPDATE")
@DataChangeLog(menu = "AGENT_CONTRACT", table = "agent_contract")
public ApiResponse<Void> terminate(@PathVariable Long contractId,
@RequestBody Map<String, String> body) {
service.terminate(contractId, body.get("reason"));
return ApiResponse.ok();
}
/** 위촉계약 레코드 삭제. DELETE 권한 필요. */
@DeleteMapping("/{contractId}")
@RequirePermission(menu = "AGENT_CONTRACT", perm = "DELETE")
@DataChangeLog(menu = "AGENT_CONTRACT", table = "agent_contract")
public ApiResponse<Void> delete(@PathVariable Long contractId) {
service.delete(contractId);
return ApiResponse.ok();
}
}
@@ -13,6 +13,18 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
/**
* 공제/차감(agent_deduction) 관리 컨트롤러.
*
* <p>"차감"은 설계사에게 줄 수수료에서 미리 빼야 할 금액(예: 대출 상환, 가지급금 회수 등)을 등록해 두는 것입니다.
* 여기서 PENDING(대기) 상태로 등록해 두면, 지급 처리 단계에서 실지급액(netAmount)에서 자동으로 차감됩니다.
*
* <p>상태 흐름(DeductionStatus): PENDING(대기) → APPLIED(적용완료), 또는 CANCELLED(취소).
* 수정·취소는 아직 적용(APPLIED)되지 않은 PENDING 건만 가능합니다.
* 같은 설계사+정산월+차감유형 조합은 중복 등록이 거부됩니다.
*
* <p>권한(menu=DEDUCTION): 조회 READ, 등록 CREATE, 수정/취소 UPDATE.
*/
@Tag(name = "공제 관리") @Tag(name = "공제 관리")
@RestController @RestController
@RequestMapping("/api/deductions") @RequestMapping("/api/deductions")
@@ -21,18 +33,21 @@ public class DeductionController {
private final DeductionService deductionService; private final DeductionService deductionService;
/** 차감 목록 조회(검색 + 페이징). */
@GetMapping @GetMapping
@RequirePermission(menu = "DEDUCTION", perm = "READ") @RequirePermission(menu = "DEDUCTION", perm = "READ")
public ApiResponse<PageResponse<AgentDeductionResp>> list(@ModelAttribute AgentDeductionSearchParam param) { public ApiResponse<PageResponse<AgentDeductionResp>> list(@ModelAttribute AgentDeductionSearchParam param) {
return ApiResponse.ok(deductionService.list(param)); return ApiResponse.ok(deductionService.list(param));
} }
/** 차감 단건 상세 조회. */
@GetMapping("/{deductionId}") @GetMapping("/{deductionId}")
@RequirePermission(menu = "DEDUCTION", perm = "READ") @RequirePermission(menu = "DEDUCTION", perm = "READ")
public ApiResponse<AgentDeductionResp> detail(@PathVariable Long deductionId) { public ApiResponse<AgentDeductionResp> detail(@PathVariable Long deductionId) {
return ApiResponse.ok(deductionService.detail(deductionId)); return ApiResponse.ok(deductionService.detail(deductionId));
} }
/** 차감 등록(PENDING 상태). 동일 설계사+정산월+차감유형 중복은 거부됩니다. */
@PostMapping @PostMapping
@RequirePermission(menu = "DEDUCTION", perm = "CREATE") @RequirePermission(menu = "DEDUCTION", perm = "CREATE")
@DataChangeLog(menu = "DEDUCTION", table = "agent_deduction") @DataChangeLog(menu = "DEDUCTION", table = "agent_deduction")
@@ -40,6 +55,7 @@ public class DeductionController {
return ApiResponse.ok(deductionService.create(req)); return ApiResponse.ok(deductionService.create(req));
} }
/** 차감 금액·설명 수정. PENDING 상태에서만 가능합니다. */
@PutMapping("/{deductionId}") @PutMapping("/{deductionId}")
@RequirePermission(menu = "DEDUCTION", perm = "UPDATE") @RequirePermission(menu = "DEDUCTION", perm = "UPDATE")
@DataChangeLog(menu = "DEDUCTION", table = "agent_deduction") @DataChangeLog(menu = "DEDUCTION", table = "agent_deduction")
@@ -49,6 +65,7 @@ public class DeductionController {
return ApiResponse.ok(); return ApiResponse.ok();
} }
/** 차감 취소(→ CANCELLED). 이미 적용(APPLIED)된 차감은 취소할 수 없습니다. */
@PutMapping("/{deductionId}/cancel") @PutMapping("/{deductionId}/cancel")
@RequirePermission(menu = "DEDUCTION", perm = "UPDATE") @RequirePermission(menu = "DEDUCTION", perm = "UPDATE")
@DataChangeLog(menu = "DEDUCTION", table = "agent_deduction") @DataChangeLog(menu = "DEDUCTION", table = "agent_deduction")
@@ -16,6 +16,13 @@ import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
/**
* 데이터 사전(Data Dictionary) 관리 API.
*
* <p>DB 테이블/컬럼의 의미·도메인 코드 매핑 등을 문서화해 관리하는 메타데이터 도구다.
* 각 항목(dictId)은 특정 테이블의 컬럼을 설명하며, 도메인 코드(domainCode)와 연결될 수 있다.
* 메뉴 SYSTEM_DATA_DICT 권한으로 보호된다.</p>
*/
@Tag(name = "데이터 사전") @Tag(name = "데이터 사전")
@RestController @RestController
@RequestMapping("/api/data-dictionary") @RequestMapping("/api/data-dictionary")
@@ -24,18 +31,21 @@ public class DataDictionaryController {
private final DataDictionaryService dataDictionaryService; private final DataDictionaryService dataDictionaryService;
/** 데이터 사전 항목 목록 조회(페이징). READ 권한 필요. */
@GetMapping @GetMapping
@RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "READ") @RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "READ")
public ApiResponse<PageResponse<DataDictionaryResp>> list(@ModelAttribute DataDictionarySearchParam param) { public ApiResponse<PageResponse<DataDictionaryResp>> list(@ModelAttribute DataDictionarySearchParam param) {
return ApiResponse.ok(dataDictionaryService.list(param)); return ApiResponse.ok(dataDictionaryService.list(param));
} }
/** 사전에 등록된 테이블명 목록 조회. READ 권한 필요. */
@GetMapping("/tables") @GetMapping("/tables")
@RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "READ") @RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "READ")
public ApiResponse<List<String>> tables() { public ApiResponse<List<String>> tables() {
return ApiResponse.ok(dataDictionaryService.tables()); return ApiResponse.ok(dataDictionaryService.tables());
} }
/** 사전 뷰 기반 통합 조회(테이블명 선택 필터). 컬럼+도메인 결합 정보를 반환한다. READ 권한 필요. */
@GetMapping("/full") @GetMapping("/full")
@RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "READ") @RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "READ")
public ApiResponse<List<DataDictionaryFullResp>> full( public ApiResponse<List<DataDictionaryFullResp>> full(
@@ -43,12 +53,14 @@ public class DataDictionaryController {
return ApiResponse.ok(dataDictionaryService.full(tableName)); return ApiResponse.ok(dataDictionaryService.full(tableName));
} }
/** 데이터 사전 항목 단건 조회. READ 권한 필요. */
@GetMapping("/{dictId}") @GetMapping("/{dictId}")
@RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "READ") @RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "READ")
public ApiResponse<DataDictionaryResp> detail(@PathVariable Long dictId) { public ApiResponse<DataDictionaryResp> detail(@PathVariable Long dictId) {
return ApiResponse.ok(dataDictionaryService.detail(dictId)); return ApiResponse.ok(dataDictionaryService.detail(dictId));
} }
/** 데이터 사전 항목 등록. 도메인 코드를 지정하면 실존 여부를 검증한다. CREATE 권한 필요. */
@PostMapping @PostMapping
@RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "CREATE") @RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "CREATE")
@DataChangeLog(menu = "SYSTEM_DATA_DICT", table = "data_dictionary") @DataChangeLog(menu = "SYSTEM_DATA_DICT", table = "data_dictionary")
@@ -57,6 +69,7 @@ public class DataDictionaryController {
return ApiResponse.ok(); return ApiResponse.ok();
} }
/** 데이터 사전 항목 수정. UPDATE 권한 필요. */
@PutMapping("/{dictId}") @PutMapping("/{dictId}")
@RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "UPDATE") @RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "UPDATE")
@DataChangeLog(menu = "SYSTEM_DATA_DICT", table = "data_dictionary") @DataChangeLog(menu = "SYSTEM_DATA_DICT", table = "data_dictionary")
@@ -66,6 +79,7 @@ public class DataDictionaryController {
return ApiResponse.ok(); return ApiResponse.ok();
} }
/** 데이터 사전 항목 삭제. DELETE 권한 필요. */
@DeleteMapping("/{dictId}") @DeleteMapping("/{dictId}")
@RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "DELETE") @RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "DELETE")
@DataChangeLog(menu = "SYSTEM_DATA_DICT", table = "data_dictionary") @DataChangeLog(menu = "SYSTEM_DATA_DICT", table = "data_dictionary")
@@ -16,6 +16,13 @@ import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
/**
* 데이터 도메인 사전 관리 API.
*
* <p>도메인(domain)은 컬럼들이 공통으로 따르는 데이터 타입/형식 규칙의 정의다(예: 금액형, 날짜형 등).
* 데이터 사전(data_dictionary)의 컬럼들이 이 도메인 코드를 참조한다. 식별자는 문자열 도메인 코드(domainCode)다.
* 메뉴 SYSTEM_DATA_DOMAIN 권한으로 보호된다.</p>
*/
@Tag(name = "데이터 도메인 사전") @Tag(name = "데이터 도메인 사전")
@RestController @RestController
@RequestMapping("/api/data-domains") @RequestMapping("/api/data-domains")
@@ -24,24 +31,28 @@ public class DataDomainController {
private final DataDomainService dataDomainService; private final DataDomainService dataDomainService;
/** 도메인 목록 조회(페이징). READ 권한 필요. */
@GetMapping @GetMapping
@RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "READ") @RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "READ")
public ApiResponse<PageResponse<DataDomainResp>> list(@ModelAttribute DataDomainSearchParam param) { public ApiResponse<PageResponse<DataDomainResp>> list(@ModelAttribute DataDomainSearchParam param) {
return ApiResponse.ok(dataDomainService.list(param)); return ApiResponse.ok(dataDomainService.list(param));
} }
/** 선택 옵션용 도메인 전체 목록(코드/명칭 등 경량 정보). READ 권한 필요. */
@GetMapping("/options") @GetMapping("/options")
@RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "READ") @RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "READ")
public ApiResponse<List<DataDomainOptionResp>> options() { public ApiResponse<List<DataDomainOptionResp>> options() {
return ApiResponse.ok(dataDomainService.options()); return ApiResponse.ok(dataDomainService.options());
} }
/** 도메인 단건 상세 조회(도메인 코드 기준). READ 권한 필요. */
@GetMapping("/{domainCode}") @GetMapping("/{domainCode}")
@RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "READ") @RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "READ")
public ApiResponse<DataDomainResp> detail(@PathVariable String domainCode) { public ApiResponse<DataDomainResp> detail(@PathVariable String domainCode) {
return ApiResponse.ok(dataDomainService.detail(domainCode)); return ApiResponse.ok(dataDomainService.detail(domainCode));
} }
/** 도메인 등록. 코드 중복이면 거부된다. CREATE 권한 필요. */
@PostMapping @PostMapping
@RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "CREATE") @RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "CREATE")
@DataChangeLog(menu = "SYSTEM_DATA_DOMAIN", table = "data_domain") @DataChangeLog(menu = "SYSTEM_DATA_DOMAIN", table = "data_domain")
@@ -50,6 +61,7 @@ public class DataDomainController {
return ApiResponse.ok(); return ApiResponse.ok();
} }
/** 도메인 수정. UPDATE 권한 필요. */
@PutMapping("/{domainCode}") @PutMapping("/{domainCode}")
@RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "UPDATE") @RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "UPDATE")
@DataChangeLog(menu = "SYSTEM_DATA_DOMAIN", table = "data_domain") @DataChangeLog(menu = "SYSTEM_DATA_DOMAIN", table = "data_domain")
@@ -59,6 +71,7 @@ public class DataDomainController {
return ApiResponse.ok(); return ApiResponse.ok();
} }
/** 도메인 삭제. 이 도메인을 참조하는 컬럼이 있으면 삭제 불가(서비스에서 검증). DELETE 권한 필요. */
@DeleteMapping("/{domainCode}") @DeleteMapping("/{domainCode}")
@RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "DELETE") @RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "DELETE")
@DataChangeLog(menu = "SYSTEM_DATA_DOMAIN", table = "data_domain") @DataChangeLog(menu = "SYSTEM_DATA_DOMAIN", table = "data_domain")
@@ -0,0 +1,73 @@
package com.ga.api.controller.endorse;
import com.ga.api.service.endorse.ContractEndorsementService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.endorse.ContractEndorsementResp;
import com.ga.core.vo.endorse.ContractEndorsementSaveReq;
import com.ga.core.vo.endorse.ContractEndorsementSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 계약 변경(배서, Endorsement) 관리 API.
*
* <p>배서란 이미 체결된 계약의 내용을 바꾸는 것을 말한다(예: 보험료 증감, 피보험자/수익자 변경, 담보 추가 등).
* 변경 유형(endorsementType)과 변경 전/후 값(oldValue/newValue)을 기록하며,
* 보험료가 바뀌어 수수료 재계산이 필요한 경우(requiresCommissionRecalc) 정산 배치가 이를 반영한다.</p>
*
* <p>메뉴 ENDORSE 권한으로 보호된다.</p>
*/
@Tag(name = "계약 변경")
@RestController
@RequestMapping("/api/contract-endorsements")
@RequiredArgsConstructor
public class ContractEndorsementController {
private final ContractEndorsementService service;
/** 배서 목록 조회(페이징). READ 권한 필요. */
@GetMapping
@RequirePermission(menu = "ENDORSE", perm = "READ")
public ApiResponse<PageResponse<ContractEndorsementResp>> list(
@ModelAttribute ContractEndorsementSearchParam param) {
return ApiResponse.ok(service.list(param));
}
/** 배서 단건 상세 조회. READ 권한 필요. */
@GetMapping("/{endorsementId}")
@RequirePermission(menu = "ENDORSE", perm = "READ")
public ApiResponse<ContractEndorsementResp> detail(@PathVariable Long endorsementId) {
return ApiResponse.ok(service.detail(endorsementId));
}
/** 배서 등록. 등록 직후엔 미처리(processed=false) 상태다. CREATE 권한 필요. */
@PostMapping
@RequirePermission(menu = "ENDORSE", perm = "CREATE")
@DataChangeLog(menu = "ENDORSE", table = "contract_endorsement")
public ApiResponse<Long> create(@Valid @RequestBody ContractEndorsementSaveReq req) {
return ApiResponse.ok(service.create(req));
}
/** 배서 처리 완료 표시(processed=true). 실제 수수료 재계산은 배치(P5)가 담당한다. UPDATE 권한 필요. */
@PostMapping("/{endorsementId}/process")
@RequirePermission(menu = "ENDORSE", perm = "UPDATE")
@DataChangeLog(menu = "ENDORSE", table = "contract_endorsement")
public ApiResponse<Void> process(@PathVariable Long endorsementId) {
service.process(endorsementId);
return ApiResponse.ok();
}
/** 배서 레코드 삭제. DELETE 권한 필요. */
@DeleteMapping("/{endorsementId}")
@RequirePermission(menu = "ENDORSE", perm = "DELETE")
@DataChangeLog(menu = "ENDORSE", table = "contract_endorsement")
public ApiResponse<Void> delete(@PathVariable Long endorsementId) {
service.delete(endorsementId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,40 @@
package com.ga.api.controller.external;
import com.ga.api.service.external.ExternalCallLogService;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.external.ExternalCallLogResp;
import com.ga.core.vo.external.ExternalCallLogSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 외부연동 호출 이력 조회 API.
*
* <p>SMS/알림톡, 펌뱅킹, 푸시 등 외부 시스템 호출 결과를 감사용으로 기록한 이력을 조회한다(읽기 전용).
* 이력 적재 자체는 AOP가 자동 수행하며, 여기서는 운영/장애 추적용 조회만 제공한다.
* 메뉴 SYSTEM_EXTERNAL_CALL 권한으로 보호된다.</p>
*/
@Tag(name = "외부연동 호출이력")
@RestController
@RequiredArgsConstructor
public class ExternalCallLogController {
private final ExternalCallLogService service;
/** 외부 호출 이력 목록 조회(페이징). READ 권한 필요. */
@GetMapping("/api/external-call-logs")
@RequirePermission(menu = "SYSTEM_EXTERNAL_CALL", perm = "READ")
public ApiResponse<PageResponse<ExternalCallLogResp>> list(@ModelAttribute ExternalCallLogSearchParam param) {
return ApiResponse.ok(service.list(param));
}
/** 외부 호출 이력 단건 상세 조회. READ 권한 필요. */
@GetMapping("/api/external-call-logs/{logId}")
@RequirePermission(menu = "SYSTEM_EXTERNAL_CALL", perm = "READ")
public ApiResponse<ExternalCallLogResp> detail(@PathVariable Long logId) {
return ApiResponse.ok(service.detail(logId));
}
}
@@ -0,0 +1,56 @@
package com.ga.api.controller.income;
import com.ga.api.service.income.AgentAnnualIncomeService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.income.AgentAnnualIncomeResp;
import com.ga.core.vo.income.AgentAnnualIncomeSearchParam;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* [연간 사업소득 API]
*
* 신입 개발자 안내:
* - 설계사별로 한 해(연도) 동안의 총 사업소득과 떼인 원천세 등을 합산해 보관하는 자료다(연말정산/지급명세서의 기초).
* - 월별 지급/정산 데이터를 연 단위로 집계(regenerate)하여 agent_annual_income 에 UPSERT 한다.
* - 상세는 (설계사ID, 정산연도) 조합으로 1건을 조회한다.
*/
@Tag(name = "연간 사업소득")
@RestController
@RequestMapping("/api/agent-annual-incomes")
@RequiredArgsConstructor
public class AgentAnnualIncomeController {
private final AgentAnnualIncomeService service;
@GetMapping
@Operation(summary = "연간소득 목록 조회")
@RequirePermission(menu = "ANNUAL_INCOME", perm = "READ")
public ApiResponse<PageResponse<AgentAnnualIncomeResp>> list(
@ModelAttribute AgentAnnualIncomeSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{agentId}/{settleYear}")
@Operation(summary = "연간소득 상세 조회")
@RequirePermission(menu = "ANNUAL_INCOME", perm = "READ")
public ApiResponse<AgentAnnualIncomeResp> detail(
@PathVariable Long agentId,
@PathVariable int settleYear) {
return ApiResponse.ok(service.detail(agentId, settleYear));
}
// 지정 연도의 모든 설계사 연간소득을 다시 집계(UPSERT). 월별 데이터가 바뀌었을 때 다시 돌린다.
@PostMapping("/regenerate")
@Operation(summary = "연도 전체 재집계")
@RequirePermission(menu = "ANNUAL_INCOME", perm = "EXECUTE")
@DataChangeLog(menu = "ANNUAL_INCOME", table = "agent_annual_income")
public ApiResponse<Integer> regenerate(@RequestParam int year) {
return ApiResponse.ok(service.regenerate(year));
}
}
@@ -0,0 +1,91 @@
package com.ga.api.controller.income;
import com.ga.api.service.income.YearEndStatementService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.excel.ExcelService;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.income.YearEndStatementExcelVO;
import com.ga.core.vo.income.YearEndStatementResp;
import com.ga.core.vo.income.YearEndStatementSaveReq;
import com.ga.core.vo.income.YearEndStatementSearchParam;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* [연말 지급명세서 API]
*
* 신입 개발자 안내:
* - 연말정산을 위해 "한 해 동안 이 설계사에게 얼마를 지급하고 세금을 얼마 뗐는지"를 국세청 제출용으로 정리한 명세서다.
* - 데이터 출처: 연간 사업소득(agent_annual_income) 집계 결과를 기반으로 한다.
* - 흐름: regenerate(연도별 UPSERT 생성) → generate(단건 파일 발급) → send(설계사에게 발송 처리) → export(엑셀 다운로드).
* - export 는 데이터가 많을 수 있어 Cursor 스트리밍(ExcelService.exportLargeExcel)으로 내려보낸다.
*/
@Tag(name = "연말 지급명세서")
@RestController
@RequestMapping("/api/year-end-statements")
@RequiredArgsConstructor
public class YearEndStatementController {
private final YearEndStatementService service;
private final ExcelService excelService;
@GetMapping
@Operation(summary = "연말 지급명세서 목록")
@RequirePermission(menu = "YEAR_END_STMT", perm = "READ")
public ApiResponse<PageResponse<YearEndStatementResp>> list(@ModelAttribute YearEndStatementSearchParam p) {
return ApiResponse.ok(service.list(p));
}
@GetMapping("/{id}")
@Operation(summary = "연말 지급명세서 상세")
@RequirePermission(menu = "YEAR_END_STMT", perm = "READ")
public ApiResponse<YearEndStatementResp> detail(@PathVariable Long id) {
return ApiResponse.ok(service.detail(id));
}
@PostMapping("/regenerate")
@Operation(summary = "연도별 재집계 (UPSERT)")
@RequirePermission(menu = "YEAR_END_STMT", perm = "EXECUTE")
@DataChangeLog(menu = "YEAR_END_STMT", table = "year_end_statement")
public ApiResponse<Integer> regenerate(@RequestBody YearEndStatementSaveReq req) {
return ApiResponse.ok(service.regenerate(req));
}
@PostMapping("/{id}/generate")
@Operation(summary = "단건 파일 발급")
@RequirePermission(menu = "YEAR_END_STMT", perm = "EXECUTE")
@DataChangeLog(menu = "YEAR_END_STMT", table = "year_end_statement")
public ApiResponse<Void> generate(@PathVariable Long id, @RequestBody(required = false) Map<String, String> body) {
String format = body == null ? null : body.get("fileFormat");
service.generate(id, format);
return ApiResponse.ok();
}
@PutMapping("/send")
@Operation(summary = "발송 처리")
@RequirePermission(menu = "YEAR_END_STMT", perm = "UPDATE")
@DataChangeLog(menu = "YEAR_END_STMT", table = "year_end_statement")
public ApiResponse<Void> send(@RequestBody YearEndStatementSaveReq req) {
service.send(req);
return ApiResponse.ok();
}
// 대용량 엑셀 다운로드: 조회 결과를 Cursor 로 스트리밍하며 한 번에 메모리에 올리지 않고 파일로 내려쓴다.
@GetMapping("/export")
@Operation(summary = "엑셀 다운로드")
@RequirePermission(menu = "YEAR_END_STMT", perm = "EXPORT")
public void export(@ModelAttribute YearEndStatementSearchParam p, HttpServletResponse res) {
excelService.exportLargeExcel(
"year-end-statement.xlsx",
YearEndStatementExcelVO.class,
() -> service.exportCursor(p),
res);
}
}
@@ -7,18 +7,27 @@ import lombok.Data;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
/**
* 분급(分給) 계획 생성 요청 DTO.
* 분급이란 총수수료를 한 번에 주지 않고 여러 회차로 나눠 지급하는 것으로,
* 이 요청을 받아 회차별 지급 스케줄(installment_plan)을 자동 생성한다.
*/
@Data @Data
public class GeneratePlanReq { public class GeneratePlanReq {
/** 분급 대상 계약 ID. */
@NotNull @NotNull
private Long contractId; private Long contractId;
/** 나눠서 지급할 총수수료 금액(양수). 분급 비율에 따라 회차별로 쪼개진다. */
@NotNull @NotNull
@Positive @Positive
private BigDecimal totalCommission; private BigDecimal totalCommission;
/** 계약일. 회차별 지급 예정월 계산의 기준이 된다. */
@NotNull @NotNull
private LocalDate contractDate; private LocalDate contractDate;
/** 상품 분류. 분류에 따라 적용할 분급 비율표(installment_ratio)가 달라진다. */
private String productCategory; private String productCategory;
} }
@@ -15,6 +15,10 @@ import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
/**
* 분급(分給) 계획 관리 REST 컨트롤러.
* 총수수료를 여러 회차로 나눠 지급하는 분급 계획(installment_plan)의 조회·생성·지급/이연/취소를 처리한다.
*/
@Tag(name = "분급 계획 관리") @Tag(name = "분급 계획 관리")
@RestController @RestController
@RequestMapping("/api/installment-plans") @RequestMapping("/api/installment-plans")
@@ -23,21 +27,24 @@ public class InstallmentController {
private final InstallmentService installmentService; private final InstallmentService installmentService;
/** 분급 계획(회차) 목록 페이징 조회. */
@GetMapping @GetMapping
@RequirePermission(menu = "INSTALLMENT", perm = "READ") @RequirePermission(menu = "INSTALLMENT_PLAN", perm = "READ")
public ApiResponse<PageResponse<InstallmentPlanResp>> list(InstallmentPlanSearchParam param) { public ApiResponse<PageResponse<InstallmentPlanResp>> list(InstallmentPlanSearchParam param) {
return ApiResponse.ok(installmentService.list(param)); return ApiResponse.ok(installmentService.list(param));
} }
/** 분급 계획 단건 상세 조회. */
@GetMapping("/{planId}") @GetMapping("/{planId}")
@RequirePermission(menu = "INSTALLMENT", perm = "READ") @RequirePermission(menu = "INSTALLMENT_PLAN", perm = "READ")
public ApiResponse<InstallmentPlanResp> detail(@PathVariable Long planId) { public ApiResponse<InstallmentPlanResp> detail(@PathVariable Long planId) {
return ApiResponse.ok(installmentService.detail(planId)); return ApiResponse.ok(installmentService.detail(planId));
} }
/** 분급 계획 생성. 총수수료를 분급 비율표에 따라 회차별 지급 스케줄로 자동 분해한다. */
@PostMapping("/generate") @PostMapping("/generate")
@RequirePermission(menu = "INSTALLMENT", perm = "CREATE") @RequirePermission(menu = "INSTALLMENT_PLAN", perm = "CREATE")
@DataChangeLog(menu = "INSTALLMENT", table = "installment_plan") @DataChangeLog(menu = "INSTALLMENT_PLAN", table = "installment_plan")
public ApiResponse<Void> generate(@Valid @RequestBody GeneratePlanReq req) { public ApiResponse<Void> generate(@Valid @RequestBody GeneratePlanReq req) {
installmentService.createPlan( installmentService.createPlan(
req.getContractId(), req.getContractId(),
@@ -48,33 +55,37 @@ public class InstallmentController {
return ApiResponse.ok(); return ApiResponse.ok();
} }
/** 특정 회차 지급 완료 처리(지급건 ID와 지급액 기록). */
@PutMapping("/{planId}/pay") @PutMapping("/{planId}/pay")
@RequirePermission(menu = "INSTALLMENT", perm = "UPDATE") @RequirePermission(menu = "INSTALLMENT_PLAN", perm = "UPDATE")
@DataChangeLog(menu = "INSTALLMENT", table = "installment_plan") @DataChangeLog(menu = "INSTALLMENT_PLAN", table = "installment_plan")
public ApiResponse<Void> pay(@PathVariable Long planId, public ApiResponse<Void> pay(@PathVariable Long planId,
@Valid @RequestBody PayPlanReq req) { @Valid @RequestBody PayPlanReq req) {
installmentService.payPlan(planId, req.getPaymentId(), req.getPaidAmount()); installmentService.payPlan(planId, req.getPaymentId(), req.getPaidAmount());
return ApiResponse.ok(); return ApiResponse.ok();
} }
/** 특정 회차 지급 이연(다음 기회로 미룸) 처리. */
@PutMapping("/{planId}/defer") @PutMapping("/{planId}/defer")
@RequirePermission(menu = "INSTALLMENT", perm = "UPDATE") @RequirePermission(menu = "INSTALLMENT_PLAN", perm = "UPDATE")
@DataChangeLog(menu = "INSTALLMENT", table = "installment_plan") @DataChangeLog(menu = "INSTALLMENT_PLAN", table = "installment_plan")
public ApiResponse<Void> defer(@PathVariable Long planId) { public ApiResponse<Void> defer(@PathVariable Long planId) {
installmentService.deferPlan(planId); installmentService.deferPlan(planId);
return ApiResponse.ok(); return ApiResponse.ok();
} }
/** 특정 회차 지급 취소(예: 계약 해지로 향후 분급 중단). */
@PutMapping("/{planId}/cancel") @PutMapping("/{planId}/cancel")
@RequirePermission(menu = "INSTALLMENT", perm = "UPDATE") @RequirePermission(menu = "INSTALLMENT_PLAN", perm = "UPDATE")
@DataChangeLog(menu = "INSTALLMENT", table = "installment_plan") @DataChangeLog(menu = "INSTALLMENT_PLAN", table = "installment_plan")
public ApiResponse<Void> cancel(@PathVariable Long planId) { public ApiResponse<Void> cancel(@PathVariable Long planId) {
installmentService.cancelPlan(planId); installmentService.cancelPlan(planId);
return ApiResponse.ok(); return ApiResponse.ok();
} }
/** 특정 정산월(settleMonth)에 지급 예정인 분급 회차 목록 조회. 월 정산 배치가 이 목록을 사용한다. */
@GetMapping("/scheduled") @GetMapping("/scheduled")
@RequirePermission(menu = "INSTALLMENT", perm = "READ") @RequirePermission(menu = "INSTALLMENT_PLAN", perm = "READ")
public ApiResponse<List<InstallmentPlanVO>> scheduled(@RequestParam String settleMonth) { public ApiResponse<List<InstallmentPlanVO>> scheduled(@RequestParam String settleMonth) {
return ApiResponse.ok(installmentService.selectScheduledForMonth(settleMonth)); return ApiResponse.ok(installmentService.selectScheduledForMonth(settleMonth));
} }
@@ -13,6 +13,11 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
/**
* 분급 비율(installment_ratio) 관리 REST 컨트롤러.
* 분급 비율표는 "총수수료를 몇 회차에 걸쳐, 각 회차 몇 %씩 지급할지"를 정의하는 기준표이며,
* 분급 계획(installment_plan) 생성 시 이 비율이 적용된다.
*/
@Tag(name = "분급 비율 관리") @Tag(name = "분급 비율 관리")
@RestController @RestController
@RequestMapping("/api/installment-ratios") @RequestMapping("/api/installment-ratios")
@@ -21,15 +26,17 @@ public class InstallmentRatioController {
private final InstallmentService installmentService; private final InstallmentService installmentService;
/** 분급 비율표 목록 페이징 조회. */
@GetMapping @GetMapping
@RequirePermission(menu = "INSTALLMENT", perm = "READ") @RequirePermission(menu = "INSTALLMENT_RATIO", perm = "READ")
public ApiResponse<PageResponse<InstallmentRatioResp>> list(InstallmentRatioSearchParam param) { public ApiResponse<PageResponse<InstallmentRatioResp>> list(InstallmentRatioSearchParam param) {
return ApiResponse.ok(installmentService.listRatios(param)); return ApiResponse.ok(installmentService.listRatios(param));
} }
/** 분급 비율표 신규 등록. */
@PostMapping @PostMapping
@RequirePermission(menu = "INSTALLMENT", perm = "CREATE") @RequirePermission(menu = "INSTALLMENT_RATIO", perm = "CREATE")
@DataChangeLog(menu = "INSTALLMENT", table = "installment_ratio") @DataChangeLog(menu = "INSTALLMENT_RATIO", table = "installment_ratio")
public ApiResponse<Void> create(@Valid @RequestBody InstallmentRatioSaveReq req) { public ApiResponse<Void> create(@Valid @RequestBody InstallmentRatioSaveReq req) {
installmentService.createRatio(req); installmentService.createRatio(req);
return ApiResponse.ok(); return ApiResponse.ok();
@@ -6,12 +6,18 @@ import lombok.Data;
import java.math.BigDecimal; import java.math.BigDecimal;
/**
* 분급 회차 지급 처리 요청 DTO.
* 특정 회차(plan)를 실제로 지급 완료 처리할 때, 어떤 지급건(paymentId)으로 얼마(paidAmount)를 지급했는지 전달한다.
*/
@Data @Data
public class PayPlanReq { public class PayPlanReq {
/** 실제 지급이 일어난 지급(payment) 레코드 ID. */
@NotNull @NotNull
private Long paymentId; private Long paymentId;
/** 이번 회차에 지급한 금액(양수). */
@NotNull @NotNull
@Positive @Positive
private BigDecimal paidAmount; private BigDecimal paidAmount;
@@ -0,0 +1,30 @@
package com.ga.api.controller.internal;
import com.ga.api.service.withdraw.WithdrawBankRetryService;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 운영용 내부 트리거. 펌뱅킹 재시도를 수동으로 실행한다.
* cron 활성화 환경에서는 자동 실행되지만 운영 중 즉시 회수가 필요할 때 사용.
*/
@Tag(name = "내부 트리거")
@RestController
@RequestMapping("/api/internal/bank-retry")
@RequiredArgsConstructor
public class BankRetryController {
private final WithdrawBankRetryService bankRetryService;
/** 펌뱅킹 이체 실패(FAILED) 건을 즉시 1회 재시도하고 처리 건수를 반환한다. WITHDRAW EXECUTE 권한 필요. */
@PostMapping
@RequirePermission(menu = "WITHDRAW", perm = "EXECUTE")
public ApiResponse<Integer> retry() {
return ApiResponse.ok(bankRetryService.retryOnce());
}
}
@@ -0,0 +1,30 @@
package com.ga.api.controller.internal;
import com.ga.api.service.withdraw.WithdrawBankSettleService;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 펌뱅킹 SETTLED 동기화 수동 트리거.
* cron 활성화 환경에서는 자동 실행되지만 운영 중 즉시 입금 확정 동기화가 필요할 때 사용.
*/
@Tag(name = "내부 트리거")
@RestController
@RequestMapping("/api/internal/bank-settle")
@RequiredArgsConstructor
public class BankSettleController {
private final WithdrawBankSettleService bankSettleService;
/** 펌뱅킹 입금 확정(SETTLED) 상태를 즉시 1회 동기화하고 처리 건수를 반환한다. WITHDRAW EXECUTE 권한 필요. */
@PostMapping
@RequirePermission(menu = "WITHDRAW", perm = "EXECUTE")
public ApiResponse<Integer> sync() {
return ApiResponse.ok(bankSettleService.syncOnce());
}
}
@@ -0,0 +1,45 @@
package com.ga.api.controller.kpi;
import com.ga.api.service.kpi.ForecastKpiMonthlyService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.kpi.ForecastKpiMonthlyResp;
import com.ga.core.vo.kpi.ForecastKpiMonthlySaveReq;
import com.ga.core.vo.kpi.ForecastKpiMonthlySearchParam;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 차월(다음 달) KPI 예측 REST 컨트롤러.
* 과거 실적 데이터를 이동평균(SMA) 등으로 분석해 다음 달 실적 KPI를 미리 예측·관리한다.
*/
@Tag(name = "차월 KPI 예측")
@RestController
@RequestMapping("/api/forecast-kpi")
@RequiredArgsConstructor
public class ForecastKpiMonthlyController {
private final ForecastKpiMonthlyService service;
/** 예측값 목록 페이징 조회. */
@GetMapping
@Operation(summary = "예측 목록")
@RequirePermission(menu = "FORECAST_KPI", perm = "READ")
public ApiResponse<PageResponse<ForecastKpiMonthlyResp>> list(@ModelAttribute ForecastKpiMonthlySearchParam p) {
return ApiResponse.ok(service.list(p));
}
/** 차월 예측 재산출(실적 변동 시 다시 계산). 갱신된 행 수를 반환한다. */
@PostMapping("/regenerate")
@Operation(summary = "차월 예측 재산출")
@RequirePermission(menu = "FORECAST_KPI", perm = "EXECUTE")
@DataChangeLog(menu = "FORECAST_KPI", table = "forecast_kpi_monthly")
public ApiResponse<Integer> regenerate(@Valid @RequestBody ForecastKpiMonthlySaveReq req) {
return ApiResponse.ok(service.regenerate(req));
}
}
@@ -0,0 +1,62 @@
package com.ga.api.controller.kpi;
import com.ga.api.service.kpi.KpiService;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.kpi.KpiCumulativeResp;
import com.ga.core.vo.kpi.KpiMonthlySummaryResp;
import com.ga.core.vo.kpi.KpiOrgSummaryResp;
import com.ga.core.vo.kpi.KpiRetentionResp;
import com.ga.core.vo.kpi.KpiSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* KPI 대시보드 REST 컨트롤러.
* 실적 집계(KPI)를 여러 관점으로 조회하는 읽기 전용 화면용 API다.
* - monthly-summary : 월별 실적 요약
* - org-summary : 조직(지점/본부)별 실적 요약
* - retention : 계약 유지율 지표
* - cumulative : 누적 실적 추이
*/
@Tag(name = "KPI 대시보드")
@RestController
@RequestMapping("/api/kpi")
@RequiredArgsConstructor
public class KpiController {
private final KpiService service;
/** 월별 실적 요약 조회. */
@GetMapping("/monthly-summary")
@RequirePermission(menu = "KPI_MONTHLY", perm = "READ")
public ApiResponse<PageResponse<KpiMonthlySummaryResp>> monthlySummary(@ModelAttribute KpiSearchParam param) {
return ApiResponse.ok(service.monthlySummary(param));
}
/** 조직별 실적 요약 조회. */
@GetMapping("/org-summary")
@RequirePermission(menu = "KPI_ORG", perm = "READ")
public ApiResponse<PageResponse<KpiOrgSummaryResp>> orgSummary(@ModelAttribute KpiSearchParam param) {
return ApiResponse.ok(service.orgSummary(param));
}
/** 계약 유지율 지표 조회. */
@GetMapping("/retention")
@RequirePermission(menu = "KPI_RETENTION", perm = "READ")
public ApiResponse<PageResponse<KpiRetentionResp>> retention(@ModelAttribute KpiSearchParam param) {
return ApiResponse.ok(service.retention(param));
}
/** 누적 실적 추이 조회. */
@GetMapping("/cumulative")
@RequirePermission(menu = "KPI_CUMULATIVE", perm = "READ")
public ApiResponse<PageResponse<KpiCumulativeResp>> cumulative(@ModelAttribute KpiSearchParam param) {
return ApiResponse.ok(service.cumulative(param));
}
}
@@ -0,0 +1,65 @@
package com.ga.api.controller.kpi;
import com.ga.api.service.kpi.RetentionAnomalyAlertService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.kpi.RetentionAnomalyAlertResp;
import com.ga.core.vo.kpi.RetentionAnomalyAlertSaveReq;
import com.ga.core.vo.kpi.RetentionAnomalyAlertSearchParam;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 유지율 이상치(retention anomaly) 알림 REST 컨트롤러.
* 특정 조직/설계사의 계약 유지율이 평소 대비 비정상적으로 떨어진 경우를 탐지해 알림으로 관리한다.
* (유지율 급락은 부실모집/불완전판매 신호일 수 있어 모니터링 대상이다)
*/
@Tag(name = "유지율 이상치")
@RestController
@RequestMapping("/api/retention-anomalies")
@RequiredArgsConstructor
public class RetentionAnomalyAlertController {
private final RetentionAnomalyAlertService service;
/** 이상치 알림 목록 페이징 조회. */
@GetMapping
@Operation(summary = "이상치 목록")
@RequirePermission(menu = "RETENTION_ANOMALY", perm = "READ")
public ApiResponse<PageResponse<RetentionAnomalyAlertResp>> list(@ModelAttribute RetentionAnomalyAlertSearchParam p) {
return ApiResponse.ok(service.list(p));
}
/** 이상치 알림 단건 상세 조회. */
@GetMapping("/{id}")
@Operation(summary = "이상치 단건 상세")
@RequirePermission(menu = "RETENTION_ANOMALY", perm = "READ")
public ApiResponse<RetentionAnomalyAlertResp> detail(@PathVariable Long id) {
return ApiResponse.ok(service.detail(id));
}
/** 지정한 월(alertMonth)에 대해 이상치를 다시 탐지·저장한다. 새로 탐지된 건수를 반환. */
@PostMapping("/detect")
@Operation(summary = "이상치 재탐지 (월 단위)")
@RequirePermission(menu = "RETENTION_ANOMALY", perm = "UPDATE")
@DataChangeLog(menu = "RETENTION_ANOMALY", table = "retention_anomaly_alert")
public ApiResponse<Integer> detect(@RequestBody Map<String, String> body) {
return ApiResponse.ok(service.detect(body.get("alertMonth")));
}
/** 탐지된 이상치를 검토/완료 상태로 처리(담당자 확인 결과 기록). */
@PutMapping("/review")
@Operation(summary = "이상치 검토/완료 처리")
@RequirePermission(menu = "RETENTION_ANOMALY", perm = "UPDATE")
@DataChangeLog(menu = "RETENTION_ANOMALY", table = "retention_anomaly_alert")
public ApiResponse<Void> review(@RequestBody RetentionAnomalyAlertSaveReq req) {
service.review(req);
return ApiResponse.ok();
}
}
@@ -15,6 +15,14 @@ import org.springframework.web.bind.annotation.*;
import java.util.Map; import java.util.Map;
/**
* 수수료 원장(ledger) 관리 REST 컨트롤러.
* 원장은 설계사에게 지급된/지급될 수수료 내역을 종류별로 기록하는 장부다.
* - 모집(recruit) : 신계약 체결로 발생한 모집수수료 원장
* - 유지(maintain) : 계약 유지에 따라 매월 발생하는 유지수수료 원장
* - 예외(exception) : 정상 계산에 안 잡히는 수기 조정 수수료(승인 절차 필요)
* 조회/상세 외에 대용량 엑셀 다운로드(exportLargeExcel: 커서 스트리밍)를 제공한다.
*/
@Tag(name = "원장 관리") @Tag(name = "원장 관리")
@RestController @RestController
@RequestMapping("/api/ledger") @RequestMapping("/api/ledger")
@@ -24,7 +32,7 @@ public class LedgerController {
private final LedgerService service; private final LedgerService service;
private final ExcelService excelService; private final ExcelService excelService;
// ===== 모집 ===== // ===== 모집수수료 원장 =====
@GetMapping("/recruit") @GetMapping("/recruit")
@RequirePermission(menu = "LEDGER_RECRUIT", perm = "READ") @RequirePermission(menu = "LEDGER_RECRUIT", perm = "READ")
public ApiResponse<PageResponse<LedgerResp>> listRecruit(@ModelAttribute LedgerSearchParam param) { public ApiResponse<PageResponse<LedgerResp>> listRecruit(@ModelAttribute LedgerSearchParam param) {
@@ -37,6 +45,7 @@ public class LedgerController {
return ApiResponse.ok(service.detailRecruit(ledgerId)); return ApiResponse.ok(service.detailRecruit(ledgerId));
} }
/** 모집수수료 원장 엑셀 다운로드. 대용량 대비 커서로 한 건씩 흘려보내 메모리 폭증을 막는다. */
@GetMapping("/recruit/export") @GetMapping("/recruit/export")
@RequirePermission(menu = "LEDGER_RECRUIT", perm = "EXPORT") @RequirePermission(menu = "LEDGER_RECRUIT", perm = "EXPORT")
public void exportRecruit(@ModelAttribute LedgerSearchParam param, HttpServletResponse res) { public void exportRecruit(@ModelAttribute LedgerSearchParam param, HttpServletResponse res) {
@@ -44,7 +53,7 @@ public class LedgerController {
() -> service.exportRecruitCursor(param), res); () -> service.exportRecruitCursor(param), res);
} }
// ===== 유지 ===== // ===== 유지수수료 원장 =====
@GetMapping("/maintain") @GetMapping("/maintain")
@RequirePermission(menu = "LEDGER_MAINTAIN", perm = "READ") @RequirePermission(menu = "LEDGER_MAINTAIN", perm = "READ")
public ApiResponse<PageResponse<LedgerResp>> listMaintain(@ModelAttribute LedgerSearchParam param) { public ApiResponse<PageResponse<LedgerResp>> listMaintain(@ModelAttribute LedgerSearchParam param) {
@@ -57,6 +66,7 @@ public class LedgerController {
return ApiResponse.ok(service.detailMaintain(ledgerId)); return ApiResponse.ok(service.detailMaintain(ledgerId));
} }
/** 유지수수료 원장 엑셀 다운로드(커서 스트리밍). */
@GetMapping("/maintain/export") @GetMapping("/maintain/export")
@RequirePermission(menu = "LEDGER_MAINTAIN", perm = "EXPORT") @RequirePermission(menu = "LEDGER_MAINTAIN", perm = "EXPORT")
public void exportMaintain(@ModelAttribute LedgerSearchParam param, HttpServletResponse res) { public void exportMaintain(@ModelAttribute LedgerSearchParam param, HttpServletResponse res) {
@@ -64,7 +74,7 @@ public class LedgerController {
() -> service.exportMaintainCursor(param), res); () -> service.exportMaintainCursor(param), res);
} }
// ===== 예외 ===== // ===== 예외수수료 원장(수기 등록 + 승인) =====
@GetMapping("/exception") @GetMapping("/exception")
@RequirePermission(menu = "LEDGER_EXCEPTION", perm = "READ") @RequirePermission(menu = "LEDGER_EXCEPTION", perm = "READ")
public ApiResponse<PageResponse<ExceptionLedgerResp>> listException(@ModelAttribute LedgerSearchParam param) { public ApiResponse<PageResponse<ExceptionLedgerResp>> listException(@ModelAttribute LedgerSearchParam param) {
@@ -77,6 +87,7 @@ public class LedgerController {
return ApiResponse.ok(service.detailException(ledgerId)); return ApiResponse.ok(service.detailException(ledgerId));
} }
/** 예외수수료 원장 수기 등록. 등록 직후에는 승인대기(PENDING) 상태다. */
@PostMapping("/exception") @PostMapping("/exception")
@RequirePermission(menu = "LEDGER_EXCEPTION", perm = "CREATE") @RequirePermission(menu = "LEDGER_EXCEPTION", perm = "CREATE")
@DataChangeLog(menu = "LEDGER_EXCEPTION", table = "exception_ledger") @DataChangeLog(menu = "LEDGER_EXCEPTION", table = "exception_ledger")
@@ -84,6 +95,7 @@ public class LedgerController {
return ApiResponse.ok(service.createException(req)); return ApiResponse.ok(service.createException(req));
} }
/** 예외수수료 원장 승인/반려 처리(body 의 status: APPROVED 또는 REJECTED). */
@PutMapping("/exception/{ledgerId}/approve") @PutMapping("/exception/{ledgerId}/approve")
@RequirePermission(menu = "LEDGER_EXCEPTION", perm = "APPROVE") @RequirePermission(menu = "LEDGER_EXCEPTION", perm = "APPROVE")
@DataChangeLog(menu = "LEDGER_EXCEPTION", table = "exception_ledger") @DataChangeLog(menu = "LEDGER_EXCEPTION", table = "exception_ledger")
@@ -0,0 +1,81 @@
package com.ga.api.controller.license;
import com.ga.api.service.license.AgentLicenseService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.license.AgentLicenseResp;
import com.ga.core.vo.license.AgentLicenseSaveReq;
import com.ga.core.vo.license.AgentLicenseSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 설계사 자격(License) 관리 API.
*
* <p>보험설계사는 판매 가능한 상품군별로 자격(생명/손해 등 licenseType)을 보유해야 한다.
* 각 자격은 발급일(issuedDate)~만료일(expiresDate)을 가지며 갱신(renewedFrom) 이력이 연결될 수 있다.
* 만료 임박 자격을 조회해 갱신을 독려하는 기능이 핵심이다. 메뉴 AGENT_LICENSE 권한으로 보호된다.</p>
*/
@Tag(name = "자격 관리")
@RestController
@RequestMapping("/api/agent-licenses")
@RequiredArgsConstructor
public class AgentLicenseController {
private final AgentLicenseService service;
/** 자격 목록 조회(페이징). READ 권한 필요. */
@GetMapping
@RequirePermission(menu = "AGENT_LICENSE", perm = "READ")
public ApiResponse<PageResponse<AgentLicenseResp>> list(@ModelAttribute AgentLicenseSearchParam param) {
return ApiResponse.ok(service.list(param));
}
/** 자격 단건 상세 조회. READ 권한 필요. */
@GetMapping("/{licenseId}")
@RequirePermission(menu = "AGENT_LICENSE", perm = "READ")
public ApiResponse<AgentLicenseResp> detail(@PathVariable Long licenseId) {
return ApiResponse.ok(service.detail(licenseId));
}
/** 만료 임박 자격 조회(기본 30일 이내). 갱신 알림/관리에 사용한다. READ 권한 필요. */
@GetMapping("/expiring")
@RequirePermission(menu = "AGENT_LICENSE", perm = "READ")
public ApiResponse<List<AgentLicenseResp>> expiring(
@RequestParam(defaultValue = "30") int days) {
return ApiResponse.ok(service.expiring(days));
}
/** 자격 등록. 등록 시 ACTIVE 상태로 시작한다. CREATE 권한 필요. */
@PostMapping
@RequirePermission(menu = "AGENT_LICENSE", perm = "CREATE")
@DataChangeLog(menu = "AGENT_LICENSE", table = "agent_license")
public ApiResponse<Long> create(@Valid @RequestBody AgentLicenseSaveReq req) {
return ApiResponse.ok(service.create(req));
}
/** 자격 수정. UPDATE 권한 필요. */
@PutMapping("/{licenseId}")
@RequirePermission(menu = "AGENT_LICENSE", perm = "UPDATE")
@DataChangeLog(menu = "AGENT_LICENSE", table = "agent_license")
public ApiResponse<Void> update(@PathVariable Long licenseId,
@Valid @RequestBody AgentLicenseSaveReq req) {
service.update(licenseId, req);
return ApiResponse.ok();
}
/** 자격 레코드 삭제. DELETE 권한 필요. */
@DeleteMapping("/{licenseId}")
@RequirePermission(menu = "AGENT_LICENSE", perm = "DELETE")
@DataChangeLog(menu = "AGENT_LICENSE", table = "agent_license")
public ApiResponse<Void> delete(@PathVariable Long licenseId) {
service.delete(licenseId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,149 @@
package com.ga.api.controller.mobile;
import com.ga.api.service.mobile.MobileMeService;
import com.ga.api.service.mobile.MobileMeService.MeSummary;
import com.ga.api.service.mobile.MobileMeService.MobileWithdrawSaveReq;
import com.ga.api.service.mobile.MobileMeService.WithdrawableBalance;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.notice.UserNotificationResp;
import com.ga.core.vo.notice.UserNotificationSearchParam;
import com.ga.core.vo.settle.CommissionStatementResp;
import com.ga.core.vo.settle.CommissionStatementSearchParam;
import com.ga.core.vo.settle.SettleMasterResp;
import com.ga.core.vo.withdraw.WithdrawRequestResp;
import com.ga.core.vo.withdraw.WithdrawRequestSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
/**
* 모바일 PWA 전용 — 토큰의 사용자 본인 데이터만 반환.
*
* <p>모든 endpoint 는 인증만 통과하면 사용 가능 (별도 메뉴 권한 없음).
* agentId / userId 는 서버측에서 토큰 → user 조회로 강제 결정. 클라이언트 파라미터는 무시한다.
*/
@Tag(name = "모바일 셀프 조회")
@RestController
@RequestMapping("/api/mobile/me")
@RequiredArgsConstructor
public class MobileMeController {
private final MobileMeService service;
/** 내 홈 요약(명세서 수/출금 신청 수/미읽음 알림 수). */
@GetMapping("/summary")
public ApiResponse<MeSummary> summary() {
return ApiResponse.ok(service.summary());
}
/** 내 수수료 명세서 목록(서버가 본인 agentId로 강제 필터). */
@GetMapping("/statements")
public ApiResponse<PageResponse<CommissionStatementResp>> statements(
@ModelAttribute CommissionStatementSearchParam param) {
return ApiResponse.ok(service.statements(param));
}
/** 내 명세서 단건 상세. 본인 소유가 아니면 FORBIDDEN. */
@GetMapping("/statements/{statementId}")
public ApiResponse<CommissionStatementResp> statementDetail(@PathVariable Long statementId) {
return ApiResponse.ok(service.statementDetail(statementId));
}
/** 내 명세서 엑셀 다운로드. 본인 소유 검증 후 파일 스트림을 내려준다. */
@GetMapping("/statements/{statementId}/download")
public void downloadStatement(@PathVariable Long statementId, HttpServletResponse response) throws IOException {
byte[] content = service.downloadStatement(statementId);
String encoded = URLEncoder.encode("수수료명세서_" + statementId + ".xlsx", StandardCharsets.UTF_8)
.replace("+", "%20");
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encoded);
response.setContentLength(content.length);
response.getOutputStream().write(content);
}
/** 출금 신청 가능한 내 정산 마스터 목록(출금 폼 드롭다운용). */
@GetMapping("/settle-masters")
public ApiResponse<List<SettleMasterResp>> withdrawableSettleMasters() {
return ApiResponse.ok(service.withdrawableSettleMasters());
}
/** 특정 정산 마스터의 출금 가능 잔액 조회(출금 폼 미리 표시용). 본인 소유 검증 포함. */
@GetMapping("/settle-masters/{settleMasterId}/withdrawable-balance")
public ApiResponse<WithdrawableBalance> withdrawableBalance(@PathVariable Long settleMasterId) {
return ApiResponse.ok(service.withdrawableBalance(settleMasterId));
}
/** 내 출금 신청 목록(서버가 본인 agentId로 강제 필터). */
@GetMapping("/withdraw-requests")
public ApiResponse<PageResponse<WithdrawRequestResp>> withdrawRequests(
@ModelAttribute WithdrawRequestSearchParam param) {
return ApiResponse.ok(service.withdrawRequests(param));
}
/** 출금 신청 등록. agentId/신청자는 토큰에서 강제 결정하고 잔액 한도를 검증한다. */
@PostMapping("/withdraw-requests")
public ApiResponse<Long> createWithdrawRequest(@Valid @RequestBody MobileWithdrawSaveReq req) {
return ApiResponse.ok(service.createWithdrawRequest(req));
}
/** 내 출금 신청 취소. 본인 소유 검증 후 취소한다. */
@PostMapping("/withdraw-requests/{requestId}/cancel")
public ApiResponse<Void> cancelWithdrawRequest(@PathVariable Long requestId) {
service.cancelMyWithdrawRequest(requestId);
return ApiResponse.ok();
}
/** 내 전화번호 직접 수정(관리자 거치지 않음). */
@PutMapping("/profile/phone")
public ApiResponse<Void> updateMyPhone(@RequestBody PhoneReq req) {
service.updateMyPhone(req.getPhone());
return ApiResponse.ok();
}
@lombok.Data
public static class PhoneReq {
private String phone;
}
/** 내 알림 목록(서버가 본인 userId로 강제 필터). */
@GetMapping("/notifications")
public ApiResponse<PageResponse<UserNotificationResp>> notifications(
@ModelAttribute UserNotificationSearchParam param) {
return ApiResponse.ok(service.notifications(param));
}
/** 내 알림 단건 읽음 처리. */
@PostMapping("/notifications/{notificationId}/read")
public ApiResponse<Void> markRead(@PathVariable Long notificationId) {
service.markNotificationRead(notificationId);
return ApiResponse.ok();
}
/** 내 알림 전체 읽음 처리(처리 건수 반환). */
@PostMapping("/notifications/read-all")
public ApiResponse<Integer> markAllRead() {
return ApiResponse.ok(service.markAllNotificationsRead());
}
/** 푸시용 디바이스 토큰 등록. body: {token, platform?} */
@PostMapping("/device-tokens")
public ApiResponse<Void> registerDeviceToken(@RequestBody java.util.Map<String, String> body) {
service.registerDeviceToken(body.get("token"), body.get("platform"));
return ApiResponse.ok();
}
/** 디바이스 토큰 해제(로그아웃 등). body: {token} */
@PostMapping("/device-tokens/unregister")
public ApiResponse<Void> unregisterDeviceToken(@RequestBody java.util.Map<String, String> body) {
service.unregisterDeviceToken(body.get("token"));
return ApiResponse.ok();
}
}
@@ -0,0 +1,119 @@
package com.ga.api.controller.notice;
import com.ga.api.service.notice.NoticeService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.common.util.SecurityUtil;
import com.ga.core.vo.notice.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 공지사항 및 사용자 알림 API.
*
* <p>두 가지 도메인을 함께 다룬다.
* <ul>
* <li>공지사항(notice): 관리자가 등록하는 게시물. 기간(startDate~endDate)과 활성여부로 노출을 제어한다.</li>
* <li>사용자 알림(user-notification): 개인에게 발송되는 알림. 읽음 처리/미읽음 개수 조회 등을 제공한다.</li>
* </ul>
* 모두 메뉴 NOTICE 권한으로 보호된다.</p>
*/
@Tag(name = "공지사항 & 알림")
@RestController
@RequiredArgsConstructor
public class NoticeController {
private final NoticeService service;
// ── Notice ────────────────────────────────────────────────────────────────
/** 공지사항 목록 조회(페이징). READ 권한 필요. */
@GetMapping("/api/notices")
@RequirePermission(menu = "NOTICE", perm = "READ")
public ApiResponse<PageResponse<NoticeResp>> list(@ModelAttribute NoticeSearchParam param) {
return ApiResponse.ok(service.list(param));
}
/** 현재 노출 기간이고 활성 상태인 공지만 조회. READ 권한 필요. */
@GetMapping("/api/notices/active")
@RequirePermission(menu = "NOTICE", perm = "READ")
public ApiResponse<List<NoticeResp>> activeList() {
return ApiResponse.ok(service.activeList());
}
/** 공지사항 단건 상세 조회. READ 권한 필요. */
@GetMapping("/api/notices/{noticeId}")
@RequirePermission(menu = "NOTICE", perm = "READ")
public ApiResponse<NoticeResp> detail(@PathVariable Long noticeId) {
return ApiResponse.ok(service.detail(noticeId));
}
/** 공지사항 등록. 등록 시 활성(isActive=true) 상태로 시작한다. CREATE 권한 필요. */
@PostMapping("/api/notices")
@RequirePermission(menu = "NOTICE", perm = "CREATE")
@DataChangeLog(menu = "NOTICE", table = "notice")
public ApiResponse<Long> create(@Valid @RequestBody NoticeSaveReq req) {
return ApiResponse.ok(service.create(req));
}
/** 공지사항 수정. UPDATE 권한 필요. */
@PutMapping("/api/notices/{noticeId}")
@RequirePermission(menu = "NOTICE", perm = "UPDATE")
@DataChangeLog(menu = "NOTICE", table = "notice")
public ApiResponse<Void> update(@PathVariable Long noticeId,
@Valid @RequestBody NoticeSaveReq req) {
service.update(noticeId, req);
return ApiResponse.ok();
}
/** 공지사항 삭제. DELETE 권한 필요. */
@DeleteMapping("/api/notices/{noticeId}")
@RequirePermission(menu = "NOTICE", perm = "DELETE")
@DataChangeLog(menu = "NOTICE", table = "notice")
public ApiResponse<Void> delete(@PathVariable Long noticeId) {
service.delete(noticeId);
return ApiResponse.ok();
}
// ── UserNotification ──────────────────────────────────────────────────────
/** 사용자 알림 목록 조회(페이징). READ 권한 필요. */
@GetMapping("/api/user-notifications")
@RequirePermission(menu = "NOTICE", perm = "READ")
public ApiResponse<PageResponse<UserNotificationResp>> listNotifications(
@ModelAttribute UserNotificationSearchParam param) {
return ApiResponse.ok(service.listNotifications(param));
}
/** 미읽음 알림 개수 조회. userId 미지정 시 현재 로그인 사용자 기준. READ 권한 필요. */
@GetMapping("/api/user-notifications/unread-count")
@RequirePermission(menu = "NOTICE", perm = "READ")
public ApiResponse<Integer> unreadCount(@RequestParam(required = false) Long userId) {
Long target = userId != null ? userId : SecurityUtil.getCurrentUserId();
return ApiResponse.ok(service.unreadCount(target));
}
/** 알림 단건 읽음 처리. userId 미지정 시 현재 로그인 사용자 기준. READ 권한 필요. */
@PostMapping("/api/user-notifications/{notificationId}/read")
@RequirePermission(menu = "NOTICE", perm = "READ")
public ApiResponse<Void> markRead(@PathVariable Long notificationId,
@RequestParam(required = false) Long userId) {
Long target = userId != null ? userId : SecurityUtil.getCurrentUserId();
service.markRead(notificationId, target);
return ApiResponse.ok();
}
/** 해당 사용자의 모든 알림을 읽음 처리. READ 권한 필요. */
@PostMapping("/api/user-notifications/read-all")
@RequirePermission(menu = "NOTICE", perm = "READ")
public ApiResponse<Void> markAllRead(@RequestParam Long userId) {
service.markAllRead(userId);
return ApiResponse.ok();
}
}
@@ -16,6 +16,15 @@ import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
/**
* 설계사(보험모집인) 관리 API.
*
* <p>설계사는 GA(보험대리점)에 소속되어 보험계약을 모집하는 사람이며, 수수료 정산의 기본 단위다.
* 각 설계사는 특정 조직(지점/본부)과 직급(grade)에 소속되며, 입사/전배/승진/퇴사 등의 이력이 관리된다.</p>
*
* <p>모든 엔드포인트는 {@code @RequirePermission}으로 메뉴(ORG_AGENT)별 권한(READ/CREATE/UPDATE/EXPORT)을
* 검증하며, 데이터 변경 작업은 {@code @DataChangeLog}로 변경 이력을 자동 적재한다.</p>
*/
@Tag(name = "설계사 관리") @Tag(name = "설계사 관리")
@RestController @RestController
@RequestMapping("/api/agents") @RequestMapping("/api/agents")
@@ -25,18 +34,21 @@ public class AgentController {
private final AgentService agentService; private final AgentService agentService;
private final ExcelService excelService; private final ExcelService excelService;
/** 설계사 목록 조회(검색조건 + 페이징). READ 권한 필요. */
@GetMapping @GetMapping
@RequirePermission(menu = "ORG_AGENT", perm = "READ") @RequirePermission(menu = "ORG_AGENT", perm = "READ")
public ApiResponse<PageResponse<AgentResp>> list(@ModelAttribute AgentSearchParam param) { public ApiResponse<PageResponse<AgentResp>> list(@ModelAttribute AgentSearchParam param) {
return ApiResponse.ok(agentService.list(param)); return ApiResponse.ok(agentService.list(param));
} }
/** 설계사 단건 상세 조회. READ 권한 필요. */
@GetMapping("/{agentId}") @GetMapping("/{agentId}")
@RequirePermission(menu = "ORG_AGENT", perm = "READ") @RequirePermission(menu = "ORG_AGENT", perm = "READ")
public ApiResponse<AgentResp> detail(@PathVariable Long agentId) { public ApiResponse<AgentResp> detail(@PathVariable Long agentId) {
return ApiResponse.ok(agentService.detail(agentId)); return ApiResponse.ok(agentService.detail(agentId));
} }
/** 신규 설계사 등록(입사). 등록 시 입사 이력이 함께 생성된다. CREATE 권한 필요. */
@PostMapping @PostMapping
@RequirePermission(menu = "ORG_AGENT", perm = "CREATE") @RequirePermission(menu = "ORG_AGENT", perm = "CREATE")
@DataChangeLog(menu = "ORG_AGENT", table = "agent") @DataChangeLog(menu = "ORG_AGENT", table = "agent")
@@ -44,6 +56,7 @@ public class AgentController {
return ApiResponse.ok(agentService.create(req)); return ApiResponse.ok(agentService.create(req));
} }
/** 설계사 정보 수정. 조직/직급이 바뀌면 전배/승진 이력이 자동 기록된다. UPDATE 권한 필요. */
@PutMapping("/{agentId}") @PutMapping("/{agentId}")
@RequirePermission(menu = "ORG_AGENT", perm = "UPDATE") @RequirePermission(menu = "ORG_AGENT", perm = "UPDATE")
@DataChangeLog(menu = "ORG_AGENT", table = "agent") @DataChangeLog(menu = "ORG_AGENT", table = "agent")
@@ -52,6 +65,7 @@ public class AgentController {
return ApiResponse.ok(); return ApiResponse.ok();
} }
/** 설계사 상태 변경(예: ACTIVE 재직 → 퇴사/휴직 등). body의 "status" 값을 코드로 검증한다. UPDATE 권한 필요. */
@PutMapping("/{agentId}/status") @PutMapping("/{agentId}/status")
@RequirePermission(menu = "ORG_AGENT", perm = "UPDATE") @RequirePermission(menu = "ORG_AGENT", perm = "UPDATE")
@DataChangeLog(menu = "ORG_AGENT", table = "agent") @DataChangeLog(menu = "ORG_AGENT", table = "agent")
@@ -60,12 +74,14 @@ public class AgentController {
return ApiResponse.ok(); return ApiResponse.ok();
} }
/** 설계사 조직/직급 변경 이력(입사·전배·승진) 조회. READ 권한 필요. */
@GetMapping("/{agentId}/history") @GetMapping("/{agentId}/history")
@RequirePermission(menu = "ORG_AGENT", perm = "READ") @RequirePermission(menu = "ORG_AGENT", perm = "READ")
public ApiResponse<List<AgentOrgHistoryResp>> history(@PathVariable Long agentId) { public ApiResponse<List<AgentOrgHistoryResp>> history(@PathVariable Long agentId) {
return ApiResponse.ok(agentService.history(agentId)); return ApiResponse.ok(agentService.history(agentId));
} }
/** 설계사 목록 엑셀 다운로드. 대용량 대비 커서 스트리밍으로 출력한다. EXPORT 권한 필요. */
@GetMapping("/export") @GetMapping("/export")
@RequirePermission(menu = "ORG_AGENT", perm = "EXPORT") @RequirePermission(menu = "ORG_AGENT", perm = "EXPORT")
public void export(@ModelAttribute AgentSearchParam param, HttpServletResponse response) { public void export(@ModelAttribute AgentSearchParam param, HttpServletResponse response) {
@@ -14,6 +14,14 @@ import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
/**
* 조직(본부/지점 등) 관리 API.
*
* <p>조직은 부모-자식 관계의 계층(트리) 구조를 가진다. 설계사는 말단 조직에 소속되며,
* 수수료 정산·실적 집계는 이 조직 계층을 따라 상위로 롤업된다.</p>
*
* <p>메뉴 ORG_TREE 권한으로 보호되며, 삭제는 하위 조직/소속 설계사가 없을 때만 허용된다(아래 서비스 참고).</p>
*/
@Tag(name = "조직 관리") @Tag(name = "조직 관리")
@RestController @RestController
@RequestMapping("/api/orgs") @RequestMapping("/api/orgs")
@@ -22,12 +30,14 @@ public class OrganizationController {
private final OrganizationService service; private final OrganizationService service;
/** 조직 전체를 트리(계층) 구조로 조회. READ 권한 필요. */
@GetMapping("/tree") @GetMapping("/tree")
@RequirePermission(menu = "ORG_TREE", perm = "READ") @RequirePermission(menu = "ORG_TREE", perm = "READ")
public ApiResponse<List<OrganizationResp>> tree() { public ApiResponse<List<OrganizationResp>> tree() {
return ApiResponse.ok(service.getTree()); return ApiResponse.ok(service.getTree());
} }
/** 조직 목록을 평면(flat) 리스트로 조회(검색어/조직유형/활성여부 필터). READ 권한 필요. */
@GetMapping @GetMapping
@RequirePermission(menu = "ORG_TREE", perm = "READ") @RequirePermission(menu = "ORG_TREE", perm = "READ")
public ApiResponse<List<OrganizationResp>> list(@RequestParam(required = false) String keyword, public ApiResponse<List<OrganizationResp>> list(@RequestParam(required = false) String keyword,
@@ -36,12 +46,14 @@ public class OrganizationController {
return ApiResponse.ok(service.list(keyword, orgType, isActive)); return ApiResponse.ok(service.list(keyword, orgType, isActive));
} }
/** 조직 단건 상세 조회. READ 권한 필요. */
@GetMapping("/{orgId}") @GetMapping("/{orgId}")
@RequirePermission(menu = "ORG_TREE", perm = "READ") @RequirePermission(menu = "ORG_TREE", perm = "READ")
public ApiResponse<OrganizationVO> detail(@PathVariable Long orgId) { public ApiResponse<OrganizationVO> detail(@PathVariable Long orgId) {
return ApiResponse.ok(service.get(orgId)); return ApiResponse.ok(service.get(orgId));
} }
/** 신규 조직 등록. CREATE 권한 필요. */
@PostMapping @PostMapping
@RequirePermission(menu = "ORG_TREE", perm = "CREATE") @RequirePermission(menu = "ORG_TREE", perm = "CREATE")
@DataChangeLog(menu = "ORG_TREE", table = "organization") @DataChangeLog(menu = "ORG_TREE", table = "organization")
@@ -49,6 +61,7 @@ public class OrganizationController {
return ApiResponse.ok(service.create(req)); return ApiResponse.ok(service.create(req));
} }
/** 조직 정보 수정. UPDATE 권한 필요. */
@PutMapping("/{orgId}") @PutMapping("/{orgId}")
@RequirePermission(menu = "ORG_TREE", perm = "UPDATE") @RequirePermission(menu = "ORG_TREE", perm = "UPDATE")
@DataChangeLog(menu = "ORG_TREE", table = "organization") @DataChangeLog(menu = "ORG_TREE", table = "organization")
@@ -57,6 +70,7 @@ public class OrganizationController {
return ApiResponse.ok(); return ApiResponse.ok();
} }
/** 조직 삭제. 하위 조직 또는 소속 설계사가 있으면 삭제 불가(서비스에서 검증). DELETE 권한 필요. */
@DeleteMapping("/{orgId}") @DeleteMapping("/{orgId}")
@RequirePermission(menu = "ORG_TREE", perm = "DELETE") @RequirePermission(menu = "ORG_TREE", perm = "DELETE")
@DataChangeLog(menu = "ORG_TREE", table = "organization") @DataChangeLog(menu = "ORG_TREE", table = "organization")
@@ -11,6 +11,14 @@ import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
/**
* 보험사(원수사) 관리 REST 컨트롤러.
* 우리 GA가 상품을 위탁판매하는 보험회사(예: 삼성생명, KB손해보험 등) 마스터를 관리한다.
* 모든 상품/요율/수수료 계산의 출발점이 되는 기준 데이터이다.
*
* - @RequirePermission: 메뉴/권한(READ·CREATE·UPDATE) 체크. 권한 없으면 호출 거부.
* - @DataChangeLog: 데이터 변경 시 감사 로그(누가/언제/무엇을 바꿨는지) 자동 기록.
*/
@Tag(name = "보험사 관리") @Tag(name = "보험사 관리")
@RestController @RestController
@RequestMapping("/api/companies") @RequestMapping("/api/companies")
@@ -19,18 +27,21 @@ public class CompanyController {
private final InsuranceCompanyService companyService; private final InsuranceCompanyService companyService;
/** 보험사 목록 조회. activeOnly=true 면 사용중(활성) 보험사만 반환. */
@GetMapping @GetMapping
@RequirePermission(menu = "COMPANY", perm = "READ") @RequirePermission(menu = "COMPANY", perm = "READ")
public ApiResponse<List<InsuranceCompanyVO>> list(@RequestParam(required = false, defaultValue = "false") boolean activeOnly) { public ApiResponse<List<InsuranceCompanyVO>> list(@RequestParam(required = false, defaultValue = "false") boolean activeOnly) {
return ApiResponse.ok(companyService.list(activeOnly)); return ApiResponse.ok(companyService.list(activeOnly));
} }
/** 보험사 단건 상세 조회 (URL 경로의 companyId 기준). */
@GetMapping("/{companyId}") @GetMapping("/{companyId}")
@RequirePermission(menu = "COMPANY", perm = "READ") @RequirePermission(menu = "COMPANY", perm = "READ")
public ApiResponse<InsuranceCompanyVO> detail(@PathVariable Long companyId) { public ApiResponse<InsuranceCompanyVO> detail(@PathVariable Long companyId) {
return ApiResponse.ok(companyService.detail(companyId)); return ApiResponse.ok(companyService.detail(companyId));
} }
/** 보험사 신규 등록. 생성된 PK(companyId)를 반환. */
@PostMapping @PostMapping
@RequirePermission(menu = "COMPANY", perm = "CREATE") @RequirePermission(menu = "COMPANY", perm = "CREATE")
@DataChangeLog(menu = "COMPANY", table = "insurance_company") @DataChangeLog(menu = "COMPANY", table = "insurance_company")
@@ -38,6 +49,7 @@ public class CompanyController {
return ApiResponse.ok(companyService.create(vo)); return ApiResponse.ok(companyService.create(vo));
} }
/** 보험사 정보 수정 (경로 companyId 대상). */
@PutMapping("/{companyId}") @PutMapping("/{companyId}")
@RequirePermission(menu = "COMPANY", perm = "UPDATE") @RequirePermission(menu = "COMPANY", perm = "UPDATE")
@DataChangeLog(menu = "COMPANY", table = "insurance_company") @DataChangeLog(menu = "COMPANY", table = "insurance_company")
@@ -15,6 +15,11 @@ import org.springframework.web.bind.annotation.*;
import java.util.Map; import java.util.Map;
/**
* 보험계약 관리 REST 컨트롤러.
* 계약(contract)은 "어떤 설계사가 어떤 고객에게 어떤 상품을 팔았는가"를 나타내는 핵심 거래 데이터로,
* 이후 수수료 계산·분급(installment)·차등환수(chargeback)의 기준이 된다.
*/
@Tag(name = "계약 관리") @Tag(name = "계약 관리")
@RestController @RestController
@RequestMapping("/api/contracts") @RequestMapping("/api/contracts")
@@ -23,18 +28,21 @@ public class ContractController {
private final ContractService service; private final ContractService service;
/** 계약 목록 조회. @ModelAttribute 로 검색조건(증권번호·기간 등)을 받아 페이징 결과를 반환. */
@GetMapping @GetMapping
@RequirePermission(menu = "CONTRACT_LIST", perm = "READ") @RequirePermission(menu = "CONTRACT_LIST", perm = "READ")
public ApiResponse<PageResponse<ContractResp>> list(@ModelAttribute ContractSearchParam param) { public ApiResponse<PageResponse<ContractResp>> list(@ModelAttribute ContractSearchParam param) {
return ApiResponse.ok(service.list(param)); return ApiResponse.ok(service.list(param));
} }
/** 계약 단건 상세 조회. */
@GetMapping("/{contractId}") @GetMapping("/{contractId}")
@RequirePermission(menu = "CONTRACT_LIST", perm = "READ") @RequirePermission(menu = "CONTRACT_LIST", perm = "READ")
public ApiResponse<ContractResp> detail(@PathVariable Long contractId) { public ApiResponse<ContractResp> detail(@PathVariable Long contractId) {
return ApiResponse.ok(service.detail(contractId)); return ApiResponse.ok(service.detail(contractId));
} }
/** 계약 신규 등록. @Valid 로 필수값 검증 후 생성된 PK(contractId) 반환. */
@PostMapping @PostMapping
@RequirePermission(menu = "CONTRACT_LIST", perm = "CREATE") @RequirePermission(menu = "CONTRACT_LIST", perm = "CREATE")
@DataChangeLog(menu = "CONTRACT_LIST", table = "contract") @DataChangeLog(menu = "CONTRACT_LIST", table = "contract")
@@ -42,6 +50,7 @@ public class ContractController {
return ApiResponse.ok(service.create(req)); return ApiResponse.ok(service.create(req));
} }
/** 계약 정보 수정. */
@PutMapping("/{contractId}") @PutMapping("/{contractId}")
@RequirePermission(menu = "CONTRACT_LIST", perm = "UPDATE") @RequirePermission(menu = "CONTRACT_LIST", perm = "UPDATE")
@DataChangeLog(menu = "CONTRACT_LIST", table = "contract") @DataChangeLog(menu = "CONTRACT_LIST", table = "contract")
@@ -50,6 +59,7 @@ public class ContractController {
return ApiResponse.ok(); return ApiResponse.ok();
} }
/** 계약 상태 변경(예: 정상 ACTIVE → 해지 CANCELLED). body 의 "status" 값만 사용. 해지 시 차등환수 계산 대상이 됨. */
@PutMapping("/{contractId}/status") @PutMapping("/{contractId}/status")
@RequirePermission(menu = "CONTRACT_LIST", perm = "UPDATE") @RequirePermission(menu = "CONTRACT_LIST", perm = "UPDATE")
@DataChangeLog(menu = "CONTRACT_LIST", table = "contract") @DataChangeLog(menu = "CONTRACT_LIST", table = "contract")
@@ -4,14 +4,19 @@ import com.ga.api.service.product.ProductService;
import com.ga.common.annotation.DataChangeLog; import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission; import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse; import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.product.ProductResp; import com.ga.core.vo.product.ProductResp;
import com.ga.core.vo.product.ProductVO; import com.ga.core.vo.product.ProductVO;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; /**
* 보험상품 관리 REST 컨트롤러.
* 상품(product) 보험사가 판매하는 개별 상품으로, 상품에는
* company_rate(보험사가 GA에 주는 요율)·payout_rate(GA가 설계사에게 지급하는 요율)
* 수수료 계산의 핵심 요율 정보가 붙는다.
*/
@Tag(name = "상품 관리") @Tag(name = "상품 관리")
@RestController @RestController
@RequestMapping("/api/products") @RequestMapping("/api/products")
@@ -20,21 +25,26 @@ public class ProductController {
private final ProductService productService; private final ProductService productService;
/** 상품 목록 조회. 보험사/보험종류/키워드/활성여부로 필터링하며 페이징 처리(pageNum/pageSize)된다. */
@GetMapping @GetMapping
@RequirePermission(menu = "PRODUCT", perm = "READ") @RequirePermission(menu = "PRODUCT", perm = "READ")
public ApiResponse<List<ProductResp>> list(@RequestParam(required = false) Long companyId, public ApiResponse<PageResponse<ProductResp>> list(@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(required = false) String insuranceType, @RequestParam(defaultValue = "10") int pageSize,
@RequestParam(required = false) String keyword, @RequestParam(required = false) Long companyId,
@RequestParam(required = false) String isActive) { @RequestParam(required = false) String insuranceType,
return ApiResponse.ok(productService.list(companyId, insuranceType, keyword, isActive)); @RequestParam(required = false) String keyword,
@RequestParam(required = false) String isActive) {
return ApiResponse.ok(productService.list(pageNum, pageSize, companyId, insuranceType, keyword, isActive));
} }
/** 상품 단건 상세 조회. */
@GetMapping("/{productId}") @GetMapping("/{productId}")
@RequirePermission(menu = "PRODUCT", perm = "READ") @RequirePermission(menu = "PRODUCT", perm = "READ")
public ApiResponse<ProductResp> detail(@PathVariable Long productId) { public ApiResponse<ProductResp> detail(@PathVariable Long productId) {
return ApiResponse.ok(productService.detail(productId)); return ApiResponse.ok(productService.detail(productId));
} }
/** 상품 신규 등록. 생성된 PK(productId) 반환. */
@PostMapping @PostMapping
@RequirePermission(menu = "PRODUCT", perm = "CREATE") @RequirePermission(menu = "PRODUCT", perm = "CREATE")
@DataChangeLog(menu = "PRODUCT", table = "product") @DataChangeLog(menu = "PRODUCT", table = "product")
@@ -42,6 +52,7 @@ public class ProductController {
return ApiResponse.ok(productService.create(vo)); return ApiResponse.ok(productService.create(vo));
} }
/** 상품 정보(요율 등) 수정. */
@PutMapping("/{productId}") @PutMapping("/{productId}")
@RequirePermission(menu = "PRODUCT", perm = "UPDATE") @RequirePermission(menu = "PRODUCT", perm = "UPDATE")
@DataChangeLog(menu = "PRODUCT", table = "product") @DataChangeLog(menu = "PRODUCT", table = "product")
@@ -50,6 +61,7 @@ public class ProductController {
return ApiResponse.ok(); return ApiResponse.ok();
} }
/** 상품 삭제. */
@DeleteMapping("/{productId}") @DeleteMapping("/{productId}")
@RequirePermission(menu = "PRODUCT", perm = "DELETE") @RequirePermission(menu = "PRODUCT", perm = "DELETE")
@DataChangeLog(menu = "PRODUCT", table = "product") @DataChangeLog(menu = "PRODUCT", table = "product")
@@ -12,6 +12,19 @@ import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
/**
* [데이터 수신 매핑/원본 관리 API]
*
* 신입 개발자 안내:
* - 보험사마다 보내주는 수수료 데이터의 컬럼명/코드값이 제각각이다. 이를 우리 표준으로 바꾸기 위한 설정이 "매핑"이다.
* - 구성 요소:
* company_profile : 보험사별 수신 설정(파일 형식, 헤더 위치 )
* company_field_mapping : 보험사 컬럼 우리 DB 필드 매핑
* code_mapping : 보험사 코드값 우리 표준 코드값 매핑
* raw_commission_data : 수신한 원본 데이터(파싱 상태 포함)
* parse_error_log : 파싱 실패 건과 처리(resolve) 상태
* - 업로드 템플릿(upload) "엑셀 수동 적재"라면, 여기 매핑은 "보험사별 자동 수신/파싱 규칙" 가깝다.
*/
@Tag(name = "데이터 수신") @Tag(name = "데이터 수신")
@RestController @RestController
@RequestMapping("/api/receive") @RequestMapping("/api/receive")
@@ -82,6 +95,7 @@ public class ReceiveController {
} }
// 원본 데이터 + 에러 // 원본 데이터 + 에러
// raw: 수신한 원본 목록(보험사/정산월/파싱상태로 필터). errors: 파싱 실패 로그. resolve: 실패 건을 처리완료로 표시.
@GetMapping("/raw") @GetMapping("/raw")
@RequirePermission(menu = "RECEIVE_DATA", perm = "READ") @RequirePermission(menu = "RECEIVE_DATA", perm = "READ")
public ApiResponse<List<RawCommissionDataVO>> rawList(@RequestParam(required = false) String companyCode, public ApiResponse<List<RawCommissionDataVO>> rawList(@RequestParam(required = false) String companyCode,
@@ -4,6 +4,11 @@ import jakarta.validation.constraints.NotBlank;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
/**
* 파싱 에러 처리(resolve) 요청 본문.
* status: 처리 결과 상태(: 해결됨/보류 ) 필수.
* note : 처리 사유/메모 선택.
*/
@Getter @Getter
@Setter @Setter
public class ResolveErrorReq { public class ResolveErrorReq {
@@ -7,12 +7,18 @@ import lombok.Data;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
/**
* 계약 단위 규제 한도(: 1200%) 위반 여부 체크 요청 DTO.
* "이 계약에 수수료를 additionalAmount 만큼 더 지급해도 규제 한도를 넘지 않는가?" 묻는다.
*/
@Data @Data
public class ContractLimitCheckReq { public class ContractLimitCheckReq {
/** 한도 체크 대상 계약 ID. */
@NotNull @NotNull
private Long contractId; private Long contractId;
/** 추가로 지급하려는 수수료 금액(양수만 허용). 이 금액을 더했을 때 한도 초과 여부를 검사한다. */
@NotNull @NotNull
@Positive @Positive
private BigDecimal additionalAmount; private BigDecimal additionalAmount;
@@ -17,6 +17,13 @@ import org.springframework.web.bind.annotation.*;
import java.time.LocalDate; import java.time.LocalDate;
/**
* 규제 한도 관리 REST 컨트롤러.
* 금융당국 규제(대표적으로 보험모집 수수료의 '1200%': 초년도 수수료를 월납보험료의 12배 이내로 제한)
* 한도마스터(regulatory_limit) 테이블로 관리하고, 계약별 위반 여부를 점검한다.
* - limitService : 한도 마스터 CRUD + 활성/비활성 토글
* - limitChecker : 실제 계약에 적용해 한도 초과 여부 계산(별도 상세 주석 파일)
*/
@Tag(name = "규제 한도 관리") @Tag(name = "규제 한도 관리")
@RestController @RestController
@RequestMapping("/api/regulatory-limits") @RequestMapping("/api/regulatory-limits")
@@ -26,18 +33,21 @@ public class RegulatoryLimitController {
private final RegulatoryLimitService limitService; private final RegulatoryLimitService limitService;
private final RegulatoryLimitChecker limitChecker; private final RegulatoryLimitChecker limitChecker;
/** 한도 마스터 목록 페이징 조회. */
@GetMapping @GetMapping
@RequirePermission(menu = "REGULATORY_LIMIT", perm = "READ") @RequirePermission(menu = "REGULATORY_LIMIT", perm = "READ")
public ApiResponse<PageResponse<RegulatoryLimitResp>> list(RegulatoryLimitSearchParam param) { public ApiResponse<PageResponse<RegulatoryLimitResp>> list(RegulatoryLimitSearchParam param) {
return ApiResponse.ok(limitService.list(param)); return ApiResponse.ok(limitService.list(param));
} }
/** 한도 마스터 단건 상세 조회. */
@GetMapping("/{limitId}") @GetMapping("/{limitId}")
@RequirePermission(menu = "REGULATORY_LIMIT", perm = "READ") @RequirePermission(menu = "REGULATORY_LIMIT", perm = "READ")
public ApiResponse<RegulatoryLimitResp> detail(@PathVariable Long limitId) { public ApiResponse<RegulatoryLimitResp> detail(@PathVariable Long limitId) {
return ApiResponse.ok(limitService.detail(limitId)); return ApiResponse.ok(limitService.detail(limitId));
} }
/** 한도 마스터 신규 등록. */
@PostMapping @PostMapping
@RequirePermission(menu = "REGULATORY_LIMIT", perm = "CREATE") @RequirePermission(menu = "REGULATORY_LIMIT", perm = "CREATE")
@DataChangeLog(menu = "REGULATORY_LIMIT", table = "regulatory_limit") @DataChangeLog(menu = "REGULATORY_LIMIT", table = "regulatory_limit")
@@ -46,6 +56,7 @@ public class RegulatoryLimitController {
return ApiResponse.ok(); return ApiResponse.ok();
} }
/** 한도 마스터 수정. */
@PutMapping("/{limitId}") @PutMapping("/{limitId}")
@RequirePermission(menu = "REGULATORY_LIMIT", perm = "UPDATE") @RequirePermission(menu = "REGULATORY_LIMIT", perm = "UPDATE")
@DataChangeLog(menu = "REGULATORY_LIMIT", table = "regulatory_limit") @DataChangeLog(menu = "REGULATORY_LIMIT", table = "regulatory_limit")
@@ -55,6 +66,7 @@ public class RegulatoryLimitController {
return ApiResponse.ok(); return ApiResponse.ok();
} }
/** 한도 마스터 비활성화(삭제 대신 사용 중지 처리). 비활성 한도는 위반 체크에서 제외된다. */
@PutMapping("/{limitId}/deactivate") @PutMapping("/{limitId}/deactivate")
@RequirePermission(menu = "REGULATORY_LIMIT", perm = "UPDATE") @RequirePermission(menu = "REGULATORY_LIMIT", perm = "UPDATE")
@DataChangeLog(menu = "REGULATORY_LIMIT", table = "regulatory_limit") @DataChangeLog(menu = "REGULATORY_LIMIT", table = "regulatory_limit")
@@ -63,6 +75,20 @@ public class RegulatoryLimitController {
return ApiResponse.ok(); return ApiResponse.ok();
} }
/** 한도 마스터 재활성화. 같은 유형의 활성 한도가 이미 있으면 중복 오류. */
@PutMapping("/{limitId}/activate")
@RequirePermission(menu = "REGULATORY_LIMIT", perm = "UPDATE")
@DataChangeLog(menu = "REGULATORY_LIMIT", table = "regulatory_limit")
public ApiResponse<Void> activate(@PathVariable Long limitId) {
limitService.activate(limitId);
return ApiResponse.ok();
}
/**
* 특정 계약에 수수료를 지급했을 규제 한도 위반 여부를 점검한다.
* limitType FIRST_YEAR_RATIO 초년도( 1년) 기준, (TOTAL_RATIO) 계약 전체 누적 기준으로 검사.
* baseDate 없으면 오늘 날짜를 기준일로 사용한다.
*/
@PostMapping("/check-contract") @PostMapping("/check-contract")
@RequirePermission(menu = "REGULATORY_LIMIT", perm = "READ") @RequirePermission(menu = "REGULATORY_LIMIT", perm = "READ")
public ApiResponse<ViolationResult> checkContract(@Valid @RequestBody ContractLimitCheckReq req) { public ApiResponse<ViolationResult> checkContract(@Valid @RequestBody ContractLimitCheckReq req) {
@@ -0,0 +1,110 @@
package com.ga.api.controller.report;
import com.ga.api.service.report.ReportService;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.excel.ExcelService;
import com.ga.common.model.ApiResponse;
import com.ga.core.vo.report.ChargebackRiskRow;
import com.ga.core.vo.report.OrgReportRow;
import com.ga.core.vo.report.PerfReportRow;
import com.ga.core.vo.report.SimplifiedPaymentRow;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
/**
* 리포트 실적 / 조직별 / 환수위험. 읽기전용 집계 (신규 테이블 없음).
* 화면의 조회 + 엑셀 export 엔드포인트 제공.
*
* [신입 개발자 안내]
* - 모든 리포트는 기존 정산/지급 데이터를 SQL 모아 보여주는 "읽기 전용 집계"(저장 테이블을 새로 만들지 않음).
* - 리포트마다 화면 조회용(JSON 목록) 엑셀 다운로드용(/export, Cursor 스트리밍) 개가 쌍이다.
* - year/settleMonth 파라미터가 없으면 defaultYear/defaultMonth "올해/이번 달" 기본 적용한다.
* - 리포트 종류:
* performance : 설계사/연도별 실적
* org : 조직(지점) 집계
* chargeback-risk : 환수(이미 수수료를 도로 회수) 위험 목록
* simplified-payment : 사업소득 간이지급명세서(국세청 제출용 월별 자료)
*/
@Tag(name = "리포트")
@RestController
@RequestMapping("/api/report")
@RequiredArgsConstructor
public class ReportController {
private final ReportService service;
private final ExcelService excelService;
private String defaultYear(String year) {
return (year != null && !year.isBlank()) ? year : String.valueOf(LocalDate.now().getYear());
}
private String defaultMonth(String settleMonth) {
return (settleMonth != null && !settleMonth.isBlank())
? settleMonth : LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMM"));
}
// 실적 리포트
@GetMapping("/performance")
@RequirePermission(menu = "REPORT_PERFORMANCE", perm = "READ")
public ApiResponse<List<PerfReportRow>> performance(@RequestParam(required = false) String year) {
return ApiResponse.ok(service.performance(defaultYear(year)));
}
@GetMapping("/performance/export")
@RequirePermission(menu = "REPORT_PERFORMANCE", perm = "READ")
public void performanceExport(@RequestParam(required = false) String year, HttpServletResponse response) {
String y = defaultYear(year);
excelService.exportLargeExcel("실적리포트", PerfReportRow.class,
() -> service.performanceCursor(y), response);
}
// 조직별 리포트
@GetMapping("/org")
@RequirePermission(menu = "REPORT_ORG", perm = "READ")
public ApiResponse<List<OrgReportRow>> org(@RequestParam(required = false) String settleMonth) {
return ApiResponse.ok(service.org(defaultMonth(settleMonth)));
}
@GetMapping("/org/export")
@RequirePermission(menu = "REPORT_ORG", perm = "READ")
public void orgExport(@RequestParam(required = false) String settleMonth, HttpServletResponse response) {
String m = defaultMonth(settleMonth);
excelService.exportLargeExcel("조직별리포트", OrgReportRow.class,
() -> service.orgCursor(m), response);
}
// 환수위험 리포트
@GetMapping("/chargeback-risk")
@RequirePermission(menu = "REPORT_CHARGEBACK", perm = "READ")
public ApiResponse<List<ChargebackRiskRow>> chargebackRisk() {
return ApiResponse.ok(service.chargebackRisk());
}
@GetMapping("/chargeback-risk/export")
@RequirePermission(menu = "REPORT_CHARGEBACK", perm = "READ")
public void chargebackRiskExport(HttpServletResponse response) {
excelService.exportLargeExcel("환수위험리포트", ChargebackRiskRow.class,
() -> service.chargebackRiskCursor(), response);
}
// 사업소득 간이지급명세서 (월별)
@GetMapping("/simplified-payment")
@RequirePermission(menu = "REPORT_SIMPLIFIED_PAYMENT", perm = "READ")
public ApiResponse<List<SimplifiedPaymentRow>> simplifiedPayment(@RequestParam(required = false) String settleMonth) {
return ApiResponse.ok(service.simplifiedPayment(defaultMonth(settleMonth)));
}
@GetMapping("/simplified-payment/export")
@RequirePermission(menu = "REPORT_SIMPLIFIED_PAYMENT", perm = "READ")
public void simplifiedPaymentExport(@RequestParam(required = false) String settleMonth, HttpServletResponse response) {
String m = defaultMonth(settleMonth);
excelService.exportLargeExcel("간이지급명세서_" + m, SimplifiedPaymentRow.class,
() -> service.simplifiedPaymentCursor(m), response);
}
}
@@ -11,6 +11,16 @@ import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
/**
* 수수료 규정() 관리 REST 컨트롤러.
* 수수료 계산에 쓰이는 각종 기준표를 CRUD 한다. 다섯 가지 그룹으로 구성된다.
* - commission_rate : 상품별 보험사 지급요율(company_rate) 기준표
* - payout_rule : 설계사 등급(grade) 지급요율(payout_rate) 규칙
* - override_rule : 상위 관리자에게 주는 오버라이드(육성) 수당 규칙
* - chargeback_rule : 조기해지 환수율(차등환수) 규칙
* - exception_type_code : 예외 처리 유형 코드
* 기준표들을 잘못 등록하면 직원 수수료 계산이 틀어지므로 권한·감사로그가 엄격히 걸려 있다.
*/
@Tag(name = "수수료 규정") @Tag(name = "수수료 규정")
@RestController @RestController
@RequestMapping("/api/rules") @RequestMapping("/api/rules")
@@ -19,7 +29,7 @@ public class RuleController {
private final RuleService ruleService; private final RuleService ruleService;
// ========== commission_rate ========== // ========== commission_rate : 상품별 보험사 지급요율(company_rate) 기준표 CRUD ==========
@GetMapping("/commission-rates") @GetMapping("/commission-rates")
@RequirePermission(menu = "RULE_COMMISSION", perm = "READ") @RequirePermission(menu = "RULE_COMMISSION", perm = "READ")
public ApiResponse<List<CommissionRateVO>> listCommissionRates(@RequestParam(required = false) Long productId, public ApiResponse<List<CommissionRateVO>> listCommissionRates(@RequestParam(required = false) Long productId,
@@ -50,7 +60,7 @@ public class RuleController {
return ApiResponse.ok(); return ApiResponse.ok();
} }
// ========== payout_rule ========== // ========== payout_rule : 설계사 등급(grade) 지급요율(payout_rate) 규칙 CRUD ==========
@GetMapping("/payout") @GetMapping("/payout")
@RequirePermission(menu = "RULE_PAYOUT", perm = "READ") @RequirePermission(menu = "RULE_PAYOUT", perm = "READ")
public ApiResponse<List<PayoutRuleVO>> listPayoutRules(@RequestParam(required = false) Long gradeId, public ApiResponse<List<PayoutRuleVO>> listPayoutRules(@RequestParam(required = false) Long gradeId,
@@ -81,7 +91,7 @@ public class RuleController {
return ApiResponse.ok(); return ApiResponse.ok();
} }
// ========== override_rule ========== // ========== override_rule : 상위 관리자 오버라이드(육성) 수당 규칙 CRUD ==========
@GetMapping("/override") @GetMapping("/override")
@RequirePermission(menu = "RULE_OVERRIDE", perm = "READ") @RequirePermission(menu = "RULE_OVERRIDE", perm = "READ")
public ApiResponse<List<OverrideRuleVO>> listOverrideRules() { public ApiResponse<List<OverrideRuleVO>> listOverrideRules() {
@@ -111,7 +121,7 @@ public class RuleController {
return ApiResponse.ok(); return ApiResponse.ok();
} }
// ========== chargeback_rule ========== // ========== chargeback_rule : 조기해지 환수율(차등환수) 규칙 CRUD ==========
@GetMapping("/chargeback") @GetMapping("/chargeback")
@RequirePermission(menu = "RULE_CHARGEBACK", perm = "READ") @RequirePermission(menu = "RULE_CHARGEBACK", perm = "READ")
public ApiResponse<List<ChargebackRuleVO>> listChargebackRules(@RequestParam(required = false) String companyCode) { public ApiResponse<List<ChargebackRuleVO>> listChargebackRules(@RequestParam(required = false) String companyCode) {
@@ -141,7 +151,7 @@ public class RuleController {
return ApiResponse.ok(); return ApiResponse.ok();
} }
// ========== exception_type_code ========== // ========== exception_type_code : 예외 처리 유형 코드 관리(코드값 기반) ==========
@GetMapping("/exception-codes") @GetMapping("/exception-codes")
@RequirePermission(menu = "RULE_EXCEPTION", perm = "READ") @RequirePermission(menu = "RULE_EXCEPTION", perm = "READ")
public ApiResponse<List<ExceptionTypeCodeVO>> listExceptionCodes() { public ApiResponse<List<ExceptionTypeCodeVO>> listExceptionCodes() {
@@ -0,0 +1,62 @@
package com.ga.api.controller.settle;
import com.ga.api.service.settle.CarrierReconciliationOutService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.settle.CarrierReconciliationOutResp;
import com.ga.core.vo.settle.CarrierReconciliationOutSaveReq;
import com.ga.core.vo.settle.CarrierReconciliationOutSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
/**
* 보험사 회신(carrier_reconciliation_out) 관리 컨트롤러.
*
* <p>GA가 보험사로 보낸 정산 자료에 대해 "어떤 파일을 언제 회신/전송했는지" 이력을 관리하는 화면입니다.
* 건은 "보험사 1곳 × 정산월 1개" 유일하며, 전송 실패(FAIL) 건은 retry 다시 READY 돌려 배치가 재전송합니다.
*
* <p>상태: READY(전송대기) (배치 처리) 성공/FAIL. 권한(menu=RECON_OUT): 조회 READ, 등록 CREATE, 재전송 UPDATE.
*/
@Tag(name = "보험사 회신 관리")
@RestController
@RequestMapping("/api/carrier-reconciliation")
@RequiredArgsConstructor
public class CarrierReconciliationOutController {
private final CarrierReconciliationOutService service;
/** 보험사 회신 이력 목록 조회(검색 + 페이징). */
@GetMapping
@RequirePermission(menu = "RECON_OUT", perm = "READ")
public ApiResponse<PageResponse<CarrierReconciliationOutResp>> list(@ModelAttribute CarrierReconciliationOutSearchParam param) {
return ApiResponse.ok(service.list(param));
}
/** 회신 이력 단건 상세 조회. */
@GetMapping("/{reconOutId}")
@RequirePermission(menu = "RECON_OUT", perm = "READ")
public ApiResponse<CarrierReconciliationOutResp> detail(@PathVariable Long reconOutId) {
return ApiResponse.ok(service.detail(reconOutId));
}
/** 회신 이력 신규 등록(READY 상태로 시작). 같은 보험사+정산월 중복 등록은 거부됩니다. */
@PostMapping
@RequirePermission(menu = "RECON_OUT", perm = "CREATE")
@DataChangeLog(menu = "RECON_OUT", table = "carrier_reconciliation_out")
public ApiResponse<Long> create(@Valid @RequestBody CarrierReconciliationOutSaveReq req) {
return ApiResponse.ok(service.create(req));
}
/** 전송 실패(FAIL) 건 재전송. 상태를 READY 로 되돌려 배치가 다시 처리하도록 합니다. */
@PostMapping("/{reconOutId}/retry")
@RequirePermission(menu = "RECON_OUT", perm = "UPDATE")
@DataChangeLog(menu = "RECON_OUT", table = "carrier_reconciliation_out")
public ApiResponse<Void> retry(@PathVariable Long reconOutId) {
service.retry(reconOutId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,68 @@
package com.ga.api.controller.settle;
import com.ga.api.service.settle.ChargebackService;
import com.ga.api.service.settle.ChargebackUploadService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.settle.ChargebackResp;
import com.ga.core.vo.settle.ChargebackSearchParam;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.constraints.Pattern;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
/**
* 환수(chargeback) 자료 관리 컨트롤러.
*
* <p>"환수" 이미 설계사에게 지급한 수수료를 도로 거둬들이는 것입니다. 계약이 일찍 해지/실효되면
* 보험사가 GA에게 수수료를 돌려달라 하고, GA는 그만큼을 설계사 정산에서 회수합니다.
*
* <p> 업무는 보험사가 보내준 환수 엑셀(증권번호·환수금액·실효월·환수율) 업로드(upload)
* chargeback 테이블에 적재하는 것입니다. 존재하지 않는 증권번호나 잘못된 금액은 에러행으로 돌려주고,
* 같은 계약+정산월 중복은 건너뜁니다(스킵).
*
* <p>권한(menu=CHARGEBACK_DATA): 조회 READ, 엑셀 EXPORT, 업로드 CREATE.
*/
@Tag(name = "환수자료 관리")
@Validated
@RestController
@RequestMapping("/api/chargebacks")
@RequiredArgsConstructor
public class ChargebackController {
private final ChargebackService chargebackService;
private final ChargebackUploadService chargebackUploadService;
@Operation(summary = "환수자료 목록 조회")
@GetMapping
@RequirePermission(menu = "CHARGEBACK_DATA", perm = "READ")
public ApiResponse<PageResponse<ChargebackResp>> list(@ModelAttribute ChargebackSearchParam param) {
return ApiResponse.ok(chargebackService.list(param));
}
@Operation(summary = "환수자료 엑셀 다운로드")
@GetMapping("/export")
@RequirePermission(menu = "CHARGEBACK_DATA", perm = "EXPORT")
public void export(@ModelAttribute ChargebackSearchParam param, HttpServletResponse response) {
chargebackService.export(param, response);
}
@Operation(summary = "보험사 환수자료 업로드",
description = "정산월(YYYYMM) + 엑셀 파일(증권번호/환수금액/실효월/환수율). " +
"미존재 증권번호·cbAmount 오류는 에러행으로 반환. 동일 계약+정산월 중복은 스킵.")
@PostMapping("/upload")
@RequirePermission(menu = "CHARGEBACK_DATA", perm = "CREATE")
@DataChangeLog(menu = "CHARGEBACK_DATA", table = "chargeback")
public ApiResponse<ChargebackUploadService.UploadResult> upload(
@Pattern(regexp = "\\d{6}", message = "settleMonth는 YYYYMM 형식이어야 합니다")
@RequestParam String settleMonth,
@RequestParam("file") MultipartFile file) {
return ApiResponse.ok(chargebackUploadService.upload(settleMonth, file));
}
}
@@ -0,0 +1,95 @@
package com.ga.api.controller.settle;
import com.ga.api.service.settle.CommissionDisputeService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.settle.CommissionDisputeResp;
import com.ga.core.vo.settle.CommissionDisputeSaveReq;
import com.ga.core.vo.settle.CommissionDisputeSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.Map;
/**
* 수수료 이의신청(commission_dispute) 관리 컨트롤러.
*
* <p>설계사가 "내 수수료 정산 금액이 이상하다" 문제를 제기하면 이의신청 건이 만들어집니다.
* 담당자는 검토를 시작(startReview)하고, 타당하면 승인(approve)하며 보정 금액을 정정(correction) 연결하거나,
* 타당하지 않으면 기각(reject)합니다.
*
* <p>상태 흐름(DisputeStatus): OPEN(접수) IN_REVIEW(검토중) APPROVED(승인)/REJECTED(기각).
* 승인/기각은 IN_REVIEW 상태에서만 가능합니다.
*
* <p>권한(menu=DISPUTE): 조회 READ, 접수 CREATE, 검토시작 UPDATE, 승인/기각 APPROVE.
*/
@Tag(name = "수수료 이의신청 관리")
@RestController
@RequestMapping("/api/disputes")
@RequiredArgsConstructor
public class CommissionDisputeController {
private final CommissionDisputeService service;
/** 이의신청 목록 조회(검색 + 페이징). */
@GetMapping
@RequirePermission(menu = "DISPUTE", perm = "READ")
public ApiResponse<PageResponse<CommissionDisputeResp>> list(@ModelAttribute CommissionDisputeSearchParam param) {
return ApiResponse.ok(service.list(param));
}
/** 이의신청 단건 상세 조회. */
@GetMapping("/{disputeId}")
@RequirePermission(menu = "DISPUTE", perm = "READ")
public ApiResponse<CommissionDisputeResp> detail(@PathVariable Long disputeId) {
return ApiResponse.ok(service.detail(disputeId));
}
/** 이의신청 접수(OPEN 상태로 생성). */
@PostMapping
@RequirePermission(menu = "DISPUTE", perm = "CREATE")
@DataChangeLog(menu = "DISPUTE", table = "commission_dispute")
public ApiResponse<Long> create(@Valid @RequestBody CommissionDisputeSaveReq req) {
return ApiResponse.ok(service.create(req));
}
/** 검토 시작(OPEN → IN_REVIEW). */
@PostMapping("/{disputeId}/review")
@RequirePermission(menu = "DISPUTE", perm = "UPDATE")
@DataChangeLog(menu = "DISPUTE", table = "commission_dispute")
public ApiResponse<Void> startReview(@PathVariable Long disputeId) {
service.startReview(disputeId);
return ApiResponse.ok();
}
/**
* 이의신청 승인(IN_REVIEW APPROVED).
* body resolution(처리내용), adjustmentAmount(보정금액, 선택), correctionId(연결된 정산정정ID, 선택) 받습니다.
*/
@PostMapping("/{disputeId}/approve")
@RequirePermission(menu = "DISPUTE", perm = "APPROVE")
@DataChangeLog(menu = "DISPUTE", table = "commission_dispute")
public ApiResponse<Void> approve(@PathVariable Long disputeId, @RequestBody Map<String, Object> body) {
String resolution = (String) body.get("resolution");
BigDecimal adjustmentAmount = body.get("adjustmentAmount") != null
? new BigDecimal(body.get("adjustmentAmount").toString()) : null;
Long correctionId = body.get("correctionId") != null
? Long.valueOf(body.get("correctionId").toString()) : null;
service.approve(disputeId, resolution, adjustmentAmount, correctionId);
return ApiResponse.ok();
}
/** 이의신청 기각(IN_REVIEW → REJECTED). body 의 resolution 에 기각 사유를 담습니다. */
@PostMapping("/{disputeId}/reject")
@RequirePermission(menu = "DISPUTE", perm = "APPROVE")
@DataChangeLog(menu = "DISPUTE", table = "commission_dispute")
public ApiResponse<Void> reject(@PathVariable Long disputeId, @RequestBody Map<String, String> body) {
service.reject(disputeId, body.get("resolution"));
return ApiResponse.ok();
}
}
@@ -0,0 +1,76 @@
package com.ga.api.controller.settle;
import com.ga.api.service.settle.IncentivePaymentService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.settle.IncentivePaymentResp;
import com.ga.core.vo.settle.IncentivePaymentSaveReq;
import com.ga.core.vo.settle.IncentivePaymentSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 시책 지급(incentive_payment) 관리 컨트롤러.
*
* <p>"시책" 보험사/GA가 특정 기간·상품에 거는 추가 인센티브(판매 장려금)입니다.
* 시책 프로그램(incentive_program) 조건을 충족한 설계사에게 지급할 금액을 건별로 만들어 확정/취소합니다.
*
* <p>상태 흐름(IncentivePayStatus): CALCULATED(산출) CONFIRMED(확정) PAID(지급완료), 또는 CANCELLED(취소).
* 확정은 CALCULATED 상태에서만, 취소는 PAID 아닐 때만 가능합니다. 생성·확정 정산월 마감 여부를 확인합니다.
*
* <p>권한(menu=INCENTIVE_PAY): 조회 READ, 생성 CREATE, 확정 APPROVE, 취소 UPDATE.
*/
@Tag(name = "시책 지급 관리")
@RestController
@RequestMapping("/api/incentive-payments")
@RequiredArgsConstructor
public class IncentivePaymentController {
private final IncentivePaymentService service;
/** 시책 지급 목록 조회(검색 + 페이징). */
@GetMapping
@RequirePermission(menu = "INCENTIVE_PAY", perm = "READ")
public ApiResponse<PageResponse<IncentivePaymentResp>> list(@ModelAttribute IncentivePaymentSearchParam param) {
return ApiResponse.ok(service.list(param));
}
/** 시책 지급 단건 상세 조회. */
@GetMapping("/{incentivePayId}")
@RequirePermission(menu = "INCENTIVE_PAY", perm = "READ")
public ApiResponse<IncentivePaymentResp> detail(@PathVariable Long incentivePayId) {
return ApiResponse.ok(service.detail(incentivePayId));
}
/** 시책 지급 생성(CALCULATED 상태). 해당 정산월이 마감되어 있으면 거부됩니다. */
@PostMapping
@RequirePermission(menu = "INCENTIVE_PAY", perm = "CREATE")
@DataChangeLog(menu = "INCENTIVE_PAY", table = "incentive_payment")
public ApiResponse<Long> create(@Valid @RequestBody IncentivePaymentSaveReq req) {
return ApiResponse.ok(service.create(req));
}
/** 시책 지급 확정(CALCULATED → CONFIRMED). 마감 가드 적용. APPROVE 권한 필요. */
@PostMapping("/{incentivePayId}/confirm")
@RequirePermission(menu = "INCENTIVE_PAY", perm = "APPROVE")
@DataChangeLog(menu = "INCENTIVE_PAY", table = "incentive_payment")
public ApiResponse<Void> confirm(@PathVariable Long incentivePayId) {
service.confirm(incentivePayId);
return ApiResponse.ok();
}
/** 시책 지급 취소(→ CANCELLED). body 의 cancelReason 에 취소 사유를 담습니다. 지급완료건은 취소 불가. */
@PostMapping("/{incentivePayId}/cancel")
@RequirePermission(menu = "INCENTIVE_PAY", perm = "UPDATE")
@DataChangeLog(menu = "INCENTIVE_PAY", table = "incentive_payment")
public ApiResponse<Void> cancel(@PathVariable Long incentivePayId, @RequestBody Map<String, String> body) {
service.cancel(incentivePayId, body.get("cancelReason"));
return ApiResponse.ok();
}
}
@@ -0,0 +1,75 @@
package com.ga.api.controller.settle;
import com.ga.api.service.settle.IncentiveProgramService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.settle.IncentiveProgramResp;
import com.ga.core.vo.settle.IncentiveProgramSaveReq;
import com.ga.core.vo.settle.IncentiveProgramSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 시책 프로그램(incentive_program) 관리 컨트롤러.
*
* <p>시책 "프로그램" 인센티브 지급의 규칙 정의입니다. 대상(등급/조직/상품군), 기간(시작월~종료월),
* 지급 방식(정률 payRate 또는 정액 payAmountFixed), 예산(budgetAmount) 등을 설정합니다.
* 실제 지급(incentive_payment) 프로그램 조건에 따라 산출됩니다.
*
* <p>활성/비활성(isActive) toggleActive 전환합니다. 비활성 프로그램은 신규 지급 대상에서 제외됩니다.
* 권한(menu=INCENTIVE_PROG): 조회 READ, 생성 CREATE, 수정/활성토글 UPDATE.
*/
@Tag(name = "시책 프로그램 관리")
@RestController
@RequestMapping("/api/incentive-programs")
@RequiredArgsConstructor
public class IncentiveProgramController {
private final IncentiveProgramService service;
/** 시책 프로그램 목록 조회(검색 + 페이징). */
@GetMapping
@RequirePermission(menu = "INCENTIVE_PROG", perm = "READ")
public ApiResponse<PageResponse<IncentiveProgramResp>> list(@ModelAttribute IncentiveProgramSearchParam param) {
return ApiResponse.ok(service.list(param));
}
/** 시책 프로그램 단건 상세 조회. */
@GetMapping("/{programId}")
@RequirePermission(menu = "INCENTIVE_PROG", perm = "READ")
public ApiResponse<IncentiveProgramResp> detail(@PathVariable Long programId) {
return ApiResponse.ok(service.detail(programId));
}
/** 시책 프로그램 신규 등록(기본 활성 상태로 생성). */
@PostMapping
@RequirePermission(menu = "INCENTIVE_PROG", perm = "CREATE")
@DataChangeLog(menu = "INCENTIVE_PROG", table = "incentive_program")
public ApiResponse<Long> create(@Valid @RequestBody IncentiveProgramSaveReq req) {
return ApiResponse.ok(service.create(req));
}
/** 시책 프로그램 수정(전체 필드 갱신). */
@PutMapping("/{programId}")
@RequirePermission(menu = "INCENTIVE_PROG", perm = "UPDATE")
@DataChangeLog(menu = "INCENTIVE_PROG", table = "incentive_program")
public ApiResponse<Void> update(@PathVariable Long programId, @Valid @RequestBody IncentiveProgramSaveReq req) {
service.update(programId, req);
return ApiResponse.ok();
}
/** 활성/비활성 토글. body 의 isActive(true/false)로 사용 여부를 전환합니다. */
@PutMapping("/{programId}/active")
@RequirePermission(menu = "INCENTIVE_PROG", perm = "UPDATE")
@DataChangeLog(menu = "INCENTIVE_PROG", table = "incentive_program")
public ApiResponse<Void> toggleActive(@PathVariable Long programId, @RequestBody Map<String, Boolean> body) {
service.toggleActive(programId, Boolean.TRUE.equals(body.get("isActive")));
return ApiResponse.ok();
}
}

Some files were not shown because too many files have changed in this diff Show More