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>
9.5 KiB
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에 갚아야 할 미수채권(환수채권) 이다. 표준 처리:
- 이월(carryover): 당월 net<0 → 음수분(|net|)을 환수채권으로 신규 등록, 당월 실지급(payable)=0.
- 상계 회수(recovery): 차월 이후 net>0이면 미결 채권을 FIFO(오래된 origin월 우선) 로 상계 회수. payable = net − 회수액.
- 대손/직접상환(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 | 발생액(양수, =당월 |
| 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 컬럼 추가 (기존 의미 불변)
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, componentcommission/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 직접:
- reverse: 이 settleMonth의 기존 clawback_recovery 행 조회 → 각 receivable.balance += recovered_amount, recovered_amount -= , status 재계산(CLEARED→RECOVERING/OUTSTANDING) → recovery 행 삭제. (재실행 안전)
- carryover: carryover>0 → upsert clawback_receivable (agent_id, origin=settleMonth) {original_amount=carryover, balance=carryover, recovered_amount=0, status=OUTSTANDING}. (ON CONFLICT 갱신)
- 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 직접)
./gradlew build(test 포함) SUCCESSFUL, ga-frontendnpm run buildPASS.- Flyway V111 success, schema v111. 테이블 2개·컬럼 3개·메뉴 1·권한·공통코드 확인.
- GET 3종 + write-off PUT 실호출 5xx 0.
- 배치 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=net−recovered 확인.
- (c) 동일월 재실행 → 회수 중복 없음(reverse 멱등) 확인.
- 부산물 전부 롤백, pre-e2e 복원.
7. 후속 갭 (본 P9 범위 외, 기록)
- 시상/인정실적 환수 ledger (incar
인정실적수수료환수/신인도입유치자시상환수). - 지급대상건 구성·수정(수동 포함/제외 워크플로우, incar
지급대상건구성/수정/조회). - 환수 보험사자료 업로드(incar
환수보험사자료 업로드— 보험사 통보 기반 환수 인입). - withdraw 가용한도 검증을 net_amount→payable_amount 로 전환.