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>
This commit is contained in:
GA Pro
2026-06-05 00:18:58 +09:00
parent b12858c991
commit 51ee03453c
23 changed files with 1700 additions and 6 deletions
+81
View File
@@ -0,0 +1,81 @@
import api, { PageResponse, unwrap } from './request';
// ── Types ──────────────────────────────────────────
export type ClawbackStatus = 'OUTSTANDING' | 'RECOVERING' | 'CLEARED' | 'WRITTEN_OFF';
export interface ClawbackReceivableRow extends Record<string, unknown> {
receivableId: number;
agentId: number;
agentName: string;
originSettleMonth: string;
originalAmount: number;
recoveredAmount: number;
balance: number;
status: ClawbackStatus;
statusName: string;
lastRecoveryMonth?: string;
writeoffReason?: string;
createdAt: string;
}
export interface ClawbackRecoveryItem {
settleMonth: string;
recoveredAmount: number;
createdAt: string;
}
export interface ClawbackReceivableDetail extends ClawbackReceivableRow {
recoveries: ClawbackRecoveryItem[];
}
export interface ClawbackSummaryRow extends Record<string, unknown> {
agentId: number;
agentName: string;
totalBalance: number;
receivableCount: number;
}
export interface ClawbackSearchParam {
agentId?: number;
agentName?: string;
status?: string;
originSettleMonth?: string;
pageNum?: number;
pageSize?: number;
}
export interface WriteOffParam {
status: 'CLEARED' | 'WRITTEN_OFF';
writeoffReason: string;
}
// ── API ───────────────────────────────────────────
export const clawbackReceivableApi = {
list: (p: ClawbackSearchParam) =>
unwrap<PageResponse<ClawbackReceivableRow>>(
api.get('/api/clawback-receivables', { params: p }),
),
summary: () =>
unwrap<ClawbackSummaryRow[]>(
api.get('/api/clawback-receivables/summary'),
),
detail: (id: number) =>
unwrap<ClawbackReceivableDetail>(
api.get(`/api/clawback-receivables/${id}`),
),
writeOff: (id: number, body: WriteOffParam) =>
unwrap<ClawbackReceivableRow>(
api.put(`/api/clawback-receivables/${id}/write-off`, body),
),
export: (p: ClawbackSearchParam) =>
api.get('/api/clawback-receivables/export', {
params: p,
responseType: 'blob',
}),
};