diff --git a/docs/DOMAIN_GAP_P9_환수채권이월.md b/docs/DOMAIN_GAP_P9_환수채권이월.md new file mode 100644 index 0000000..00895e2 --- /dev/null +++ b/docs/DOMAIN_GAP_P9_환수채권이월.md @@ -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 로 전환. + + diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/ClawbackReceivableController.java b/ga-api/src/main/java/com/ga/api/controller/commission/ClawbackReceivableController.java new file mode 100644 index 0000000..f0abded --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/commission/ClawbackReceivableController.java @@ -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> list( + @ModelAttribute ClawbackReceivableSearchParam param) { + return ApiResponse.ok(service.list(param)); + } + + @GetMapping("/summary") + @RequirePermission(menu = "CLAWBACK_RECEIVABLE", perm = "READ") + public ApiResponse>> summary( + @ModelAttribute ClawbackReceivableSearchParam param) { + return ApiResponse.ok(service.summary(param)); + } + + @GetMapping("/{id}") + @RequirePermission(menu = "CLAWBACK_RECEIVABLE", perm = "READ") + public ApiResponse 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 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); + } +} diff --git a/ga-api/src/main/java/com/ga/api/service/commission/ClawbackReceivableExcelRow.java b/ga-api/src/main/java/com/ga/api/service/commission/ClawbackReceivableExcelRow.java new file mode 100644 index 0000000..4da2491 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/commission/ClawbackReceivableExcelRow.java @@ -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; +} diff --git a/ga-api/src/main/java/com/ga/api/service/commission/ClawbackReceivableService.java b/ga-api/src/main/java/com/ga/api/service/commission/ClawbackReceivableService.java new file mode 100644 index 0000000..c04df06 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/commission/ClawbackReceivableService.java @@ -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 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> 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 rows = mapper.selectList(param); + List 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 Cursor listCursor(List list) { + return new Cursor() { + private final Iterator 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 iterator() { + return new Iterator() { + @Override public boolean hasNext() { return it.hasNext(); } + @Override public T next() { index++; return it.next(); } + }; + } + }; + } +} diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/step/AggregateStep.java b/ga-batch/src/main/java/com/ga/batch/settlement/step/AggregateStep.java index df7575a..63cd668 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/step/AggregateStep.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/step/AggregateStep.java @@ -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 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 prev = recoveryMapper.selectByMonth(settleMonth); + if (prev.isEmpty()) { + return; + } + + // receivable별로 이번 달 회수액 합산 (동일 receivable 다건 방어) + Map sumByReceivable = new HashMap<>(); + for (ClawbackRecoveryVO r : prev) { + sumByReceivable.merge(r.getReceivableId(), r.getRecoveredAmount(), BigDecimal::add); + } + + // 각 receivable의 현재 상태를 읽어 balance/recoveredAmount 복원 + for (Map.Entry 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 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 { diff --git a/ga-common/src/main/resources/db/migration/V111__환수채권이월.sql b/ga-common/src/main/resources/db/migration/V111__환수채권이월.sql new file mode 100644 index 0000000..d875bd4 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V111__환수채권이월.sql @@ -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; diff --git a/ga-core/src/main/java/com/ga/core/mapper/commission/ClawbackReceivableMapper.java b/ga-core/src/main/java/com/ga/core/mapper/commission/ClawbackReceivableMapper.java new file mode 100644 index 0000000..de3c343 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/mapper/commission/ClawbackReceivableMapper.java @@ -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 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 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> summaryByAgent(ClawbackReceivableSearchParam param); + + int countByCondition(ClawbackReceivableSearchParam param); +} diff --git a/ga-core/src/main/java/com/ga/core/mapper/commission/ClawbackRecoveryMapper.java b/ga-core/src/main/java/com/ga/core/mapper/commission/ClawbackRecoveryMapper.java new file mode 100644 index 0000000..83103df --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/mapper/commission/ClawbackRecoveryMapper.java @@ -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 selectByReceivableId(@Param("receivableId") Long receivableId); + + /** + * 채권 + 정산월 기준 회수 이력 조회 (역전 시 balance 복원 금액 확인용). + */ + List selectByReceivableMonth(@Param("receivableId") Long receivableId, + @Param("settleMonth") String settleMonth); + + /** + * 특정 정산월의 전체 회수 이력 조회. + * 배치 reverse 단계에서 해당 월 회수 건을 일괄 조회 후 balance 복원에 사용. + */ + List selectByMonth(@Param("settleMonth") String settleMonth); + + /** + * 채권 + 정산월 기준 회수 이력 삭제 — 배치 역전(멱등 재실행) 지원. + */ + int deleteByReceivableMonth(@Param("receivableId") Long receivableId, + @Param("settleMonth") String settleMonth); + + /** + * 정산월 단위 전체 회수 이력 삭제 — 월 전체 역전 시 사용. + */ + int deleteByMonth(@Param("settleMonth") String settleMonth); +} diff --git a/ga-core/src/main/java/com/ga/core/mapstruct/ClawbackReceivableMapStruct.java b/ga-core/src/main/java/com/ga/core/mapstruct/ClawbackReceivableMapStruct.java new file mode 100644 index 0000000..b2120bc --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/mapstruct/ClawbackReceivableMapStruct.java @@ -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); +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackReceivableResp.java b/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackReceivableResp.java new file mode 100644 index 0000000..f655b0f --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackReceivableResp.java @@ -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 recoveries; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackReceivableSaveReq.java b/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackReceivableSaveReq.java new file mode 100644 index 0000000..14181ed --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackReceivableSaveReq.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackReceivableSearchParam.java b/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackReceivableSearchParam.java new file mode 100644 index 0000000..1d288ab --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackReceivableSearchParam.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackReceivableStatus.java b/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackReceivableStatus.java new file mode 100644 index 0000000..8467437 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackReceivableStatus.java @@ -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; + } +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackReceivableVO.java b/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackReceivableVO.java new file mode 100644 index 0000000..87a5af0 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackReceivableVO.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackRecoveryResp.java b/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackRecoveryResp.java new file mode 100644 index 0000000..aa1c995 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackRecoveryResp.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackRecoveryVO.java b/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackRecoveryVO.java new file mode 100644 index 0000000..05b4285 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackRecoveryVO.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/settle/SettleMasterVO.java b/ga-core/src/main/java/com/ga/core/vo/settle/SettleMasterVO.java index 5349d99..efa7308 100644 --- a/ga-core/src/main/java/com/ga/core/vo/settle/SettleMasterVO.java +++ b/ga-core/src/main/java/com/ga/core/vo/settle/SettleMasterVO.java @@ -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 } diff --git a/ga-core/src/main/resources/mapper/commission/ClawbackReceivableMapper.xml b/ga-core/src/main/resources/mapper/commission/ClawbackReceivableMapper.xml new file mode 100644 index 0000000..1e56a5e --- /dev/null +++ b/ga-core/src/main/resources/mapper/commission/ClawbackReceivableMapper.xml @@ -0,0 +1,197 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + FROM clawback_receivable r + LEFT JOIN agent a ON a.agent_id = r.agent_id + + + + + + + + + + + + + + + + + + + + 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() + + + + + UPDATE clawback_receivable + SET recovered_amount = #{recoveredAmount}, + balance = #{balance}, + status = #{status}, + last_recovery_month = #{lastRecoveryMonth}, + updated_at = NOW() + WHERE receivable_id = #{receivableId} + + + + + 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} + + + + + + + + + diff --git a/ga-core/src/main/resources/mapper/commission/ClawbackRecoveryMapper.xml b/ga-core/src/main/resources/mapper/commission/ClawbackRecoveryMapper.xml new file mode 100644 index 0000000..e15bade --- /dev/null +++ b/ga-core/src/main/resources/mapper/commission/ClawbackRecoveryMapper.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + INSERT INTO clawback_recovery + (receivable_id, settle_month, recovered_amount, created_at) + VALUES + (#{receivableId}, #{settleMonth}, #{recoveredAmount}, NOW()) + + + + + + + + + + + + + + DELETE FROM clawback_recovery + WHERE receivable_id = #{receivableId} + AND settle_month = #{settleMonth} + + + + + DELETE FROM clawback_recovery + WHERE settle_month = #{settleMonth} + + + diff --git a/ga-core/src/main/resources/mapper/settle/SettleMasterMapper.xml b/ga-core/src/main/resources/mapper/settle/SettleMasterMapper.xml index 5eb5e11..b6a4d23 100644 --- a/ga-core/src/main/resources/mapper/settle/SettleMasterMapper.xml +++ b/ga-core/src/main/resources/mapper/settle/SettleMasterMapper.xml @@ -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') diff --git a/ga-frontend/src/App.tsx b/ga-frontend/src/App.tsx index 1c751da..c1ce281 100644 --- a/ga-frontend/src/App.tsx +++ b/ga-frontend/src/App.tsx @@ -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() { } /> } /> + {/* P9: 환수채권 */} + } /> + {/* P7: 수수료 계산 7도메인 */} } /> } /> diff --git a/ga-frontend/src/api/clawbackReceivable.ts b/ga-frontend/src/api/clawbackReceivable.ts new file mode 100644 index 0000000..496c490 --- /dev/null +++ b/ga-frontend/src/api/clawbackReceivable.ts @@ -0,0 +1,81 @@ +import api, { PageResponse, unwrap } from './request'; + +// ── Types ────────────────────────────────────────── + +export type ClawbackStatus = 'OUTSTANDING' | 'RECOVERING' | 'CLEARED' | 'WRITTEN_OFF'; + +export interface ClawbackReceivableRow extends Record { + 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 { + 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>( + api.get('/api/clawback-receivables', { params: p }), + ), + + summary: () => + unwrap( + api.get('/api/clawback-receivables/summary'), + ), + + detail: (id: number) => + unwrap( + api.get(`/api/clawback-receivables/${id}`), + ), + + writeOff: (id: number, body: WriteOffParam) => + unwrap( + api.put(`/api/clawback-receivables/${id}/write-off`, body), + ), + + export: (p: ClawbackSearchParam) => + api.get('/api/clawback-receivables/export', { + params: p, + responseType: 'blob', + }), +}; diff --git a/ga-frontend/src/pages/commission/ClawbackReceivable.tsx b/ga-frontend/src/pages/commission/ClawbackReceivable.tsx new file mode 100644 index 0000000..5e23808 --- /dev/null +++ b/ga-frontend/src/pages/commission/ClawbackReceivable.tsx @@ -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 = { + OUTSTANDING: COLOR_ERROR, + RECOVERING: COLOR_WARNING, + CLEARED: COLOR_SUCCESS, + WRITTEN_OFF: GRAY[400], +}; + +const STATUS_LABEL: Record = { + 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 ( + + {label} + + ); +} + +const GRID_COLS: ColDef[] = [ + { 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 ? : null, + }, + { field: 'lastRecoveryMonth', headerName: '최근회수월', width: 120 }, +]; + +const MENU_CODE = 'CLAWBACK_RECEIVABLE'; + +export default function ClawbackReceivable() { + const qc = useQueryClient(); + const [params, setParams] = useState({ pageSize: 200 }); + const [selectedRow, setSelectedRow] = useState(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) + : 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 ( + + 엑셀 다운로드 + + } + > + setParams({ ...(v as ClawbackSearchParam), pageSize: 200 })} + onReset={() => setParams({ pageSize: 200 })} + /> + + + + 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]); + }} + /> + + + {/* ── 상세 Drawer ─────────────────────────────── */} + setDrawerOpen(false)} + extra={ + setWriteOffOpen(true)} + > + 대손/상환 처리 + + } + > + {detailLoading || !detail ? ( + 불러오는 중... + ) : ( + + )} + + + {/* ── 대손/상환 처리 모달 ──────────────────────── */} + { setWriteOffOpen(false); writeOffForm.resetFields(); }} + okText="처리" + cancelText="취소" + confirmLoading={writeOffMutation.isPending} + > +
+ + + + + + +
+
+
+ ); +} + +// ── 상세 내용 컴포넌트 ───────────────────────────────── + +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: }, + { 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 ( + <> +
+ {fields.map(({ label, value }) => ( + <> + {label} + {value} + + ))} +
+ + {detail.recoveries.length > 0 && ( + <> + + 회수 이력 + + ({ + color: COLOR_PRIMARY, + children: ( + + + {r.settleMonth} 정산월 + + + 회수액: {fmt(r.recoveredAmount)}원 + + + {dayjs(r.createdAt).format('YYYY-MM-DD')} + + + ), + }))} + /> + + )} + + {detail.recoveries.length === 0 && ( + 회수 이력이 없습니다. + )} + + ); +}