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:
+64
@@ -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(); }
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user