feat: 수수료 공식 갭 보완 + 8종 신규모듈 풀스택 (V116~V123)

수수료 공식 갭분석(5 병렬감사) 후 확정 버그 3건 수정 + 개념적으로 빠진 모듈 8종 풀스택 구현.

[확정 버그 3건]
- 1200% 누적한도(checkContractTotal)를 배치 CalcRecruitStep에 연결(기존 1차년 한도만 적용
  → 두 한도 중 더 제한적인 값으로 clip+이연). +CalcRecruitStepTest
- BatchInstallmentPlanGenerator planMonth 반영(하드코딩 1 제거, API판과 동일). +단위테스트
- PersistencyBonus 임계유지율 게이트 + FIXED 정액 룰 도출. +PersistencyBonusServiceTest

[신규 모듈 8종]
- MOD-1 도입수수료(recruiter/development) / MOD-2 선지급·차익정산 / MOD-3 환수 분할상환
- MOD-4 익월부활 재지급 / MOD-5 정산대사 입수 / MOD-6 환수 시효 / MOD-7 환수 감면
- MOD-8 채널별 수수료차등(sales_channel)
각 DB(테이블+메뉴+권한+공통코드)→VO/Mapper/Enum→Service/Controller→화면 풀스택.
배치 정산연계: AggregateStep에 도입/익월부활 가산(11종), 선지급 FIFO 상계(멱등 reverse),
MOD-8 채널 우선 요율조회(NULL 폴백 하위호환).

[검증]
- 전체 ./gradlew build(전 모듈+전 테스트) GREEN
- Flyway V116~V123 운영DB 적용(schema v115→v123)
- 라이브 스모크 8 GET=200, MOD-1 RATE공식 50000 실측, 액션 POST 5xx 0건
- 적대적 코드리뷰 APPROVED(MOD-8 계산기 미연결 버그 발견·수정)

