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
@@ -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(); }
};
}
};
}
}