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:
@@ -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=net−recovered 확인.
|
||||
- (c) 동일월 재실행 → 회수 중복 없음(reverse 멱등) 확인.
|
||||
- 부산물 전부 롤백, pre-e2e 복원.
|
||||
|
||||
---
|
||||
|
||||
## 7. 후속 갭 (본 P9 범위 외, 기록)
|
||||
- 시상/인정실적 환수 ledger (incar `인정실적수수료환수`/`신인도입유치자시상환수`).
|
||||
- 지급대상건 구성·수정(수동 포함/제외 워크플로우, incar `지급대상건구성/수정/조회`).
|
||||
- 환수 보험사자료 업로드(incar `환수보험사자료 업로드` — 보험사 통보 기반 환수 인입).
|
||||
- withdraw 가용한도 검증을 net_amount→payable_amount 로 전환.
|
||||
</content>
|
||||
</invoke>
|
||||
+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(); }
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 < #{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 < #{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')
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user