스펙: docs/DOMAIN_GAP_MOD1-8.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-06-06 17:32:25 +09:00
parent 00ead64c4c
commit ff6d399dd3
114 changed files with 6419 additions and 53 deletions
+1
View File
@@ -48,3 +48,4 @@ ga-frontend/dist/
login.json
token.txt
__pycache__/
.omc/
+168
View File
@@ -0,0 +1,168 @@
# DOMAIN_GAP_MOD1-8 — 수수료 갭 8종 신규모듈 권위 스펙
> 2026-06-06 갭분석 결과 "개념적으로 빠진" 수수료 모듈 8종. 업무 수치는 **데모 기본값**(운영 시 룰테이블 조정).
> 모든 레이어 에이전트는 이 문서를 단일 진실원으로 따른다. 테이블/컬럼/Enum/경로/메뉴코드명을 **그대로** 사용.
## 공통 규약 (기존 패턴 — V92/V95/V96 미러)
- PK `BIGSERIAL`, 금액 `NUMERIC(15,2)`, 율 `NUMERIC(7,4)`(0~1 소수), 정산월 `CHAR(6)`(YYYYMM).
- 감사: `created_at TIMESTAMP NOT NULL DEFAULT NOW()`, `created_by BIGINT`, `updated_at TIMESTAMP`.
- 모든 테이블 `COMMENT ON TABLE/COLUMN`, 인덱스 `idx_<약어>_...`.
- 메뉴: `menu`(parent=GRP_*, menu_type='PAGE', menu_level=2) + `menu_permission`(READ/CREATE/UPDATE/DELETE/EXPORT) + `role_menu_permission`(SUPER_ADMIN/ADMIN 전체, MANAGER READ/CREATE/UPDATE/EXPORT, AGENT READ). NOT EXISTS/ON CONFLICT 가드.
- 공통코드: `common_code_group` + `common_code`.
- core: `XxxVO`(전컬럼)/`XxxResp`(+조인명)/`XxxSaveReq`(입력)/`XxxSearchParam`(extends PageParam) + Mapper(.java/.xml, selectList/selectById/insert/update/delete + 도메인쿼리) + Enum(상태) + MapStruct(필요시). Mapper는 BaseMapper 상속 안 함(개별 정의, 기존 commission Mapper 미러).
- api: `XxxService`(@Service @Transactional(readOnly=true), 쓰기 @Transactional) + `XxxController`(@RestController @RequestMapping("/api/...") @RequirePermission, @DataChangeLog 쓰기, ApiResponse/PageResponse 반환). 기존 commission Controller 미러.
- frontend: `ga-frontend/src/pages/<area>/Xxx.tsx` + `api/<area>.ts` 함수 + `App.tsx` 라우트. 100건↑/셀편집/합계는 AG Grid, 단순 CRUD는 Ant Design DataTable.
- 마이그레이션 번호: MOD-1=V116, MOD-2=V117, MOD-3=V118, MOD-4=V119, MOD-5=V120, MOD-6=V121, MOD-7=V122, MOD-8=V123. (필요 시 모듈당 2파일 가능 — 예 V116_a 테이블 / 단일 파일에 메뉴·코드 포함 권장)
---
## MOD-1 — 슈퍼바이저/도입수수료 (recruiter/development commission) [V116]
설계사를 GA에 **도입(리크루팅)**한 상위 설계사에게 지급하는 별도 트랙(오버라이드와 무관, recruiter 관계 기반).
### DB V116
- `ALTER TABLE agent ADD COLUMN recruiter_agent_id BIGINT REFERENCES agent(agent_id);` (도입자, NULL 허용)
- `recruiter_commission_rule`(rule_id PK, recruiter_type VARCHAR(20) CHECK IN('NEW_RECRUIT','DEVELOPMENT','TRAINING'), calc_type VARCHAR(10) CHECK IN('RATE','FIXED'), commission_rate NUMERIC(7,4), fixed_amount NUMERIC(15,2), effective_from DATE NOT NULL, effective_to DATE, 감사컬럼). CHECK rate 0~1.
- `recruiter_commission_ledger`(ledger_id PK, recruiter_agent_id BIGINT NOT NULL REFERENCES agent, recruit_agent_id BIGINT NOT NULL REFERENCES agent, settle_month CHAR(6) NOT NULL, recruiter_type VARCHAR(20) NOT NULL, base_amount NUMERIC(15,2) NOT NULL, commission_amount NUMERIC(15,2) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'CALCULATED' CHECK IN('CALCULATED','CONFIRMED','PAID','CANCELLED'), 감사). idx (recruiter_agent_id, settle_month).
- 메뉴 `RECRUITER_COMM`('도입수수료', '/commission/recruiter', 'commission/RecruiterCommission', GRP_COMMISSION, sort 150) + 권한 + 공통코드 `RECRUITER_TYPE`(NEW_RECRUIT 신인도입/DEVELOPMENT 육성/TRAINING 교육).
- 공식(데모): commission = base_amount × rule.commission_rate (RATE) / fixed_amount (FIXED). base_amount=도입 설계사의 당월 모집수수료 합(데모 입력 허용).
### core/api
- `RecruiterCommissionRule*` + `RecruiterCommissionLedger*` VO/Resp/SaveReq/SearchParam, Mapper(.java/.xml: selectList/selectById/insert/update/delete + `aggregateByAgent(settleMonth)` = SUM(commission_amount) GROUP BY recruiter_agent_id). Enum `RecruiterType`.
- `RecruiterCommissionService`: rule CRUD + ledger list/detail/createLedger(룰 calc_type 권위, ReferralCommissionService 미러) + aggregate. Controller `/api/recruiter-commissions` (rules/ledgers).
- **정산 반영**: `AggregateStep`에 recruiterCommissionMapper.aggregateByAgent 추가 → other_commission_total 합산(기존 9종 패턴 동일, recruiter_agent_id 기준 agentId). (지급성이므로 gross 가산.)
### frontend
- `pages/commission/RecruiterCommission.tsx`(룰 탭 + 원장 탭, 등록 모달), `api/commission.ts`에 함수 추가, App.tsx 라우트.
---
## MOD-2 — 선지급/차익정산 (advance payment recovery + delta settlement) [V117]
신입/램프업 설계사에 미리 지급한 **선지급금**을 확정수수료로 우선 회수, 차액만 실지급.
### DB V117 (GRP_SETTLE)
- `advance_payment`(advance_id PK, agent_id BIGINT NOT NULL REFERENCES agent, advance_month CHAR(6) NOT NULL, principal_amount NUMERIC(15,2) NOT NULL CHECK>0, recovered_amount NUMERIC(15,2) NOT NULL DEFAULT 0, balance NUMERIC(15,2) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'OUTSTANDING' CHECK IN('OUTSTANDING','PARTIAL','CLEARED','WAIVED'), memo VARCHAR(200), 감사). CHECK balance>=0. idx(agent_id, status).
- `advance_recovery_ledger`(recovery_id PK, advance_id BIGINT NOT NULL REFERENCES advance_payment, settle_month CHAR(6) NOT NULL, recovery_amount NUMERIC(15,2) NOT NULL CHECK>0, 감사). idx(advance_id), idx(settle_month).
- 메뉴 `ADVANCE_PAYMENT`('선지급정산','/settle/advance-payment','settle/AdvancePayment', GRP_SETTLE, sort 적절) + 권한 + 공통코드 `ADVANCE_STATUS`(OUTSTANDING 미상환/PARTIAL 부분/CLEARED 완료/WAIVED 면제).
### core/api/batch
- `AdvancePayment*` + `AdvanceRecoveryLedger*` VO/Resp/SaveReq/SearchParam, Mapper. 도메인쿼리: `outstandingByAgent(agentId)`=SUM(balance) WHERE status IN('OUTSTANDING','PARTIAL'); aging은 advance_month 기준.
- `AdvancePaymentService`: 선지급 등록(balance=principal)/조회/aging 리포트/면제(waive). Controller `/api/advance-payments`.
- **정산 연계(AggregateStep)**: net(=tax후) 계산 직후, P9 환수상계와 **동일 위치/유사 패턴**으로 선지급 잔액 FIFO 우선상계. recovered_advance = min(payable, outstanding). 차익정산 delta = payable recovered_advance. payable 최종 = delta. advance_recovery_ledger 기록 + advance_payment.recovered/balance/status 갱신. **멱등**: 당월 recovery reverse 후 재적용(P9 reverseClawbackRecovery 패턴). 환수상계와 선지급상계 순서: 환수(채권) → 선지급. 둘 다 payable 차감.
- 주의: 기존 settle_master payable_amount 의미에 선지급 차감 반영. 새 컬럼 불필요(payable에 통합) — 단 advance_recovery_ledger로 추적.
### frontend
- `pages/settle/AdvancePayment.tsx`(선지급 목록+잔액/상태, 등록·면제 모달, 상계내역 Drawer), `api/settle.ts` 또는 `api/operation.ts` 함수.
---
## MOD-3 — 환수 분할상환 (installment clawback) [V118]
대액 환수채권을 N개월 분할 납부 계획으로.
### DB V118 (GRP_COMMISSION)
- `clawback_installment_plan`(plan_id PK, receivable_id BIGINT NOT NULL REFERENCES clawback_receivable(receivable_id), installment_no INT NOT NULL, installment_amount NUMERIC(15,2) NOT NULL CHECK>0, due_month CHAR(6) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'SCHEDULED' CHECK IN('SCHEDULED','RECOVERED','CANCELLED'), recovered_at TIMESTAMP, 감사). UNIQUE(receivable_id, installment_no). idx(due_month, status).
- 메뉴 `CLAWBACK_INSTALLMENT`('환수분할상환','/commission/clawback-installment','commission/ClawbackInstallment', GRP_COMMISSION, sort) + 권한. 공통코드 `CB_INSTALLMENT_STATUS`(SCHEDULED 예정/RECOVERED 회수/CANCELLED 취소).
### core/api
- `ClawbackInstallmentPlan*` VO/Resp/SaveReq/SearchParam + Mapper(selectByReceivable, insertBatch, updateStatus).
- `ClawbackInstallmentService`: receivable 1건 + 회차수 N → 균등분할(마지막 회차 잔차흡수, InstallmentPlanGenerator 잔차패턴) 계획 생성. due_month=기준월+회차. list/detail/generate/cancel. Controller `/api/clawback-installments`.
- 월별 상계 연계는 데모 수준(계획 생성·조회·상태전이). (AggregateStep 통합은 선택 — 미연계 시 문서화.)
### frontend
- `pages/commission/ClawbackInstallment.tsx`(채권 선택→분할 생성, 회차 목록), `api/commission.ts`.
---
## MOD-4 — 익월 부활 재지급 (lapsed reinstatement repayment) [V119]
실효(보류)된 계약이 익월 부활 시 보류했던 수수료를 make-good 재지급.
### DB V119 (GRP_COMMISSION)
- `lapsed_reinstatement_recovery`(recovery_id PK, contract_id BIGINT NOT NULL REFERENCES contract(contract_id), agent_id BIGINT NOT NULL REFERENCES agent, lapsed_month CHAR(6) NOT NULL, reinstated_month CHAR(6) NOT NULL, withheld_amount NUMERIC(15,2) NOT NULL CHECK>=0, repaid_amount NUMERIC(15,2) NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'PENDING' CHECK IN('PENDING','REPAID','CANCELLED'), 감사). idx(agent_id, reinstated_month), idx(contract_id).
- 메뉴 `LAPSED_REINSTATEMENT`('익월부활재지급','/commission/lapsed-reinstatement','commission/LapsedReinstatement', GRP_COMMISSION, sort) + 권한. 공통코드 `LAPSE_RECOVERY_STATUS`(PENDING 대기/REPAID 재지급완료/CANCELLED 취소).
### core/api
- `LapsedReinstatementRecovery*` VO/Resp/SaveReq/SearchParam + Mapper(selectList/selectById/insert/updateStatus + aggregateByAgent=SUM(repaid_amount) WHERE status='REPAID' GROUP BY agent_id).
- `LapsedReinstatementService`: 부활 등록(withheld_amount=보류액, 데모 입력)/repay(상태 REPAID, repaid=withheld)/cancel. Controller `/api/lapsed-reinstatements`.
- **정산 반영(선택)**: aggregateByAgent를 AggregateStep other_commission_total에 가산(REPAID만). 미연계 시 문서화.
### frontend
- `pages/commission/LapsedReinstatement.tsx`, `api/commission.ts`.
---
## MOD-5 — 정산대사 입수 (carrier reconciliation IN) [V120]
보험사 통보 정산자료 vs GA 원장 대사 차이 산출.
### DB V120 (GRP_SETTLE)
- `carrier_reconciliation_in`(recon_id PK, company_id BIGINT NOT NULL REFERENCES insurance_company(company_id), settle_month CHAR(6) NOT NULL, file_name VARCHAR(200), record_count INT NOT NULL DEFAULT 0, reported_total NUMERIC(15,2) NOT NULL DEFAULT 0, system_total NUMERIC(15,2) NOT NULL DEFAULT 0, variance_amount NUMERIC(15,2) NOT NULL DEFAULT 0, status VARCHAR(20) NOT NULL DEFAULT 'RECEIVED' CHECK IN('RECEIVED','MATCHED','VARIANCE','APPROVED'), 감사). UNIQUE(company_id, settle_month). idx(settle_month).
- `reconciliation_variance`(variance_id PK, recon_id BIGINT NOT NULL REFERENCES carrier_reconciliation_in, policy_no VARCHAR(50), expected_amount NUMERIC(15,2), reported_amount NUMERIC(15,2), variance_reason VARCHAR(100), 감사). idx(recon_id).
- 메뉴 `CARRIER_RECON_IN`('정산대사입수','/settle/carrier-recon-in','settle/CarrierReconIn', GRP_SETTLE, sort) + 권한. 공통코드 `RECON_IN_STATUS`(RECEIVED 입수/MATCHED 일치/VARIANCE 차이/APPROVED 승인).
- insurance_company PK 컬럼명 확인 후 사용(company_id 가정, 다르면 실제명).
### core/api
- `CarrierReconciliationIn*` + `ReconciliationVariance*` VO/Resp/SaveReq/SearchParam + Mapper.
- `CarrierReconciliationInService`: 대사 등록(reported_total 입력)/reconcile(system_total=settle_master 또는 income_commission 집계, variance=reportedsystem, status 자동 MATCHED/VARIANCE)/approve. Controller `/api/carrier-recon-in`.
### frontend
- `pages/settle/CarrierReconIn.tsx`(대사 목록 + 차이내역 Drawer), `api/settle.ts`/`operation.ts`.
---
## MOD-6 — 환수 시효/소멸 (statute of limitations) [V121]
오래된 미결 환수채권을 만료개월 경과 시 자동 대손(WRITTEN_OFF/EXPIRED).
### DB V121 (GRP_COMMISSION)
- `statute_config`(config_id PK, entity_type VARCHAR(30) NOT NULL UNIQUE CHECK IN('CLAWBACK_RECEIVABLE'), expiration_months INT NOT NULL CHECK>0, is_active CHAR(1) NOT NULL DEFAULT 'Y', 감사). 시드 1행: ('CLAWBACK_RECEIVABLE', 36, 'Y').
- 메뉴 `STATUTE_CONFIG`('환수시효관리','/commission/statute','commission/StatuteConfig', GRP_COMMISSION, sort) + 권한.
- (별도 status 코드 불필요 — 기존 clawback_receivable status에 WRITTEN_OFF 존재 가정. 없으면 사유코드만 추가.)
### core/api
- `StatuteConfig*` VO/Resp/SaveReq + Mapper. clawback_receivable에 만료대상 조회/대손처리 쿼리(`selectExpired(beforeMonth)`, `writeOffExpired`) 추가(기존 ClawbackReceivableMapper에).
- `StatuteService`: config CRUD + `expireOverdue(baseMonth)` = origin_settle_month <= baseMonthexpiration_months AND status='OUTSTANDING' → WRITTEN_OFF(reason EXPIRED) + 카운트. Controller `/api/statute` (config + POST expire).
### frontend
- `pages/commission/StatuteConfig.tsx`(설정 + 만료실행 버튼 + 결과), `api/commission.ts`.
---
## MOD-7 — 환수 감면/면제 (chargeback waiver/reduction) [V122]
재직 설계사 환수에 대한 감면 신청·승인 워크플로.
### DB V122 (GRP_COMMISSION)
- `chargeback_waiver_request`(request_id PK, contract_id BIGINT NOT NULL REFERENCES contract, agent_id BIGINT NOT NULL REFERENCES agent, settle_month CHAR(6) NOT NULL, original_chargeback NUMERIC(15,2) NOT NULL CHECK>=0, waiver_amount NUMERIC(15,2) NOT NULL CHECK>=0, reason VARCHAR(200), approval_status VARCHAR(20) NOT NULL DEFAULT 'REQUESTED' CHECK IN('REQUESTED','APPROVED','REJECTED'), approver_id BIGINT, approved_at TIMESTAMP, 감사). CHECK waiver_amount<=original_chargeback. idx(agent_id, settle_month).
- 메뉴 `CHARGEBACK_WAIVER`('환수감면신청','/commission/chargeback-waiver','commission/ChargebackWaiver', GRP_COMMISSION, sort) + 권한. 공통코드 `WAIVER_STATUS`(REQUESTED 신청/APPROVED 승인/REJECTED 반려).
### core/api
- `ChargebackWaiverRequest*` VO/Resp/SaveReq/SearchParam + Mapper.
- `ChargebackWaiverService`: 신청(create)/approve(승인 시 approval_status, approver/approved_at; 환수액 조정은 데모 수준 — 승인분 추적)/reject. waiver<=original 검증. Controller `/api/chargeback-waivers`.
### frontend
- `pages/commission/ChargebackWaiver.tsx`(신청 목록 + 승인/반려 액션), `api/commission.ts`.
---
## MOD-8 — 채널별 수수료 차등 (channel-specific commission) [V123]
판매채널(직판/방카/홈쇼핑/제휴)별 요율 차등. **하위호환 필수**(channel NULL = 기존 동작).
### DB V123
- `ALTER TABLE contract ADD COLUMN sales_channel VARCHAR(20);` (NULL 허용, NULL=DIRECT 취급)
- 공통코드 `SALES_CHANNEL`(DIRECT 직판/BANCASSURANCE 방카/HOME_SHOPPING 홈쇼핑/PARTNERSHIP 제휴).
- commission_rate / payout_rule 룰 테이블에 `sales_channel VARCHAR(20)` 컬럼 추가(NULL=모든채널). **실제 룰 테이블명·조회 SQL 확인 필수**(CommissionCalculator가 쓰는 rate 조회). 조회 우선순위: channel 일치 룰 우선, 없으면 channel IS NULL 룰 폴백.
- 메뉴 신규 불필요(계약 화면에서 노출). 단 계약 등록/조회에 sales_channel 추가.
### core/api/batch
- ContractVO/Resp/SaveReq에 salesChannel. ContractMapper.xml insert/update/select에 sales_channel.
- rate 조회 Mapper SQL에 channel 파라미터 추가(`AND (sales_channel = #{channel} OR sales_channel IS NULL) ORDER BY sales_channel NULLS LAST` 패턴으로 channel 우선). CommissionCalculator가 contract.salesChannel 전달.
- **하위호환 회귀 필수 검증**: channel NULL 계약은 기존과 동일 rate. 기존 CommissionCalculatorTest 통과 유지.
### frontend
- 계약 페이지(`pages/contracts/...` 또는 product)에서 sales_channel select 노출(공통코드 SALES_CHANNEL).
---
## 실행 순서 (5-Wave, 레이어별 병렬)
1. **DBA**: V116~V123 작성 (dba-architect). → SQL 파싱/리뷰.
2. **CORE**: 8모듈 VO/Mapper/Enum/MapStruct (core-architect). → `:ga-core:compileJava` GREEN.
3. **API+BATCH**: Service/Controller (api-developer) ‖ AggregateStep/배치 연계 (batch-engineer). → `:ga-api:compileJava :ga-batch:compileJava` GREEN.
4. **FRONTEND**: 화면+api+라우트 (frontend-developer). → `tsc` GREEN.
5. **INFRA/검증**: `./gradlew build`(test) GREEN + Flyway 적용 + 스모크 (PL + infra-reviewer).
- 각 Wave 후 PL이 빌드로 직접 검증. 컬럼/이름 불일치는 PL이 즉시 수정.
@@ -0,0 +1,51 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.AdvancePaymentService;
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.core.vo.commission.AdvancePaymentResp;
import com.ga.core.vo.commission.AdvancePaymentSaveReq;
import com.ga.core.vo.commission.AdvancePaymentSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@Tag(name = "선지급정산")
@RestController
@RequestMapping("/api/advance-payments")
@RequiredArgsConstructor
public class AdvancePaymentController {
private final AdvancePaymentService service;
@GetMapping
@RequirePermission(menu = "ADVANCE_PAYMENT", perm = "READ")
public ApiResponse<PageResponse<AdvancePaymentResp>> list(
@ModelAttribute AdvancePaymentSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{advanceId}")
@RequirePermission(menu = "ADVANCE_PAYMENT", perm = "READ")
public ApiResponse<AdvancePaymentResp> detail(@PathVariable Long advanceId) {
return ApiResponse.ok(service.detail(advanceId));
}
@PostMapping
@RequirePermission(menu = "ADVANCE_PAYMENT", perm = "CREATE")
@DataChangeLog(menu = "ADVANCE_PAYMENT", table = "advance_payment")
public ApiResponse<Long> create(@Valid @RequestBody AdvancePaymentSaveReq req) {
return ApiResponse.ok(service.create(req));
}
@PostMapping("/{advanceId}/waive")
@RequirePermission(menu = "ADVANCE_PAYMENT", perm = "UPDATE")
@DataChangeLog(menu = "ADVANCE_PAYMENT", table = "advance_payment")
public ApiResponse<Void> waive(@PathVariable Long advanceId) {
service.waive(advanceId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,59 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.CarrierReconciliationInService;
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.core.vo.commission.CarrierReconciliationInResp;
import com.ga.core.vo.commission.CarrierReconciliationInSaveReq;
import com.ga.core.vo.commission.CarrierReconciliationInSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@Tag(name = "정산대사입수")
@RestController
@RequestMapping("/api/carrier-recon-in")
@RequiredArgsConstructor
public class CarrierReconciliationInController {
private final CarrierReconciliationInService service;
@GetMapping
@RequirePermission(menu = "CARRIER_RECON_IN", perm = "READ")
public ApiResponse<PageResponse<CarrierReconciliationInResp>> list(
@ModelAttribute CarrierReconciliationInSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{reconId}")
@RequirePermission(menu = "CARRIER_RECON_IN", perm = "READ")
public ApiResponse<CarrierReconciliationInResp> detail(@PathVariable Long reconId) {
return ApiResponse.ok(service.detail(reconId));
}
@PostMapping
@RequirePermission(menu = "CARRIER_RECON_IN", perm = "CREATE")
@DataChangeLog(menu = "CARRIER_RECON_IN", table = "carrier_reconciliation_in")
public ApiResponse<Long> create(@Valid @RequestBody CarrierReconciliationInSaveReq req) {
return ApiResponse.ok(service.create(req));
}
@PostMapping("/{reconId}/reconcile")
@RequirePermission(menu = "CARRIER_RECON_IN", perm = "UPDATE")
@DataChangeLog(menu = "CARRIER_RECON_IN", table = "carrier_reconciliation_in")
public ApiResponse<Void> reconcile(@PathVariable Long reconId) {
service.reconcile(reconId);
return ApiResponse.ok();
}
@PostMapping("/{reconId}/approve")
@RequirePermission(menu = "CARRIER_RECON_IN", perm = "UPDATE")
@DataChangeLog(menu = "CARRIER_RECON_IN", table = "carrier_reconciliation_in")
public ApiResponse<Void> approve(@PathVariable Long reconId) {
service.approve(reconId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,59 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.ChargebackWaiverService;
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.core.vo.commission.ChargebackWaiverRequestResp;
import com.ga.core.vo.commission.ChargebackWaiverRequestSaveReq;
import com.ga.core.vo.commission.ChargebackWaiverRequestSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@Tag(name = "환수감면신청")
@RestController
@RequestMapping("/api/chargeback-waivers")
@RequiredArgsConstructor
public class ChargebackWaiverController {
private final ChargebackWaiverService service;
@GetMapping
@RequirePermission(menu = "CHARGEBACK_WAIVER", perm = "READ")
public ApiResponse<PageResponse<ChargebackWaiverRequestResp>> list(
@ModelAttribute ChargebackWaiverRequestSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{requestId}")
@RequirePermission(menu = "CHARGEBACK_WAIVER", perm = "READ")
public ApiResponse<ChargebackWaiverRequestResp> detail(@PathVariable Long requestId) {
return ApiResponse.ok(service.detail(requestId));
}
@PostMapping
@RequirePermission(menu = "CHARGEBACK_WAIVER", perm = "CREATE")
@DataChangeLog(menu = "CHARGEBACK_WAIVER", table = "chargeback_waiver_request")
public ApiResponse<Long> create(@Valid @RequestBody ChargebackWaiverRequestSaveReq req) {
return ApiResponse.ok(service.create(req));
}
@PostMapping("/{requestId}/approve")
@RequirePermission(menu = "CHARGEBACK_WAIVER", perm = "UPDATE")
@DataChangeLog(menu = "CHARGEBACK_WAIVER", table = "chargeback_waiver_request")
public ApiResponse<Void> approve(@PathVariable Long requestId) {
service.approve(requestId);
return ApiResponse.ok();
}
@PostMapping("/{requestId}/reject")
@RequirePermission(menu = "CHARGEBACK_WAIVER", perm = "UPDATE")
@DataChangeLog(menu = "CHARGEBACK_WAIVER", table = "chargeback_waiver_request")
public ApiResponse<Void> reject(@PathVariable Long requestId) {
service.reject(requestId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,68 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.ClawbackInstallmentService;
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.core.vo.commission.ClawbackInstallmentPlanResp;
import com.ga.core.vo.commission.ClawbackInstallmentPlanSearchParam;
import com.ga.core.vo.commission.ClawbackInstallmentPlanVO;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@Tag(name = "환수분할상환")
@RestController
@RequestMapping("/api/clawback-installments")
@RequiredArgsConstructor
public class ClawbackInstallmentController {
private final ClawbackInstallmentService service;
@GetMapping
@RequirePermission(menu = "CLAWBACK_INSTALLMENT", perm = "READ")
public ApiResponse<PageResponse<ClawbackInstallmentPlanResp>> list(
@ModelAttribute ClawbackInstallmentPlanSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{planId}")
@RequirePermission(menu = "CLAWBACK_INSTALLMENT", perm = "READ")
public ApiResponse<ClawbackInstallmentPlanResp> detail(@PathVariable Long planId) {
return ApiResponse.ok(service.detail(planId));
}
@GetMapping("/by-receivable/{receivableId}")
@RequirePermission(menu = "CLAWBACK_INSTALLMENT", perm = "READ")
public ApiResponse<List<ClawbackInstallmentPlanVO>> listByReceivable(
@PathVariable Long receivableId) {
return ApiResponse.ok(service.listByReceivable(receivableId));
}
/**
* 분할상환 계획 생성.
* Request body: { "count": N, "baseMonth": "YYYYMM" }
*/
@PostMapping("/generate/{receivableId}")
@RequirePermission(menu = "CLAWBACK_INSTALLMENT", perm = "CREATE")
@DataChangeLog(menu = "CLAWBACK_INSTALLMENT", table = "clawback_installment_plan")
public ApiResponse<Void> generate(@PathVariable Long receivableId,
@RequestBody Map<String, Object> body) {
int count = Integer.parseInt(body.get("count").toString());
String baseMonth = body.get("baseMonth").toString();
service.generate(receivableId, count, baseMonth);
return ApiResponse.ok();
}
@PostMapping("/{planId}/cancel")
@RequirePermission(menu = "CLAWBACK_INSTALLMENT", perm = "UPDATE")
@DataChangeLog(menu = "CLAWBACK_INSTALLMENT", table = "clawback_installment_plan")
public ApiResponse<Void> cancel(@PathVariable Long planId) {
service.cancel(planId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,59 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.LapsedReinstatementService;
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.core.vo.commission.LapsedReinstatementRecoveryResp;
import com.ga.core.vo.commission.LapsedReinstatementRecoverySaveReq;
import com.ga.core.vo.commission.LapsedReinstatementRecoverySearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@Tag(name = "익월부활재지급")
@RestController
@RequestMapping("/api/lapsed-reinstatements")
@RequiredArgsConstructor
public class LapsedReinstatementController {
private final LapsedReinstatementService service;
@GetMapping
@RequirePermission(menu = "LAPSED_REINSTATEMENT", perm = "READ")
public ApiResponse<PageResponse<LapsedReinstatementRecoveryResp>> list(
@ModelAttribute LapsedReinstatementRecoverySearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{recoveryId}")
@RequirePermission(menu = "LAPSED_REINSTATEMENT", perm = "READ")
public ApiResponse<LapsedReinstatementRecoveryResp> detail(@PathVariable Long recoveryId) {
return ApiResponse.ok(service.detail(recoveryId));
}
@PostMapping
@RequirePermission(menu = "LAPSED_REINSTATEMENT", perm = "CREATE")
@DataChangeLog(menu = "LAPSED_REINSTATEMENT", table = "lapsed_reinstatement_recovery")
public ApiResponse<Long> create(@Valid @RequestBody LapsedReinstatementRecoverySaveReq req) {
return ApiResponse.ok(service.create(req));
}
@PostMapping("/{recoveryId}/repay")
@RequirePermission(menu = "LAPSED_REINSTATEMENT", perm = "UPDATE")
@DataChangeLog(menu = "LAPSED_REINSTATEMENT", table = "lapsed_reinstatement_recovery")
public ApiResponse<Void> repay(@PathVariable Long recoveryId) {
service.repay(recoveryId);
return ApiResponse.ok();
}
@PostMapping("/{recoveryId}/cancel")
@RequirePermission(menu = "LAPSED_REINSTATEMENT", perm = "UPDATE")
@DataChangeLog(menu = "LAPSED_REINSTATEMENT", table = "lapsed_reinstatement_recovery")
public ApiResponse<Void> cancel(@PathVariable Long recoveryId) {
service.cancel(recoveryId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,82 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.RecruiterCommissionService;
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.core.vo.commission.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@Tag(name = "도입수수료")
@RestController
@RequestMapping("/api/recruiter-commissions")
@RequiredArgsConstructor
public class RecruiterCommissionController {
private final RecruiterCommissionService service;
// ── Rule ──────────────────────────────────────────────────────────────────
@GetMapping("/rules")
@RequirePermission(menu = "RECRUITER_COMM", perm = "READ")
public ApiResponse<PageResponse<RecruiterCommissionRuleResp>> listRules(
@ModelAttribute RecruiterCommissionRuleSearchParam param) {
return ApiResponse.ok(service.listRules(param));
}
@GetMapping("/rules/{ruleId}")
@RequirePermission(menu = "RECRUITER_COMM", perm = "READ")
public ApiResponse<RecruiterCommissionRuleResp> detailRule(@PathVariable Long ruleId) {
return ApiResponse.ok(service.detailRule(ruleId));
}
@PostMapping("/rules")
@RequirePermission(menu = "RECRUITER_COMM", perm = "CREATE")
@DataChangeLog(menu = "RECRUITER_COMM", table = "recruiter_commission_rule")
public ApiResponse<Long> createRule(@Valid @RequestBody RecruiterCommissionRuleSaveReq req) {
return ApiResponse.ok(service.createRule(req));
}
@PutMapping("/rules/{ruleId}")
@RequirePermission(menu = "RECRUITER_COMM", perm = "UPDATE")
@DataChangeLog(menu = "RECRUITER_COMM", table = "recruiter_commission_rule")
public ApiResponse<Void> updateRule(@PathVariable Long ruleId,
@Valid @RequestBody RecruiterCommissionRuleSaveReq req) {
service.updateRule(ruleId, req);
return ApiResponse.ok();
}
@DeleteMapping("/rules/{ruleId}")
@RequirePermission(menu = "RECRUITER_COMM", perm = "DELETE")
@DataChangeLog(menu = "RECRUITER_COMM", table = "recruiter_commission_rule")
public ApiResponse<Void> deleteRule(@PathVariable Long ruleId) {
service.deleteRule(ruleId);
return ApiResponse.ok();
}
// ── Ledger ────────────────────────────────────────────────────────────────
@GetMapping("/ledgers")
@RequirePermission(menu = "RECRUITER_COMM", perm = "READ")
public ApiResponse<PageResponse<RecruiterCommissionLedgerResp>> listLedgers(
@ModelAttribute RecruiterCommissionLedgerSearchParam param) {
return ApiResponse.ok(service.listLedgers(param));
}
@GetMapping("/ledgers/{ledgerId}")
@RequirePermission(menu = "RECRUITER_COMM", perm = "READ")
public ApiResponse<RecruiterCommissionLedgerResp> detailLedger(@PathVariable Long ledgerId) {
return ApiResponse.ok(service.detailLedger(ledgerId));
}
@PostMapping("/ledgers")
@RequirePermission(menu = "RECRUITER_COMM", perm = "CREATE")
@DataChangeLog(menu = "RECRUITER_COMM", table = "recruiter_commission_ledger")
public ApiResponse<Long> createLedger(@Valid @RequestBody RecruiterCommissionLedgerSaveReq req) {
return ApiResponse.ok(service.createLedger(req));
}
}
@@ -0,0 +1,68 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.StatuteService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.core.vo.commission.StatuteConfigResp;
import com.ga.core.vo.commission.StatuteConfigSaveReq;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@Tag(name = "환수시효관리")
@RestController
@RequestMapping("/api/statute")
@RequiredArgsConstructor
public class StatuteController {
private final StatuteService service;
@GetMapping
@RequirePermission(menu = "STATUTE_CONFIG", perm = "READ")
public ApiResponse<List<StatuteConfigResp>> list() {
return ApiResponse.ok(service.listConfigs());
}
@GetMapping("/{configId}")
@RequirePermission(menu = "STATUTE_CONFIG", perm = "READ")
public ApiResponse<StatuteConfigResp> detail(@PathVariable Long configId) {
return ApiResponse.ok(service.detailConfig(configId));
}
@PostMapping
@RequirePermission(menu = "STATUTE_CONFIG", perm = "CREATE")
@DataChangeLog(menu = "STATUTE_CONFIG", table = "statute_config")
public ApiResponse<Long> create(@Valid @RequestBody StatuteConfigSaveReq req) {
return ApiResponse.ok(service.createConfig(req));
}
@PutMapping("/{configId}")
@RequirePermission(menu = "STATUTE_CONFIG", perm = "UPDATE")
@DataChangeLog(menu = "STATUTE_CONFIG", table = "statute_config")
public ApiResponse<Void> update(@PathVariable Long configId,
@Valid @RequestBody StatuteConfigSaveReq req) {
service.updateConfig(configId, req);
return ApiResponse.ok();
}
/**
* 만료 대상 환수채권 일괄 대손 처리.
* Request body: { "baseMonth": "YYYYMM" }
*/
@PostMapping("/expire")
@RequirePermission(menu = "STATUTE_CONFIG", perm = "UPDATE")
@DataChangeLog(menu = "STATUTE_CONFIG", table = "clawback_receivable")
public ApiResponse<Integer> expire(@RequestBody Map<String, String> body) {
String baseMonth = body.get("baseMonth");
if (baseMonth == null || baseMonth.isBlank()) {
baseMonth = com.ga.common.util.DateUtil.getSettleMonth();
}
return ApiResponse.ok(service.expireOverdue(baseMonth));
}
}
@@ -0,0 +1,81 @@
package com.ga.api.service.commission;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.commission.AdvancePaymentMapper;
import com.ga.core.vo.commission.AdvancePaymentResp;
import com.ga.core.vo.commission.AdvancePaymentSaveReq;
import com.ga.core.vo.commission.AdvancePaymentSearchParam;
import com.ga.core.vo.commission.AdvancePaymentVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AdvancePaymentService {
private final AdvancePaymentMapper mapper;
public PageResponse<AdvancePaymentResp> list(AdvancePaymentSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
public AdvancePaymentResp detail(Long advanceId) {
AdvancePaymentResp resp = mapper.selectById(advanceId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
/**
* 선지급 등록 — balance = principal, status = OUTSTANDING.
*/
@Transactional
public Long create(AdvancePaymentSaveReq req) {
AdvancePaymentVO vo = new AdvancePaymentVO();
vo.setAgentId(req.getAgentId());
vo.setAdvanceMonth(req.getAdvanceMonth());
vo.setPrincipalAmount(req.getPrincipalAmount());
vo.setRecoveredAmount(BigDecimal.ZERO);
vo.setBalance(req.getPrincipalAmount());
vo.setStatus("OUTSTANDING");
vo.setMemo(req.getMemo());
mapper.insert(vo);
return vo.getAdvanceId();
}
/**
* 선지급 면제 — status = WAIVED, balance = 0.
*/
@Transactional
public void waive(Long advanceId) {
AdvancePaymentResp resp = mapper.selectById(advanceId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
if ("CLEARED".equals(resp.getStatus()) || "WAIVED".equals(resp.getStatus())) {
throw new BizException(ErrorCode.INVALID_PARAMETER);
}
AdvancePaymentVO vo = new AdvancePaymentVO();
vo.setAdvanceId(advanceId);
vo.setAgentId(resp.getAgentId());
vo.setAdvanceMonth(resp.getAdvanceMonth());
vo.setPrincipalAmount(resp.getPrincipalAmount());
vo.setRecoveredAmount(resp.getRecoveredAmount());
vo.setBalance(BigDecimal.ZERO);
vo.setStatus("WAIVED");
vo.setMemo(resp.getMemo());
mapper.update(vo);
}
/**
* 설계사의 미상환 잔액 합계 (OUTSTANDING + PARTIAL).
*/
public BigDecimal outstandingByAgent(Long agentId) {
BigDecimal total = mapper.outstandingByAgent(agentId);
return total != null ? total : BigDecimal.ZERO;
}
}
@@ -0,0 +1,104 @@
package com.ga.api.service.commission;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.common.util.SecurityUtil;
import com.ga.core.mapper.commission.CarrierReconciliationInMapper;
import com.ga.core.mapper.commission.IncomeCommissionLedgerMapper;
import com.ga.core.vo.commission.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CarrierReconciliationInService {
private final CarrierReconciliationInMapper mapper;
private final IncomeCommissionLedgerMapper incomeMapper;
public PageResponse<CarrierReconciliationInResp> list(CarrierReconciliationInSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
public CarrierReconciliationInResp detail(Long reconId) {
CarrierReconciliationInResp resp = mapper.selectById(reconId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
/**
* 대사 등록 — reported_total 입력, status = RECEIVED.
*/
@Transactional
public Long create(CarrierReconciliationInSaveReq req) {
CarrierReconciliationInVO vo = new CarrierReconciliationInVO();
vo.setCompanyId(req.getCompanyId());
vo.setSettleMonth(req.getSettleMonth());
vo.setFileName(req.getFileName());
vo.setRecordCount(req.getRecordCount() != null ? req.getRecordCount() : 0);
vo.setReportedTotal(req.getReportedTotal());
vo.setSystemTotal(BigDecimal.ZERO);
vo.setVarianceAmount(BigDecimal.ZERO);
vo.setStatus("RECEIVED");
vo.setCreatedBy(SecurityUtil.getCurrentUserId());
mapper.insert(vo);
return vo.getReconId();
}
/**
* 대사 실행 — system_total = income_commission 집계, variance = reported system.
* |variance| < 1 이면 MATCHED, 아니면 VARIANCE.
*/
@Transactional
public void reconcile(Long reconId) {
CarrierReconciliationInResp resp = mapper.selectById(reconId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
if ("APPROVED".equals(resp.getStatus())) throw new BizException(ErrorCode.INVALID_PARAMETER);
BigDecimal systemTotal = incomeMapper.sumByCarrierAndMonth(
resp.getCompanyId(), resp.getSettleMonth());
if (systemTotal == null) systemTotal = BigDecimal.ZERO;
BigDecimal variance = resp.getReportedTotal().subtract(systemTotal);
String newStatus = variance.abs().compareTo(BigDecimal.ONE) < 0 ? "MATCHED" : "VARIANCE";
CarrierReconciliationInVO vo = buildVO(resp);
vo.setSystemTotal(systemTotal);
vo.setVarianceAmount(variance);
vo.setStatus(newStatus);
mapper.update(vo);
}
/**
* 승인 처리 — status = APPROVED.
*/
@Transactional
public void approve(Long reconId) {
CarrierReconciliationInResp resp = mapper.selectById(reconId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
if ("RECEIVED".equals(resp.getStatus())) throw new BizException(ErrorCode.INVALID_PARAMETER);
CarrierReconciliationInVO vo = buildVO(resp);
vo.setStatus("APPROVED");
mapper.update(vo);
}
private CarrierReconciliationInVO buildVO(CarrierReconciliationInResp resp) {
CarrierReconciliationInVO vo = new CarrierReconciliationInVO();
vo.setReconId(resp.getReconId());
vo.setCompanyId(resp.getCompanyId());
vo.setSettleMonth(resp.getSettleMonth());
vo.setFileName(resp.getFileName());
vo.setRecordCount(resp.getRecordCount());
vo.setReportedTotal(resp.getReportedTotal());
vo.setSystemTotal(resp.getSystemTotal());
vo.setVarianceAmount(resp.getVarianceAmount());
vo.setStatus(resp.getStatus());
return vo;
}
}
@@ -0,0 +1,75 @@
package com.ga.api.service.commission;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.common.util.SecurityUtil;
import com.ga.core.mapper.commission.ChargebackWaiverRequestMapper;
import com.ga.core.vo.commission.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ChargebackWaiverService {
private final ChargebackWaiverRequestMapper mapper;
public PageResponse<ChargebackWaiverRequestResp> list(ChargebackWaiverRequestSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
public ChargebackWaiverRequestResp detail(Long requestId) {
ChargebackWaiverRequestResp resp = mapper.selectById(requestId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
/**
* 감면 신청 등록 — waiver_amount <= original_chargeback 검증.
*/
@Transactional
public Long create(ChargebackWaiverRequestSaveReq req) {
if (req.getWaiverAmount().compareTo(req.getOriginalChargeback()) > 0) {
throw new BizException(ErrorCode.INVALID_PARAMETER);
}
ChargebackWaiverRequestVO vo = new ChargebackWaiverRequestVO();
vo.setContractId(req.getContractId());
vo.setAgentId(req.getAgentId());
vo.setSettleMonth(req.getSettleMonth());
vo.setOriginalChargeback(req.getOriginalChargeback());
vo.setWaiverAmount(req.getWaiverAmount());
vo.setReason(req.getReason());
vo.setApprovalStatus("REQUESTED");
vo.setCreatedBy(SecurityUtil.getCurrentUserId());
mapper.insert(vo);
return vo.getRequestId();
}
/**
* 승인 처리 — approval_status = APPROVED, approver_id = 현재 사용자, approved_at = now.
*/
@Transactional
public void approve(Long requestId) {
ChargebackWaiverRequestResp resp = mapper.selectById(requestId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!"REQUESTED".equals(resp.getApprovalStatus())) throw new BizException(ErrorCode.INVALID_PARAMETER);
mapper.updateApproval(requestId, "APPROVED", SecurityUtil.getCurrentUserId(), LocalDateTime.now());
}
/**
* 반려 처리 — approval_status = REJECTED.
*/
@Transactional
public void reject(Long requestId) {
ChargebackWaiverRequestResp resp = mapper.selectById(requestId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!"REQUESTED".equals(resp.getApprovalStatus())) throw new BizException(ErrorCode.INVALID_PARAMETER);
mapper.updateApproval(requestId, "REJECTED", SecurityUtil.getCurrentUserId(), LocalDateTime.now());
}
}
@@ -0,0 +1,91 @@
package com.ga.api.service.commission;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.common.util.DateUtil;
import com.ga.core.mapper.commission.ClawbackInstallmentPlanMapper;
import com.ga.core.mapper.commission.ClawbackReceivableMapper;
import com.ga.core.vo.commission.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.List;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ClawbackInstallmentService {
private final ClawbackInstallmentPlanMapper planMapper;
private final ClawbackReceivableMapper receivableMapper;
public PageResponse<ClawbackInstallmentPlanResp> list(ClawbackInstallmentPlanSearchParam param) {
param.startPage();
return PageResponse.of(planMapper.selectList(param));
}
public ClawbackInstallmentPlanResp detail(Long planId) {
ClawbackInstallmentPlanResp resp = planMapper.selectById(planId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
public List<ClawbackInstallmentPlanVO> listByReceivable(Long receivableId) {
return planMapper.selectByReceivable(receivableId);
}
/**
* 환수채권을 N회차로 균등 분할상환 계획 생성.
* 마지막 회차는 잔차(총액 - 앞 회차 합계) 흡수.
* 기준월: baseMonth(YYYYMM), due_month = baseMonth + (회차-1)
*/
@Transactional
public void generate(Long receivableId, int count, String baseMonth) {
if (count < 1) throw new BizException(ErrorCode.INVALID_PARAMETER);
// 이미 SCHEDULED 계획이 있으면 중복 방지
List<ClawbackInstallmentPlanVO> existing = planMapper.selectByReceivable(receivableId);
boolean hasScheduled = existing.stream()
.anyMatch(p -> "SCHEDULED".equals(p.getStatus()));
if (hasScheduled) throw new BizException(ErrorCode.DUPLICATE_DATA);
ClawbackReceivableResp receivableResp = receivableMapper.selectById(receivableId);
if (receivableResp == null) throw new BizException(ErrorCode.NOT_FOUND);
BigDecimal total = receivableResp.getBalance();
if (total == null || total.compareTo(BigDecimal.ZERO) <= 0)
throw new BizException(ErrorCode.INVALID_PARAMETER);
BigDecimal unit = total.divide(BigDecimal.valueOf(count), 2, RoundingMode.DOWN);
BigDecimal sumBefore = unit.multiply(BigDecimal.valueOf(count - 1));
BigDecimal last = total.subtract(sumBefore);
List<ClawbackInstallmentPlanVO> plans = new ArrayList<>();
for (int i = 1; i <= count; i++) {
ClawbackInstallmentPlanVO vo = new ClawbackInstallmentPlanVO();
vo.setReceivableId(receivableId);
vo.setInstallmentNo(i);
vo.setInstallmentAmount(i == count ? last : unit);
vo.setDueMonth(DateUtil.addMonth(baseMonth, i - 1));
vo.setStatus("SCHEDULED");
plans.add(vo);
}
planMapper.insertBatch(plans);
}
/**
* 분할상환 계획 취소 (SCHEDULED → CANCELLED).
*/
@Transactional
public void cancel(Long planId) {
ClawbackInstallmentPlanResp resp = planMapper.selectById(planId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!"SCHEDULED".equals(resp.getStatus())) throw new BizException(ErrorCode.INVALID_PARAMETER);
planMapper.updateStatus(planId, "CANCELLED");
}
}
@@ -0,0 +1,70 @@
package com.ga.api.service.commission;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.commission.LapsedReinstatementRecoveryMapper;
import com.ga.core.vo.commission.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class LapsedReinstatementService {
private final LapsedReinstatementRecoveryMapper mapper;
public PageResponse<LapsedReinstatementRecoveryResp> list(LapsedReinstatementRecoverySearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
public LapsedReinstatementRecoveryResp detail(Long recoveryId) {
LapsedReinstatementRecoveryResp resp = mapper.selectById(recoveryId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
/**
* 부활 등록 — withheld_amount 입력, status = PENDING.
*/
@Transactional
public Long create(LapsedReinstatementRecoverySaveReq req) {
LapsedReinstatementRecoveryVO vo = new LapsedReinstatementRecoveryVO();
vo.setContractId(req.getContractId());
vo.setAgentId(req.getAgentId());
vo.setLapsedMonth(req.getLapsedMonth());
vo.setReinstatedMonth(req.getReinstatedMonth());
vo.setWithheldAmount(req.getWithheldAmount());
vo.setRepaidAmount(java.math.BigDecimal.ZERO);
vo.setStatus("PENDING");
mapper.insert(vo);
return vo.getRecoveryId();
}
/**
* 재지급 처리 — status = REPAID, repaid_amount = withheld_amount.
*/
@Transactional
public void repay(Long recoveryId) {
LapsedReinstatementRecoveryResp resp = mapper.selectById(recoveryId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!"PENDING".equals(resp.getStatus())) throw new BizException(ErrorCode.INVALID_PARAMETER);
mapper.updateStatus(recoveryId, "REPAID", resp.getWithheldAmount());
}
/**
* 취소 — status = CANCELLED.
*/
@Transactional
public void cancel(Long recoveryId) {
LapsedReinstatementRecoveryResp resp = mapper.selectById(recoveryId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
if ("REPAID".equals(resp.getStatus()) || "CANCELLED".equals(resp.getStatus())) {
throw new BizException(ErrorCode.INVALID_PARAMETER);
}
mapper.updateStatus(recoveryId, "CANCELLED", java.math.BigDecimal.ZERO);
}
}
@@ -10,6 +10,9 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
@@ -86,6 +89,24 @@ public class PersistencyBonusService {
*/
@Transactional
public void upsertBonus(PersistencyBonusSaveReq req) {
BigDecimal bonusAmount = req.getBonusAmount();
// 지속수당 지급 게이트: 룰이 지정되면 유지율이 임계(retention_rate_threshold) 이상이어야 지급.
// FIXED형 룰이면 지급액을 룰의 bonus_value(정액)로 도출한다.
if (req.getRuleId() != null) {
PersistencyBonusRuleResp rule = ruleMapper.selectById(req.getRuleId());
if (rule == null) throw new BizException(ErrorCode.NOT_FOUND);
if (rule.getRetentionRateThreshold() != null
&& (req.getRetentionRate() == null
|| req.getRetentionRate().compareTo(rule.getRetentionRateThreshold()) < 0)) {
throw new BizException(ErrorCode.INVALID_PARAMETER,
"유지율이 지급 임계(" + rule.getRetentionRateThreshold() + ")에 미달합니다.");
}
if ("FIXED".equals(rule.getBonusType()) && rule.getBonusValue() != null) {
bonusAmount = rule.getBonusValue().setScale(2, RoundingMode.HALF_UP);
}
}
PersistencyBonusVO vo = new PersistencyBonusVO();
vo.setAgentId(req.getAgentId());
vo.setRuleId(req.getRuleId());
@@ -93,7 +114,7 @@ public class PersistencyBonusService {
vo.setRetentionMonth(req.getRetentionMonth());
vo.setSettleMonth(req.getSettleMonth());
vo.setRetentionRate(req.getRetentionRate());
vo.setBonusAmount(req.getBonusAmount());
vo.setBonusAmount(bonusAmount);
vo.setStatus("CALCULATED");
bonusMapper.upsert(vo);
}
@@ -0,0 +1,147 @@
package com.ga.api.service.commission;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.commission.RecruiterCommissionLedgerMapper;
import com.ga.core.mapper.commission.RecruiterCommissionRuleMapper;
import com.ga.core.vo.commission.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class RecruiterCommissionService {
private final RecruiterCommissionRuleMapper ruleMapper;
private final RecruiterCommissionLedgerMapper ledgerMapper;
// ── Rule ──────────────────────────────────────────────────────────────────
public PageResponse<RecruiterCommissionRuleResp> listRules(RecruiterCommissionRuleSearchParam param) {
param.startPage();
return PageResponse.of(ruleMapper.selectList(param));
}
public RecruiterCommissionRuleResp detailRule(Long ruleId) {
RecruiterCommissionRuleResp resp = ruleMapper.selectById(ruleId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public Long createRule(RecruiterCommissionRuleSaveReq req) {
RecruiterCommissionRuleVO vo = new RecruiterCommissionRuleVO();
vo.setRecruiterType(req.getRecruiterType());
vo.setCalcType(req.getCalcType());
vo.setCommissionRate(req.getCommissionRate());
vo.setFixedAmount(req.getFixedAmount());
vo.setEffectiveFrom(req.getEffectiveFrom());
vo.setEffectiveTo(req.getEffectiveTo());
ruleMapper.insert(vo);
return vo.getRuleId();
}
@Transactional
public void updateRule(Long ruleId, RecruiterCommissionRuleSaveReq req) {
RecruiterCommissionRuleResp resp = ruleMapper.selectById(ruleId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
RecruiterCommissionRuleVO vo = new RecruiterCommissionRuleVO();
vo.setRuleId(ruleId);
vo.setRecruiterType(req.getRecruiterType());
vo.setCalcType(req.getCalcType());
vo.setCommissionRate(req.getCommissionRate());
vo.setFixedAmount(req.getFixedAmount());
vo.setEffectiveFrom(req.getEffectiveFrom());
vo.setEffectiveTo(req.getEffectiveTo());
ruleMapper.update(vo);
}
@Transactional
public void deleteRule(Long ruleId) {
RecruiterCommissionRuleResp resp = ruleMapper.selectById(ruleId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
ruleMapper.delete(ruleId);
}
// ── Ledger ────────────────────────────────────────────────────────────────
public PageResponse<RecruiterCommissionLedgerResp> listLedgers(RecruiterCommissionLedgerSearchParam param) {
param.startPage();
return PageResponse.of(ledgerMapper.selectList(param));
}
public RecruiterCommissionLedgerResp detailLedger(Long ledgerId) {
RecruiterCommissionLedgerResp resp = ledgerMapper.selectById(ledgerId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
/**
* 도입수수료 원장 생성.
* 활성 룰 목록 중 recruiterType + 오늘 기준 유효한 룰 우선 적용:
* RATE → commissionAmount = baseAmount × commissionRate
* FIXED → commissionAmount = fixedAmount
* 룰 없음 → 요청 commissionAmount 사용 (수동 등록 호환).
*/
@Transactional
public Long createLedger(RecruiterCommissionLedgerSaveReq req) {
RecruiterCommissionRuleVO activeRule = findActiveRule(req.getRecruiterType());
BigDecimal commissionAmount;
if (activeRule != null) {
if ("FIXED".equals(activeRule.getCalcType())) {
if (activeRule.getFixedAmount() == null) throw new BizException(ErrorCode.NOT_FOUND);
commissionAmount = activeRule.getFixedAmount().setScale(2, RoundingMode.HALF_UP);
} else { // RATE
BigDecimal rate = activeRule.getCommissionRate();
if (rate == null) throw new BizException(ErrorCode.NOT_FOUND);
commissionAmount = req.getBaseAmount().multiply(rate).setScale(2, RoundingMode.HALF_UP);
}
} else {
commissionAmount = req.getCommissionAmount();
if (commissionAmount == null) throw new BizException(ErrorCode.NOT_FOUND);
}
RecruiterCommissionLedgerVO vo = new RecruiterCommissionLedgerVO();
vo.setRecruiterAgentId(req.getRecruiterAgentId());
vo.setRecruitAgentId(req.getRecruitAgentId());
vo.setSettleMonth(req.getSettleMonth());
vo.setRecruiterType(req.getRecruiterType());
vo.setBaseAmount(req.getBaseAmount());
vo.setCommissionAmount(commissionAmount);
vo.setStatus("CALCULATED");
ledgerMapper.insert(vo);
return vo.getLedgerId();
}
/** recruiterType + 오늘 날짜 기준 유효 룰 첫 번째 반환. */
private RecruiterCommissionRuleVO findActiveRule(String recruiterType) {
RecruiterCommissionRuleSearchParam param = new RecruiterCommissionRuleSearchParam();
param.setRecruiterType(recruiterType);
List<RecruiterCommissionRuleResp> list = ruleMapper.selectList(param);
java.time.LocalDate today = java.time.LocalDate.now();
return list.stream()
.filter(r -> !today.isBefore(r.getEffectiveFrom())
&& (r.getEffectiveTo() == null || !today.isAfter(r.getEffectiveTo())))
.findFirst()
.map(r -> {
RecruiterCommissionRuleVO vo = new RecruiterCommissionRuleVO();
vo.setRuleId(r.getRuleId());
vo.setRecruiterType(r.getRecruiterType());
vo.setCalcType(r.getCalcType());
vo.setCommissionRate(r.getCommissionRate());
vo.setFixedAmount(r.getFixedAmount());
vo.setEffectiveFrom(r.getEffectiveFrom());
vo.setEffectiveTo(r.getEffectiveTo());
return vo;
})
.orElse(null);
}
}
@@ -0,0 +1,71 @@
package com.ga.api.service.commission;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.util.DateUtil;
import com.ga.core.mapper.commission.ClawbackReceivableMapper;
import com.ga.core.mapper.commission.StatuteConfigMapper;
import com.ga.core.vo.commission.StatuteConfigResp;
import com.ga.core.vo.commission.StatuteConfigSaveReq;
import com.ga.core.vo.commission.StatuteConfigVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class StatuteService {
private final StatuteConfigMapper configMapper;
private final ClawbackReceivableMapper receivableMapper;
public List<StatuteConfigResp> listConfigs() {
return configMapper.selectList();
}
public StatuteConfigResp detailConfig(Long configId) {
StatuteConfigResp resp = configMapper.selectById(configId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public Long createConfig(StatuteConfigSaveReq req) {
StatuteConfigVO vo = new StatuteConfigVO();
vo.setEntityType(req.getEntityType());
vo.setExpirationMonths(req.getExpirationMonths());
vo.setIsActive(req.getIsActive() != null ? req.getIsActive() : "Y");
configMapper.insert(vo);
return vo.getConfigId();
}
@Transactional
public void updateConfig(Long configId, StatuteConfigSaveReq req) {
StatuteConfigResp resp = configMapper.selectById(configId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
StatuteConfigVO vo = new StatuteConfigVO();
vo.setConfigId(configId);
vo.setEntityType(req.getEntityType());
vo.setExpirationMonths(req.getExpirationMonths());
vo.setIsActive(req.getIsActive() != null ? req.getIsActive() : resp.getIsActive());
configMapper.update(vo);
}
/**
* 만료 대상 채권 일괄 대손 처리.
* beforeMonth = baseMonth - expiration_months (YYYYMM 연산).
* 처리 건수 반환.
*/
@Transactional
public int expireOverdue(String baseMonth) {
StatuteConfigResp config = configMapper.selectByEntityType("CLAWBACK_RECEIVABLE");
if (config == null || !"Y".equals(config.getIsActive())) {
throw new BizException(ErrorCode.NOT_FOUND);
}
String beforeMonth = DateUtil.addMonth(baseMonth, -config.getExpirationMonths());
return receivableMapper.writeOffExpired(beforeMonth, "EXPIRED");
}
}
@@ -0,0 +1,89 @@
package com.ga.api.service.commission;
import com.ga.common.exception.BizException;
import com.ga.core.mapper.commission.PersistencyBonusMapper;
import com.ga.core.mapper.commission.PersistencyBonusRuleMapper;
import com.ga.core.vo.commission.PersistencyBonusRuleResp;
import com.ga.core.vo.commission.PersistencyBonusSaveReq;
import com.ga.core.vo.commission.PersistencyBonusVO;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PersistencyBonusServiceTest {
@Mock PersistencyBonusRuleMapper ruleMapper;
@Mock PersistencyBonusMapper bonusMapper;
@InjectMocks PersistencyBonusService service;
private PersistencyBonusRuleResp rule(String bonusType, String threshold, String bonusValue) {
PersistencyBonusRuleResp r = new PersistencyBonusRuleResp();
r.setRuleId(7L);
r.setBonusType(bonusType);
r.setRetentionRateThreshold(threshold != null ? new BigDecimal(threshold) : null);
r.setBonusValue(bonusValue != null ? new BigDecimal(bonusValue) : null);
return r;
}
private PersistencyBonusSaveReq req(String retentionRate, String bonusAmount) {
PersistencyBonusSaveReq req = new PersistencyBonusSaveReq();
req.setAgentId(1L);
req.setRuleId(7L);
req.setBaseMonth("202506");
req.setRetentionMonth(13);
req.setSettleMonth("202506");
req.setRetentionRate(retentionRate != null ? new BigDecimal(retentionRate) : null);
req.setBonusAmount(bonusAmount != null ? new BigDecimal(bonusAmount) : null);
return req;
}
@Test
@DisplayName("유지율이 임계 미달이면 지급 차단(BizException), 원장 미생성")
void belowThreshold_rejected() {
when(ruleMapper.selectById(7L)).thenReturn(rule("RATE", "0.90", null));
assertThatThrownBy(() -> service.upsertBonus(req("0.85", "30000")))
.isInstanceOf(BizException.class);
verify(bonusMapper, never()).upsert(any());
}
@Test
@DisplayName("유지율이 임계 이상이면 정상 지급(경계=이상)")
void atThreshold_ok() {
when(ruleMapper.selectById(7L)).thenReturn(rule("RATE", "0.90", null));
ArgumentCaptor<PersistencyBonusVO> captor = ArgumentCaptor.forClass(PersistencyBonusVO.class);
service.upsertBonus(req("0.90", "30000"));
verify(bonusMapper).upsert(captor.capture());
assertThat(captor.getValue().getBonusAmount()).isEqualByComparingTo("30000");
}
@Test
@DisplayName("FIXED형 룰이면 지급액을 룰의 bonus_value(정액)로 도출")
void fixedRule_derivesAmountFromRule() {
when(ruleMapper.selectById(7L)).thenReturn(rule("FIXED", "0.90", "50000"));
ArgumentCaptor<PersistencyBonusVO> captor = ArgumentCaptor.forClass(PersistencyBonusVO.class);
// 요청 bonusAmount(999)는 무시되고 룰 정액 50000 으로 대체
service.upsertBonus(req("0.95", "999"));
verify(bonusMapper).upsert(captor.capture());
assertThat(captor.getValue().getBonusAmount()).isEqualByComparingTo("50000.00");
}
}
@@ -50,7 +50,7 @@ public class CommissionCalculator {
if (agent == null) return null;
LocalDate baseDate = c.getContractDate() != null ? c.getContractDate() : LocalDate.now();
BigDecimal companyRate = ruleMapper.findCompanyRate(c.getProductId(), year, baseDate);
BigDecimal companyRate = ruleMapper.findCompanyRate(c.getProductId(), year, baseDate, c.getSalesChannel());
if (companyRate == null) {
log.warn("[수수료율 누락] company_rate 없음 → 0 폴백(과소지급 위험): contractId={}, productId={}, year={}, baseDate={}",
c.getContractId(), c.getProductId(), year, baseDate);
@@ -58,7 +58,7 @@ public class CommissionCalculator {
}
BigDecimal payoutRate = ruleMapper.findPayoutRate(
agent.getGradeId(), c.getInsuranceType(), year, baseDate);
agent.getGradeId(), c.getInsuranceType(), year, baseDate, c.getSalesChannel());
if (payoutRate == null) {
log.warn("[수수료율 누락] payout_rate 없음 → 0 폴백(과소지급 위험): contractId={}, gradeId={}, insuranceType={}, year={}, baseDate={}",
c.getContractId(), agent.getGradeId(), c.getInsuranceType(), year, baseDate);
@@ -40,6 +40,9 @@ public class BatchInstallmentPlanGenerator {
List<InstallmentPlanVO> plans = new ArrayList<>();
for (int i = 0; i < ratios.size(); i++) {
InstallmentRatioVO ratio = ratios.get(i);
// 마스터의 plan_month 가 NULL 이면 연 단위(첫 달 지급), NOT NULL 이면 그 회차(월) 그대로 사용.
int planMonth = ratio.getPlanMonth() != null ? ratio.getPlanMonth() : 1;
BigDecimal amount;
if (fullSchedule && i == ratios.size() - 1) {
amount = totalCommission.subtract(allocated);
@@ -52,8 +55,10 @@ public class BatchInstallmentPlanGenerator {
InstallmentPlanVO plan = new InstallmentPlanVO();
plan.setContractId(contractId);
plan.setPlanYear(ratio.getPlanYear());
plan.setPlanMonth(1);
plan.setSettleMonth(contractDate.plusYears(ratio.getPlanYear()).format(MONTH_FMT));
plan.setPlanMonth(planMonth);
plan.setSettleMonth(
contractDate.plusYears(ratio.getPlanYear()).plusMonths(planMonth - 1L).format(MONTH_FMT)
);
plan.setExpectedAmount(amount);
plan.setStatus(InstallmentStatus.SCHEDULED.code());
plans.add(plan);
@@ -4,13 +4,17 @@ import com.ga.batch.settlement.SettlementContext;
import com.ga.batch.settlement.domain.BatchTaxCalculator;
import com.ga.batch.settlement.domain.TaxBreakdown;
import com.ga.common.util.MoneyUtil;
import com.ga.core.mapper.commission.AdvancePaymentMapper;
import com.ga.core.mapper.commission.AdvanceRecoveryLedgerMapper;
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.LapsedReinstatementRecoveryMapper;
import com.ga.core.mapper.commission.LifeSupportLedgerMapper;
import com.ga.core.mapper.commission.PersistencyBonusMapper;
import com.ga.core.mapper.commission.RecruiterCommissionLedgerMapper;
import com.ga.core.mapper.commission.ReferralCommissionLedgerMapper;
import com.ga.core.mapper.commission.ReinstatementCommissionLedgerMapper;
import com.ga.core.mapper.commission.RenewalCommissionLedgerMapper;
@@ -24,6 +28,8 @@ 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.AdvancePaymentVO;
import com.ga.core.vo.commission.AdvanceRecoveryLedgerVO;
import com.ga.core.vo.commission.ClawbackReceivableResp;
import com.ga.core.vo.commission.ClawbackReceivableVO;
import com.ga.core.vo.commission.ClawbackRecoveryVO;
@@ -96,6 +102,14 @@ public class AggregateStep implements Tasklet {
private final LifeSupportLedgerMapper lifeSupportMapper;
private final BranchMgrAllowanceLedgerMapper branchMgrAllowanceMapper;
// MOD-1 도입수수료 / MOD-4 익월부활재지급
private final RecruiterCommissionLedgerMapper recruiterCommissionMapper;
private final LapsedReinstatementRecoveryMapper lapsedReinstatementMapper;
// MOD-2 선지급 상계
private final AdvancePaymentMapper advancePaymentMapper;
private final AdvanceRecoveryLedgerMapper advanceRecoveryMapper;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
log.info("[Step8 aggregate] settleMonth={}", ctx.getSettleMonth());
@@ -125,6 +139,11 @@ public class AggregateStep implements Tasklet {
Map<Long, BigDecimal> lifeSupportMap = toMap(lifeSupportMapper.aggregateByAgent(ctx.getSettleMonth()));
Map<Long, BigDecimal> branchMgrMap = toMap(branchMgrAllowanceMapper.aggregateByAgent(ctx.getSettleMonth()));
// MOD-1 도입수수료 (recruiter_agent_id 기준 agentId)
Map<Long, BigDecimal> recruiterCommMap = toMap(recruiterCommissionMapper.aggregateByAgent(ctx.getSettleMonth()));
// MOD-4 익월부활재지급 (REPAID 상태만)
Map<Long, BigDecimal> lapsedReinstatMap = toMap(lapsedReinstatementMapper.aggregateByAgent(ctx.getSettleMonth()));
// 합집합 agent_id
Set<Long> agentIds = new HashSet<>();
agentIds.addAll(recruitMap.keySet());
@@ -142,6 +161,8 @@ public class AggregateStep implements Tasklet {
agentIds.addAll(awardMap.keySet());
agentIds.addAll(lifeSupportMap.keySet());
agentIds.addAll(branchMgrMap.keySet());
agentIds.addAll(recruiterCommMap.keySet());
agentIds.addAll(lapsedReinstatMap.keySet());
// 활성 설계사 누락 보충
AgentSearchParam ap = new AgentSearchParam();
@@ -168,6 +189,9 @@ public class AggregateStep implements Tasklet {
// 동일 월 재실행 시 중복 회수가 발생하지 않도록 보장한다.
reverseClawbackRecovery(ctx.getSettleMonth());
// MOD-2 멱등 reverse: 선지급 상계 이력 역산 후 advance_payment balance 복원
reverseAdvanceRecovery(ctx.getSettleMonth());
for (Long agentId : agentIds) {
BigDecimal recruit = recruitMap.getOrDefault(agentId, BigDecimal.ZERO);
BigDecimal maintain = maintainMap.getOrDefault(agentId, BigDecimal.ZERO);
@@ -187,6 +211,8 @@ public class AggregateStep implements Tasklet {
otherCommission = MoneyUtil.add(otherCommission, awardMap.getOrDefault(agentId, BigDecimal.ZERO));
otherCommission = MoneyUtil.add(otherCommission, lifeSupportMap.getOrDefault(agentId, BigDecimal.ZERO));
otherCommission = MoneyUtil.add(otherCommission, branchMgrMap.getOrDefault(agentId, BigDecimal.ZERO));
otherCommission = MoneyUtil.add(otherCommission, recruiterCommMap.getOrDefault(agentId, BigDecimal.ZERO));
otherCommission = MoneyUtil.add(otherCommission, lapsedReinstatMap.getOrDefault(agentId, BigDecimal.ZERO));
BigDecimal gross = MoneyUtil.add(MoneyUtil.add(MoneyUtil.add(recruit, maintain), ov), ep)
.subtract(cb).subtract(em);
@@ -230,6 +256,17 @@ public class AggregateStep implements Tasklet {
log.info("[Step8 clawback] agentId={}, net={}, priorBalance={}, recovered={}, carryover={}, payable={}",
agentId, netAmount, priorBalance, recovered, carryover, payable);
// MOD-2 선지급 FIFO 상계: 환수채권 상계 후 payable에서 추가 차감
BigDecimal advanceOutstanding = advancePaymentMapper.outstandingByAgent(agentId);
if (advanceOutstanding == null) advanceOutstanding = BigDecimal.ZERO;
if (advanceOutstanding.signum() > 0 && payable.signum() > 0) {
BigDecimal recoveredAdvance = payable.min(advanceOutstanding);
payable = payable.subtract(recoveredAdvance);
applyAdvanceRecovery(agentId, ctx.getSettleMonth(), recoveredAdvance);
log.info("[Step8 advance] agentId={}, advanceOutstanding={}, recoveredAdvance={}, payable={}",
agentId, advanceOutstanding, recoveredAdvance, payable);
}
// settle_master UPSERT
SettleMasterVO vo = new SettleMasterVO();
vo.setAgentId(agentId);
@@ -312,6 +349,74 @@ public class AggregateStep implements Tasklet {
return RepeatStatus.FINISHED;
}
/**
* MOD-2 멱등 reverse: settleMonth에 기록된 advance_recovery_ledger를 조회하여
* 각 advance_payment의 balance/recoveredAmount/status를 복원한 뒤 이력을 삭제한다.
* P9 reverseClawbackRecovery와 동일 사상.
*/
private void reverseAdvanceRecovery(String settleMonth) {
// 당월 상계 이력이 있는 선지급금만 월 단위로 역산(P9 reverseClawbackRecovery와 동일 사상).
for (Long advanceId : advanceRecoveryMapper.selectAdvanceIdsBySettleMonth(settleMonth)) {
List<com.ga.core.vo.commission.AdvanceRecoveryLedgerVO> prevLedgers =
advanceRecoveryMapper.selectBySettleMonth(advanceId, settleMonth);
if (prevLedgers.isEmpty()) continue;
BigDecimal toRestore = prevLedgers.stream()
.map(com.ga.core.vo.commission.AdvanceRecoveryLedgerVO::getRecoveryAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
com.ga.core.vo.commission.AdvancePaymentResp ap = advancePaymentMapper.selectById(advanceId);
if (ap == null) continue;
BigDecimal newRecovered = ap.getRecoveredAmount().subtract(toRestore);
BigDecimal newBalance = ap.getBalance().add(toRestore);
String newStatus = newRecovered.signum() == 0 ? "OUTSTANDING" : "PARTIAL";
AdvancePaymentVO updated = new AdvancePaymentVO();
updated.setAdvanceId(advanceId);
updated.setRecoveredAmount(newRecovered);
updated.setBalance(newBalance);
updated.setStatus(newStatus);
updated.setMemo(ap.getMemo());
advancePaymentMapper.update(updated);
advanceRecoveryMapper.deleteBySettleMonth(advanceId, settleMonth);
}
}
/**
* MOD-2 FIFO 선지급 상계 적용: 오래된 advance_month 순으로 balance를 차감하고
* advance_recovery_ledger 이력을 INSERT한다.
*/
private void applyAdvanceRecovery(Long agentId, String settleMonth, BigDecimal recoveredTotal) {
List<AdvancePaymentVO> fifo = advancePaymentMapper.selectOutstandingFifo(agentId);
BigDecimal remaining = recoveredTotal;
for (AdvancePaymentVO ap : fifo) {
if (remaining.signum() == 0) break;
BigDecimal take = remaining.min(ap.getBalance());
BigDecimal newRecovered = ap.getRecoveredAmount().add(take);
BigDecimal newBalance = ap.getBalance().subtract(take);
String newStatus = newBalance.signum() == 0 ? "CLEARED" : "PARTIAL";
AdvancePaymentVO updated = new AdvancePaymentVO();
updated.setAdvanceId(ap.getAdvanceId());
updated.setRecoveredAmount(newRecovered);
updated.setBalance(newBalance);
updated.setStatus(newStatus);
updated.setMemo(ap.getMemo());
advancePaymentMapper.update(updated);
AdvanceRecoveryLedgerVO ledger = new AdvanceRecoveryLedgerVO();
ledger.setAdvanceId(ap.getAdvanceId());
ledger.setSettleMonth(settleMonth);
ledger.setRecoveryAmount(take);
advanceRecoveryMapper.insert(ledger);
remaining = remaining.subtract(take);
}
}
/**
* 멱등 reverse: settleMonth의 기존 clawback_recovery 이력을 조회하여
* 각 receivable의 balance/recoveredAmount/status를 복원한 뒤 회수 이력을 전량 삭제한다.
@@ -75,32 +75,43 @@ public class CalcRecruitStep implements Tasklet {
LedgerVO led = calculator.calcRecruitLedger(c, ctx.getSettleMonth());
if (led == null || MoneyUtil.isZero(led.getAgentAmount())) continue;
// 1200%룰 검증 (1차년도 한도)
// 1200%룰 검증: 1차년 한도(FIRST_YEAR) + 누적 한도(TOTAL 1200%) 중
// 더 제한적인 한도로 당월 지급액을 clip, 초과분은 분급(7년)으로 이연.
if (c.getContractDate() != null) {
BigDecimal originalAmount = led.getAgentAmount();
BigDecimal allowed = originalAmount;
ViolationResult firstYearCheck = regulatoryChecker.checkFirstYear(
c.getContractId(), led.getAgentAmount(), c.getContractDate());
c.getContractId(), originalAmount, c.getContractDate());
if (firstYearCheck.violated()) {
violationCount++;
// 1차년 한도 잔여분까지만 당월 지급, 초과분은 분급(7년)으로 이연.
BigDecimal allowed = MoneyUtil.sub(
BigDecimal fyAllowed = MoneyUtil.sub(
firstYearCheck.limitAmount(), firstYearCheck.currentTotal());
if (allowed.signum() < 0) allowed = BigDecimal.ZERO;
BigDecimal excess = MoneyUtil.sub(led.getAgentAmount(), allowed);
if (fyAllowed.signum() < 0) fyAllowed = BigDecimal.ZERO;
allowed = allowed.min(fyAllowed);
}
if (excess.signum() > 0) {
// 초과분만 분급 계획 생성 (7년 35/25/15/10/7/5/3)
List<InstallmentPlanVO> plans = installmentGenerator.generate(
c.getContractId(), excess,
c.getContractDate(), c.getInsuranceType());
installmentBatch.addAll(plans);
// 당월 원장은 한도까지만 지급(초과분 차감) + 세액 재계산
led.setAgentAmount(allowed);
led.setTaxAmount(MoneyUtil.tax(allowed));
log.warn("[Step4] 1200%룰 1차년 한도초과 → clip+이연: contractId={}, 지급(한도잔여)={}, 이연(분급)={}",
c.getContractId(), allowed, excess);
}
ViolationResult totalCheck = regulatoryChecker.checkContractTotal(
c.getContractId(), originalAmount, c.getContractDate());
if (totalCheck.violated()) {
BigDecimal totAllowed = MoneyUtil.sub(
totalCheck.limitAmount(), totalCheck.currentTotal());
if (totAllowed.signum() < 0) totAllowed = BigDecimal.ZERO;
allowed = allowed.min(totAllowed);
}
BigDecimal excess = MoneyUtil.sub(originalAmount, allowed);
if (excess.signum() > 0) {
violationCount++;
// 초과분만 분급 계획 생성 (7년 35/25/15/10/7/5/3)
List<InstallmentPlanVO> plans = installmentGenerator.generate(
c.getContractId(), excess,
c.getContractDate(), c.getInsuranceType());
installmentBatch.addAll(plans);
// 당월 원장은 한도까지만 지급(초과분 차감) + 세액 재계산
led.setAgentAmount(allowed);
led.setTaxAmount(MoneyUtil.tax(allowed));
log.warn("[Step4] 1200%룰 한도초과 → clip+이연: contractId={}, 지급(한도잔여)={}, 이연(분급)={}, firstYear={}, total={}",
c.getContractId(), allowed, excess, firstYearCheck.violated(), totalCheck.violated());
}
}
@@ -54,9 +54,9 @@ class CommissionCalculatorTest {
@Test
@DisplayName("calcRecruitLedger: premium × company_rate × payout_rate, 세금은 3.3%")
void recruit_basic() {
when(ruleMapper.findCompanyRate(eq(1L), eq(1), any(LocalDate.class)))
when(ruleMapper.findCompanyRate(eq(1L), eq(1), any(LocalDate.class), any()))
.thenReturn(new BigDecimal("7.5"));
when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(1), any(LocalDate.class)))
when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(1), any(LocalDate.class), any()))
.thenReturn(new BigDecimal("60.0"));
LedgerVO led = calculator.calcRecruitLedger(contract, "202405");
@@ -83,12 +83,30 @@ class CommissionCalculatorTest {
assertThat(led.getPayMethod()).isEqualTo("MONTHLY");
}
@Test
@DisplayName("MOD-8 채널요율: 계약 sales_channel 이 요율 조회로 전달된다 (채널별 룰 선택)")
void recruit_passesSalesChannel() {
contract.setSalesChannel("BANCASSURANCE");
// 채널 일치 요율(방카)만 stub — 채널이 전달되지 않으면 매칭 실패로 0 폴백됨
when(ruleMapper.findCompanyRate(eq(1L), eq(1), any(LocalDate.class), eq("BANCASSURANCE")))
.thenReturn(new BigDecimal("5.0"));
when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(1), any(LocalDate.class), eq("BANCASSURANCE")))
.thenReturn(new BigDecimal("80.0"));
LedgerVO led = calculator.calcRecruitLedger(contract, "202405");
assertThat(led.getCompanyRate()).isEqualByComparingTo("5.0");
assertThat(led.getPayoutRate()).isEqualByComparingTo("80.0");
// 1,000,000 × 5% = 50,000 → × 80% = 40,000 (채널 요율이 실제로 적용됨)
assertThat(led.getAgentAmount()).isEqualByComparingTo("40000.00");
}
@Test
@DisplayName("calcMaintainLedger: 회차에 따라 commission_year 가 다름")
void maintain_year3() {
when(ruleMapper.findCompanyRate(eq(1L), eq(3), any(LocalDate.class)))
when(ruleMapper.findCompanyRate(eq(1L), eq(3), any(LocalDate.class), any()))
.thenReturn(new BigDecimal("3.0"));
when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(3), any(LocalDate.class)))
when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(3), any(LocalDate.class), any()))
.thenReturn(new BigDecimal("50.0"));
LedgerVO led = calculator.calcMaintainLedger(contract, "202405", 3);
@@ -115,8 +133,8 @@ class CommissionCalculatorTest {
@Test
@DisplayName("규정 누락 시 0 으로 폴백 → 모든 금액 0")
void missingRates_fallbackZero() {
when(ruleMapper.findCompanyRate(any(), any(), any())).thenReturn(null);
when(ruleMapper.findPayoutRate(any(), any(), any(), any())).thenReturn(null);
when(ruleMapper.findCompanyRate(any(), any(), any(), any())).thenReturn(null);
when(ruleMapper.findPayoutRate(any(), any(), any(), any(), any())).thenReturn(null);
LedgerVO led = calculator.calcRecruitLedger(contract, "202405");
@@ -132,9 +150,9 @@ class CommissionCalculatorTest {
@DisplayName("contractDate 가 null 이면 오늘 날짜로 대체 (예외 없음)")
void nullContractDate_usesToday() {
contract.setContractDate(null);
when(ruleMapper.findCompanyRate(eq(1L), eq(1), any(LocalDate.class)))
when(ruleMapper.findCompanyRate(eq(1L), eq(1), any(LocalDate.class), any()))
.thenReturn(new BigDecimal("5"));
when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(1), any(LocalDate.class)))
when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(1), any(LocalDate.class), any()))
.thenReturn(new BigDecimal("100"));
LedgerVO led = calculator.calcRecruitLedger(contract, "202405");
@@ -148,8 +166,8 @@ class CommissionCalculatorTest {
@DisplayName("premium 이 null 이면 0 으로 처리 (HALF_UP 0.00)")
void nullPremium() {
contract.setPremium(null);
when(ruleMapper.findCompanyRate(any(), any(), any())).thenReturn(new BigDecimal("7.5"));
when(ruleMapper.findPayoutRate(any(), any(), any(), any())).thenReturn(new BigDecimal("60"));
when(ruleMapper.findCompanyRate(any(), any(), any(), any())).thenReturn(new BigDecimal("7.5"));
when(ruleMapper.findPayoutRate(any(), any(), any(), any(), any())).thenReturn(new BigDecimal("60"));
LedgerVO led = calculator.calcRecruitLedger(contract, "202405");
@@ -0,0 +1,73 @@
package com.ga.batch.settlement.domain;
import com.ga.core.mapper.installment.InstallmentRatioMapper;
import com.ga.core.vo.installment.InstallmentPlanVO;
import com.ga.core.vo.installment.InstallmentRatioVO;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
/**
* 분급 계획 생성기 — plan_month 반영 검증 (API InstallmentPlanGenerator와 동일 출력).
*/
@ExtendWith(MockitoExtension.class)
class BatchInstallmentPlanGeneratorTest {
@Mock InstallmentRatioMapper ratioMapper;
@InjectMocks BatchInstallmentPlanGenerator generator;
static final LocalDate CONTRACT_DATE = LocalDate.of(2024, 1, 15);
private InstallmentRatioVO ratio(int year, Integer month, String pct) {
InstallmentRatioVO r = new InstallmentRatioVO();
r.setPlanYear(year);
r.setPlanMonth(month);
r.setRatioPercent(new BigDecimal(pct));
return r;
}
@Test
@DisplayName("plan_month 가 설정되면 settleMonth = 계약일 +N년 +(plan_month-1)월")
void usesPlanMonthInSettleMonth() {
// 1차년 4월차 100% (단일 회차)
when(ratioMapper.selectAllActiveRatios(anyString(), anyString()))
.thenReturn(List.of(ratio(1, 4, "100")));
List<InstallmentPlanVO> plans = generator.generate(
100L, new BigDecimal("700000"), CONTRACT_DATE, "WHOLE_LIFE");
assertThat(plans).hasSize(1);
InstallmentPlanVO p = plans.get(0);
assertThat(p.getPlanMonth()).isEqualTo(4);
// 2024-01 +1년 +3개월 = 2025-04
assertThat(p.getSettleMonth()).isEqualTo("202504");
assertThat(p.getExpectedAmount()).isEqualByComparingTo("700000");
}
@Test
@DisplayName("plan_month 가 NULL 이면 연 단위(첫 달) — settleMonth = 계약일 +N년")
void nullPlanMonthDefaultsToYearStart() {
when(ratioMapper.selectAllActiveRatios(any(), anyString()))
.thenReturn(List.of(ratio(2, null, "100")));
List<InstallmentPlanVO> plans = generator.generate(
100L, new BigDecimal("500000"), CONTRACT_DATE, null);
InstallmentPlanVO p = plans.get(0);
assertThat(p.getPlanMonth()).isEqualTo(1);
// 2024-01 +2년 +0개월 = 2026-01
assertThat(p.getSettleMonth()).isEqualTo("202601");
}
}
@@ -129,9 +129,9 @@ class SettlementScenarioTest {
@Test
@DisplayName("① 보험 인입 → 모집수수료 원장: 1,000,000 ×60% ×75% = 450,000, 세금 14,850")
void s1_보험인입_모집수수료_원장계산() {
when(ruleMapper.findCompanyRate(eq(PRODUCT_ID), eq(1), any(LocalDate.class)))
when(ruleMapper.findCompanyRate(eq(PRODUCT_ID), eq(1), any(LocalDate.class), any()))
.thenReturn(new BigDecimal("60.0"));
when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(1), any(LocalDate.class)))
when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(1), any(LocalDate.class), any()))
.thenReturn(new BigDecimal("75.0"));
LedgerVO led = commissionCalculator.calcRecruitLedger(contract, "202401");
@@ -226,9 +226,9 @@ class SettlementScenarioTest {
@Test
@DisplayName("④ 유지수수료 2차년: 1,000,000 ×10% ×50% = 50,000")
void s4_유지수수료_2차년() {
when(ruleMapper.findCompanyRate(eq(PRODUCT_ID), eq(2), any(LocalDate.class)))
when(ruleMapper.findCompanyRate(eq(PRODUCT_ID), eq(2), any(LocalDate.class), any()))
.thenReturn(new BigDecimal("10.0"));
when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(2), any(LocalDate.class)))
when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(2), any(LocalDate.class), any()))
.thenReturn(new BigDecimal("50.0"));
LedgerVO led = commissionCalculator.calcMaintainLedger(contract, "202501", 2);
@@ -241,10 +241,10 @@ class SettlementScenarioTest {
@Test
@DisplayName("⑤ 계약 승계: 유지수수료는 후임(박승계 등급3 40%)에게 귀속, 모집은 원설계사 유지")
void s5_계약승계_유지수수료_후임귀속() {
when(ruleMapper.findCompanyRate(eq(PRODUCT_ID), eq(2), any(LocalDate.class)))
when(ruleMapper.findCompanyRate(eq(PRODUCT_ID), eq(2), any(LocalDate.class), any()))
.thenReturn(new BigDecimal("10.0"));
// 후임 박승계 등급3 의 2차년 지급률 40%
when(ruleMapper.findPayoutRate(eq(3L), eq("WHOLE_LIFE"), eq(2), any(LocalDate.class)))
when(ruleMapper.findPayoutRate(eq(3L), eq("WHOLE_LIFE"), eq(2), any(LocalDate.class), any()))
.thenReturn(new BigDecimal("40.0"));
LedgerVO led = commissionCalculator.calcMaintainLedger(contract, "202501", 2, PARK);
@@ -57,6 +57,30 @@ class CalcRecruitStepTest {
step = new CalcRecruitStep(ctx, contractMapper, recruitMapper,
calculator, regulatoryChecker, installmentGenerator, installmentPlanMapper);
when(ctx.getSettleMonth()).thenReturn(MONTH);
// 기본: 두 한도 모두 위반 아님(각 테스트에서 필요한 한도만 violated 로 오버라이드)
when(regulatoryChecker.checkFirstYear(any(), any(BigDecimal.class), any(LocalDate.class)))
.thenReturn(ViolationResult.ok(BigDecimal.ZERO, null, "FIRST_YEAR_RATIO"));
when(regulatoryChecker.checkContractTotal(any(), any(BigDecimal.class), any(LocalDate.class)))
.thenReturn(ViolationResult.ok(BigDecimal.ZERO, null, "TOTAL_RATIO"));
}
private ContractResp contract() {
ContractResp c = new ContractResp();
c.setContractId(CONTRACT_ID);
c.setAgentId(10L);
c.setInsuranceType("WHOLE_LIFE");
c.setContractDate(CONTRACT_DATE);
return c;
}
private LedgerVO recruitLedger(ContractResp c, String amount, String tax) {
LedgerVO led = new LedgerVO();
led.setContractId(CONTRACT_ID);
led.setAgentId(10L);
led.setAgentAmount(new BigDecimal(amount));
led.setTaxAmount(new BigDecimal(tax));
when(calculator.calcRecruitLedger(eq(c), eq(MONTH))).thenReturn(led);
return led;
}
@Test
@@ -100,4 +124,62 @@ class CalcRecruitStepTest {
assertThat(saved.getAgentAmount()).isEqualByComparingTo("350000");
assertThat(saved.getTaxAmount()).isEqualByComparingTo("11550.00");
}
@Test
@DisplayName("1200% 누적한도 잔여 300,000 < 모집수수료 450,000 → 300,000 지급 + 초과 150,000 분급 이연")
void contractTotalLimit_clipAndDefer() {
ContractResp c = contract();
when(contractMapper.selectList(any())).thenReturn(List.of(c));
recruitLedger(c, "450000", "14850");
// 1차년은 통과(기본), 누적 1200% 한도만 위반: 한도 12,000,000 / 기지급 11,700,000 → 잔여 300,000
when(regulatoryChecker.checkContractTotal(eq(CONTRACT_ID), any(BigDecimal.class), any(LocalDate.class)))
.thenReturn(ViolationResult.violated(
new BigDecimal("11700000"), new BigDecimal("12000000"), "TOTAL_RATIO", "초과"));
when(installmentGenerator.generate(eq(CONTRACT_ID), any(BigDecimal.class), any(LocalDate.class), anyString()))
.thenReturn(List.of(new InstallmentPlanVO()));
ArgumentCaptor<List<LedgerVO>> ledgerCaptor = ArgumentCaptor.forClass(List.class);
when(recruitMapper.batchInsert(ledgerCaptor.capture())).thenReturn(1);
step.execute(null, null);
ArgumentCaptor<BigDecimal> excessCaptor = ArgumentCaptor.forClass(BigDecimal.class);
org.mockito.Mockito.verify(installmentGenerator)
.generate(eq(CONTRACT_ID), excessCaptor.capture(), any(LocalDate.class), anyString());
assertThat(excessCaptor.getValue()).isEqualByComparingTo("150000");
LedgerVO saved = ledgerCaptor.getValue().get(0);
assertThat(saved.getAgentAmount()).isEqualByComparingTo("300000");
assertThat(saved.getTaxAmount()).isEqualByComparingTo("9900.00");
}
@Test
@DisplayName("1차년·누적 둘 다 위반 → 더 제한적인 누적한도 잔여 300,000 으로 clip")
void bothLimits_mostRestrictiveApplies() {
ContractResp c = contract();
when(contractMapper.selectList(any())).thenReturn(List.of(c));
recruitLedger(c, "450000", "14850");
// 1차년 잔여 350,000, 누적 잔여 300,000 → min(450000, 350000, 300000) = 300,000
when(regulatoryChecker.checkFirstYear(eq(CONTRACT_ID), any(BigDecimal.class), any(LocalDate.class)))
.thenReturn(ViolationResult.violated(
BigDecimal.ZERO, new BigDecimal("350000"), "FIRST_YEAR_RATIO", "초과"));
when(regulatoryChecker.checkContractTotal(eq(CONTRACT_ID), any(BigDecimal.class), any(LocalDate.class)))
.thenReturn(ViolationResult.violated(
new BigDecimal("11700000"), new BigDecimal("12000000"), "TOTAL_RATIO", "초과"));
when(installmentGenerator.generate(eq(CONTRACT_ID), any(BigDecimal.class), any(LocalDate.class), anyString()))
.thenReturn(List.of(new InstallmentPlanVO()));
ArgumentCaptor<List<LedgerVO>> ledgerCaptor = ArgumentCaptor.forClass(List.class);
when(recruitMapper.batchInsert(ledgerCaptor.capture())).thenReturn(1);
step.execute(null, null);
ArgumentCaptor<BigDecimal> excessCaptor = ArgumentCaptor.forClass(BigDecimal.class);
org.mockito.Mockito.verify(installmentGenerator)
.generate(eq(CONTRACT_ID), excessCaptor.capture(), any(LocalDate.class), anyString());
assertThat(excessCaptor.getValue()).isEqualByComparingTo("150000");
LedgerVO saved = ledgerCaptor.getValue().get(0);
assertThat(saved.getAgentAmount()).isEqualByComparingTo("300000");
}
}
@@ -0,0 +1,156 @@
-- V116: MOD-1 슈퍼바이저/도입수수료 (recruiter/development commission)
-- agent.recruiter_agent_id 컬럼 추가
-- recruiter_commission_rule / recruiter_commission_ledger 테이블
-- 메뉴 RECRUITER_COMM (GRP_COMMISSION 하위)
-- 공통코드 RECRUITER_TYPE
-- ============================================================
-- 1. agent 테이블에 도입자 컬럼 추가
-- ============================================================
ALTER TABLE agent ADD COLUMN IF NOT EXISTS recruiter_agent_id BIGINT REFERENCES agent(agent_id);
COMMENT ON COLUMN agent.recruiter_agent_id IS '도입자 설계사 ID (NULL=도입자 없음)';
-- ============================================================
-- 2. recruiter_commission_rule (도입수수료 룰 마스터)
-- ============================================================
CREATE TABLE recruiter_commission_rule (
rule_id BIGSERIAL PRIMARY KEY,
recruiter_type VARCHAR(20) NOT NULL,
calc_type VARCHAR(10) NOT NULL,
commission_rate NUMERIC(7,4),
fixed_amount NUMERIC(15,2),
effective_from DATE NOT NULL,
effective_to DATE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
CONSTRAINT chk_rcr_recruiter_type CHECK (recruiter_type IN ('NEW_RECRUIT','DEVELOPMENT','TRAINING')),
CONSTRAINT chk_rcr_calc_type CHECK (calc_type IN ('RATE','FIXED')),
CONSTRAINT chk_rcr_rate_range CHECK (commission_rate IS NULL OR (commission_rate >= 0 AND commission_rate <= 1))
);
COMMENT ON TABLE recruiter_commission_rule IS '도입수수료 룰 마스터';
COMMENT ON COLUMN recruiter_commission_rule.rule_id IS 'PK';
COMMENT ON COLUMN recruiter_commission_rule.recruiter_type IS '도입유형: NEW_RECRUIT/DEVELOPMENT/TRAINING';
COMMENT ON COLUMN recruiter_commission_rule.calc_type IS '계산방식: RATE(정률)/FIXED(정액)';
COMMENT ON COLUMN recruiter_commission_rule.commission_rate IS '정률 요율 (0~1)';
COMMENT ON COLUMN recruiter_commission_rule.fixed_amount IS '정액 금액';
COMMENT ON COLUMN recruiter_commission_rule.effective_from IS '적용 시작일';
COMMENT ON COLUMN recruiter_commission_rule.effective_to IS '적용 종료일 (NULL=무기한)';
CREATE INDEX idx_rcr_type_from ON recruiter_commission_rule(recruiter_type, effective_from);
-- ============================================================
-- 3. recruiter_commission_ledger (도입수수료 원장)
-- ============================================================
CREATE TABLE recruiter_commission_ledger (
ledger_id BIGSERIAL PRIMARY KEY,
recruiter_agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
recruit_agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
settle_month CHAR(6) NOT NULL,
recruiter_type VARCHAR(20) NOT NULL,
base_amount NUMERIC(15,2) NOT NULL,
commission_amount NUMERIC(15,2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'CALCULATED',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
CONSTRAINT chk_rcl_status CHECK (status IN ('CALCULATED','CONFIRMED','PAID','CANCELLED'))
);
COMMENT ON TABLE recruiter_commission_ledger IS '도입수수료 원장';
COMMENT ON COLUMN recruiter_commission_ledger.ledger_id IS 'PK';
COMMENT ON COLUMN recruiter_commission_ledger.recruiter_agent_id IS '도입자 설계사 ID';
COMMENT ON COLUMN recruiter_commission_ledger.recruit_agent_id IS '피도입(신규) 설계사 ID';
COMMENT ON COLUMN recruiter_commission_ledger.settle_month IS '정산월 YYYYMM';
COMMENT ON COLUMN recruiter_commission_ledger.recruiter_type IS '도입유형';
COMMENT ON COLUMN recruiter_commission_ledger.base_amount IS '기준금액 (피도입 당월 모집수수료 합)';
COMMENT ON COLUMN recruiter_commission_ledger.commission_amount IS '도입수수료 금액';
COMMENT ON COLUMN recruiter_commission_ledger.status IS '상태: CALCULATED/CONFIRMED/PAID/CANCELLED';
CREATE INDEX idx_rcl_recruiter_month ON recruiter_commission_ledger(recruiter_agent_id, settle_month);
CREATE INDEX idx_rcl_recruit_month ON recruiter_commission_ledger(recruit_agent_id, settle_month);
-- ============================================================
-- 4. 메뉴 INSERT: RECRUITER_COMM (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
('RECRUITER_COMM', '도입수수료', '/commission/recruiter', 'commission/RecruiterCommission', 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/CREATE/UPDATE/DELETE/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),
('CREATE', '등록', 20),
('UPDATE', '수정', 30),
('DELETE', '삭제', 40),
('EXPORT', '엑셀', 60)
) AS p(code, name, sort)
WHERE m.menu_code = 'RECRUITER_COMM'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
-- ============================================================
-- 6. 역할별 권한 부여
-- ============================================================
-- SUPER_ADMIN / ADMIN — 전체 권한
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 = 'RECRUITER_COMM'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- MANAGER — READ/CREATE/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 = 'MANAGER'
AND mp.perm_code IN ('READ','CREATE','UPDATE','EXPORT')
AND m.menu_code = 'RECRUITER_COMM'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- AGENT — 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 = 'AGENT'
AND mp.perm_code = 'READ'
AND m.menu_code = 'RECRUITER_COMM'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- ============================================================
-- 7. 공통코드: RECRUITER_TYPE
-- ============================================================
INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
('RECRUITER_TYPE', '도입수수료유형', 'NEW_RECRUIT=신인도입/DEVELOPMENT=육성/TRAINING=교육', 'Y', 460)
ON CONFLICT (group_code) DO NOTHING;
INSERT INTO common_code (group_code, code, code_name, sort_order) VALUES
('RECRUITER_TYPE', 'NEW_RECRUIT', '신인도입', 10),
('RECRUITER_TYPE', 'DEVELOPMENT', '육성', 20),
('RECRUITER_TYPE', 'TRAINING', '교육', 30)
ON CONFLICT (group_code, code) DO NOTHING;
@@ -0,0 +1,142 @@
-- V117: MOD-2 선지급/차익정산 (advance payment recovery + delta settlement)
-- advance_payment / advance_recovery_ledger 테이블
-- 메뉴 ADVANCE_PAYMENT (GRP_SETTLE 하위)
-- 공통코드 ADVANCE_STATUS
-- ============================================================
-- 1. advance_payment (선지급금 마스터)
-- ============================================================
CREATE TABLE advance_payment (
advance_id BIGSERIAL PRIMARY KEY,
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
advance_month CHAR(6) NOT NULL,
principal_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',
memo VARCHAR(200),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
CONSTRAINT chk_adv_principal_pos CHECK (principal_amount > 0),
CONSTRAINT chk_adv_balance_nn CHECK (balance >= 0),
CONSTRAINT chk_adv_status CHECK (status IN ('OUTSTANDING','PARTIAL','CLEARED','WAIVED'))
);
COMMENT ON TABLE advance_payment IS '선지급금 마스터';
COMMENT ON COLUMN advance_payment.advance_id IS 'PK';
COMMENT ON COLUMN advance_payment.agent_id IS '설계사 ID';
COMMENT ON COLUMN advance_payment.advance_month IS '선지급 발생월 YYYYMM';
COMMENT ON COLUMN advance_payment.principal_amount IS '원금';
COMMENT ON COLUMN advance_payment.recovered_amount IS '회수 누계';
COMMENT ON COLUMN advance_payment.balance IS '잔액 (principal - recovered)';
COMMENT ON COLUMN advance_payment.status IS '상태: OUTSTANDING/PARTIAL/CLEARED/WAIVED';
COMMENT ON COLUMN advance_payment.memo IS '메모';
CREATE INDEX idx_adv_agent_status ON advance_payment(agent_id, status);
CREATE INDEX idx_adv_month ON advance_payment(advance_month);
-- ============================================================
-- 2. advance_recovery_ledger (선지급 상계 이력)
-- ============================================================
CREATE TABLE advance_recovery_ledger (
recovery_id BIGSERIAL PRIMARY KEY,
advance_id BIGINT NOT NULL REFERENCES advance_payment(advance_id),
settle_month CHAR(6) NOT NULL,
recovery_amount NUMERIC(15,2) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
CONSTRAINT chk_arl_recovery_pos CHECK (recovery_amount > 0)
);
COMMENT ON TABLE advance_recovery_ledger IS '선지급 상계 이력';
COMMENT ON COLUMN advance_recovery_ledger.recovery_id IS 'PK';
COMMENT ON COLUMN advance_recovery_ledger.advance_id IS '선지급금 ID';
COMMENT ON COLUMN advance_recovery_ledger.settle_month IS '상계 정산월 YYYYMM';
COMMENT ON COLUMN advance_recovery_ledger.recovery_amount IS '당월 상계 금액';
CREATE INDEX idx_arl_advance ON advance_recovery_ledger(advance_id);
CREATE INDEX idx_arl_settle_month ON advance_recovery_ledger(settle_month);
-- ============================================================
-- 3. 메뉴 INSERT: ADVANCE_PAYMENT (GRP_SETTLE 하위 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
('ADVANCE_PAYMENT', '선지급정산', '/settle/advance-payment', 'settle/AdvancePayment', 130)
) AS x(code, name, path, cmpt, sort)
ON p.menu_code = 'GRP_SETTLE'
WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code);
-- ============================================================
-- 4. 메뉴 권한 항목 등록 (READ/CREATE/UPDATE/DELETE/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),
('CREATE', '등록', 20),
('UPDATE', '수정', 30),
('DELETE', '삭제', 40),
('EXPORT', '엑셀', 60)
) AS p(code, name, sort)
WHERE m.menu_code = 'ADVANCE_PAYMENT'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
-- ============================================================
-- 5. 역할별 권한 부여
-- ============================================================
-- SUPER_ADMIN / ADMIN — 전체 권한
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 = 'ADVANCE_PAYMENT'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- MANAGER — READ/CREATE/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 = 'MANAGER'
AND mp.perm_code IN ('READ','CREATE','UPDATE','EXPORT')
AND m.menu_code = 'ADVANCE_PAYMENT'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- AGENT — 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 = 'AGENT'
AND mp.perm_code = 'READ'
AND m.menu_code = 'ADVANCE_PAYMENT'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- ============================================================
-- 6. 공통코드: ADVANCE_STATUS
-- ============================================================
INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
('ADVANCE_STATUS', '선지급상태', 'OUTSTANDING=미상환/PARTIAL=부분/CLEARED=완료/WAIVED=면제', 'Y', 470)
ON CONFLICT (group_code) DO NOTHING;
INSERT INTO common_code (group_code, code, code_name, sort_order) VALUES
('ADVANCE_STATUS', 'OUTSTANDING', '미상환', 10),
('ADVANCE_STATUS', 'PARTIAL', '부분상환', 20),
('ADVANCE_STATUS', 'CLEARED', '상환완료', 30),
('ADVANCE_STATUS', 'WAIVED', '면제', 40)
ON CONFLICT (group_code, code) DO NOTHING;
@@ -0,0 +1,116 @@
-- V118: MOD-3 환수 분할상환 (installment clawback)
-- clawback_installment_plan 테이블
-- 메뉴 CLAWBACK_INSTALLMENT (GRP_COMMISSION 하위)
-- 공통코드 CB_INSTALLMENT_STATUS
-- ============================================================
-- 1. clawback_installment_plan (환수 분할상환 계획)
-- ============================================================
CREATE TABLE clawback_installment_plan (
plan_id BIGSERIAL PRIMARY KEY,
receivable_id BIGINT NOT NULL REFERENCES clawback_receivable(receivable_id),
installment_no INT NOT NULL,
installment_amount NUMERIC(15,2) NOT NULL,
due_month CHAR(6) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'SCHEDULED',
recovered_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
CONSTRAINT chk_cip_amount_pos CHECK (installment_amount > 0),
CONSTRAINT chk_cip_status CHECK (status IN ('SCHEDULED','RECOVERED','CANCELLED')),
CONSTRAINT uq_cip_receivable_no UNIQUE (receivable_id, installment_no)
);
COMMENT ON TABLE clawback_installment_plan IS '환수 분할상환 계획';
COMMENT ON COLUMN clawback_installment_plan.plan_id IS 'PK';
COMMENT ON COLUMN clawback_installment_plan.receivable_id IS '환수채권 ID';
COMMENT ON COLUMN clawback_installment_plan.installment_no IS '분할 회차 번호 (1부터)';
COMMENT ON COLUMN clawback_installment_plan.installment_amount IS '회차 납부 금액';
COMMENT ON COLUMN clawback_installment_plan.due_month IS '납부 예정월 YYYYMM';
COMMENT ON COLUMN clawback_installment_plan.status IS '상태: SCHEDULED/RECOVERED/CANCELLED';
COMMENT ON COLUMN clawback_installment_plan.recovered_at IS '실제 회수 일시';
CREATE INDEX idx_cip_due_status ON clawback_installment_plan(due_month, status);
CREATE INDEX idx_cip_receivable_id ON clawback_installment_plan(receivable_id);
-- ============================================================
-- 2. 메뉴 INSERT: CLAWBACK_INSTALLMENT (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_INSTALLMENT', '환수분할상환', '/commission/clawback-installment', 'commission/ClawbackInstallment', 160)
) 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);
-- ============================================================
-- 3. 메뉴 권한 항목 등록 (READ/CREATE/UPDATE/DELETE/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),
('CREATE', '등록', 20),
('UPDATE', '수정', 30),
('DELETE', '삭제', 40),
('EXPORT', '엑셀', 60)
) AS p(code, name, sort)
WHERE m.menu_code = 'CLAWBACK_INSTALLMENT'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
-- ============================================================
-- 4. 역할별 권한 부여
-- ============================================================
-- SUPER_ADMIN / ADMIN — 전체 권한
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_INSTALLMENT'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- MANAGER — READ/CREATE/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 = 'MANAGER'
AND mp.perm_code IN ('READ','CREATE','UPDATE','EXPORT')
AND m.menu_code = 'CLAWBACK_INSTALLMENT'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- AGENT — 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 = 'AGENT'
AND mp.perm_code = 'READ'
AND m.menu_code = 'CLAWBACK_INSTALLMENT'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- ============================================================
-- 5. 공통코드: CB_INSTALLMENT_STATUS
-- ============================================================
INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
('CB_INSTALLMENT_STATUS', '환수분할상환상태', 'SCHEDULED=예정/RECOVERED=회수/CANCELLED=취소', 'Y', 480)
ON CONFLICT (group_code) DO NOTHING;
INSERT INTO common_code (group_code, code, code_name, sort_order) VALUES
('CB_INSTALLMENT_STATUS', 'SCHEDULED', '예정', 10),
('CB_INSTALLMENT_STATUS', 'RECOVERED', '회수', 20),
('CB_INSTALLMENT_STATUS', 'CANCELLED', '취소', 30)
ON CONFLICT (group_code, code) DO NOTHING;
@@ -0,0 +1,117 @@
-- V119: MOD-4 익월 부활 재지급 (lapsed reinstatement repayment)
-- lapsed_reinstatement_recovery 테이블
-- 메뉴 LAPSED_REINSTATEMENT (GRP_COMMISSION 하위)
-- 공통코드 LAPSE_RECOVERY_STATUS
-- ============================================================
-- 1. lapsed_reinstatement_recovery (익월 부활 재지급 원장)
-- ============================================================
CREATE TABLE lapsed_reinstatement_recovery (
recovery_id BIGSERIAL PRIMARY KEY,
contract_id BIGINT NOT NULL REFERENCES contract(contract_id),
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
lapsed_month CHAR(6) NOT NULL,
reinstated_month CHAR(6) NOT NULL,
withheld_amount NUMERIC(15,2) NOT NULL,
repaid_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
CONSTRAINT chk_lrr_withheld_nn CHECK (withheld_amount >= 0),
CONSTRAINT chk_lrr_status CHECK (status IN ('PENDING','REPAID','CANCELLED'))
);
COMMENT ON TABLE lapsed_reinstatement_recovery IS '익월 부활 재지급 원장';
COMMENT ON COLUMN lapsed_reinstatement_recovery.recovery_id IS 'PK';
COMMENT ON COLUMN lapsed_reinstatement_recovery.contract_id IS '계약 ID';
COMMENT ON COLUMN lapsed_reinstatement_recovery.agent_id IS '설계사 ID';
COMMENT ON COLUMN lapsed_reinstatement_recovery.lapsed_month IS '실효 발생월 YYYYMM';
COMMENT ON COLUMN lapsed_reinstatement_recovery.reinstated_month IS '부활 확정월 YYYYMM';
COMMENT ON COLUMN lapsed_reinstatement_recovery.withheld_amount IS '보류된 수수료 금액';
COMMENT ON COLUMN lapsed_reinstatement_recovery.repaid_amount IS '재지급 완료 금액';
COMMENT ON COLUMN lapsed_reinstatement_recovery.status IS '상태: PENDING/REPAID/CANCELLED';
CREATE INDEX idx_lrr_agent_reinstated ON lapsed_reinstatement_recovery(agent_id, reinstated_month);
CREATE INDEX idx_lrr_contract ON lapsed_reinstatement_recovery(contract_id);
-- ============================================================
-- 2. 메뉴 INSERT: LAPSED_REINSTATEMENT (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
('LAPSED_REINSTATEMENT', '익월부활재지급', '/commission/lapsed-reinstatement', 'commission/LapsedReinstatement', 170)
) 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);
-- ============================================================
-- 3. 메뉴 권한 항목 등록 (READ/CREATE/UPDATE/DELETE/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),
('CREATE', '등록', 20),
('UPDATE', '수정', 30),
('DELETE', '삭제', 40),
('EXPORT', '엑셀', 60)
) AS p(code, name, sort)
WHERE m.menu_code = 'LAPSED_REINSTATEMENT'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
-- ============================================================
-- 4. 역할별 권한 부여
-- ============================================================
-- SUPER_ADMIN / ADMIN — 전체 권한
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 = 'LAPSED_REINSTATEMENT'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- MANAGER — READ/CREATE/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 = 'MANAGER'
AND mp.perm_code IN ('READ','CREATE','UPDATE','EXPORT')
AND m.menu_code = 'LAPSED_REINSTATEMENT'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- AGENT — 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 = 'AGENT'
AND mp.perm_code = 'READ'
AND m.menu_code = 'LAPSED_REINSTATEMENT'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- ============================================================
-- 5. 공통코드: LAPSE_RECOVERY_STATUS
-- ============================================================
INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
('LAPSE_RECOVERY_STATUS', '부활재지급상태', 'PENDING=대기/REPAID=재지급완료/CANCELLED=취소', 'Y', 490)
ON CONFLICT (group_code) DO NOTHING;
INSERT INTO common_code (group_code, code, code_name, sort_order) VALUES
('LAPSE_RECOVERY_STATUS', 'PENDING', '대기', 10),
('LAPSE_RECOVERY_STATUS', 'REPAID', '재지급완료', 20),
('LAPSE_RECOVERY_STATUS', 'CANCELLED', '취소', 30)
ON CONFLICT (group_code, code) DO NOTHING;
@@ -0,0 +1,146 @@
-- V120: MOD-5 정산대사 입수 (carrier reconciliation IN)
-- carrier_reconciliation_in / reconciliation_variance 테이블
-- 메뉴 CARRIER_RECON_IN (GRP_SETTLE 하위)
-- 공통코드 RECON_IN_STATUS
-- insurance_company PK: company_id (SERIAL, V2 확인)
-- ============================================================
-- 1. carrier_reconciliation_in (보험사 정산대사 입수 마스터)
-- ============================================================
CREATE TABLE carrier_reconciliation_in (
recon_id BIGSERIAL PRIMARY KEY,
company_id BIGINT NOT NULL REFERENCES insurance_company(company_id),
settle_month CHAR(6) NOT NULL,
file_name VARCHAR(200),
record_count INT NOT NULL DEFAULT 0,
reported_total NUMERIC(15,2) NOT NULL DEFAULT 0,
system_total NUMERIC(15,2) NOT NULL DEFAULT 0,
variance_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'RECEIVED',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
CONSTRAINT chk_cri_status CHECK (status IN ('RECEIVED','MATCHED','VARIANCE','APPROVED')),
CONSTRAINT uq_cri_company_month UNIQUE (company_id, settle_month)
);
COMMENT ON TABLE carrier_reconciliation_in IS '보험사 정산대사 입수 마스터';
COMMENT ON COLUMN carrier_reconciliation_in.recon_id IS 'PK';
COMMENT ON COLUMN carrier_reconciliation_in.company_id IS '보험사 ID';
COMMENT ON COLUMN carrier_reconciliation_in.settle_month IS '정산월 YYYYMM';
COMMENT ON COLUMN carrier_reconciliation_in.file_name IS '입수 파일명';
COMMENT ON COLUMN carrier_reconciliation_in.record_count IS '보험사 통보 건수';
COMMENT ON COLUMN carrier_reconciliation_in.reported_total IS '보험사 통보 금액 합계';
COMMENT ON COLUMN carrier_reconciliation_in.system_total IS 'GA 시스템 집계 금액';
COMMENT ON COLUMN carrier_reconciliation_in.variance_amount IS '차이 금액 (reported - system)';
COMMENT ON COLUMN carrier_reconciliation_in.status IS '상태: RECEIVED/MATCHED/VARIANCE/APPROVED';
CREATE INDEX idx_cri_settle_month ON carrier_reconciliation_in(settle_month);
CREATE INDEX idx_cri_company_month ON carrier_reconciliation_in(company_id, settle_month);
-- ============================================================
-- 2. reconciliation_variance (대사 차이 상세)
-- ============================================================
CREATE TABLE reconciliation_variance (
variance_id BIGSERIAL PRIMARY KEY,
recon_id BIGINT NOT NULL REFERENCES carrier_reconciliation_in(recon_id),
policy_no VARCHAR(50),
expected_amount NUMERIC(15,2),
reported_amount NUMERIC(15,2),
variance_reason VARCHAR(100),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT
);
COMMENT ON TABLE reconciliation_variance IS '대사 차이 상세';
COMMENT ON COLUMN reconciliation_variance.variance_id IS 'PK';
COMMENT ON COLUMN reconciliation_variance.recon_id IS '대사 입수 ID';
COMMENT ON COLUMN reconciliation_variance.policy_no IS '증권번호';
COMMENT ON COLUMN reconciliation_variance.expected_amount IS 'GA 시스템 예상 금액';
COMMENT ON COLUMN reconciliation_variance.reported_amount IS '보험사 통보 금액';
COMMENT ON COLUMN reconciliation_variance.variance_reason IS '차이 사유';
CREATE INDEX idx_rv_recon_id ON reconciliation_variance(recon_id);
-- ============================================================
-- 3. 메뉴 INSERT: CARRIER_RECON_IN (GRP_SETTLE 하위 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
('CARRIER_RECON_IN', '정산대사입수', '/settle/carrier-recon-in', 'settle/CarrierReconIn', 140)
) AS x(code, name, path, cmpt, sort)
ON p.menu_code = 'GRP_SETTLE'
WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code);
-- ============================================================
-- 4. 메뉴 권한 항목 등록 (READ/CREATE/UPDATE/DELETE/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),
('CREATE', '등록', 20),
('UPDATE', '수정', 30),
('DELETE', '삭제', 40),
('EXPORT', '엑셀', 60)
) AS p(code, name, sort)
WHERE m.menu_code = 'CARRIER_RECON_IN'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
-- ============================================================
-- 5. 역할별 권한 부여
-- ============================================================
-- SUPER_ADMIN / ADMIN — 전체 권한
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 = 'CARRIER_RECON_IN'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- MANAGER — READ/CREATE/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 = 'MANAGER'
AND mp.perm_code IN ('READ','CREATE','UPDATE','EXPORT')
AND m.menu_code = 'CARRIER_RECON_IN'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- AGENT — 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 = 'AGENT'
AND mp.perm_code = 'READ'
AND m.menu_code = 'CARRIER_RECON_IN'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- ============================================================
-- 6. 공통코드: RECON_IN_STATUS
-- ============================================================
INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
('RECON_IN_STATUS', '정산대사상태', 'RECEIVED=입수/MATCHED=일치/VARIANCE=차이/APPROVED=승인', 'Y', 500)
ON CONFLICT (group_code) DO NOTHING;
INSERT INTO common_code (group_code, code, code_name, sort_order) VALUES
('RECON_IN_STATUS', 'RECEIVED', '입수', 10),
('RECON_IN_STATUS', 'MATCHED', '일치', 20),
('RECON_IN_STATUS', 'VARIANCE', '차이', 30),
('RECON_IN_STATUS', 'APPROVED', '승인', 40)
ON CONFLICT (group_code, code) DO NOTHING;
@@ -0,0 +1,102 @@
-- V121: MOD-6 환수 시효/소멸 (statute of limitations)
-- statute_config 테이블 + 시드 1행
-- 메뉴 STATUTE_CONFIG (GRP_COMMISSION 하위)
-- clawback_receivable.status CHECK: WRITTEN_OFF 이미 존재 (V111 확인) → 제약 변경 불필요
-- ============================================================
-- 1. statute_config (환수 시효 설정)
-- ============================================================
CREATE TABLE statute_config (
config_id BIGSERIAL PRIMARY KEY,
entity_type VARCHAR(30) NOT NULL,
expiration_months INT NOT NULL,
is_active CHAR(1) NOT NULL DEFAULT 'Y',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
CONSTRAINT chk_sc_entity_type CHECK (entity_type IN ('CLAWBACK_RECEIVABLE')),
CONSTRAINT chk_sc_expiration_pos CHECK (expiration_months > 0),
CONSTRAINT chk_sc_is_active CHECK (is_active IN ('Y','N')),
CONSTRAINT uq_sc_entity_type UNIQUE (entity_type)
);
COMMENT ON TABLE statute_config IS '환수 시효 설정';
COMMENT ON COLUMN statute_config.config_id IS 'PK';
COMMENT ON COLUMN statute_config.entity_type IS '대상 엔티티 타입 (CLAWBACK_RECEIVABLE)';
COMMENT ON COLUMN statute_config.expiration_months IS '시효 만료 개월수';
COMMENT ON COLUMN statute_config.is_active IS '활성 여부 (Y/N)';
-- ============================================================
-- 2. 시드 데이터: CLAWBACK_RECEIVABLE 36개월
-- ============================================================
INSERT INTO statute_config (entity_type, expiration_months, is_active)
VALUES ('CLAWBACK_RECEIVABLE', 36, 'Y')
ON CONFLICT (entity_type) DO NOTHING;
-- ============================================================
-- 3. 메뉴 INSERT: STATUTE_CONFIG (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
('STATUTE_CONFIG', '환수시효관리', '/commission/statute', 'commission/StatuteConfig', 180)
) 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);
-- ============================================================
-- 4. 메뉴 권한 항목 등록 (READ/CREATE/UPDATE/DELETE/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),
('CREATE', '등록', 20),
('UPDATE', '수정', 30),
('DELETE', '삭제', 40),
('EXPORT', '엑셀', 60)
) AS p(code, name, sort)
WHERE m.menu_code = 'STATUTE_CONFIG'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
-- ============================================================
-- 5. 역할별 권한 부여
-- ============================================================
-- SUPER_ADMIN / ADMIN — 전체 권한
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 = 'STATUTE_CONFIG'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- MANAGER — READ/CREATE/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 = 'MANAGER'
AND mp.perm_code IN ('READ','CREATE','UPDATE','EXPORT')
AND m.menu_code = 'STATUTE_CONFIG'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- AGENT — 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 = 'AGENT'
AND mp.perm_code = 'READ'
AND m.menu_code = 'STATUTE_CONFIG'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
@@ -0,0 +1,123 @@
-- V122: MOD-7 환수 감면/면제 (chargeback waiver/reduction)
-- chargeback_waiver_request 테이블
-- 메뉴 CHARGEBACK_WAIVER (GRP_COMMISSION 하위)
-- 공통코드 WAIVER_STATUS
-- ============================================================
-- 1. chargeback_waiver_request (환수 감면 신청)
-- ============================================================
CREATE TABLE chargeback_waiver_request (
request_id BIGSERIAL PRIMARY KEY,
contract_id BIGINT NOT NULL REFERENCES contract(contract_id),
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
settle_month CHAR(6) NOT NULL,
original_chargeback NUMERIC(15,2) NOT NULL,
waiver_amount NUMERIC(15,2) NOT NULL,
reason VARCHAR(200),
approval_status VARCHAR(20) NOT NULL DEFAULT 'REQUESTED',
approver_id BIGINT,
approved_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
CONSTRAINT chk_cwr_original_nn CHECK (original_chargeback >= 0),
CONSTRAINT chk_cwr_waiver_nn CHECK (waiver_amount >= 0),
CONSTRAINT chk_cwr_waiver_lte_orig CHECK (waiver_amount <= original_chargeback),
CONSTRAINT chk_cwr_status CHECK (approval_status IN ('REQUESTED','APPROVED','REJECTED'))
);
COMMENT ON TABLE chargeback_waiver_request IS '환수 감면 신청';
COMMENT ON COLUMN chargeback_waiver_request.request_id IS 'PK';
COMMENT ON COLUMN chargeback_waiver_request.contract_id IS '계약 ID';
COMMENT ON COLUMN chargeback_waiver_request.agent_id IS '설계사 ID';
COMMENT ON COLUMN chargeback_waiver_request.settle_month IS '정산월 YYYYMM';
COMMENT ON COLUMN chargeback_waiver_request.original_chargeback IS '원래 환수액';
COMMENT ON COLUMN chargeback_waiver_request.waiver_amount IS '감면 신청 금액 (<=original_chargeback)';
COMMENT ON COLUMN chargeback_waiver_request.reason IS '감면 사유';
COMMENT ON COLUMN chargeback_waiver_request.approval_status IS '승인 상태: REQUESTED/APPROVED/REJECTED';
COMMENT ON COLUMN chargeback_waiver_request.approver_id IS '승인자 사용자 ID';
COMMENT ON COLUMN chargeback_waiver_request.approved_at IS '승인/반려 일시';
CREATE INDEX idx_cwr_agent_month ON chargeback_waiver_request(agent_id, settle_month);
CREATE INDEX idx_cwr_status ON chargeback_waiver_request(approval_status);
-- ============================================================
-- 2. 메뉴 INSERT: CHARGEBACK_WAIVER (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
('CHARGEBACK_WAIVER', '환수감면신청', '/commission/chargeback-waiver', 'commission/ChargebackWaiver', 190)
) 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);
-- ============================================================
-- 3. 메뉴 권한 항목 등록 (READ/CREATE/UPDATE/DELETE/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),
('CREATE', '등록', 20),
('UPDATE', '수정', 30),
('DELETE', '삭제', 40),
('EXPORT', '엑셀', 60)
) AS p(code, name, sort)
WHERE m.menu_code = 'CHARGEBACK_WAIVER'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
-- ============================================================
-- 4. 역할별 권한 부여
-- ============================================================
-- SUPER_ADMIN / ADMIN — 전체 권한
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 = 'CHARGEBACK_WAIVER'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- MANAGER — READ/CREATE/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 = 'MANAGER'
AND mp.perm_code IN ('READ','CREATE','UPDATE','EXPORT')
AND m.menu_code = 'CHARGEBACK_WAIVER'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- AGENT — 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 = 'AGENT'
AND mp.perm_code = 'READ'
AND m.menu_code = 'CHARGEBACK_WAIVER'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- ============================================================
-- 5. 공통코드: WAIVER_STATUS
-- ============================================================
INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
('WAIVER_STATUS', '환수감면상태', 'REQUESTED=신청/APPROVED=승인/REJECTED=반려', 'Y', 510)
ON CONFLICT (group_code) DO NOTHING;
INSERT INTO common_code (group_code, code, code_name, sort_order) VALUES
('WAIVER_STATUS', 'REQUESTED', '신청', 10),
('WAIVER_STATUS', 'APPROVED', '승인', 20),
('WAIVER_STATUS', 'REJECTED', '반려', 30)
ON CONFLICT (group_code, code) DO NOTHING;
@@ -0,0 +1,51 @@
-- V123: MOD-8 채널별 수수료 차등 (channel-specific commission)
-- contract.sales_channel 컬럼 추가
-- commission_rate.sales_channel 컬럼 추가 (V3 확인: product_id 기반 룰 테이블)
-- payout_rule.sales_channel 컬럼 추가 (V3 확인: grade_id 기반 룰 테이블)
-- 공통코드 SALES_CHANNEL
-- 하위호환: sales_channel NULL = DIRECT 취급, NULL 룰이 폴백
-- ============================================================
-- 1. contract 테이블에 판매채널 컬럼 추가
-- ============================================================
ALTER TABLE contract ADD COLUMN IF NOT EXISTS sales_channel VARCHAR(20);
COMMENT ON COLUMN contract.sales_channel IS '판매채널 (NULL=DIRECT 취급): DIRECT/BANCASSURANCE/HOME_SHOPPING/PARTNERSHIP';
-- ============================================================
-- 2. commission_rate 테이블에 sales_channel 컬럼 추가
-- NULL = 모든 채널 적용 (폴백 룰)
-- ============================================================
ALTER TABLE commission_rate ADD COLUMN IF NOT EXISTS sales_channel VARCHAR(20);
COMMENT ON COLUMN commission_rate.sales_channel IS '판매채널 조건 (NULL=모든 채널 폴백)';
CREATE INDEX idx_cr_product_channel ON commission_rate(product_id, sales_channel, effective_from);
-- ============================================================
-- 3. payout_rule 테이블에 sales_channel 컬럼 추가
-- NULL = 모든 채널 적용 (폴백 룰)
-- ============================================================
ALTER TABLE payout_rule ADD COLUMN IF NOT EXISTS sales_channel VARCHAR(20);
COMMENT ON COLUMN payout_rule.sales_channel IS '판매채널 조건 (NULL=모든 채널 폴백)';
CREATE INDEX idx_pr_grade_channel ON payout_rule(grade_id, insurance_type, sales_channel, effective_from);
-- ============================================================
-- 4. 공통코드: SALES_CHANNEL
-- ============================================================
INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
('SALES_CHANNEL', '판매채널', 'DIRECT=직판/BANCASSURANCE=방카/HOME_SHOPPING=홈쇼핑/PARTNERSHIP=제휴', 'Y', 520)
ON CONFLICT (group_code) DO NOTHING;
INSERT INTO common_code (group_code, code, code_name, sort_order) VALUES
('SALES_CHANNEL', 'DIRECT', '직판', 10),
('SALES_CHANNEL', 'BANCASSURANCE', '방카', 20),
('SALES_CHANNEL', 'HOME_SHOPPING', '홈쇼핑', 30),
('SALES_CHANNEL', 'PARTNERSHIP', '제휴', 40)
ON CONFLICT (group_code, code) DO NOTHING;
@@ -0,0 +1,39 @@
package com.ga.core.mapper.commission;
import com.ga.core.vo.commission.AdvancePaymentResp;
import com.ga.core.vo.commission.AdvancePaymentSearchParam;
import com.ga.core.vo.commission.AdvancePaymentVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.math.BigDecimal;
import java.util.List;
/**
* advance_payment Mapper — 선지급금 마스터 (V117)
*/
@Mapper
public interface AdvancePaymentMapper {
AdvancePaymentResp selectById(@Param("advanceId") Long advanceId);
List<AdvancePaymentResp> selectList(AdvancePaymentSearchParam param);
int insert(AdvancePaymentVO vo);
int update(AdvancePaymentVO vo);
int delete(@Param("advanceId") Long advanceId);
/**
* 설계사의 미상환 잔액 합계.
* status IN ('OUTSTANDING','PARTIAL') 대상 SUM(balance).
*/
BigDecimal outstandingByAgent(@Param("agentId") Long agentId);
/**
* 설계사의 FIFO 상계 대상 선지급금 목록.
* status IN ('OUTSTANDING','PARTIAL') ORDER BY advance_month ASC, advance_id ASC.
*/
List<AdvancePaymentVO> selectOutstandingFifo(@Param("agentId") Long agentId);
}
@@ -0,0 +1,34 @@
package com.ga.core.mapper.commission;
import com.ga.core.vo.commission.AdvanceRecoveryLedgerVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* advance_recovery_ledger Mapper — 선지급 상계 이력 (V117)
*/
@Mapper
public interface AdvanceRecoveryLedgerMapper {
int insert(AdvanceRecoveryLedgerVO vo);
/**
* 배치 멱등용: 당월 상계 이력 삭제 (역산 후 재적용 패턴).
*/
int deleteBySettleMonth(@Param("advanceId") Long advanceId,
@Param("settleMonth") String settleMonth);
/**
* 선지급금 ID + 정산월 기준 상계 이력 조회.
*/
List<AdvanceRecoveryLedgerVO> selectBySettleMonth(@Param("advanceId") Long advanceId,
@Param("settleMonth") String settleMonth);
/**
* 배치 멱등용: 해당 정산월에 상계 이력이 있는 선지급금 ID 목록(중복 제거).
* reverse 단계에서 월 단위로 역산 대상을 식별한다.
*/
List<Long> selectAdvanceIdsBySettleMonth(@Param("settleMonth") String settleMonth);
}
@@ -0,0 +1,29 @@
package com.ga.core.mapper.commission;
import com.ga.core.vo.commission.CarrierReconciliationInResp;
import com.ga.core.vo.commission.CarrierReconciliationInSearchParam;
import com.ga.core.vo.commission.CarrierReconciliationInVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* carrier_reconciliation_in Mapper — 보험사 정산대사 입수 마스터 (V120)
*/
@Mapper
public interface CarrierReconciliationInMapper {
CarrierReconciliationInResp selectById(@Param("reconId") Long reconId);
List<CarrierReconciliationInResp> selectList(CarrierReconciliationInSearchParam param);
int insert(CarrierReconciliationInVO vo);
int update(CarrierReconciliationInVO vo);
/**
* 보험사 + 정산월 기준 upsert — UNIQUE(company_id, settle_month) 충돌 시 갱신.
*/
int upsert(CarrierReconciliationInVO vo);
}
@@ -0,0 +1,31 @@
package com.ga.core.mapper.commission;
import com.ga.core.vo.commission.ChargebackWaiverRequestResp;
import com.ga.core.vo.commission.ChargebackWaiverRequestSearchParam;
import com.ga.core.vo.commission.ChargebackWaiverRequestVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
import java.util.List;
/**
* chargeback_waiver_request Mapper — 환수 감면 신청 (V122)
*/
@Mapper
public interface ChargebackWaiverRequestMapper {
ChargebackWaiverRequestResp selectById(@Param("requestId") Long requestId);
List<ChargebackWaiverRequestResp> selectList(ChargebackWaiverRequestSearchParam param);
int insert(ChargebackWaiverRequestVO vo);
/**
* 승인/반려 처리 — approval_status, approver_id, approved_at, updated_at 갱신.
*/
int updateApproval(@Param("requestId") Long requestId,
@Param("approvalStatus") String approvalStatus,
@Param("approverId") Long approverId,
@Param("approvedAt") LocalDateTime approvedAt);
}
@@ -0,0 +1,36 @@
package com.ga.core.mapper.commission;
import com.ga.core.vo.commission.ClawbackInstallmentPlanResp;
import com.ga.core.vo.commission.ClawbackInstallmentPlanSearchParam;
import com.ga.core.vo.commission.ClawbackInstallmentPlanVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* clawback_installment_plan Mapper — 환수 분할상환 계획 (V118)
*/
@Mapper
public interface ClawbackInstallmentPlanMapper {
ClawbackInstallmentPlanResp selectById(@Param("planId") Long planId);
List<ClawbackInstallmentPlanResp> selectList(ClawbackInstallmentPlanSearchParam param);
/**
* 채권 ID 기준 분할상환 계획 목록 — 회차순 정렬.
*/
List<ClawbackInstallmentPlanVO> selectByReceivable(@Param("receivableId") Long receivableId);
/**
* 분할상환 계획 일괄 등록.
*/
int insertBatch(List<ClawbackInstallmentPlanVO> plans);
/**
* 분할상환 회차 상태 갱신 (SCHEDULED→RECOVERED 또는 CANCELLED).
*/
int updateStatus(@Param("planId") Long planId,
@Param("status") String status);
}
@@ -77,4 +77,20 @@ public interface ClawbackReceivableMapper {
List<Map<String, Object>> summaryByAgent(ClawbackReceivableSearchParam param);
int countByCondition(ClawbackReceivableSearchParam param);
/**
* [MOD-6] 시효 만료 대상 채권 조회.
* origin_settle_month <= beforeMonth AND status IN('OUTSTANDING','RECOVERING')
* beforeMonth = baseMonth - expiration_months (Service에서 계산 후 전달).
*/
List<ClawbackReceivableVO> selectExpired(@Param("beforeMonth") String beforeMonth);
/**
* [MOD-6] 만료 대상 채권 일괄 대손 처리.
* status='WRITTEN_OFF', writeoff_reason=reason, written_off_at=NOW(), updated_at=NOW()
* 대상: origin_settle_month <= beforeMonth AND status IN('OUTSTANDING','RECOVERING')
* 처리 건수 반환.
*/
int writeOffExpired(@Param("beforeMonth") String beforeMonth,
@Param("reason") String reason);
}
@@ -0,0 +1,33 @@
package com.ga.core.mapper.commission;
import com.ga.core.vo.commission.LapsedReinstatementRecoveryResp;
import com.ga.core.vo.commission.LapsedReinstatementRecoverySearchParam;
import com.ga.core.vo.commission.LapsedReinstatementRecoveryVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
/**
* lapsed_reinstatement_recovery Mapper — 익월 부활 재지급 원장 (V119)
*/
@Mapper
public interface LapsedReinstatementRecoveryMapper {
LapsedReinstatementRecoveryResp selectById(@Param("recoveryId") Long recoveryId);
List<LapsedReinstatementRecoveryResp> selectList(LapsedReinstatementRecoverySearchParam param);
int insert(LapsedReinstatementRecoveryVO vo);
int updateStatus(@Param("recoveryId") Long recoveryId,
@Param("status") String status,
@Param("repaidAmount") java.math.BigDecimal repaidAmount);
/**
* 정산월 기준 설계사별 재지급 합계 — AggregateStep other_commission_total 가산용.
* status='REPAID' 대상 SUM(repaid_amount) GROUP BY agent_id.
*/
List<Map<String, Object>> aggregateByAgent(@Param("settleMonth") String settleMonth);
}
@@ -0,0 +1,24 @@
package com.ga.core.mapper.commission;
import com.ga.core.vo.commission.ReconciliationVarianceVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* reconciliation_variance Mapper — 대사 차이 상세 (V120)
*/
@Mapper
public interface ReconciliationVarianceMapper {
/**
* 차이 상세 일괄 등록.
*/
int insertBatch(List<ReconciliationVarianceVO> variances);
/**
* 대사 입수 ID 기준 차이 상세 목록 조회.
*/
List<ReconciliationVarianceVO> selectByRecon(@Param("reconId") Long reconId);
}
@@ -0,0 +1,33 @@
package com.ga.core.mapper.commission;
import com.ga.core.vo.commission.RecruiterCommissionLedgerResp;
import com.ga.core.vo.commission.RecruiterCommissionLedgerSearchParam;
import com.ga.core.vo.commission.RecruiterCommissionLedgerVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
/**
* recruiter_commission_ledger Mapper — 도입수수료 원장 (V116)
*/
@Mapper
public interface RecruiterCommissionLedgerMapper {
RecruiterCommissionLedgerResp selectById(@Param("ledgerId") Long ledgerId);
List<RecruiterCommissionLedgerResp> selectList(RecruiterCommissionLedgerSearchParam param);
int insert(RecruiterCommissionLedgerVO vo);
int updateStatus(@Param("ledgerId") Long ledgerId, @Param("status") String status);
int delete(@Param("ledgerId") Long ledgerId);
/**
* 정산월 기준 도입자 설계사별 도입수수료 합계 — AggregateStep other_commission_total 가산용.
* recruiter_agent_id 기준으로 agentId alias, CANCELLED 제외.
*/
List<Map<String, Object>> aggregateByAgent(@Param("settleMonth") String settleMonth);
}
@@ -0,0 +1,26 @@
package com.ga.core.mapper.commission;
import com.ga.core.vo.commission.RecruiterCommissionRuleResp;
import com.ga.core.vo.commission.RecruiterCommissionRuleSearchParam;
import com.ga.core.vo.commission.RecruiterCommissionRuleVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* recruiter_commission_rule Mapper — 도입수수료 룰 마스터 (V116)
*/
@Mapper
public interface RecruiterCommissionRuleMapper {
RecruiterCommissionRuleResp selectById(@Param("ruleId") Long ruleId);
List<RecruiterCommissionRuleResp> selectList(RecruiterCommissionRuleSearchParam param);
int insert(RecruiterCommissionRuleVO vo);
int update(RecruiterCommissionRuleVO vo);
int delete(@Param("ruleId") Long ruleId);
}
@@ -0,0 +1,28 @@
package com.ga.core.mapper.commission;
import com.ga.core.vo.commission.StatuteConfigResp;
import com.ga.core.vo.commission.StatuteConfigVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* statute_config Mapper — 환수 시효 설정 (V121)
*/
@Mapper
public interface StatuteConfigMapper {
StatuteConfigResp selectById(@Param("configId") Long configId);
/**
* entity_type 기준 단건 조회 (UNIQUE).
*/
StatuteConfigResp selectByEntityType(@Param("entityType") String entityType);
List<StatuteConfigResp> selectList();
int insert(StatuteConfigVO vo);
int update(StatuteConfigVO vo);
}
@@ -17,10 +17,11 @@ public interface RuleMapper {
// === commission_rate ===
List<CommissionRateVO> selectCommissionRates(@Param("productId") Long productId,
@Param("year") Integer year);
/** 계약일에 유효한 보험사 수수료율 */
/** 계약일에 유효한 보험사 수수료율 (판매채널 우선, NULL=모든채널 폴백) */
BigDecimal findCompanyRate(@Param("productId") Long productId,
@Param("year") Integer year,
@Param("baseDate") LocalDate baseDate);
@Param("baseDate") LocalDate baseDate,
@Param("salesChannel") String salesChannel);
int insertCommissionRate(CommissionRateVO vo);
int updateCommissionRate(CommissionRateVO vo);
int deleteCommissionRate(@Param("rateId") Long rateId);
@@ -31,7 +32,8 @@ public interface RuleMapper {
BigDecimal findPayoutRate(@Param("gradeId") Long gradeId,
@Param("insuranceType") String insuranceType,
@Param("year") Integer year,
@Param("baseDate") LocalDate baseDate);
@Param("baseDate") LocalDate baseDate,
@Param("salesChannel") String salesChannel);
int insertPayoutRule(PayoutRuleVO vo);
int updatePayoutRule(PayoutRuleVO vo);
int deletePayoutRule(@Param("ruleId") Long ruleId);
@@ -0,0 +1,28 @@
package com.ga.core.vo.commission;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* advance_payment 조회 응답 — 설계사명 조인 포함 (V117)
*/
@Data
public class AdvancePaymentResp {
private Long advanceId;
private Long agentId;
/** 설계사명 */
private String agentName;
/** 조직명 */
private String orgName;
private String advanceMonth;
private BigDecimal principalAmount;
private BigDecimal recoveredAmount;
private BigDecimal balance;
private String status;
/** 공통코드 ADVANCE_STATUS */
private String statusName;
private String memo;
private LocalDateTime createdAt;
}
@@ -0,0 +1,27 @@
package com.ga.core.vo.commission;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
/**
* advance_payment 등록 요청 (V117)
*/
@Data
public class AdvancePaymentSaveReq {
@NotNull
private Long agentId;
@NotBlank
private String advanceMonth;
@NotNull
@DecimalMin(value = "0", inclusive = false)
private BigDecimal principalAmount;
private String memo;
}
@@ -0,0 +1,17 @@
package com.ga.core.vo.commission;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* advance_payment 검색 조건 (V117)
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class AdvancePaymentSearchParam extends SearchParam {
private Long agentId;
private String agentName;
private String advanceMonth;
private String status;
}
@@ -0,0 +1,29 @@
package com.ga.core.vo.commission;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* advance_payment — 선지급금 마스터 (V117)
*/
@Data
public class AdvancePaymentVO {
private Long advanceId;
private Long agentId;
/** 선지급 발생월 YYYYMM */
private String advanceMonth;
/** 원금 */
private BigDecimal principalAmount;
/** 회수 누계 */
private BigDecimal recoveredAmount;
/** 잔액 (principal - recovered) */
private BigDecimal balance;
/** 상태: OUTSTANDING/PARTIAL/CLEARED/WAIVED */
private String status;
private String memo;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,21 @@
package com.ga.core.vo.commission;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* advance_recovery_ledger — 선지급 상계 이력 (V117)
*/
@Data
public class AdvanceRecoveryLedgerVO {
private Long recoveryId;
private Long advanceId;
/** 상계 정산월 YYYYMM */
private String settleMonth;
/** 당월 상계 금액 */
private BigDecimal recoveryAmount;
private LocalDateTime createdAt;
private Long createdBy;
}
@@ -0,0 +1,28 @@
package com.ga.core.vo.commission;
import com.fasterxml.jackson.annotation.JsonValue;
/**
* 선지급 상태 (ADVANCE_STATUS 그룹, V117)
*/
public enum AdvanceStatus {
OUTSTANDING("미상환"),
PARTIAL("부분상환"),
CLEARED("상환완료"),
WAIVED("면제");
private final String label;
AdvanceStatus(String label) {
this.label = label;
}
@JsonValue
public String getCode() {
return this.name();
}
public String getLabel() {
return label;
}
}
@@ -0,0 +1,29 @@
package com.ga.core.vo.commission;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* carrier_reconciliation_in 조회 응답 — 보험사명 조인 포함 (V120)
*/
@Data
public class CarrierReconciliationInResp {
private Long reconId;
private Long companyId;
/** 보험사 코드 */
private String companyCode;
/** 보험사명 */
private String companyName;
private String settleMonth;
private String fileName;
private Integer recordCount;
private BigDecimal reportedTotal;
private BigDecimal systemTotal;
private BigDecimal varianceAmount;
private String status;
/** 공통코드 RECON_IN_STATUS */
private String statusName;
private LocalDateTime createdAt;
}
@@ -0,0 +1,29 @@
package com.ga.core.vo.commission;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
/**
* carrier_reconciliation_in 등록/수정 요청 (V120)
*/
@Data
public class CarrierReconciliationInSaveReq {
@NotNull
private Long companyId;
@NotBlank
private String settleMonth;
private String fileName;
private Integer recordCount;
@NotNull
@DecimalMin("0")
private BigDecimal reportedTotal;
}
@@ -0,0 +1,16 @@
package com.ga.core.vo.commission;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* carrier_reconciliation_in 검색 조건 (V120)
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class CarrierReconciliationInSearchParam extends SearchParam {
private Long companyId;
private String settleMonth;
private String status;
}
@@ -0,0 +1,30 @@
package com.ga.core.vo.commission;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* carrier_reconciliation_in — 보험사 정산대사 입수 마스터 (V120)
*/
@Data
public class CarrierReconciliationInVO {
private Long reconId;
private Long companyId;
/** 정산월 YYYYMM */
private String settleMonth;
private String fileName;
private Integer recordCount;
/** 보험사 통보 금액 합계 */
private BigDecimal reportedTotal;
/** GA 시스템 집계 금액 */
private BigDecimal systemTotal;
/** 차이 금액 (reported - system) */
private BigDecimal varianceAmount;
/** 상태: RECEIVED/MATCHED/VARIANCE/APPROVED */
private String status;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,27 @@
package com.ga.core.vo.commission;
import com.fasterxml.jackson.annotation.JsonValue;
/**
* 환수 분할상환 상태 (CB_INSTALLMENT_STATUS 그룹, V118)
*/
public enum CbInstallmentStatus {
SCHEDULED("예정"),
RECOVERED("회수"),
CANCELLED("취소");
private final String label;
CbInstallmentStatus(String label) {
this.label = label;
}
@JsonValue
public String getCode() {
return this.name();
}
public String getLabel() {
return label;
}
}
@@ -0,0 +1,32 @@
package com.ga.core.vo.commission;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* chargeback_waiver_request 조회 응답 — 설계사명·계약정보 조인 포함 (V122)
*/
@Data
public class ChargebackWaiverRequestResp {
private Long requestId;
private Long contractId;
/** 증권번호 */
private String policyNo;
private Long agentId;
/** 설계사명 */
private String agentName;
/** 조직명 */
private String orgName;
private String settleMonth;
private BigDecimal originalChargeback;
private BigDecimal waiverAmount;
private String reason;
private String approvalStatus;
/** 공통코드 WAIVER_STATUS */
private String approvalStatusName;
private Long approverId;
private LocalDateTime approvedAt;
private LocalDateTime createdAt;
}
@@ -0,0 +1,35 @@
package com.ga.core.vo.commission;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
/**
* chargeback_waiver_request 등록 요청 (V122).
* waiver_amount <= original_chargeback 검증은 Service에서 수행.
*/
@Data
public class ChargebackWaiverRequestSaveReq {
@NotNull
private Long contractId;
@NotNull
private Long agentId;
@NotBlank
private String settleMonth;
@NotNull
@DecimalMin("0")
private BigDecimal originalChargeback;
@NotNull
@DecimalMin("0")
private BigDecimal waiverAmount;
private String reason;
}
@@ -0,0 +1,17 @@
package com.ga.core.vo.commission;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* chargeback_waiver_request 검색 조건 (V122)
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class ChargebackWaiverRequestSearchParam extends SearchParam {
private Long agentId;
private String agentName;
private String settleMonth;
private String approvalStatus;
}
@@ -0,0 +1,33 @@
package com.ga.core.vo.commission;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* chargeback_waiver_request — 환수 감면 신청 (V122)
*/
@Data
public class ChargebackWaiverRequestVO {
private Long requestId;
private Long contractId;
private Long agentId;
/** 정산월 YYYYMM */
private String settleMonth;
/** 원래 환수액 */
private BigDecimal originalChargeback;
/** 감면 신청 금액 (<=originalChargeback) */
private BigDecimal waiverAmount;
/** 감면 사유 */
private String reason;
/** 승인 상태: REQUESTED/APPROVED/REJECTED */
private String approvalStatus;
/** 승인자 사용자 ID */
private Long approverId;
/** 승인/반려 일시 */
private LocalDateTime approvedAt;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,28 @@
package com.ga.core.vo.commission;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* clawback_installment_plan 조회 응답 — 채권 정보 조인 포함 (V118)
*/
@Data
public class ClawbackInstallmentPlanResp {
private Long planId;
private Long receivableId;
/** 채권 발생월 (clawback_receivable.origin_settle_month) */
private String originSettleMonth;
/** 채권 설계사 ID */
private Long agentId;
/** 채권 설계사명 */
private String agentName;
private Integer installmentNo;
private BigDecimal installmentAmount;
private String dueMonth;
private String status;
/** 공통코드 CB_INSTALLMENT_STATUS */
private String statusName;
private LocalDateTime recoveredAt;
}
@@ -0,0 +1,31 @@
package com.ga.core.vo.commission;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
/**
* clawback_installment_plan 단건 등록 요청 (V118).
* 일괄 생성은 Service의 generate(receivableId, count) 사용.
*/
@Data
public class ClawbackInstallmentPlanSaveReq {
@NotNull
private Long receivableId;
@NotNull
@Min(1)
private Integer installmentNo;
@NotNull
@DecimalMin(value = "0", inclusive = false)
private BigDecimal installmentAmount;
@NotBlank
private String dueMonth;
}
@@ -0,0 +1,17 @@
package com.ga.core.vo.commission;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* clawback_installment_plan 검색 조건 (V118)
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class ClawbackInstallmentPlanSearchParam extends SearchParam {
private Long receivableId;
private Long agentId;
private String dueMonth;
private String status;
}
@@ -0,0 +1,28 @@
package com.ga.core.vo.commission;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* clawback_installment_plan — 환수 분할상환 계획 (V118)
*/
@Data
public class ClawbackInstallmentPlanVO {
private Long planId;
private Long receivableId;
/** 분할 회차 번호 (1부터) */
private Integer installmentNo;
/** 회차 납부 금액 */
private BigDecimal installmentAmount;
/** 납부 예정월 YYYYMM */
private String dueMonth;
/** 상태: SCHEDULED/RECOVERED/CANCELLED */
private String status;
/** 실제 회수 일시 */
private LocalDateTime recoveredAt;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,27 @@
package com.ga.core.vo.commission;
import com.fasterxml.jackson.annotation.JsonValue;
/**
* 익월 부활 재지급 상태 (LAPSE_RECOVERY_STATUS 그룹, V119)
*/
public enum LapseRecoveryStatus {
PENDING("대기"),
REPAID("재지급완료"),
CANCELLED("취소");
private final String label;
LapseRecoveryStatus(String label) {
this.label = label;
}
@JsonValue
public String getCode() {
return this.name();
}
public String getLabel() {
return label;
}
}
@@ -0,0 +1,28 @@
package com.ga.core.vo.commission;
import lombok.Data;
import java.math.BigDecimal;
/**
* lapsed_reinstatement_recovery 조회 응답 — 설계사명·계약정보 조인 포함 (V119)
*/
@Data
public class LapsedReinstatementRecoveryResp {
private Long recoveryId;
private Long contractId;
/** 증권번호 */
private String policyNo;
private Long agentId;
/** 설계사명 */
private String agentName;
/** 조직명 */
private String orgName;
private String lapsedMonth;
private String reinstatedMonth;
private BigDecimal withheldAmount;
private BigDecimal repaidAmount;
private String status;
/** 공통코드 LAPSE_RECOVERY_STATUS */
private String statusName;
}
@@ -0,0 +1,31 @@
package com.ga.core.vo.commission;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
/**
* lapsed_reinstatement_recovery 등록 요청 (V119)
*/
@Data
public class LapsedReinstatementRecoverySaveReq {
@NotNull
private Long contractId;
@NotNull
private Long agentId;
@NotBlank
private String lapsedMonth;
@NotBlank
private String reinstatedMonth;
@NotNull
@DecimalMin("0")
private BigDecimal withheldAmount;
}
@@ -0,0 +1,18 @@
package com.ga.core.vo.commission;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* lapsed_reinstatement_recovery 검색 조건 (V119)
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class LapsedReinstatementRecoverySearchParam extends SearchParam {
private Long agentId;
private String agentName;
private String reinstatedMonth;
private String lapsedMonth;
private String status;
}
@@ -0,0 +1,29 @@
package com.ga.core.vo.commission;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* lapsed_reinstatement_recovery — 익월 부활 재지급 원장 (V119)
*/
@Data
public class LapsedReinstatementRecoveryVO {
private Long recoveryId;
private Long contractId;
private Long agentId;
/** 실효 발생월 YYYYMM */
private String lapsedMonth;
/** 부활 확정월 YYYYMM */
private String reinstatedMonth;
/** 보류된 수수료 금액 */
private BigDecimal withheldAmount;
/** 재지급 완료 금액 */
private BigDecimal repaidAmount;
/** 상태: PENDING/REPAID/CANCELLED */
private String status;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,28 @@
package com.ga.core.vo.commission;
import com.fasterxml.jackson.annotation.JsonValue;
/**
* 정산대사 입수 상태 (RECON_IN_STATUS 그룹, V120)
*/
public enum ReconInStatus {
RECEIVED("입수"),
MATCHED("일치"),
VARIANCE("차이"),
APPROVED("승인");
private final String label;
ReconInStatus(String label) {
this.label = label;
}
@JsonValue
public String getCode() {
return this.name();
}
public String getLabel() {
return label;
}
}
@@ -0,0 +1,25 @@
package com.ga.core.vo.commission;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* reconciliation_variance — 대사 차이 상세 (V120)
*/
@Data
public class ReconciliationVarianceVO {
private Long varianceId;
private Long reconId;
/** 증권번호 */
private String policyNo;
/** GA 시스템 예상 금액 */
private BigDecimal expectedAmount;
/** 보험사 통보 금액 */
private BigDecimal reportedAmount;
/** 차이 사유 */
private String varianceReason;
private LocalDateTime createdAt;
private Long createdBy;
}
@@ -0,0 +1,28 @@
package com.ga.core.vo.commission;
import lombok.Data;
import java.math.BigDecimal;
/**
* recruiter_commission_ledger 조회 응답 — 설계사명·공통코드명 조인 포함 (V116)
*/
@Data
public class RecruiterCommissionLedgerResp {
private Long ledgerId;
private Long recruiterAgentId;
/** 도입자 설계사명 */
private String recruiterAgentName;
private Long recruitAgentId;
/** 피도입 설계사명 */
private String recruitAgentName;
private String settleMonth;
private String recruiterType;
/** 공통코드 RECRUITER_TYPE */
private String recruiterTypeName;
private BigDecimal baseAmount;
private BigDecimal commissionAmount;
private String status;
/** 공통코드 상태명 */
private String statusName;
}
@@ -0,0 +1,35 @@
package com.ga.core.vo.commission;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
/**
* recruiter_commission_ledger 등록 요청 (V116)
*/
@Data
public class RecruiterCommissionLedgerSaveReq {
@NotNull
private Long recruiterAgentId;
@NotNull
private Long recruitAgentId;
@NotBlank
private String settleMonth;
@NotBlank
private String recruiterType;
@NotNull
@DecimalMin("0")
private BigDecimal baseAmount;
@NotNull
@DecimalMin("0")
private BigDecimal commissionAmount;
}
@@ -0,0 +1,18 @@
package com.ga.core.vo.commission;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* recruiter_commission_ledger 검색 조건 (V116)
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class RecruiterCommissionLedgerSearchParam extends SearchParam {
private Long recruiterAgentId;
private Long recruitAgentId;
private String settleMonth;
private String recruiterType;
private String status;
}
@@ -0,0 +1,28 @@
package com.ga.core.vo.commission;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* recruiter_commission_ledger — 도입수수료 원장 (V116)
*/
@Data
public class RecruiterCommissionLedgerVO {
private Long ledgerId;
private Long recruiterAgentId;
private Long recruitAgentId;
private String settleMonth;
/** RecruiterType: NEW_RECRUIT/DEVELOPMENT/TRAINING */
private String recruiterType;
/** 기준금액 (피도입 설계사 당월 모집수수료 합) */
private BigDecimal baseAmount;
/** 도입수수료 금액 */
private BigDecimal commissionAmount;
/** 상태: CALCULATED/CONFIRMED/PAID/CANCELLED */
private String status;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,24 @@
package com.ga.core.vo.commission;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* recruiter_commission_rule 조회 응답 — 공통코드명 조인 포함 (V116)
*/
@Data
public class RecruiterCommissionRuleResp {
private Long ruleId;
private String recruiterType;
/** 공통코드 RECRUITER_TYPE */
private String recruiterTypeName;
private String calcType;
/** 공통코드 CALC_TYPE */
private String calcTypeName;
private BigDecimal commissionRate;
private BigDecimal fixedAmount;
private LocalDate effectiveFrom;
private LocalDate effectiveTo;
}
@@ -0,0 +1,35 @@
package com.ga.core.vo.commission;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* recruiter_commission_rule 등록/수정 요청 (V116)
*/
@Data
public class RecruiterCommissionRuleSaveReq {
@NotBlank
private String recruiterType;
@NotBlank
private String calcType;
@DecimalMin("0")
@DecimalMax("1")
private BigDecimal commissionRate;
@DecimalMin("0")
private BigDecimal fixedAmount;
@NotNull
private LocalDate effectiveFrom;
private LocalDate effectiveTo;
}
@@ -0,0 +1,15 @@
package com.ga.core.vo.commission;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* recruiter_commission_rule 검색 조건 (V116)
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class RecruiterCommissionRuleSearchParam extends SearchParam {
private String recruiterType;
private String calcType;
}
@@ -0,0 +1,28 @@
package com.ga.core.vo.commission;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* recruiter_commission_rule — 도입수수료 룰 마스터 (V116)
*/
@Data
public class RecruiterCommissionRuleVO {
private Long ruleId;
/** RecruiterType: NEW_RECRUIT/DEVELOPMENT/TRAINING */
private String recruiterType;
/** CalcType: RATE/FIXED */
private String calcType;
/** 정률 요율 (0~1, calc_type=RATE 일 때 사용) */
private BigDecimal commissionRate;
/** 정액 금액 (calc_type=FIXED 일 때 사용) */
private BigDecimal fixedAmount;
private LocalDate effectiveFrom;
private LocalDate effectiveTo;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,27 @@
package com.ga.core.vo.commission;
import com.fasterxml.jackson.annotation.JsonValue;
/**
* 도입수수료 유형 (RECRUITER_TYPE 그룹, V116)
*/
public enum RecruiterType {
NEW_RECRUIT("신인도입"),
DEVELOPMENT("육성"),
TRAINING("교육");
private final String label;
RecruiterType(String label) {
this.label = label;
}
@JsonValue
public String getCode() {
return this.name();
}
public String getLabel() {
return label;
}
}
@@ -0,0 +1,14 @@
package com.ga.core.vo.commission;
import lombok.Data;
/**
* statute_config 조회 응답 (V121)
*/
@Data
public class StatuteConfigResp {
private Long configId;
private String entityType;
private Integer expirationMonths;
private String isActive;
}
@@ -0,0 +1,22 @@
package com.ga.core.vo.commission;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* statute_config 등록/수정 요청 (V121)
*/
@Data
public class StatuteConfigSaveReq {
@NotBlank
private String entityType;
@NotNull
@Min(1)
private Integer expirationMonths;
private String isActive;
}
@@ -0,0 +1,22 @@
package com.ga.core.vo.commission;
import lombok.Data;
import java.time.LocalDateTime;
/**
* statute_config — 환수 시효 설정 (V121)
*/
@Data
public class StatuteConfigVO {
private Long configId;
/** 대상 엔티티 타입 (CLAWBACK_RECEIVABLE) */
private String entityType;
/** 시효 만료 개월수 */
private Integer expirationMonths;
/** 활성 여부 (Y/N) */
private String isActive;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,27 @@
package com.ga.core.vo.commission;
import com.fasterxml.jackson.annotation.JsonValue;
/**
* 환수 감면 승인 상태 (WAIVER_STATUS 그룹, V122)
*/
public enum WaiverStatus {
REQUESTED("신청"),
APPROVED("승인"),
REJECTED("반려");
private final String label;
WaiverStatus(String label) {
this.label = label;
}
@JsonValue
public String getCode() {
return this.name();
}
public String getLabel() {
return label;
}
}
@@ -30,4 +30,8 @@ public class ContractResp {
private LocalDate contractDate;
private LocalDate effectiveDate;
private LocalDate lapseDate;
/** 판매채널 (NULL=DIRECT 취급): DIRECT/BANCASSURANCE/HOME_SHOPPING/PARTNERSHIP (V123) */
private String salesChannel;
/** 공통코드 SALES_CHANNEL */
private String salesChannelName;
}
@@ -23,4 +23,6 @@ public class ContractSaveReq {
private Integer coveragePeriod;
@NotNull private LocalDate contractDate;
private LocalDate effectiveDate;
/** 판매채널 (NULL=DIRECT): DIRECT/BANCASSURANCE/HOME_SHOPPING/PARTNERSHIP (V123) */
private String salesChannel;
}
@@ -23,6 +23,8 @@ public class ContractVO {
private LocalDate effectiveDate;
private LocalDate lapseDate;
private LocalDate revivalDate;
/** 판매채널 (NULL=DIRECT 취급): DIRECT/BANCASSURANCE/HOME_SHOPPING/PARTNERSHIP (V123) */
private String salesChannel;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,92 @@
<?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.AdvancePaymentMapper">
<resultMap id="VOMap" type="com.ga.core.vo.commission.AdvancePaymentVO"/>
<resultMap id="RespMap" type="com.ga.core.vo.commission.AdvancePaymentResp"/>
<!-- 목록/단건 조회 컬럼 — 설계사명·조직명·공통코드명 조인 포함 -->
<sql id="advJoinCols">
ap.advance_id,
ap.agent_id, a.agent_name, o.org_name,
ap.advance_month,
ap.principal_amount,
ap.recovered_amount,
ap.balance,
ap.status,
(SELECT cc.code_name FROM common_code cc
WHERE cc.group_code = 'ADVANCE_STATUS' AND cc.code = ap.status) AS status_name,
ap.memo,
ap.created_at
</sql>
<sql id="advJoins">
FROM advance_payment ap
LEFT JOIN agent a ON a.agent_id = ap.agent_id
LEFT JOIN organization o ON o.org_id = a.org_id
</sql>
<select id="selectById" resultMap="RespMap">
SELECT <include refid="advJoinCols"/>
<include refid="advJoins"/>
WHERE ap.advance_id = #{advanceId}
</select>
<select id="selectList" resultMap="RespMap">
SELECT <include refid="advJoinCols"/>
<include refid="advJoins"/>
<where>
<if test="agentId != null">AND ap.agent_id = #{agentId}</if>
<if test="agentName != null and agentName != ''">AND a.agent_name LIKE '%' || #{agentName} || '%'</if>
<if test="advanceMonth != null and advanceMonth != ''">AND ap.advance_month = #{advanceMonth}</if>
<if test="status != null and status != ''">AND ap.status = #{status}</if>
</where>
ORDER BY ap.advance_month DESC, ap.advance_id DESC
</select>
<!-- balance = principal_amount (초기 등록 시 잔액=원금) -->
<insert id="insert" useGeneratedKeys="true" keyProperty="advanceId">
INSERT INTO advance_payment
(agent_id, advance_month, principal_amount, recovered_amount, balance,
status, memo, created_by, created_at)
VALUES
(#{agentId}, #{advanceMonth}, #{principalAmount}, 0, #{principalAmount},
'OUTSTANDING', #{memo}, #{createdBy}, NOW())
</insert>
<!-- 회수 후 갱신 — recovered_amount, balance, status, updated_at -->
<update id="update">
UPDATE advance_payment
SET recovered_amount = #{recoveredAmount},
balance = #{balance},
status = #{status},
memo = #{memo},
updated_at = NOW()
WHERE advance_id = #{advanceId}
</update>
<delete id="delete">
DELETE FROM advance_payment WHERE advance_id = #{advanceId}
</delete>
<!-- 설계사의 미상환 잔액 합계 — OUTSTANDING/PARTIAL 대상 -->
<select id="outstandingByAgent" resultType="java.math.BigDecimal">
SELECT COALESCE(SUM(balance), 0)
FROM advance_payment
WHERE agent_id = #{agentId}
AND status IN ('OUTSTANDING', 'PARTIAL')
</select>
<!-- FIFO 상계 대상 목록 — 오래된 발생월 우선 -->
<select id="selectOutstandingFifo" resultMap="VOMap">
SELECT advance_id, agent_id, advance_month,
principal_amount, recovered_amount, balance,
status, memo, created_at, created_by, updated_at
FROM advance_payment
WHERE agent_id = #{agentId}
AND status IN ('OUTSTANDING', 'PARTIAL')
ORDER BY advance_month ASC, advance_id ASC
</select>
</mapper>
@@ -0,0 +1,40 @@
<?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.AdvanceRecoveryLedgerMapper">
<resultMap id="VOMap" type="com.ga.core.vo.commission.AdvanceRecoveryLedgerVO"/>
<insert id="insert" useGeneratedKeys="true" keyProperty="recoveryId">
INSERT INTO advance_recovery_ledger
(advance_id, settle_month, recovery_amount, created_by, created_at)
VALUES
(#{advanceId}, #{settleMonth}, #{recoveryAmount}, #{createdBy}, NOW())
</insert>
<!--
배치 멱등용: 당월 상계 이력 삭제.
P9 reverseClawbackRecovery 패턴 — 재실행 시 역산 후 재적용.
-->
<delete id="deleteBySettleMonth">
DELETE FROM advance_recovery_ledger
WHERE advance_id = #{advanceId}
AND settle_month = #{settleMonth}
</delete>
<select id="selectBySettleMonth" resultMap="VOMap">
SELECT recovery_id, advance_id, settle_month, recovery_amount,
created_at, created_by
FROM advance_recovery_ledger
WHERE advance_id = #{advanceId}
AND settle_month = #{settleMonth}
ORDER BY recovery_id ASC
</select>
<select id="selectAdvanceIdsBySettleMonth" resultType="long">
SELECT DISTINCT advance_id
FROM advance_recovery_ledger
WHERE settle_month = #{settleMonth}
</select>
</mapper>
@@ -0,0 +1,95 @@
<?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.CarrierReconciliationInMapper">
<resultMap id="VOMap" type="com.ga.core.vo.commission.CarrierReconciliationInVO"/>
<resultMap id="RespMap" type="com.ga.core.vo.commission.CarrierReconciliationInResp"/>
<!-- 목록/단건 조회 컬럼 — 보험사명 조인 포함 -->
<sql id="criJoinCols">
cr.recon_id,
cr.company_id, ic.company_code, ic.company_name,
cr.settle_month,
cr.file_name,
cr.record_count,
cr.reported_total,
cr.system_total,
cr.variance_amount,
cr.status,
(SELECT cc.code_name FROM common_code cc
WHERE cc.group_code = 'RECON_IN_STATUS' AND cc.code = cr.status) AS status_name,
cr.created_at
</sql>
<sql id="criJoins">
FROM carrier_reconciliation_in cr
LEFT JOIN insurance_company ic ON ic.company_id = cr.company_id
</sql>
<select id="selectById" resultMap="RespMap">
SELECT <include refid="criJoinCols"/>
<include refid="criJoins"/>
WHERE cr.recon_id = #{reconId}
</select>
<select id="selectList" resultMap="RespMap">
SELECT <include refid="criJoinCols"/>
<include refid="criJoins"/>
<where>
<if test="companyId != null">AND cr.company_id = #{companyId}</if>
<if test="settleMonth != null and settleMonth != ''">AND cr.settle_month = #{settleMonth}</if>
<if test="status != null and status != ''">AND cr.status = #{status}</if>
</where>
ORDER BY cr.settle_month DESC, cr.recon_id DESC
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="reconId">
INSERT INTO carrier_reconciliation_in
(company_id, settle_month, file_name, record_count,
reported_total, system_total, variance_amount, status,
created_by, created_at)
VALUES
(#{companyId}, #{settleMonth}, #{fileName}, COALESCE(#{recordCount}, 0),
COALESCE(#{reportedTotal}, 0), COALESCE(#{systemTotal}, 0),
COALESCE(#{varianceAmount}, 0),
COALESCE(#{status}, 'RECEIVED'), #{createdBy}, NOW())
</insert>
<update id="update">
UPDATE carrier_reconciliation_in
SET file_name = #{fileName},
record_count = #{recordCount},
reported_total = #{reportedTotal},
system_total = #{systemTotal},
variance_amount = #{varianceAmount},
status = #{status},
updated_at = NOW()
WHERE recon_id = #{reconId}
</update>
<!--
보험사 + 정산월 기준 upsert.
UNIQUE(company_id, settle_month) 충돌 시 갱신 — 멱등 재처리 지원.
-->
<insert id="upsert" useGeneratedKeys="true" keyProperty="reconId">
INSERT INTO carrier_reconciliation_in
(company_id, settle_month, file_name, record_count,
reported_total, system_total, variance_amount, status,
created_by, created_at)
VALUES
(#{companyId}, #{settleMonth}, #{fileName}, COALESCE(#{recordCount}, 0),
COALESCE(#{reportedTotal}, 0), COALESCE(#{systemTotal}, 0),
COALESCE(#{varianceAmount}, 0),
COALESCE(#{status}, 'RECEIVED'), #{createdBy}, NOW())
ON CONFLICT (company_id, settle_month) DO UPDATE
SET file_name = EXCLUDED.file_name,
record_count = EXCLUDED.record_count,
reported_total = EXCLUDED.reported_total,
system_total = EXCLUDED.system_total,
variance_amount = EXCLUDED.variance_amount,
status = EXCLUDED.status,
updated_at = NOW()
</insert>
</mapper>
@@ -0,0 +1,71 @@
<?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.ChargebackWaiverRequestMapper">
<resultMap id="VOMap" type="com.ga.core.vo.commission.ChargebackWaiverRequestVO"/>
<resultMap id="RespMap" type="com.ga.core.vo.commission.ChargebackWaiverRequestResp"/>
<!-- 목록/단건 조회 컬럼 — 설계사명·증권번호·공통코드명 조인 포함 -->
<sql id="cwrJoinCols">
wr.request_id,
wr.contract_id, ct.policy_no,
wr.agent_id, a.agent_name, o.org_name,
wr.settle_month,
wr.original_chargeback,
wr.waiver_amount,
wr.reason,
wr.approval_status,
(SELECT cc.code_name FROM common_code cc
WHERE cc.group_code = 'WAIVER_STATUS' AND cc.code = wr.approval_status) AS approval_status_name,
wr.approver_id,
wr.approved_at,
wr.created_at
</sql>
<sql id="cwrJoins">
FROM chargeback_waiver_request wr
LEFT JOIN agent a ON a.agent_id = wr.agent_id
LEFT JOIN organization o ON o.org_id = a.org_id
LEFT JOIN contract ct ON ct.contract_id = wr.contract_id
</sql>
<select id="selectById" resultMap="RespMap">
SELECT <include refid="cwrJoinCols"/>
<include refid="cwrJoins"/>
WHERE wr.request_id = #{requestId}
</select>
<select id="selectList" resultMap="RespMap">
SELECT <include refid="cwrJoinCols"/>
<include refid="cwrJoins"/>
<where>
<if test="agentId != null">AND wr.agent_id = #{agentId}</if>
<if test="agentName != null and agentName != ''">AND a.agent_name LIKE '%' || #{agentName} || '%'</if>
<if test="settleMonth != null and settleMonth != ''">AND wr.settle_month = #{settleMonth}</if>
<if test="approvalStatus != null and approvalStatus != ''">AND wr.approval_status = #{approvalStatus}</if>
</where>
ORDER BY wr.settle_month DESC, wr.request_id DESC
</select>
<!-- approval_status 기본 'REQUESTED' -->
<insert id="insert" useGeneratedKeys="true" keyProperty="requestId">
INSERT INTO chargeback_waiver_request
(contract_id, agent_id, settle_month, original_chargeback,
waiver_amount, reason, approval_status, created_by, created_at)
VALUES
(#{contractId}, #{agentId}, #{settleMonth}, #{originalChargeback},
#{waiverAmount}, #{reason}, 'REQUESTED', #{createdBy}, NOW())
</insert>
<!-- 승인/반려 처리 — approval_status, approver_id, approved_at, updated_at 갱신 -->
<update id="updateApproval">
UPDATE chargeback_waiver_request
SET approval_status = #{approvalStatus},
approver_id = #{approverId},
approved_at = #{approvedAt},
updated_at = NOW()
WHERE request_id = #{requestId}
</update>
</mapper>
@@ -0,0 +1,82 @@
<?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.ClawbackInstallmentPlanMapper">
<resultMap id="VOMap" type="com.ga.core.vo.commission.ClawbackInstallmentPlanVO"/>
<resultMap id="RespMap" type="com.ga.core.vo.commission.ClawbackInstallmentPlanResp"/>
<!-- 목록/단건 조회 컬럼 — 채권 정보(설계사명·발생월) 조인 포함 -->
<sql id="planJoinCols">
p.plan_id,
p.receivable_id,
r.origin_settle_month,
r.agent_id, a.agent_name,
p.installment_no,
p.installment_amount,
p.due_month,
p.status,
(SELECT cc.code_name FROM common_code cc
WHERE cc.group_code = 'CB_INSTALLMENT_STATUS' AND cc.code = p.status) AS status_name,
p.recovered_at
</sql>
<sql id="planJoins">
FROM clawback_installment_plan p
JOIN clawback_receivable r ON r.receivable_id = p.receivable_id
LEFT JOIN agent a ON a.agent_id = r.agent_id
</sql>
<select id="selectById" resultMap="RespMap">
SELECT <include refid="planJoinCols"/>
<include refid="planJoins"/>
WHERE p.plan_id = #{planId}
</select>
<select id="selectList" resultMap="RespMap">
SELECT <include refid="planJoinCols"/>
<include refid="planJoins"/>
<where>
<if test="receivableId != null">AND p.receivable_id = #{receivableId}</if>
<if test="agentId != null">AND r.agent_id = #{agentId}</if>
<if test="dueMonth != null and dueMonth != ''">AND p.due_month = #{dueMonth}</if>
<if test="status != null and status != ''">AND p.status = #{status}</if>
</where>
ORDER BY p.due_month ASC, p.installment_no ASC
</select>
<!-- 채권 ID 기준 VO 목록 — 배치/서비스 내부 용도 -->
<select id="selectByReceivable" resultMap="VOMap">
SELECT plan_id, receivable_id, installment_no, installment_amount,
due_month, status, recovered_at,
created_at, created_by, updated_at
FROM clawback_installment_plan
WHERE receivable_id = #{receivableId}
ORDER BY installment_no ASC
</select>
<!--
분할상환 계획 일괄 등록.
MyBatis foreach — Service에서 List<ClawbackInstallmentPlanVO> 전달.
-->
<insert id="insertBatch" useGeneratedKeys="true" keyProperty="planId">
INSERT INTO clawback_installment_plan
(receivable_id, installment_no, installment_amount, due_month,
status, created_by, created_at)
VALUES
<foreach collection="list" item="p" separator=",">
(#{p.receivableId}, #{p.installmentNo}, #{p.installmentAmount}, #{p.dueMonth},
COALESCE(#{p.status}, 'SCHEDULED'), #{p.createdBy}, NOW())
</foreach>
</insert>
<!-- 회차 상태 갱신 — RECOVERED 시 recovered_at=NOW() -->
<update id="updateStatus">
UPDATE clawback_installment_plan
SET status = #{status},
recovered_at = CASE WHEN #{status} = 'RECOVERED' THEN NOW() ELSE recovered_at END,
updated_at = NOW()
WHERE plan_id = #{planId}
</update>
</mapper>
@@ -194,4 +194,30 @@
</where>
</select>
<!-- [MOD-6] 시효 만료 대상 채권 조회 — beforeMonth = baseMonth - expiration_months -->
<select id="selectExpired" 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 origin_settle_month &lt;= #{beforeMonth}
AND status IN ('OUTSTANDING', 'RECOVERING')
ORDER BY origin_settle_month ASC, receivable_id ASC
</select>
<!-- [MOD-6] 만료 대상 채권 일괄 대손 처리 — WRITTEN_OFF 상태 전이 -->
<update id="writeOffExpired">
UPDATE clawback_receivable
SET balance = 0,
status = 'WRITTEN_OFF',
writeoff_reason = #{reason},
written_off_at = NOW(),
updated_at = NOW()
WHERE origin_settle_month &lt;= #{beforeMonth}
AND status IN ('OUTSTANDING', 'RECOVERING')
</update>
</mapper>
@@ -0,0 +1,78 @@
<?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.LapsedReinstatementRecoveryMapper">
<resultMap id="VOMap" type="com.ga.core.vo.commission.LapsedReinstatementRecoveryVO"/>
<resultMap id="RespMap" type="com.ga.core.vo.commission.LapsedReinstatementRecoveryResp"/>
<!-- 목록/단건 조회 컬럼 — 설계사명·증권번호 조인 포함 -->
<sql id="lrrJoinCols">
lr.recovery_id,
lr.contract_id, ct.policy_no,
lr.agent_id, a.agent_name, o.org_name,
lr.lapsed_month,
lr.reinstated_month,
lr.withheld_amount,
lr.repaid_amount,
lr.status,
(SELECT cc.code_name FROM common_code cc
WHERE cc.group_code = 'LAPSE_RECOVERY_STATUS' AND cc.code = lr.status) AS status_name
</sql>
<sql id="lrrJoins">
FROM lapsed_reinstatement_recovery lr
LEFT JOIN agent a ON a.agent_id = lr.agent_id
LEFT JOIN organization o ON o.org_id = a.org_id
LEFT JOIN contract ct ON ct.contract_id = lr.contract_id
</sql>
<select id="selectById" resultMap="RespMap">
SELECT <include refid="lrrJoinCols"/>
<include refid="lrrJoins"/>
WHERE lr.recovery_id = #{recoveryId}
</select>
<select id="selectList" resultMap="RespMap">
SELECT <include refid="lrrJoinCols"/>
<include refid="lrrJoins"/>
<where>
<if test="agentId != null">AND lr.agent_id = #{agentId}</if>
<if test="agentName != null and agentName != ''">AND a.agent_name LIKE '%' || #{agentName} || '%'</if>
<if test="reinstatedMonth != null and reinstatedMonth != ''">AND lr.reinstated_month = #{reinstatedMonth}</if>
<if test="lapsedMonth != null and lapsedMonth != ''">AND lr.lapsed_month = #{lapsedMonth}</if>
<if test="status != null and status != ''">AND lr.status = #{status}</if>
</where>
ORDER BY lr.reinstated_month DESC, lr.recovery_id DESC
</select>
<!-- repaid_amount 기본 0, status 기본 PENDING -->
<insert id="insert" useGeneratedKeys="true" keyProperty="recoveryId">
INSERT INTO lapsed_reinstatement_recovery
(contract_id, agent_id, lapsed_month, reinstated_month,
withheld_amount, repaid_amount, status, created_by, created_at)
VALUES
(#{contractId}, #{agentId}, #{lapsedMonth}, #{reinstatedMonth},
#{withheldAmount}, 0, 'PENDING', #{createdBy}, NOW())
</insert>
<!-- 상태 + 재지급액 갱신 — REPAID 시 repaid_amount=withheld_amount(Service에서 전달) -->
<update id="updateStatus">
UPDATE lapsed_reinstatement_recovery
SET status = #{status},
repaid_amount = #{repaidAmount},
updated_at = NOW()
WHERE recovery_id = #{recoveryId}
</update>
<!-- 정산월 기준 설계사별 재지급 합계 — status='REPAID' AND reinstated_month=settleMonth -->
<select id="aggregateByAgent" resultType="map">
SELECT agent_id AS "agentId",
SUM(repaid_amount) AS "total"
FROM lapsed_reinstatement_recovery
WHERE reinstated_month = #{settleMonth}
AND status = 'REPAID'
GROUP BY agent_id
</select>
</mapper>
@@ -0,0 +1,33 @@
<?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.ReconciliationVarianceMapper">
<resultMap id="VOMap" type="com.ga.core.vo.commission.ReconciliationVarianceVO"/>
<!--
차이 상세 일괄 등록.
MyBatis foreach — Service에서 List<ReconciliationVarianceVO> 전달.
-->
<insert id="insertBatch">
INSERT INTO reconciliation_variance
(recon_id, policy_no, expected_amount, reported_amount,
variance_reason, created_by, created_at)
VALUES
<foreach collection="list" item="v" separator=",">
(#{v.reconId}, #{v.policyNo}, #{v.expectedAmount}, #{v.reportedAmount},
#{v.varianceReason}, #{v.createdBy}, NOW())
</foreach>
</insert>
<!-- 대사 입수 ID 기준 차이 상세 목록 조회 -->
<select id="selectByRecon" resultMap="VOMap">
SELECT variance_id, recon_id, policy_no,
expected_amount, reported_amount, variance_reason,
created_at, created_by
FROM reconciliation_variance
WHERE recon_id = #{reconId}
ORDER BY variance_id ASC
</select>
</mapper>
@@ -0,0 +1,83 @@
<?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.RecruiterCommissionLedgerMapper">
<resultMap id="VOMap" type="com.ga.core.vo.commission.RecruiterCommissionLedgerVO"/>
<resultMap id="RespMap" type="com.ga.core.vo.commission.RecruiterCommissionLedgerResp"/>
<!-- 목록/단건 조회 컬럼 — 도입자·피도입 설계사명·공통코드명 조인 포함 -->
<sql id="ledgerJoinCols">
l.ledger_id,
l.recruiter_agent_id,
ra.agent_name AS recruiter_agent_name,
l.recruit_agent_id,
na.agent_name AS recruit_agent_name,
l.settle_month,
l.recruiter_type,
(SELECT cc.code_name FROM common_code cc
WHERE cc.group_code = 'RECRUITER_TYPE' AND cc.code = l.recruiter_type) AS recruiter_type_name,
l.base_amount,
l.commission_amount,
l.status,
(SELECT cc.code_name FROM common_code cc
WHERE cc.group_code = 'INCOME_STATUS' AND cc.code = l.status) AS status_name
</sql>
<sql id="ledgerJoins">
FROM recruiter_commission_ledger l
LEFT JOIN agent ra ON ra.agent_id = l.recruiter_agent_id
LEFT JOIN agent na ON na.agent_id = l.recruit_agent_id
</sql>
<select id="selectById" resultMap="RespMap">
SELECT <include refid="ledgerJoinCols"/>
<include refid="ledgerJoins"/>
WHERE l.ledger_id = #{ledgerId}
</select>
<select id="selectList" resultMap="RespMap">
SELECT <include refid="ledgerJoinCols"/>
<include refid="ledgerJoins"/>
<where>
<if test="recruiterAgentId != null">AND l.recruiter_agent_id = #{recruiterAgentId}</if>
<if test="recruitAgentId != null">AND l.recruit_agent_id = #{recruitAgentId}</if>
<if test="settleMonth != null and settleMonth != ''">AND l.settle_month = #{settleMonth}</if>
<if test="recruiterType != null and recruiterType != ''">AND l.recruiter_type = #{recruiterType}</if>
<if test="status != null and status != ''">AND l.status = #{status}</if>
</where>
ORDER BY l.settle_month DESC, l.recruiter_agent_id, l.ledger_id DESC
</select>
<!-- status 기본값 'CALCULATED' -->
<insert id="insert" useGeneratedKeys="true" keyProperty="ledgerId">
INSERT INTO recruiter_commission_ledger
(recruiter_agent_id, recruit_agent_id, settle_month, recruiter_type,
base_amount, commission_amount, status, created_by, created_at)
VALUES
(#{recruiterAgentId}, #{recruitAgentId}, #{settleMonth}, #{recruiterType},
#{baseAmount}, #{commissionAmount},
COALESCE(#{status}, 'CALCULATED'), #{createdBy}, NOW())
</insert>
<update id="updateStatus">
UPDATE recruiter_commission_ledger
SET status = #{status}, updated_at = NOW()
WHERE ledger_id = #{ledgerId}
</update>
<delete id="delete">
DELETE FROM recruiter_commission_ledger WHERE ledger_id = #{ledgerId}
</delete>
<!-- 정산월 기준 도입자 설계사별 도입수수료 합계 — CANCELLED 제외, agentId alias -->
<select id="aggregateByAgent" resultType="map">
SELECT recruiter_agent_id AS "agentId",
SUM(commission_amount) AS "total"
FROM recruiter_commission_ledger
WHERE settle_month = #{settleMonth}
AND status != 'CANCELLED'
GROUP BY recruiter_agent_id
</select>
</mapper>
@@ -0,0 +1,65 @@
<?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.RecruiterCommissionRuleMapper">
<resultMap id="VOMap" type="com.ga.core.vo.commission.RecruiterCommissionRuleVO"/>
<resultMap id="RespMap" type="com.ga.core.vo.commission.RecruiterCommissionRuleResp"/>
<!-- 목록/단건 조회 컬럼 — 공통코드명 포함 -->
<sql id="ruleCols">
r.rule_id,
r.recruiter_type,
(SELECT cc.code_name FROM common_code cc
WHERE cc.group_code = 'RECRUITER_TYPE' AND cc.code = r.recruiter_type) AS recruiter_type_name,
r.calc_type,
(SELECT cc.code_name FROM common_code cc
WHERE cc.group_code = 'CALC_TYPE' AND cc.code = r.calc_type) AS calc_type_name,
r.commission_rate,
r.fixed_amount,
r.effective_from,
r.effective_to
</sql>
<select id="selectById" resultMap="RespMap">
SELECT <include refid="ruleCols"/>
FROM recruiter_commission_rule r
WHERE r.rule_id = #{ruleId}
</select>
<select id="selectList" resultMap="RespMap">
SELECT <include refid="ruleCols"/>
FROM recruiter_commission_rule r
<where>
<if test="recruiterType != null and recruiterType != ''">AND r.recruiter_type = #{recruiterType}</if>
<if test="calcType != null and calcType != ''">AND r.calc_type = #{calcType}</if>
</where>
ORDER BY r.effective_from DESC, r.rule_id DESC
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="ruleId">
INSERT INTO recruiter_commission_rule
(recruiter_type, calc_type, commission_rate, fixed_amount,
effective_from, effective_to, created_by, created_at)
VALUES
(#{recruiterType}, #{calcType}, #{commissionRate}, #{fixedAmount},
#{effectiveFrom}, #{effectiveTo}, #{createdBy}, NOW())
</insert>
<update id="update">
UPDATE recruiter_commission_rule
SET recruiter_type = #{recruiterType},
calc_type = #{calcType},
commission_rate = #{commissionRate},
fixed_amount = #{fixedAmount},
effective_from = #{effectiveFrom},
effective_to = #{effectiveTo},
updated_at = NOW()
WHERE rule_id = #{ruleId}
</update>
<delete id="delete">
DELETE FROM recruiter_commission_rule WHERE rule_id = #{ruleId}
</delete>
</mapper>
@@ -0,0 +1,44 @@
<?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.StatuteConfigMapper">
<resultMap id="VOMap" type="com.ga.core.vo.commission.StatuteConfigVO"/>
<resultMap id="RespMap" type="com.ga.core.vo.commission.StatuteConfigResp"/>
<select id="selectById" resultMap="RespMap">
SELECT config_id, entity_type, expiration_months, is_active
FROM statute_config
WHERE config_id = #{configId}
</select>
<!-- entity_type UNIQUE — 단건 반환 -->
<select id="selectByEntityType" resultMap="RespMap">
SELECT config_id, entity_type, expiration_months, is_active
FROM statute_config
WHERE entity_type = #{entityType}
</select>
<select id="selectList" resultMap="RespMap">
SELECT config_id, entity_type, expiration_months, is_active
FROM statute_config
ORDER BY config_id ASC
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="configId">
INSERT INTO statute_config
(entity_type, expiration_months, is_active, created_by, created_at)
VALUES
(#{entityType}, #{expirationMonths}, COALESCE(#{isActive}, 'Y'),
#{createdBy}, NOW())
</insert>
<update id="update">
UPDATE statute_config
SET expiration_months = #{expirationMonths},
is_active = #{isActive},
updated_at = NOW()
WHERE config_id = #{configId}
</update>
</mapper>
@@ -18,7 +18,10 @@
ct.status,
(SELECT cc.code_name FROM common_code cc
WHERE cc.group_code='CONTRACT_STATUS' AND cc.code=ct.status) AS status_name,
ct.contract_date, ct.effective_date, ct.lapse_date
ct.contract_date, ct.effective_date, ct.lapse_date,
ct.sales_channel,
(SELECT cc.code_name FROM common_code cc
WHERE cc.group_code='SALES_CHANNEL' AND cc.code=ct.sales_channel) AS sales_channel_name
</sql>
<select id="selectList" resultMap="ContractRespMap">
@@ -84,11 +87,11 @@
<insert id="insert" parameterType="com.ga.core.vo.product.ContractVO" useGeneratedKeys="true" keyProperty="contractId">
INSERT INTO contract (agent_id, product_id, policy_no, contractor_name, insured_name,
premium, pay_cycle, pay_period, coverage_period, status,
contract_date, effective_date, created_at)
contract_date, effective_date, sales_channel, created_at)
VALUES (#{agentId}, #{productId}, #{policyNo}, #{contractorName}, #{insuredName},
#{premium}, #{payCycle}, #{payPeriod}, #{coveragePeriod},
COALESCE(#{status},'ACTIVE'),
#{contractDate}, #{effectiveDate}, #{createdAt})
#{contractDate}, #{effectiveDate}, #{salesChannel}, #{createdAt})
</insert>
<update id="update" parameterType="com.ga.core.vo.product.ContractVO">
@@ -105,6 +108,7 @@
effective_date = #{effectiveDate},
lapse_date = #{lapseDate},
revival_date = #{revivalDate},
sales_channel = #{salesChannel},
updated_at = #{updatedAt}
WHERE contract_id = #{contractId}
</update>

Some files were not shown because too many files have changed in this diff Show More