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
+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,64 @@
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;
@Tag(name = "환수채권 관리")
@RestController
@RequestMapping("/api/clawback-receivables")
@RequiredArgsConstructor
public class ClawbackReceivableController {
private final ClawbackReceivableService service;
@GetMapping
@RequirePermission(menu = "CLAWBACK_RECEIVABLE", perm = "READ")
public ApiResponse<PageResponse<ClawbackReceivableResp>> list(
@ModelAttribute ClawbackReceivableSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/summary")
@RequirePermission(menu = "CLAWBACK_RECEIVABLE", perm = "READ")
public ApiResponse<List<Map<String, Object>>> summary(
@ModelAttribute ClawbackReceivableSearchParam param) {
return ApiResponse.ok(service.summary(param));
}
@GetMapping("/{id}")
@RequirePermission(menu = "CLAWBACK_RECEIVABLE", perm = "READ")
public ApiResponse<ClawbackReceivableResp> detail(@PathVariable Long id) {
return ApiResponse.ok(service.detail(id));
}
@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();
}
@GetMapping("/export")
@RequirePermission(menu = "CLAWBACK_RECEIVABLE", perm = "EXPORT")
public void export(@ModelAttribute ClawbackReceivableSearchParam param,
HttpServletResponse response) {
service.export(param, response);
}
}
@@ -0,0 +1,33 @@
package com.ga.api.service.commission;
import com.ga.common.annotation.ExcelColumn;
import lombok.Data;
import java.math.BigDecimal;
/**
* 환수채권 엑셀 export 행.
* ga-core ClawbackReceivableResp 에 @ExcelColumn 없으므로 ga-api 전용 프로젝션.
* TODO: ga-core 에 ClawbackReceivableExcelVO + selectCursorForExcel 추가 후 이 클래스 제거 가능.
*/
@Data
public class ClawbackReceivableExcelRow {
@ExcelColumn(header = "설계사명", order = 1, width = 20)
private String agentName;
@ExcelColumn(header = "발생월", order = 2, width = 12)
private String originSettleMonth;
@ExcelColumn(header = "발생액", order = 3, width = 15)
private BigDecimal originalAmount;
@ExcelColumn(header = "회수액", order = 4, width = 15)
private BigDecimal recoveredAmount;
@ExcelColumn(header = "잔액", order = 5, width = 15)
private BigDecimal balance;
@ExcelColumn(header = "상태", order = 6, width = 15, codeGroup = "CLAWBACK_RCV_STATUS")
private String status;
}
@@ -0,0 +1,108 @@
package com.ga.api.service.commission;
import com.ga.common.excel.ExcelService;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.commission.ClawbackReceivableMapper;
import com.ga.core.vo.commission.ClawbackReceivableResp;
import com.ga.core.vo.commission.ClawbackReceivableSaveReq;
import com.ga.core.vo.commission.ClawbackReceivableSearchParam;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.apache.ibatis.cursor.Cursor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ClawbackReceivableService {
private final ClawbackReceivableMapper mapper;
private final ExcelService excelService;
public PageResponse<ClawbackReceivableResp> list(ClawbackReceivableSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
public ClawbackReceivableResp detail(Long id) {
ClawbackReceivableResp resp = mapper.selectById(id);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
public List<Map<String, Object>> summary(ClawbackReceivableSearchParam param) {
return mapper.summaryByAgent(param);
}
/**
* 대손/직접상환 처리.
* OUTSTANDING 또는 RECOVERING 채권만 허용, 전이 상태는 CLEARED/WRITTEN_OFF 만 가능.
*/
@Transactional
public void writeOff(Long id, ClawbackReceivableSaveReq req, Long userId) {
ClawbackReceivableResp target = mapper.selectById(id);
if (target == null) throw new BizException(ErrorCode.NOT_FOUND);
String currentStatus = target.getStatus();
if (!"OUTSTANDING".equals(currentStatus) && !"RECOVERING".equals(currentStatus)) {
throw new BizException(ErrorCode.INVALID_PARAMETER, "이미 종결된 채권입니다");
}
String newStatus = req.getStatus();
if (!"CLEARED".equals(newStatus) && !"WRITTEN_OFF".equals(newStatus)) {
throw new BizException(ErrorCode.INVALID_PARAMETER, "전이 가능한 상태는 CLEARED 또는 WRITTEN_OFF 입니다");
}
mapper.writeOff(id, newStatus, req.getWriteoffReason(), userId);
}
/**
* 엑셀 export.
* NOTE(TODO): ClawbackReceivableMapper 에 selectCursorForExcel 이 없어 List → 인메모리 Cursor 로 대체.
* ga-core 에 Cursor 메서드 추가 후 mapper.selectCursorForExcel(param) 으로 교체 권장.
*/
@Transactional
public void export(ClawbackReceivableSearchParam param, HttpServletResponse response) {
List<ClawbackReceivableResp> rows = mapper.selectList(param);
List<ClawbackReceivableExcelRow> excelRows = rows.stream().map(r -> {
ClawbackReceivableExcelRow row = new ClawbackReceivableExcelRow();
row.setAgentName(r.getAgentName());
row.setOriginSettleMonth(r.getOriginSettleMonth());
row.setOriginalAmount(r.getOriginalAmount());
row.setRecoveredAmount(r.getRecoveredAmount());
row.setBalance(r.getBalance());
row.setStatus(r.getStatus());
return row;
}).toList();
excelService.exportLargeExcel("환수채권목록", ClawbackReceivableExcelRow.class,
() -> listCursor(excelRows), response);
}
/** List 를 MyBatis Cursor 인터페이스로 감싸는 인메모리 어댑터 */
private static <T> Cursor<T> listCursor(List<T> list) {
return new Cursor<T>() {
private final Iterator<T> it = list.iterator();
private int index = -1;
@Override public boolean isOpen() { return true; }
@Override public boolean isConsumed() { return !it.hasNext(); }
@Override public int getCurrentIndex() { return index; }
@Override public void close() {}
@Override
public Iterator<T> iterator() {
return new Iterator<T>() {
@Override public boolean hasNext() { return it.hasNext(); }
@Override public T next() { index++; return it.next(); }
};
}
};
}
}
@@ -6,6 +6,8 @@ import com.ga.batch.settlement.domain.TaxBreakdown;
import com.ga.common.util.MoneyUtil;
import com.ga.core.mapper.commission.AwardLedgerMapper;
import com.ga.core.mapper.commission.BranchMgrAllowanceLedgerMapper;
import com.ga.core.mapper.commission.ClawbackReceivableMapper;
import com.ga.core.mapper.commission.ClawbackRecoveryMapper;
import com.ga.core.mapper.commission.CollectionCommissionLedgerMapper;
import com.ga.core.mapper.commission.LifeSupportLedgerMapper;
import com.ga.core.mapper.commission.PersistencyBonusMapper;
@@ -22,6 +24,9 @@ import com.ga.core.mapper.settle.ChargebackMapper;
import com.ga.core.mapper.settle.CommissionStatementMapper;
import com.ga.core.mapper.settle.OverrideSettleMapper;
import com.ga.core.mapper.settle.SettleMasterMapper;
import com.ga.core.vo.commission.ClawbackReceivableResp;
import com.ga.core.vo.commission.ClawbackReceivableVO;
import com.ga.core.vo.commission.ClawbackRecoveryVO;
import com.ga.core.vo.org.AgentResp;
import com.ga.core.vo.org.AgentSearchParam;
import com.ga.core.vo.org.AgentVO;
@@ -58,6 +63,7 @@ import java.util.Set;
* (사업소득 3.3% / 기타소득 8.8% + 지방소득세 10% + 부가세 10%)
* - AgentDeductionMapper: PENDING 차감 합산 → netAmount에서 차감 후 APPLIED 상태로 전이
* - CommissionStatementMapper: 설계사별 명세서 레코드(ISSUED 상태) INSERT
* - ClawbackReceivableMapper/ClawbackRecoveryMapper: 환수채권 이월/상계 (V111)
*/
@Slf4j
@Component
@@ -76,6 +82,8 @@ public class AggregateStep implements Tasklet {
private final AgentDeductionMapper deductionMapper;
private final CommissionStatementMapper statementMapper;
private final BatchTaxCalculator taxCalculator;
private final ClawbackReceivableMapper receivableMapper;
private final ClawbackRecoveryMapper recoveryMapper;
// 설계사 지급성 9종 원장 Mapper
private final CollectionCommissionLedgerMapper collectionMapper;
@@ -154,6 +162,12 @@ public class AggregateStep implements Tasklet {
List<CommissionStatementVO> statements = new ArrayList<>();
int count = 0;
// 멱등 reverse (루프 진입 전 1회):
// 이 settleMonth의 기존 clawback_recovery 이력을 이용해 각 receivable의
// balance를 복원한 뒤 회수 이력을 전량 삭제한다.
// 동일 월 재실행 시 중복 회수가 발생하지 않도록 보장한다.
reverseClawbackRecovery(ctx.getSettleMonth());
for (Long agentId : agentIds) {
BigDecimal recruit = recruitMap.getOrDefault(agentId, BigDecimal.ZERO);
BigDecimal maintain = maintainMap.getOrDefault(agentId, BigDecimal.ZERO);
@@ -194,10 +208,27 @@ public class AggregateStep implements Tasklet {
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal netAmount = tax.netAmount().subtract(deductionTotal);
if (netAmount.signum() < 0) {
log.warn("[Step8] net 음수(공제>지급): agentId={}, gross={}, deduction={}, net={} — 차월 이월 검토 필요",
agentId, gross, deductionTotal, netAmount);
// 환수채권 이월/상계 계산 (V111)
// outstandingBalance: origin_settle_month < settleMonth 인 미결 채권 잔액 합계
// (당월 발생분은 자기상계 방지를 위해 포함하지 않음)
BigDecimal priorBalance = receivableMapper.outstandingBalance(agentId, ctx.getSettleMonth());
if (priorBalance == null) priorBalance = BigDecimal.ZERO;
BigDecimal recovered;
BigDecimal carryover;
BigDecimal payable;
if (netAmount.signum() >= 0) {
recovered = netAmount.min(priorBalance); // 기존 채권 FIFO 상계 (net 한도 내)
carryover = BigDecimal.ZERO;
payable = netAmount.subtract(recovered);
} else {
recovered = BigDecimal.ZERO;
carryover = netAmount.negate(); // 음수분 신규 채권화
payable = BigDecimal.ZERO;
}
log.info("[Step8 clawback] agentId={}, net={}, priorBalance={}, recovered={}, carryover={}, payable={}",
agentId, netAmount, priorBalance, recovered, carryover, payable);
// settle_master UPSERT
SettleMasterVO vo = new SettleMasterVO();
@@ -211,12 +242,32 @@ public class AggregateStep implements Tasklet {
vo.setChargebackTotal(cb);
vo.setOtherCommissionTotal(otherCommission);
vo.setGrossAmount(gross);
vo.setTaxAmount(totalTax); // incomeTax + localTax 합산
vo.setNetAmount(netAmount); // gross - incomeTax - localTax + vat - deduction
vo.setTaxAmount(totalTax); // incomeTax + localTax 합산
vo.setNetAmount(netAmount); // gross - incomeTax - localTax + vat - deduction (불변)
vo.setClawbackRecovered(recovered);
vo.setClawbackCarryover(carryover);
vo.setPayableAmount(payable);
vo.setStatus("CALCULATED");
settleMapper.upsert(vo);
count++;
// 채권 부수효과: carryover 이월 등록 (UNIQUE upsert — 멱등)
if (carryover.signum() > 0) {
ClawbackReceivableVO rcv = new ClawbackReceivableVO();
rcv.setAgentId(agentId);
rcv.setOriginSettleMonth(ctx.getSettleMonth());
rcv.setOriginalAmount(carryover);
rcv.setBalance(carryover);
rcv.setRecoveredAmount(BigDecimal.ZERO);
rcv.setStatus("OUTSTANDING");
receivableMapper.upsertCarryover(rcv);
}
// 채권 부수효과: FIFO 상계 회수 적용
if (recovered.signum() > 0) {
applyRecovery(agentId, ctx.getSettleMonth(), recovered);
}
// 차감 적용 완료 처리
if (!agentDeductions.isEmpty()) {
agentDeductions.forEach(d -> appliedDeductionIds.add(d.getDeductionId()));
@@ -261,6 +312,95 @@ public class AggregateStep implements Tasklet {
return RepeatStatus.FINISHED;
}
/**
* 멱등 reverse: settleMonth의 기존 clawback_recovery 이력을 조회하여
* 각 receivable의 balance/recoveredAmount/status를 복원한 뒤 회수 이력을 전량 삭제한다.
*
* 알고리즘:
* 1. selectByMonth(settleMonth)로 이번 달 회수 이력 전체 조회
* 2. receivable별로 이번 달 회수액 합산
* 3. 각 receivable에 대해 selectById로 현재 상태 조회
* 4. balance += thisMonthSum, recoveredAmount -= thisMonthSum 후 status 재계산
* 5. deleteByMonth(settleMonth)로 이력 전량 삭제
*
* 재실행 안전: 1회차에 이력이 없으면 즉시 반환. 2회차에 이력이 있으면 정확히 복원.
*/
private void reverseClawbackRecovery(String settleMonth) {
List<ClawbackRecoveryVO> prev = recoveryMapper.selectByMonth(settleMonth);
if (prev.isEmpty()) {
return;
}
// receivable별로 이번 달 회수액 합산 (동일 receivable 다건 방어)
Map<Long, BigDecimal> sumByReceivable = new HashMap<>();
for (ClawbackRecoveryVO r : prev) {
sumByReceivable.merge(r.getReceivableId(), r.getRecoveredAmount(), BigDecimal::add);
}
// 각 receivable의 현재 상태를 읽어 balance/recoveredAmount 복원
for (Map.Entry<Long, BigDecimal> e : sumByReceivable.entrySet()) {
Long receivableId = e.getKey();
BigDecimal toRestore = e.getValue();
ClawbackReceivableResp current = receivableMapper.selectById(receivableId);
if (current == null) {
log.warn("[Step8 reverse] receivable not found: receivableId={}", receivableId);
continue;
}
BigDecimal newRecovered = current.getRecoveredAmount().subtract(toRestore);
BigDecimal newBalance = current.getBalance().add(toRestore);
// status 재계산: recovered가 0이면 OUTSTANDING, 0보다 크면 RECOVERING
String newStatus;
if (newRecovered.signum() == 0) {
newStatus = "OUTSTANDING";
} else {
newStatus = "RECOVERING";
}
receivableMapper.updateRecovery(receivableId, newRecovered, newBalance, newStatus, null);
}
recoveryMapper.deleteByMonth(settleMonth);
log.info("[Step8 reverse] clawback recovery reversed for settleMonth={}, receivables={}",
settleMonth, sumByReceivable.size());
}
/**
* FIFO 상계 회수 적용: 오래된 origin_settle_month 순으로 채권을 소진하며
* recoveredTotal 만큼 clawback_receivable balance를 차감하고
* clawback_recovery 이력을 INSERT한다.
*
* recovered는 priorBalance(=selectOutstandingByAgentFifo 대상 채권 합계)를
* 초과하지 않으므로 remaining은 루프 내에서 정확히 소진된다.
*/
private void applyRecovery(Long agentId, String settleMonth, BigDecimal recoveredTotal) {
List<ClawbackReceivableVO> fifoList =
receivableMapper.selectOutstandingByAgentFifo(agentId, settleMonth);
BigDecimal remaining = recoveredTotal;
for (ClawbackReceivableVO rcv : fifoList) {
if (remaining.signum() == 0) break;
BigDecimal take = remaining.min(rcv.getBalance());
BigDecimal newRecovered = rcv.getRecoveredAmount().add(take);
BigDecimal newBalance = rcv.getBalance().subtract(take);
String newStatus = newBalance.signum() == 0 ? "CLEARED" : "RECOVERING";
receivableMapper.updateRecovery(
rcv.getReceivableId(), newRecovered, newBalance, newStatus, settleMonth);
ClawbackRecoveryVO recovery = new ClawbackRecoveryVO();
recovery.setReceivableId(rcv.getReceivableId());
recovery.setSettleMonth(settleMonth);
recovery.setRecoveredAmount(take);
recoveryMapper.insertOne(recovery);
remaining = remaining.subtract(take);
}
}
private TaxType parseTaxType(AgentVO agent) {
if (agent == null || agent.getTaxType() == null) return TaxType.BUSINESS_INCOME;
try {
@@ -0,0 +1,131 @@
-- V111: 환수채권 이월/미수금 관리 (Clawback Receivable Carry-forward)
-- P9 스펙 §2 전체 구현.
-- - clawback_receivable 테이블 (환수채권 마스터)
-- - clawback_recovery 테이블 (상계 회수 이력)
-- - settle_master 컬럼 3개 추가
-- - 메뉴 / 권한 / 공통코드 시드 (V95/V96 패턴 동일, ON CONFLICT DO NOTHING 멱등)
-- ============================================================
-- 1. clawback_receivable (환수채권 마스터)
-- ============================================================
CREATE TABLE clawback_receivable (
receivable_id BIGSERIAL PRIMARY KEY,
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
origin_settle_month CHAR(6) NOT NULL,
original_amount NUMERIC(15,2) NOT NULL,
recovered_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
balance NUMERIC(15,2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'OUTSTANDING',
last_recovery_month CHAR(6),
writeoff_reason VARCHAR(300),
written_off_at TIMESTAMP,
written_off_by BIGINT,
created_at TIMESTAMP NOT NULL DEFAULT now(),
created_by BIGINT,
updated_at TIMESTAMP,
CONSTRAINT uq_clawback_rcv_agent_month UNIQUE (agent_id, origin_settle_month),
CONSTRAINT chk_clawback_rcv_status CHECK (status IN ('OUTSTANDING','RECOVERING','CLEARED','WRITTEN_OFF')),
CONSTRAINT chk_clawback_rcv_orig_amt CHECK (original_amount >= 0),
CONSTRAINT chk_clawback_rcv_balance CHECK (balance >= 0)
);
CREATE INDEX idx_clawback_rcv_agent_status ON clawback_receivable (agent_id, status);
CREATE INDEX idx_clawback_rcv_status ON clawback_receivable (status);
CREATE INDEX idx_clawback_rcv_origin_month ON clawback_receivable (origin_settle_month);
-- ============================================================
-- 2. clawback_recovery (상계 회수 이력)
-- ============================================================
CREATE TABLE clawback_recovery (
recovery_id BIGSERIAL PRIMARY KEY,
receivable_id BIGINT NOT NULL REFERENCES clawback_receivable(receivable_id),
settle_month CHAR(6) NOT NULL,
recovered_amount NUMERIC(15,2) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX idx_clawback_rec_receivable ON clawback_recovery (receivable_id);
CREATE INDEX idx_clawback_rec_month ON clawback_recovery (settle_month);
-- ============================================================
-- 3. settle_master 컬럼 3개 추가
-- ============================================================
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;
COMMENT ON COLUMN settle_master.clawback_recovered IS '당월 채권 상계 회수액';
COMMENT ON COLUMN settle_master.clawback_carryover IS '당월 발생→이월된 미수액';
COMMENT ON COLUMN settle_master.payable_amount IS '실지급(이체대상) = max(0,net) recovered';
-- ============================================================
-- 4. 메뉴 INSERT: CLAWBACK_RECEIVABLE (GRP_COMMISSION 하위 PAGE)
-- ============================================================
INSERT INTO menu (parent_menu_id, menu_code, menu_name, menu_type, menu_path, component_path, menu_level, sort_order)
SELECT p.menu_id, x.code, x.name, 'PAGE', x.path, x.cmpt, 2, x.sort
FROM menu p
JOIN (VALUES
('CLAWBACK_RECEIVABLE', '환수채권관리', '/commission/clawback-receivable', 'commission/ClawbackReceivable', 150)
) AS x(code, name, path, cmpt, sort)
ON p.menu_code = 'GRP_COMMISSION'
WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code);
-- ============================================================
-- 5. 메뉴 권한 항목 등록 (READ/UPDATE/EXPORT)
-- ============================================================
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
SELECT m.menu_id, p.code, p.name, p.sort
FROM menu m
CROSS JOIN (VALUES
('READ', '조회', 10),
('UPDATE', '수정', 30),
('EXPORT', '엑셀', 60)
) AS p(code, name, sort)
WHERE m.menu_code = 'CLAWBACK_RECEIVABLE'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
-- ============================================================
-- 6. 역할별 권한 부여
-- ============================================================
-- SUPER_ADMIN / ADMIN — 전체 권한 (READ/UPDATE/EXPORT)
INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
FROM role r
CROSS JOIN menu_permission mp
JOIN menu m ON m.menu_id = mp.menu_id
WHERE r.role_code IN ('SUPER_ADMIN', 'ADMIN')
AND m.menu_code = 'CLAWBACK_RECEIVABLE'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- MANAGER — READ 만
INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
FROM role r
CROSS JOIN menu_permission mp
JOIN menu m ON m.menu_id = mp.menu_id
WHERE r.role_code = 'MANAGER'
AND mp.perm_code = 'READ'
AND m.menu_code = 'CLAWBACK_RECEIVABLE'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- ============================================================
-- 7. 공통코드: CLAWBACK_RCV_STATUS
-- ============================================================
INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
('CLAWBACK_RCV_STATUS', '환수채권상태', 'OUTSTANDING/RECOVERING/CLEARED/WRITTEN_OFF', 'Y', 440)
ON CONFLICT (group_code) DO NOTHING;
INSERT INTO common_code (group_code, code, code_name, sort_order) VALUES
('CLAWBACK_RCV_STATUS', 'OUTSTANDING', '미회수', 10),
('CLAWBACK_RCV_STATUS', 'RECOVERING', '회수중', 20),
('CLAWBACK_RCV_STATUS', 'CLEARED', '상환완료', 30),
('CLAWBACK_RCV_STATUS', 'WRITTEN_OFF', '대손', 40)
ON CONFLICT (group_code, code) DO NOTHING;
@@ -0,0 +1,80 @@
package com.ga.core.mapper.commission;
import com.ga.core.vo.commission.ClawbackReceivableResp;
import com.ga.core.vo.commission.ClawbackReceivableSearchParam;
import com.ga.core.vo.commission.ClawbackReceivableVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
/**
* clawback_receivable Mapper — 환수채권 마스터 (V111)
*/
@Mapper
public interface ClawbackReceivableMapper {
/** 단건 조회 — 설계사명 조인 + 회수이력(collection) 포함 */
ClawbackReceivableResp selectById(@Param("receivableId") Long receivableId);
/** 목록 조회 — PageHelper 호환, 설계사명 조인 포함 */
List<ClawbackReceivableResp> selectList(ClawbackReceivableSearchParam param);
/** 설계사 + 발생월 단건 조회 (배치 결과 확인용) */
ClawbackReceivableVO selectByAgentMonth(@Param("agentId") Long agentId,
@Param("originSettleMonth") String originSettleMonth);
/**
* 배치 priorBalance 조회.
* SUM(balance) WHERE agent_id=? AND status IN('OUTSTANDING','RECOVERING')
* AND origin_settle_month < #{settleMonth}
* 당월 발생분 자기상계 방지용 — null 이면 0 반환.
*/
BigDecimal outstandingBalance(@Param("agentId") Long agentId,
@Param("settleMonth") String settleMonth);
/**
* FIFO 회수 대상 채권 목록.
* status IN('OUTSTANDING','RECOVERING') AND origin_settle_month < settleMonth
* ORDER BY origin_settle_month ASC, receivable_id ASC
*/
List<ClawbackReceivableVO> selectOutstandingByAgentFifo(@Param("agentId") Long agentId,
@Param("settleMonth") String settleMonth);
/**
* 배치 멱등 upsert.
* INSERT ... ON CONFLICT(agent_id, origin_settle_month) DO UPDATE
* SET original_amount=EXCLUDED, balance=EXCLUDED, recovered_amount=0,
* status='OUTSTANDING', updated_at=now()
*/
int upsertCarryover(ClawbackReceivableVO vo);
/**
* 회수 반영 업데이트.
* recovered_amount, balance, status, last_recovery_month, updated_at 갱신.
*/
int updateRecovery(@Param("receivableId") Long receivableId,
@Param("recoveredAmount") BigDecimal recoveredAmount,
@Param("balance") BigDecimal balance,
@Param("status") String status,
@Param("lastRecoveryMonth") String lastRecoveryMonth);
/**
* 대손/직접상환 처리.
* balance→0, status, writeoff_reason, written_off_at, written_off_by, updated_at 갱신.
*/
int writeOff(@Param("id") Long id,
@Param("status") String status,
@Param("reason") String reason,
@Param("userId") Long userId);
/**
* 설계사별 미결 잔액 집계 (summary API용).
* status IN('OUTSTANDING','RECOVERING') 대상.
*/
List<Map<String, Object>> summaryByAgent(ClawbackReceivableSearchParam param);
int countByCondition(ClawbackReceivableSearchParam param);
}
@@ -0,0 +1,44 @@
package com.ga.core.mapper.commission;
import com.ga.core.vo.commission.ClawbackRecoveryVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* clawback_recovery Mapper — 상계 회수 이력 (V111)
* 회수 생성/역전은 배치 전용, API 직접 호출 없음.
*/
@Mapper
public interface ClawbackRecoveryMapper {
/** 회수 이력 단건 INSERT */
int insertOne(ClawbackRecoveryVO vo);
/** 채권 ID 기준 전체 회수 이력 조회 (상세 Resp 조립용) */
List<ClawbackRecoveryVO> selectByReceivableId(@Param("receivableId") Long receivableId);
/**
* 채권 + 정산월 기준 회수 이력 조회 (역전 시 balance 복원 금액 확인용).
*/
List<ClawbackRecoveryVO> selectByReceivableMonth(@Param("receivableId") Long receivableId,
@Param("settleMonth") String settleMonth);
/**
* 특정 정산월의 전체 회수 이력 조회.
* 배치 reverse 단계에서 해당 월 회수 건을 일괄 조회 후 balance 복원에 사용.
*/
List<ClawbackRecoveryVO> selectByMonth(@Param("settleMonth") String settleMonth);
/**
* 채권 + 정산월 기준 회수 이력 삭제 — 배치 역전(멱등 재실행) 지원.
*/
int deleteByReceivableMonth(@Param("receivableId") Long receivableId,
@Param("settleMonth") String settleMonth);
/**
* 정산월 단위 전체 회수 이력 삭제 — 월 전체 역전 시 사용.
*/
int deleteByMonth(@Param("settleMonth") String settleMonth);
}
@@ -0,0 +1,43 @@
package com.ga.core.mapstruct;
import com.ga.core.vo.commission.ClawbackReceivableSaveReq;
import com.ga.core.vo.commission.ClawbackReceivableVO;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
/**
* ClawbackReceivable SaveReq → VO 변환.
* write-off API: Service 에서 req→VO 후 writeOff Mapper 호출.
*/
@Mapper(componentModel = "spring")
public interface ClawbackReceivableMapStruct {
@Mapping(target = "receivableId", ignore = true)
@Mapping(target = "agentId", ignore = true)
@Mapping(target = "originSettleMonth", ignore = true)
@Mapping(target = "originalAmount", ignore = true)
@Mapping(target = "recoveredAmount", ignore = true)
@Mapping(target = "balance", ignore = true)
@Mapping(target = "lastRecoveryMonth", ignore = true)
@Mapping(target = "writtenOffAt", ignore = true)
@Mapping(target = "writtenOffBy", ignore = true)
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "createdBy", ignore = true)
@Mapping(target = "updatedAt", ignore = true)
ClawbackReceivableVO toVO(ClawbackReceivableSaveReq req);
@Mapping(target = "receivableId", ignore = true)
@Mapping(target = "agentId", ignore = true)
@Mapping(target = "originSettleMonth", ignore = true)
@Mapping(target = "originalAmount", ignore = true)
@Mapping(target = "recoveredAmount", ignore = true)
@Mapping(target = "balance", ignore = true)
@Mapping(target = "lastRecoveryMonth", ignore = true)
@Mapping(target = "writtenOffAt", ignore = true)
@Mapping(target = "writtenOffBy", ignore = true)
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "createdBy", ignore = true)
@Mapping(target = "updatedAt", ignore = true)
void updateVO(ClawbackReceivableSaveReq req, @MappingTarget ClawbackReceivableVO vo);
}
@@ -0,0 +1,35 @@
package com.ga.core.vo.commission;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* clawback_receivable 조회 응답 — 설계사명 조인 + 회수이력 포함.
* 상세 조회(selectById)에서 recoveries 가 채워진다.
*/
@Data
public class ClawbackReceivableResp {
private Long receivableId;
private Long agentId;
/** agent.agent_name */
private String agentName;
private String originSettleMonth;
private BigDecimal originalAmount;
private BigDecimal recoveredAmount;
private BigDecimal balance;
/** OUTSTANDING / RECOVERING / CLEARED / WRITTEN_OFF */
private String status;
/** 공통코드 CLAWBACK_RCV_STATUS */
private String statusName;
private String lastRecoveryMonth;
private String writeoffReason;
private LocalDateTime writtenOffAt;
private Long writtenOffBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/** 회수 이력 (상세 조회 시 populate) */
private List<ClawbackRecoveryResp> recoveries;
}
@@ -0,0 +1,25 @@
package com.ga.core.vo.commission;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* clawback_receivable 대손/직접상환 처리 요청 (write-off 전용).
* 수동 회수 엔드포인트 없음 — 회수는 배치 상계만.
*/
@Data
public class ClawbackReceivableSaveReq {
/**
* 전이할 상태: CLEARED(직접상환) 또는 WRITTEN_OFF(대손).
* OUTSTANDING/RECOVERING 인 채권에만 적용 가능.
*/
@NotBlank
private String status;
/** 대손/직접상환 사유 */
@NotNull
@NotBlank
private String writeoffReason;
}
@@ -0,0 +1,18 @@
package com.ga.core.vo.commission;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* clawback_receivable 검색 조건.
* status 는 부모 SearchParam 에 이미 있음.
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class ClawbackReceivableSearchParam extends SearchParam {
private Long agentId;
private String agentName;
/** 발생 정산월 (YYYYMM) */
private String originSettleMonth;
}
@@ -0,0 +1,29 @@
package com.ga.core.vo.commission;
import com.fasterxml.jackson.annotation.JsonValue;
/**
* 환수채권 상태 코드 (CLAWBACK_RCV_STATUS 그룹, V111)
* clawback_receivable.status 에서 사용.
*/
public enum ClawbackReceivableStatus {
OUTSTANDING("미회수"),
RECOVERING("회수중"),
CLEARED("상환완료"),
WRITTEN_OFF("대손");
private final String label;
ClawbackReceivableStatus(String label) {
this.label = label;
}
@JsonValue
public String getCode() {
return this.name();
}
public String getLabel() {
return label;
}
}
@@ -0,0 +1,35 @@
package com.ga.core.vo.commission;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* clawback_receivable — 환수채권 마스터 (V111)
* balance = original_amount - recovered_amount
*/
@Data
public class ClawbackReceivableVO {
private Long receivableId;
private Long agentId;
/** 미수 발생 정산월 (YYYYMM) */
private String originSettleMonth;
/** 발생액 — 양수, 당월 |net| */
private BigDecimal originalAmount;
/** 누적 상계 회수액 */
private BigDecimal recoveredAmount;
/** 잔액 = original_amount - recovered_amount */
private BigDecimal balance;
/** OUTSTANDING / RECOVERING / CLEARED / WRITTEN_OFF */
private String status;
/** 최근 회수 정산월 (YYYYMM) */
private String lastRecoveryMonth;
/** 대손/직접상환 사유 */
private String writeoffReason;
private LocalDateTime writtenOffAt;
private Long writtenOffBy;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,20 @@
package com.ga.core.vo.commission;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* clawback_recovery 조회 응답.
*/
@Data
public class ClawbackRecoveryResp {
private Long recoveryId;
private Long receivableId;
/** 회수(상계) 정산월 (YYYYMM) */
private String settleMonth;
/** 이 월 이 채권에서 회수한 액 */
private BigDecimal recoveredAmount;
private LocalDateTime createdAt;
}
@@ -0,0 +1,21 @@
package com.ga.core.vo.commission;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* clawback_recovery — 상계 회수 이력 (V111)
* 배치 FIFO 상계 시 INSERT, 재실행(reverse) 시 DELETE.
*/
@Data
public class ClawbackRecoveryVO {
private Long recoveryId;
private Long receivableId;
/** 회수(상계) 정산월 (YYYYMM) */
private String settleMonth;
/** 이 월 이 채권에서 회수한 액 */
private BigDecimal recoveredAmount;
private LocalDateTime createdAt;
}
@@ -26,4 +26,8 @@ public class SettleMasterVO {
private LocalDateTime confirmedAt;
private String holdReason;
private String holdReasonCode; // V32 추가: HOLD_REASON 코드 (NEED_DOC/UNDER_REVIEW/DISPUTE_OPEN 등)
// V111 추가: 환수채권 이월/미수금 관리
private BigDecimal clawbackRecovered; // 당월 채권 상계 회수액
private BigDecimal clawbackCarryover; // 당월 발생→이월된 미수액
private BigDecimal payableAmount; // 실지급 = max(0,net) - recovered
}
@@ -0,0 +1,197 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ga.core.mapper.commission.ClawbackReceivableMapper">
<!-- VO: INSERT/UPDATE/배치 용도 -->
<resultMap id="VOMap" type="com.ga.core.vo.commission.ClawbackReceivableVO"/>
<!-- Resp: 목록 조회 용도 (조인 포함, recoveries 없음) -->
<resultMap id="RespMap" type="com.ga.core.vo.commission.ClawbackReceivableResp"/>
<!-- 상세 Resp: recoveries collection 포함 -->
<resultMap id="RespDetailMap" type="com.ga.core.vo.commission.ClawbackReceivableResp">
<id property="receivableId" column="receivable_id"/>
<result property="agentId" column="agent_id"/>
<result property="agentName" column="agent_name"/>
<result property="originSettleMonth" column="origin_settle_month"/>
<result property="originalAmount" column="original_amount"/>
<result property="recoveredAmount" column="recovered_amount"/>
<result property="balance" column="balance"/>
<result property="status" column="status"/>
<result property="statusName" column="status_name"/>
<result property="lastRecoveryMonth" column="last_recovery_month"/>
<result property="writeoffReason" column="writeoff_reason"/>
<result property="writtenOffAt" column="written_off_at"/>
<result property="writtenOffBy" column="written_off_by"/>
<result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/>
<collection property="recoveries"
ofType="com.ga.core.vo.commission.ClawbackRecoveryResp"
select="com.ga.core.mapper.commission.ClawbackRecoveryMapper.selectByReceivableId"
column="receivable_id"/>
</resultMap>
<!-- 목록/단건 조회 컬럼 — 설계사명 조인 + 공통코드명 포함 -->
<sql id="rcvJoinCols">
r.receivable_id, r.agent_id, a.agent_name,
r.origin_settle_month,
r.original_amount, r.recovered_amount, r.balance,
r.status,
(SELECT cc.code_name FROM common_code cc
WHERE cc.group_code = 'CLAWBACK_RCV_STATUS' AND cc.code = r.status) AS status_name,
r.last_recovery_month,
r.writeoff_reason, r.written_off_at, r.written_off_by,
r.created_at, r.updated_at
</sql>
<sql id="rcvJoins">
FROM clawback_receivable r
LEFT JOIN agent a ON a.agent_id = r.agent_id
</sql>
<!-- 단건 조회 — 회수이력은 RespDetailMap collection 으로 자동 populate -->
<select id="selectById" resultMap="RespDetailMap">
SELECT <include refid="rcvJoinCols"/>
<include refid="rcvJoins"/>
WHERE r.receivable_id = #{receivableId}
</select>
<!-- 목록 조회 — PageHelper startPage() 호환 -->
<select id="selectList" resultMap="RespMap">
SELECT <include refid="rcvJoinCols"/>
<include refid="rcvJoins"/>
<where>
<if test="agentId != null">AND r.agent_id = #{agentId}</if>
<if test="agentName != null and agentName != ''">AND a.agent_name LIKE '%' || #{agentName} || '%'</if>
<if test="status != null and status != ''">AND r.status = #{status}</if>
<if test="originSettleMonth != null and originSettleMonth != ''">AND r.origin_settle_month = #{originSettleMonth}</if>
</where>
ORDER BY r.origin_settle_month DESC, r.agent_id, r.receivable_id DESC
</select>
<!-- 설계사 + 발생월 단건 (배치 확인·reverse 용도) -->
<select id="selectByAgentMonth" resultMap="VOMap">
SELECT receivable_id, agent_id, origin_settle_month,
original_amount, recovered_amount, balance,
status, last_recovery_month,
writeoff_reason, written_off_at, written_off_by,
created_at, created_by, updated_at
FROM clawback_receivable
WHERE agent_id = #{agentId}
AND origin_settle_month = #{originSettleMonth}
</select>
<!--
배치 priorBalance: 당월(settleMonth) 이전에 발생한 미결 잔액 합계.
origin_settle_month < settleMonth 로 당월 발생분 자기상계 방지.
COALESCE 로 null → 0 변환.
-->
<select id="outstandingBalance" resultType="java.math.BigDecimal">
SELECT COALESCE(SUM(balance), 0)
FROM clawback_receivable
WHERE agent_id = #{agentId}
AND status IN ('OUTSTANDING', 'RECOVERING')
AND origin_settle_month &lt; #{settleMonth}
</select>
<!--
FIFO 회수 대상 목록: 오래된 발생월 우선(ASC), 동월 내 receivable_id ASC.
배치 applyCarryoverAndRecovery 단계 3에서 순회하며 상계.
-->
<select id="selectOutstandingByAgentFifo" resultMap="VOMap">
SELECT receivable_id, agent_id, origin_settle_month,
original_amount, recovered_amount, balance,
status, last_recovery_month,
writeoff_reason, written_off_at, written_off_by,
created_at, created_by, updated_at
FROM clawback_receivable
WHERE agent_id = #{agentId}
AND status IN ('OUTSTANDING', 'RECOVERING')
AND origin_settle_month &lt; #{settleMonth}
ORDER BY origin_settle_month ASC, receivable_id ASC
</select>
<!--
배치 멱등 upsert.
UNIQUE(agent_id, origin_settle_month) 충돌 시 발생액·잔액을 덮어쓰고
회수액=0, 상태=OUTSTANDING 으로 초기화.
재실행 시 동일 채권이 중복 생성되지 않음.
-->
<insert id="upsertCarryover">
INSERT INTO clawback_receivable
(agent_id, origin_settle_month, original_amount, recovered_amount, balance,
status, created_by, created_at)
VALUES
(#{agentId}, #{originSettleMonth}, #{originalAmount}, 0, #{balance},
'OUTSTANDING', #{createdBy}, NOW())
ON CONFLICT (agent_id, origin_settle_month) DO UPDATE
SET original_amount = EXCLUDED.original_amount,
balance = EXCLUDED.balance,
recovered_amount = 0,
status = 'OUTSTANDING',
updated_at = NOW()
</insert>
<!--
배치 회수 반영: recovered_amount, balance, status, last_recovery_month 갱신.
status 는 배치가 계산 후 전달 (RECOVERING 또는 CLEARED).
-->
<update id="updateRecovery">
UPDATE clawback_receivable
SET recovered_amount = #{recoveredAmount},
balance = #{balance},
status = #{status},
last_recovery_month = #{lastRecoveryMonth},
updated_at = NOW()
WHERE receivable_id = #{receivableId}
</update>
<!--
대손/직접상환 write-off: balance→0, 상태 전이, 사유·일시·처리자 기록.
OUTSTANDING/RECOVERING 인 경우만 Service 레이어에서 검증 후 호출.
-->
<update id="writeOff">
UPDATE clawback_receivable
SET balance = 0,
status = #{status},
writeoff_reason = #{reason},
written_off_at = NOW(),
written_off_by = #{userId},
updated_at = NOW()
WHERE receivable_id = #{id}
</update>
<!-- 설계사별 미결 잔액 집계 (summary API) — 미결 상태만 -->
<select id="summaryByAgent" resultType="map">
SELECT r.agent_id AS "agentId",
a.agent_name AS "agentName",
COUNT(*) AS "count",
SUM(r.original_amount) AS "originalTotal",
SUM(r.recovered_amount) AS "recoveredTotal",
SUM(r.balance) AS "balanceTotal"
FROM clawback_receivable r
LEFT JOIN agent a ON a.agent_id = r.agent_id
WHERE r.status IN ('OUTSTANDING', 'RECOVERING')
<if test="agentId != null">AND r.agent_id = #{agentId}</if>
<if test="originSettleMonth != null and originSettleMonth != ''">
AND r.origin_settle_month = #{originSettleMonth}
</if>
GROUP BY r.agent_id, a.agent_name
ORDER BY SUM(r.balance) DESC
</select>
<!-- 페이징 count 쿼리 -->
<select id="countByCondition" resultType="int">
SELECT COUNT(*)
FROM clawback_receivable r
LEFT JOIN agent a ON a.agent_id = r.agent_id
<where>
<if test="agentId != null">AND r.agent_id = #{agentId}</if>
<if test="agentName != null and agentName != ''">AND a.agent_name LIKE '%' || #{agentName} || '%'</if>
<if test="status != null and status != ''">AND r.status = #{status}</if>
<if test="originSettleMonth != null and originSettleMonth != ''">AND r.origin_settle_month = #{originSettleMonth}</if>
</where>
</select>
</mapper>
@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ga.core.mapper.commission.ClawbackRecoveryMapper">
<resultMap id="VOMap" type="com.ga.core.vo.commission.ClawbackRecoveryVO"/>
<!-- Resp resultMap — ClawbackRecoveryResp 는 VO와 동일 컬럼 구성 -->
<resultMap id="RespMap" type="com.ga.core.vo.commission.ClawbackRecoveryResp"/>
<!-- 회수 이력 단건 INSERT — useGeneratedKeys 로 recoveryId 반환 -->
<insert id="insertOne" useGeneratedKeys="true" keyProperty="recoveryId">
INSERT INTO clawback_recovery
(receivable_id, settle_month, recovered_amount, created_at)
VALUES
(#{receivableId}, #{settleMonth}, #{recoveredAmount}, NOW())
</insert>
<!-- 채권 ID 기준 전체 회수 이력 조회 — 상세 Resp collection 용도 -->
<select id="selectByReceivableId" resultMap="RespMap">
SELECT recovery_id, receivable_id, settle_month, recovered_amount, created_at
FROM clawback_recovery
WHERE receivable_id = #{receivableId}
ORDER BY settle_month ASC, recovery_id ASC
</select>
<!--
채권 + 정산월 기준 회수 이력 조회.
배치 reverse 단계에서 복원할 금액 합산 시 사용.
-->
<select id="selectByReceivableMonth" resultMap="VOMap">
SELECT recovery_id, receivable_id, settle_month, recovered_amount, created_at
FROM clawback_recovery
WHERE receivable_id = #{receivableId}
AND settle_month = #{settleMonth}
ORDER BY recovery_id ASC
</select>
<!--
특정 정산월 전체 회수 이력 조회.
배치 applyCarryoverAndRecovery reverse 단계 1:
해당 월 회수 건 전체를 조회 → receivable.balance 복원 → 이 메서드로 조회한 결과를 순회.
-->
<select id="selectByMonth" resultMap="VOMap">
SELECT recovery_id, receivable_id, settle_month, recovered_amount, created_at
FROM clawback_recovery
WHERE settle_month = #{settleMonth}
ORDER BY receivable_id ASC, recovery_id ASC
</select>
<!--
채권 + 정산월 기준 회수 이력 삭제.
배치 단일 채권 역전 시 사용.
-->
<delete id="deleteByReceivableMonth">
DELETE FROM clawback_recovery
WHERE receivable_id = #{receivableId}
AND settle_month = #{settleMonth}
</delete>
<!--
정산월 단위 전체 회수 이력 삭제.
배치 월 전체 재정산(reverse) 시 사용. deleteByMonth 후 채권 balance 복원 필요.
-->
<delete id="deleteByMonth">
DELETE FROM clawback_recovery
WHERE settle_month = #{settleMonth}
</delete>
</mapper>
@@ -72,13 +72,16 @@
INSERT INTO settle_master (agent_id, settle_month, recruit_total, maintain_total,
exception_plus, exception_minus, override_total, chargeback_total,
other_commission_total,
gross_amount, tax_amount, net_amount, status, calc_date)
gross_amount, tax_amount, net_amount,
clawback_recovered, clawback_carryover, payable_amount,
status, calc_date)
VALUES (#{agentId}, #{settleMonth},
COALESCE(#{recruitTotal}, 0), COALESCE(#{maintainTotal}, 0),
COALESCE(#{exceptionPlus}, 0), COALESCE(#{exceptionMinus}, 0),
COALESCE(#{overrideTotal}, 0), COALESCE(#{chargebackTotal}, 0),
COALESCE(#{otherCommissionTotal}, 0),
COALESCE(#{grossAmount}, 0), COALESCE(#{taxAmount}, 0), COALESCE(#{netAmount}, 0),
COALESCE(#{clawbackRecovered}, 0), COALESCE(#{clawbackCarryover}, 0), COALESCE(#{payableAmount}, 0),
COALESCE(#{status},'CALCULATED'), NOW())
ON CONFLICT (agent_id, settle_month) DO UPDATE SET
recruit_total = EXCLUDED.recruit_total,
@@ -91,6 +94,9 @@
gross_amount = EXCLUDED.gross_amount,
tax_amount = EXCLUDED.tax_amount,
net_amount = EXCLUDED.net_amount,
clawback_recovered = EXCLUDED.clawback_recovered,
clawback_carryover = EXCLUDED.clawback_carryover,
payable_amount = EXCLUDED.payable_amount,
status = EXCLUDED.status,
calc_date = NOW()
WHERE settle_master.status NOT IN ('CONFIRMED','PAID')
+6
View File
@@ -30,6 +30,9 @@ const GuaranteeInsurance = React.lazy(() => import('@/pages/org/GuaranteeInsur
const ReserveFund = React.lazy(() => import('@/pages/commission/ReserveFund'));
const CommissionSimulator = React.lazy(() => import('@/pages/commission/CommissionSimulator'));
// P9: 환수채권
const ClawbackReceivable = React.lazy(() => import('@/pages/commission/ClawbackReceivable'));
// P7: 수수료 계산 7도메인
const IncomeCommission = React.lazy(() => import('@/pages/commission/IncomeCommission'));
const ReferralCommission = React.lazy(() => import('@/pages/commission/ReferralCommission'));
@@ -224,6 +227,9 @@ export default function App() {
<Route path="commission/reserve" element={<ReserveFund />} />
<Route path="commission/simulator" element={<CommissionSimulator />} />
{/* P9: 환수채권 */}
<Route path="commission/clawback-receivable" element={<ClawbackReceivable />} />
{/* P7: 수수료 계산 7도메인 */}
<Route path="commission/income" element={<IncomeCommission />} />
<Route path="commission/referral" element={<ReferralCommission />} />
+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',
}),
};
@@ -0,0 +1,336 @@
import { useState } from 'react';
import {
Drawer, Form, Input, Modal, Select, Space, Tag, Timeline, Typography, message,
} from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { ColDef } from '@ag-grid-community/core';
import dayjs from 'dayjs';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import DataGrid from '@/components/common/DataGrid';
import PermissionButton from '@/components/common/PermissionButton';
import {
clawbackReceivableApi,
ClawbackReceivableRow,
ClawbackReceivableDetail,
ClawbackSearchParam,
} from '@/api/clawbackReceivable';
import { GRAY, RADIUS, SHADOW, COLOR_SUCCESS, COLOR_ERROR, COLOR_WARNING, COLOR_PRIMARY } from '@/theme/tokens';
const { Text } = Typography;
const fmt = (v: unknown) =>
typeof v === 'number' ? v.toLocaleString() : (v as string | undefined) ?? '-';
const STATUS_COLOR: Record<string, string> = {
OUTSTANDING: COLOR_ERROR,
RECOVERING: COLOR_WARNING,
CLEARED: COLOR_SUCCESS,
WRITTEN_OFF: GRAY[400],
};
const STATUS_LABEL: Record<string, string> = {
OUTSTANDING: '미회수',
RECOVERING: '회수중',
CLEARED: '상환완료',
WRITTEN_OFF: '대손',
};
function StatusChip({ status, statusName }: { status: string; statusName?: string }) {
const color = STATUS_COLOR[status] ?? GRAY[400];
const label = statusName ?? STATUS_LABEL[status] ?? status;
return (
<Tag
style={{
color,
background: `${color}1A`,
border: `1px solid ${color}40`,
borderRadius: RADIUS.sm,
fontWeight: 600,
fontSize: 12,
}}
>
{label}
</Tag>
);
}
const GRID_COLS: ColDef<ClawbackReceivableRow>[] = [
{ field: 'agentName', headerName: '설계사', width: 130, pinned: 'left' },
{ field: 'originSettleMonth', headerName: '발생월', width: 110 },
{
field: 'originalAmount',
headerName: '발생액',
flex: 1,
type: 'numericColumn',
valueFormatter: (p) => fmt(p.value),
},
{
field: 'recoveredAmount',
headerName: '회수액',
flex: 1,
type: 'numericColumn',
valueFormatter: (p) => fmt(p.value),
},
{
field: 'balance',
headerName: '잔액',
flex: 1,
type: 'numericColumn',
valueFormatter: (p) => fmt(p.value),
},
{
field: 'status',
headerName: '상태',
width: 120,
cellRenderer: (p: { data: ClawbackReceivableRow }) =>
p.data ? <StatusChip status={p.data.status} statusName={p.data.statusName} /> : null,
},
{ field: 'lastRecoveryMonth', headerName: '최근회수월', width: 120 },
];
const MENU_CODE = 'CLAWBACK_RECEIVABLE';
export default function ClawbackReceivable() {
const qc = useQueryClient();
const [params, setParams] = useState<ClawbackSearchParam>({ pageSize: 200 });
const [selectedRow, setSelectedRow] = useState<ClawbackReceivableRow | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [writeOffOpen, setWriteOffOpen] = useState(false);
const [writeOffForm] = Form.useForm();
// ── 목록 조회 ──────────────────────────────────────
const { data: listData, isLoading } = useQuery({
queryKey: ['clawback-receivables', params],
queryFn: () => clawbackReceivableApi.list(params),
retry: false,
});
// ── 상세 조회 (행 클릭 시) ─────────────────────────
const { data: detail, isLoading: detailLoading } = useQuery({
queryKey: ['clawback-receivable', selectedRow?.receivableId],
queryFn: () => clawbackReceivableApi.detail(selectedRow!.receivableId),
enabled: !!selectedRow,
retry: false,
});
// ── 대손/상환 처리 뮤테이션 ──────────────────────────
const writeOffMutation = useMutation({
mutationFn: ({ id, body }: { id: number; body: { status: 'CLEARED' | 'WRITTEN_OFF'; writeoffReason: string } }) =>
clawbackReceivableApi.writeOff(id, body),
onSuccess: () => {
message.success('처리 완료');
qc.invalidateQueries({ queryKey: ['clawback-receivables'] });
qc.invalidateQueries({ queryKey: ['clawback-receivable', selectedRow?.receivableId] });
setWriteOffOpen(false);
writeOffForm.resetFields();
},
});
const rows = listData?.list ?? [];
// ── 합계행 ─────────────────────────────────────────
const pinnedBottom =
rows.length > 0
? ({
agentName: '합계',
originalAmount: rows.reduce((s, r) => s + (r.originalAmount ?? 0), 0),
recoveredAmount: rows.reduce((s, r) => s + (r.recoveredAmount ?? 0), 0),
balance: rows.reduce((s, r) => s + (r.balance ?? 0), 0),
} as Partial<ClawbackReceivableRow>)
: undefined;
const handleRowClick = (row: ClawbackReceivableRow) => {
setSelectedRow(row);
setDrawerOpen(true);
};
const handleExport = async () => {
try {
const res = await clawbackReceivableApi.export(params);
const url = URL.createObjectURL(new Blob([res.data as BlobPart]));
const a = document.createElement('a');
a.href = url;
a.download = `환수채권_${dayjs().format('YYYYMMDD')}.xlsx`;
a.click();
URL.revokeObjectURL(url);
} catch {
/* request.ts 처리 */
}
};
const handleWriteOffSubmit = async () => {
const values = await writeOffForm.validateFields();
if (!selectedRow) return;
writeOffMutation.mutate({
id: selectedRow.receivableId,
body: { status: values.status, writeoffReason: values.writeoffReason },
});
};
const canWriteOff =
selectedRow?.status === 'OUTSTANDING' || selectedRow?.status === 'RECOVERING';
const cardStyle = {
background: '#fff',
borderRadius: RADIUS.lg,
border: `1px solid ${GRAY[100]}`,
boxShadow: SHADOW.sm,
};
return (
<PageContainer
title="환수채권관리"
description="환수채권 이월/미수금 목록 조회 및 대손/상환 처리"
extra={
<PermissionButton menuCode={MENU_CODE} permCode="EXPORT" onClick={handleExport}>
</PermissionButton>
}
>
<SearchForm
conditions={[
{ type: 'text', name: 'agentName', label: '설계사명', span: 6 },
{ type: 'code', name: 'status', label: '상태', span: 6, groupCode: 'CLAWBACK_RCV_STATUS' },
{ type: 'month', name: 'originSettleMonth', label: '발생월', span: 6 },
]}
onSearch={(v) => setParams({ ...(v as ClawbackSearchParam), pageSize: 200 })}
onReset={() => setParams({ pageSize: 200 })}
/>
<ProCard style={cardStyle}>
<DataGrid<ClawbackReceivableRow>
rows={rows}
columns={GRID_COLS}
loading={isLoading}
height={560}
rowKey="receivableId"
pinnedBottomRow={pinnedBottom}
onSelectionChanged={(e) => {
const sel = e.api.getSelectedRows();
if (sel.length > 0) handleRowClick(sel[0]);
}}
/>
</ProCard>
{/* ── 상세 Drawer ─────────────────────────────── */}
<Drawer
title="환수채권 상세"
width={520}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
<PermissionButton
menuCode={MENU_CODE}
permCode="UPDATE"
type="primary"
danger
disabled={!canWriteOff}
onClick={() => setWriteOffOpen(true)}
>
/
</PermissionButton>
}
>
{detailLoading || !detail ? (
<Text style={{ color: GRAY[400] }}> ...</Text>
) : (
<DrawerContent detail={detail} />
)}
</Drawer>
{/* ── 대손/상환 처리 모달 ──────────────────────── */}
<Modal
title="대손/상환 처리"
open={writeOffOpen}
onOk={handleWriteOffSubmit}
onCancel={() => { setWriteOffOpen(false); writeOffForm.resetFields(); }}
okText="처리"
cancelText="취소"
confirmLoading={writeOffMutation.isPending}
>
<Form form={writeOffForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="status" label="처리 유형" rules={[{ required: true, message: '처리 유형을 선택하세요' }]}>
<Select placeholder="선택">
<Select.Option value="CLEARED"> ( )</Select.Option>
<Select.Option value="WRITTEN_OFF"> </Select.Option>
</Select>
</Form.Item>
<Form.Item name="writeoffReason" label="사유" rules={[{ required: true, message: '사유를 입력하세요' }]}>
<Input.TextArea rows={3} placeholder="처리 사유를 입력하세요" maxLength={300} showCount />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
// ── 상세 내용 컴포넌트 ─────────────────────────────────
function DrawerContent({ detail }: { detail: ClawbackReceivableDetail }) {
const fields: Array<{ label: string; value: React.ReactNode }> = [
{ label: '설계사', value: detail.agentName },
{ label: '발생월', value: detail.originSettleMonth },
{ label: '발생액', value: fmt(detail.originalAmount) },
{ label: '회수액', value: fmt(detail.recoveredAmount) },
{ label: '잔액', value: fmt(detail.balance) },
{ label: '상태', value: <StatusChip status={detail.status} statusName={detail.statusName} /> },
{ label: '최근회수월', value: detail.lastRecoveryMonth ?? '-' },
{ label: '등록일', value: detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD') : '-' },
];
if (detail.writeoffReason) {
fields.push({ label: '처리사유', value: detail.writeoffReason });
}
return (
<>
<div
style={{
display: 'grid',
gridTemplateColumns: '100px 1fr',
gap: '10px 12px',
marginBottom: 24,
}}
>
{fields.map(({ label, value }) => (
<>
<Text key={`lbl-${label}`} style={{ color: GRAY[500], fontSize: 13 }}>{label}</Text>
<Text key={`val-${label}`} style={{ color: GRAY[800], fontSize: 13, fontWeight: 500 }}>{value}</Text>
</>
))}
</div>
{detail.recoveries.length > 0 && (
<>
<Text style={{ fontWeight: 600, color: GRAY[700], fontSize: 14, display: 'block', marginBottom: 12 }}>
</Text>
<Timeline
items={detail.recoveries.map((r) => ({
color: COLOR_PRIMARY,
children: (
<Space direction="vertical" size={2}>
<Text style={{ fontWeight: 600, color: GRAY[700], fontSize: 13 }}>
{r.settleMonth}
</Text>
<Text style={{ color: GRAY[500], fontSize: 12 }}>
: {fmt(r.recoveredAmount)}
</Text>
<Text style={{ color: GRAY[400], fontSize: 11 }}>
{dayjs(r.createdAt).format('YYYY-MM-DD')}
</Text>
</Space>
),
}))}
/>
</>
)}
{detail.recoveries.length === 0 && (
<Text style={{ color: GRAY[400], fontSize: 13 }}> .</Text>
)}
</>
);
}