diff --git a/.gitignore b/.gitignore index 8995367..d7514e9 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ ga-frontend/dist/ login.json token.txt __pycache__/ +.omc/ diff --git a/docs/DOMAIN_GAP_MOD1-8.md b/docs/DOMAIN_GAP_MOD1-8.md new file mode 100644 index 0000000..3b49133 --- /dev/null +++ b/docs/DOMAIN_GAP_MOD1-8.md @@ -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//Xxx.tsx` + `api/.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=reported−system, 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 <= baseMonth−expiration_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이 즉시 수정. diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/AdvancePaymentController.java b/ga-api/src/main/java/com/ga/api/controller/commission/AdvancePaymentController.java new file mode 100644 index 0000000..0724cd6 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/commission/AdvancePaymentController.java @@ -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> list( + @ModelAttribute AdvancePaymentSearchParam param) { + return ApiResponse.ok(service.list(param)); + } + + @GetMapping("/{advanceId}") + @RequirePermission(menu = "ADVANCE_PAYMENT", perm = "READ") + public ApiResponse 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 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 waive(@PathVariable Long advanceId) { + service.waive(advanceId); + return ApiResponse.ok(); + } +} diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/CarrierReconciliationInController.java b/ga-api/src/main/java/com/ga/api/controller/commission/CarrierReconciliationInController.java new file mode 100644 index 0000000..f52eaef --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/commission/CarrierReconciliationInController.java @@ -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> list( + @ModelAttribute CarrierReconciliationInSearchParam param) { + return ApiResponse.ok(service.list(param)); + } + + @GetMapping("/{reconId}") + @RequirePermission(menu = "CARRIER_RECON_IN", perm = "READ") + public ApiResponse 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 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 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 approve(@PathVariable Long reconId) { + service.approve(reconId); + return ApiResponse.ok(); + } +} diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/ChargebackWaiverController.java b/ga-api/src/main/java/com/ga/api/controller/commission/ChargebackWaiverController.java new file mode 100644 index 0000000..058474a --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/commission/ChargebackWaiverController.java @@ -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> list( + @ModelAttribute ChargebackWaiverRequestSearchParam param) { + return ApiResponse.ok(service.list(param)); + } + + @GetMapping("/{requestId}") + @RequirePermission(menu = "CHARGEBACK_WAIVER", perm = "READ") + public ApiResponse 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 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 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 reject(@PathVariable Long requestId) { + service.reject(requestId); + return ApiResponse.ok(); + } +} diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/ClawbackInstallmentController.java b/ga-api/src/main/java/com/ga/api/controller/commission/ClawbackInstallmentController.java new file mode 100644 index 0000000..15bbdcc --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/commission/ClawbackInstallmentController.java @@ -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> list( + @ModelAttribute ClawbackInstallmentPlanSearchParam param) { + return ApiResponse.ok(service.list(param)); + } + + @GetMapping("/{planId}") + @RequirePermission(menu = "CLAWBACK_INSTALLMENT", perm = "READ") + public ApiResponse detail(@PathVariable Long planId) { + return ApiResponse.ok(service.detail(planId)); + } + + @GetMapping("/by-receivable/{receivableId}") + @RequirePermission(menu = "CLAWBACK_INSTALLMENT", perm = "READ") + public ApiResponse> 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 generate(@PathVariable Long receivableId, + @RequestBody Map 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 cancel(@PathVariable Long planId) { + service.cancel(planId); + return ApiResponse.ok(); + } +} diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/LapsedReinstatementController.java b/ga-api/src/main/java/com/ga/api/controller/commission/LapsedReinstatementController.java new file mode 100644 index 0000000..d9a18b2 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/commission/LapsedReinstatementController.java @@ -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> list( + @ModelAttribute LapsedReinstatementRecoverySearchParam param) { + return ApiResponse.ok(service.list(param)); + } + + @GetMapping("/{recoveryId}") + @RequirePermission(menu = "LAPSED_REINSTATEMENT", perm = "READ") + public ApiResponse 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 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 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 cancel(@PathVariable Long recoveryId) { + service.cancel(recoveryId); + return ApiResponse.ok(); + } +} diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/RecruiterCommissionController.java b/ga-api/src/main/java/com/ga/api/controller/commission/RecruiterCommissionController.java new file mode 100644 index 0000000..aba7fb7 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/commission/RecruiterCommissionController.java @@ -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> listRules( + @ModelAttribute RecruiterCommissionRuleSearchParam param) { + return ApiResponse.ok(service.listRules(param)); + } + + @GetMapping("/rules/{ruleId}") + @RequirePermission(menu = "RECRUITER_COMM", perm = "READ") + public ApiResponse 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 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 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 deleteRule(@PathVariable Long ruleId) { + service.deleteRule(ruleId); + return ApiResponse.ok(); + } + + // ── Ledger ──────────────────────────────────────────────────────────────── + + @GetMapping("/ledgers") + @RequirePermission(menu = "RECRUITER_COMM", perm = "READ") + public ApiResponse> listLedgers( + @ModelAttribute RecruiterCommissionLedgerSearchParam param) { + return ApiResponse.ok(service.listLedgers(param)); + } + + @GetMapping("/ledgers/{ledgerId}") + @RequirePermission(menu = "RECRUITER_COMM", perm = "READ") + public ApiResponse 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 createLedger(@Valid @RequestBody RecruiterCommissionLedgerSaveReq req) { + return ApiResponse.ok(service.createLedger(req)); + } +} diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/StatuteController.java b/ga-api/src/main/java/com/ga/api/controller/commission/StatuteController.java new file mode 100644 index 0000000..0d319d9 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/commission/StatuteController.java @@ -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() { + return ApiResponse.ok(service.listConfigs()); + } + + @GetMapping("/{configId}") + @RequirePermission(menu = "STATUTE_CONFIG", perm = "READ") + public ApiResponse 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 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 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 expire(@RequestBody Map body) { + String baseMonth = body.get("baseMonth"); + if (baseMonth == null || baseMonth.isBlank()) { + baseMonth = com.ga.common.util.DateUtil.getSettleMonth(); + } + return ApiResponse.ok(service.expireOverdue(baseMonth)); + } +} diff --git a/ga-api/src/main/java/com/ga/api/service/commission/AdvancePaymentService.java b/ga-api/src/main/java/com/ga/api/service/commission/AdvancePaymentService.java new file mode 100644 index 0000000..cddb6be --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/commission/AdvancePaymentService.java @@ -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 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; + } +} diff --git a/ga-api/src/main/java/com/ga/api/service/commission/CarrierReconciliationInService.java b/ga-api/src/main/java/com/ga/api/service/commission/CarrierReconciliationInService.java new file mode 100644 index 0000000..e8bf239 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/commission/CarrierReconciliationInService.java @@ -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 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; + } +} diff --git a/ga-api/src/main/java/com/ga/api/service/commission/ChargebackWaiverService.java b/ga-api/src/main/java/com/ga/api/service/commission/ChargebackWaiverService.java new file mode 100644 index 0000000..75f4a9b --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/commission/ChargebackWaiverService.java @@ -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 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()); + } +} diff --git a/ga-api/src/main/java/com/ga/api/service/commission/ClawbackInstallmentService.java b/ga-api/src/main/java/com/ga/api/service/commission/ClawbackInstallmentService.java new file mode 100644 index 0000000..36f6f17 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/commission/ClawbackInstallmentService.java @@ -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 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 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 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 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"); + } +} diff --git a/ga-api/src/main/java/com/ga/api/service/commission/LapsedReinstatementService.java b/ga-api/src/main/java/com/ga/api/service/commission/LapsedReinstatementService.java new file mode 100644 index 0000000..6efe8df --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/commission/LapsedReinstatementService.java @@ -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 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); + } +} diff --git a/ga-api/src/main/java/com/ga/api/service/commission/PersistencyBonusService.java b/ga-api/src/main/java/com/ga/api/service/commission/PersistencyBonusService.java index 9117ef9..cf63c57 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/PersistencyBonusService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/PersistencyBonusService.java @@ -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); } diff --git a/ga-api/src/main/java/com/ga/api/service/commission/RecruiterCommissionService.java b/ga-api/src/main/java/com/ga/api/service/commission/RecruiterCommissionService.java new file mode 100644 index 0000000..5d8020c --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/commission/RecruiterCommissionService.java @@ -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 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 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 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); + } +} diff --git a/ga-api/src/main/java/com/ga/api/service/commission/StatuteService.java b/ga-api/src/main/java/com/ga/api/service/commission/StatuteService.java new file mode 100644 index 0000000..a3cc083 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/commission/StatuteService.java @@ -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 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"); + } +} diff --git a/ga-api/src/test/java/com/ga/api/service/commission/PersistencyBonusServiceTest.java b/ga-api/src/test/java/com/ga/api/service/commission/PersistencyBonusServiceTest.java new file mode 100644 index 0000000..c9c9ff4 --- /dev/null +++ b/ga-api/src/test/java/com/ga/api/service/commission/PersistencyBonusServiceTest.java @@ -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 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 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"); + } +} diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/calc/CommissionCalculator.java b/ga-batch/src/main/java/com/ga/batch/settlement/calc/CommissionCalculator.java index 50b6206..f88b7c8 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/calc/CommissionCalculator.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/calc/CommissionCalculator.java @@ -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); diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchInstallmentPlanGenerator.java b/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchInstallmentPlanGenerator.java index d5c436f..a761347 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchInstallmentPlanGenerator.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchInstallmentPlanGenerator.java @@ -40,6 +40,9 @@ public class BatchInstallmentPlanGenerator { List 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); diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/step/AggregateStep.java b/ga-batch/src/main/java/com/ga/batch/settlement/step/AggregateStep.java index 63cd668..bd5f49a 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/step/AggregateStep.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/step/AggregateStep.java @@ -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 lifeSupportMap = toMap(lifeSupportMapper.aggregateByAgent(ctx.getSettleMonth())); Map branchMgrMap = toMap(branchMgrAllowanceMapper.aggregateByAgent(ctx.getSettleMonth())); + // MOD-1 도입수수료 (recruiter_agent_id 기준 agentId) + Map recruiterCommMap = toMap(recruiterCommissionMapper.aggregateByAgent(ctx.getSettleMonth())); + // MOD-4 익월부활재지급 (REPAID 상태만) + Map lapsedReinstatMap = toMap(lapsedReinstatementMapper.aggregateByAgent(ctx.getSettleMonth())); + // 합집합 agent_id Set 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 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 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를 복원한 뒤 회수 이력을 전량 삭제한다. diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcRecruitStep.java b/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcRecruitStep.java index 6cdd6b3..1d7d559 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcRecruitStep.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcRecruitStep.java @@ -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 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 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()); } } diff --git a/ga-batch/src/test/java/com/ga/batch/settlement/calc/CommissionCalculatorTest.java b/ga-batch/src/test/java/com/ga/batch/settlement/calc/CommissionCalculatorTest.java index 6f229e6..69c634e 100644 --- a/ga-batch/src/test/java/com/ga/batch/settlement/calc/CommissionCalculatorTest.java +++ b/ga-batch/src/test/java/com/ga/batch/settlement/calc/CommissionCalculatorTest.java @@ -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"); diff --git a/ga-batch/src/test/java/com/ga/batch/settlement/domain/BatchInstallmentPlanGeneratorTest.java b/ga-batch/src/test/java/com/ga/batch/settlement/domain/BatchInstallmentPlanGeneratorTest.java new file mode 100644 index 0000000..6a25707 --- /dev/null +++ b/ga-batch/src/test/java/com/ga/batch/settlement/domain/BatchInstallmentPlanGeneratorTest.java @@ -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 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 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"); + } +} diff --git a/ga-batch/src/test/java/com/ga/batch/settlement/scenario/SettlementScenarioTest.java b/ga-batch/src/test/java/com/ga/batch/settlement/scenario/SettlementScenarioTest.java index b057e7d..5b8b08d 100644 --- a/ga-batch/src/test/java/com/ga/batch/settlement/scenario/SettlementScenarioTest.java +++ b/ga-batch/src/test/java/com/ga/batch/settlement/scenario/SettlementScenarioTest.java @@ -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); diff --git a/ga-batch/src/test/java/com/ga/batch/settlement/step/CalcRecruitStepTest.java b/ga-batch/src/test/java/com/ga/batch/settlement/step/CalcRecruitStepTest.java index a8272f3..42eb761 100644 --- a/ga-batch/src/test/java/com/ga/batch/settlement/step/CalcRecruitStepTest.java +++ b/ga-batch/src/test/java/com/ga/batch/settlement/step/CalcRecruitStepTest.java @@ -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> ledgerCaptor = ArgumentCaptor.forClass(List.class); + when(recruitMapper.batchInsert(ledgerCaptor.capture())).thenReturn(1); + + step.execute(null, null); + + ArgumentCaptor 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> ledgerCaptor = ArgumentCaptor.forClass(List.class); + when(recruitMapper.batchInsert(ledgerCaptor.capture())).thenReturn(1); + + step.execute(null, null); + + ArgumentCaptor 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"); + } } diff --git a/ga-common/src/main/resources/db/migration/V116__도입수수료.sql b/ga-common/src/main/resources/db/migration/V116__도입수수료.sql new file mode 100644 index 0000000..0a53ca9 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V116__도입수수료.sql @@ -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; diff --git a/ga-common/src/main/resources/db/migration/V117__선지급정산.sql b/ga-common/src/main/resources/db/migration/V117__선지급정산.sql new file mode 100644 index 0000000..2f68615 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V117__선지급정산.sql @@ -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; diff --git a/ga-common/src/main/resources/db/migration/V118__환수분할상환.sql b/ga-common/src/main/resources/db/migration/V118__환수분할상환.sql new file mode 100644 index 0000000..5a5c926 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V118__환수분할상환.sql @@ -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; diff --git a/ga-common/src/main/resources/db/migration/V119__익월부활재지급.sql b/ga-common/src/main/resources/db/migration/V119__익월부활재지급.sql new file mode 100644 index 0000000..9e29b9e --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V119__익월부활재지급.sql @@ -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; diff --git a/ga-common/src/main/resources/db/migration/V120__정산대사입수.sql b/ga-common/src/main/resources/db/migration/V120__정산대사입수.sql new file mode 100644 index 0000000..d37cff7 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V120__정산대사입수.sql @@ -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; diff --git a/ga-common/src/main/resources/db/migration/V121__환수시효.sql b/ga-common/src/main/resources/db/migration/V121__환수시효.sql new file mode 100644 index 0000000..e91e630 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V121__환수시효.sql @@ -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; diff --git a/ga-common/src/main/resources/db/migration/V122__환수감면.sql b/ga-common/src/main/resources/db/migration/V122__환수감면.sql new file mode 100644 index 0000000..4ad9e9d --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V122__환수감면.sql @@ -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; diff --git a/ga-common/src/main/resources/db/migration/V123__채널수수료.sql b/ga-common/src/main/resources/db/migration/V123__채널수수료.sql new file mode 100644 index 0000000..57024ee --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V123__채널수수료.sql @@ -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; diff --git a/ga-core/src/main/java/com/ga/core/mapper/commission/AdvancePaymentMapper.java b/ga-core/src/main/java/com/ga/core/mapper/commission/AdvancePaymentMapper.java new file mode 100644 index 0000000..6bf7ef5 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/mapper/commission/AdvancePaymentMapper.java @@ -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 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 selectOutstandingFifo(@Param("agentId") Long agentId); +} diff --git a/ga-core/src/main/java/com/ga/core/mapper/commission/AdvanceRecoveryLedgerMapper.java b/ga-core/src/main/java/com/ga/core/mapper/commission/AdvanceRecoveryLedgerMapper.java new file mode 100644 index 0000000..45ad63d --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/mapper/commission/AdvanceRecoveryLedgerMapper.java @@ -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 selectBySettleMonth(@Param("advanceId") Long advanceId, + @Param("settleMonth") String settleMonth); + + /** + * 배치 멱등용: 해당 정산월에 상계 이력이 있는 선지급금 ID 목록(중복 제거). + * reverse 단계에서 월 단위로 역산 대상을 식별한다. + */ + List selectAdvanceIdsBySettleMonth(@Param("settleMonth") String settleMonth); +} diff --git a/ga-core/src/main/java/com/ga/core/mapper/commission/CarrierReconciliationInMapper.java b/ga-core/src/main/java/com/ga/core/mapper/commission/CarrierReconciliationInMapper.java new file mode 100644 index 0000000..fa9f230 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/mapper/commission/CarrierReconciliationInMapper.java @@ -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 selectList(CarrierReconciliationInSearchParam param); + + int insert(CarrierReconciliationInVO vo); + + int update(CarrierReconciliationInVO vo); + + /** + * 보험사 + 정산월 기준 upsert — UNIQUE(company_id, settle_month) 충돌 시 갱신. + */ + int upsert(CarrierReconciliationInVO vo); +} diff --git a/ga-core/src/main/java/com/ga/core/mapper/commission/ChargebackWaiverRequestMapper.java b/ga-core/src/main/java/com/ga/core/mapper/commission/ChargebackWaiverRequestMapper.java new file mode 100644 index 0000000..c8f40a8 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/mapper/commission/ChargebackWaiverRequestMapper.java @@ -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 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); +} diff --git a/ga-core/src/main/java/com/ga/core/mapper/commission/ClawbackInstallmentPlanMapper.java b/ga-core/src/main/java/com/ga/core/mapper/commission/ClawbackInstallmentPlanMapper.java new file mode 100644 index 0000000..8d7024a --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/mapper/commission/ClawbackInstallmentPlanMapper.java @@ -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 selectList(ClawbackInstallmentPlanSearchParam param); + + /** + * 채권 ID 기준 분할상환 계획 목록 — 회차순 정렬. + */ + List selectByReceivable(@Param("receivableId") Long receivableId); + + /** + * 분할상환 계획 일괄 등록. + */ + int insertBatch(List plans); + + /** + * 분할상환 회차 상태 갱신 (SCHEDULED→RECOVERED 또는 CANCELLED). + */ + int updateStatus(@Param("planId") Long planId, + @Param("status") String status); +} diff --git a/ga-core/src/main/java/com/ga/core/mapper/commission/ClawbackReceivableMapper.java b/ga-core/src/main/java/com/ga/core/mapper/commission/ClawbackReceivableMapper.java index de3c343..0558c4c 100644 --- a/ga-core/src/main/java/com/ga/core/mapper/commission/ClawbackReceivableMapper.java +++ b/ga-core/src/main/java/com/ga/core/mapper/commission/ClawbackReceivableMapper.java @@ -77,4 +77,20 @@ public interface ClawbackReceivableMapper { List> summaryByAgent(ClawbackReceivableSearchParam param); int countByCondition(ClawbackReceivableSearchParam param); + + /** + * [MOD-6] 시효 만료 대상 채권 조회. + * origin_settle_month <= beforeMonth AND status IN('OUTSTANDING','RECOVERING') + * beforeMonth = baseMonth - expiration_months (Service에서 계산 후 전달). + */ + List 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); } diff --git a/ga-core/src/main/java/com/ga/core/mapper/commission/LapsedReinstatementRecoveryMapper.java b/ga-core/src/main/java/com/ga/core/mapper/commission/LapsedReinstatementRecoveryMapper.java new file mode 100644 index 0000000..cc68d89 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/mapper/commission/LapsedReinstatementRecoveryMapper.java @@ -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 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> aggregateByAgent(@Param("settleMonth") String settleMonth); +} diff --git a/ga-core/src/main/java/com/ga/core/mapper/commission/ReconciliationVarianceMapper.java b/ga-core/src/main/java/com/ga/core/mapper/commission/ReconciliationVarianceMapper.java new file mode 100644 index 0000000..ce447d1 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/mapper/commission/ReconciliationVarianceMapper.java @@ -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 variances); + + /** + * 대사 입수 ID 기준 차이 상세 목록 조회. + */ + List selectByRecon(@Param("reconId") Long reconId); +} diff --git a/ga-core/src/main/java/com/ga/core/mapper/commission/RecruiterCommissionLedgerMapper.java b/ga-core/src/main/java/com/ga/core/mapper/commission/RecruiterCommissionLedgerMapper.java new file mode 100644 index 0000000..7bdcbaf --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/mapper/commission/RecruiterCommissionLedgerMapper.java @@ -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 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> aggregateByAgent(@Param("settleMonth") String settleMonth); +} diff --git a/ga-core/src/main/java/com/ga/core/mapper/commission/RecruiterCommissionRuleMapper.java b/ga-core/src/main/java/com/ga/core/mapper/commission/RecruiterCommissionRuleMapper.java new file mode 100644 index 0000000..f932e9f --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/mapper/commission/RecruiterCommissionRuleMapper.java @@ -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 selectList(RecruiterCommissionRuleSearchParam param); + + int insert(RecruiterCommissionRuleVO vo); + + int update(RecruiterCommissionRuleVO vo); + + int delete(@Param("ruleId") Long ruleId); +} diff --git a/ga-core/src/main/java/com/ga/core/mapper/commission/StatuteConfigMapper.java b/ga-core/src/main/java/com/ga/core/mapper/commission/StatuteConfigMapper.java new file mode 100644 index 0000000..a4e2731 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/mapper/commission/StatuteConfigMapper.java @@ -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 selectList(); + + int insert(StatuteConfigVO vo); + + int update(StatuteConfigVO vo); +} diff --git a/ga-core/src/main/java/com/ga/core/mapper/rule/RuleMapper.java b/ga-core/src/main/java/com/ga/core/mapper/rule/RuleMapper.java index 48c2bc3..dc073a9 100644 --- a/ga-core/src/main/java/com/ga/core/mapper/rule/RuleMapper.java +++ b/ga-core/src/main/java/com/ga/core/mapper/rule/RuleMapper.java @@ -17,10 +17,11 @@ public interface RuleMapper { // === commission_rate === List 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); diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/AdvancePaymentResp.java b/ga-core/src/main/java/com/ga/core/vo/commission/AdvancePaymentResp.java new file mode 100644 index 0000000..6a5d84a --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/AdvancePaymentResp.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/AdvancePaymentSaveReq.java b/ga-core/src/main/java/com/ga/core/vo/commission/AdvancePaymentSaveReq.java new file mode 100644 index 0000000..41e866f --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/AdvancePaymentSaveReq.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/AdvancePaymentSearchParam.java b/ga-core/src/main/java/com/ga/core/vo/commission/AdvancePaymentSearchParam.java new file mode 100644 index 0000000..cb84f96 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/AdvancePaymentSearchParam.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/AdvancePaymentVO.java b/ga-core/src/main/java/com/ga/core/vo/commission/AdvancePaymentVO.java new file mode 100644 index 0000000..7c73f89 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/AdvancePaymentVO.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/AdvanceRecoveryLedgerVO.java b/ga-core/src/main/java/com/ga/core/vo/commission/AdvanceRecoveryLedgerVO.java new file mode 100644 index 0000000..c2fd987 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/AdvanceRecoveryLedgerVO.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/AdvanceStatus.java b/ga-core/src/main/java/com/ga/core/vo/commission/AdvanceStatus.java new file mode 100644 index 0000000..f6cf46d --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/AdvanceStatus.java @@ -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; + } +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/CarrierReconciliationInResp.java b/ga-core/src/main/java/com/ga/core/vo/commission/CarrierReconciliationInResp.java new file mode 100644 index 0000000..3bb5385 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/CarrierReconciliationInResp.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/CarrierReconciliationInSaveReq.java b/ga-core/src/main/java/com/ga/core/vo/commission/CarrierReconciliationInSaveReq.java new file mode 100644 index 0000000..995e0a3 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/CarrierReconciliationInSaveReq.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/CarrierReconciliationInSearchParam.java b/ga-core/src/main/java/com/ga/core/vo/commission/CarrierReconciliationInSearchParam.java new file mode 100644 index 0000000..4036879 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/CarrierReconciliationInSearchParam.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/CarrierReconciliationInVO.java b/ga-core/src/main/java/com/ga/core/vo/commission/CarrierReconciliationInVO.java new file mode 100644 index 0000000..065059d --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/CarrierReconciliationInVO.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/CbInstallmentStatus.java b/ga-core/src/main/java/com/ga/core/vo/commission/CbInstallmentStatus.java new file mode 100644 index 0000000..b8d54d8 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/CbInstallmentStatus.java @@ -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; + } +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/ChargebackWaiverRequestResp.java b/ga-core/src/main/java/com/ga/core/vo/commission/ChargebackWaiverRequestResp.java new file mode 100644 index 0000000..d662b9d --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/ChargebackWaiverRequestResp.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/ChargebackWaiverRequestSaveReq.java b/ga-core/src/main/java/com/ga/core/vo/commission/ChargebackWaiverRequestSaveReq.java new file mode 100644 index 0000000..bc648a2 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/ChargebackWaiverRequestSaveReq.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/ChargebackWaiverRequestSearchParam.java b/ga-core/src/main/java/com/ga/core/vo/commission/ChargebackWaiverRequestSearchParam.java new file mode 100644 index 0000000..5495b16 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/ChargebackWaiverRequestSearchParam.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/ChargebackWaiverRequestVO.java b/ga-core/src/main/java/com/ga/core/vo/commission/ChargebackWaiverRequestVO.java new file mode 100644 index 0000000..c8593b4 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/ChargebackWaiverRequestVO.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackInstallmentPlanResp.java b/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackInstallmentPlanResp.java new file mode 100644 index 0000000..3ffac6e --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackInstallmentPlanResp.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackInstallmentPlanSaveReq.java b/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackInstallmentPlanSaveReq.java new file mode 100644 index 0000000..7e72064 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackInstallmentPlanSaveReq.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackInstallmentPlanSearchParam.java b/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackInstallmentPlanSearchParam.java new file mode 100644 index 0000000..7c09bc0 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackInstallmentPlanSearchParam.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackInstallmentPlanVO.java b/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackInstallmentPlanVO.java new file mode 100644 index 0000000..b624bb6 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackInstallmentPlanVO.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/LapseRecoveryStatus.java b/ga-core/src/main/java/com/ga/core/vo/commission/LapseRecoveryStatus.java new file mode 100644 index 0000000..6bb8dc2 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/LapseRecoveryStatus.java @@ -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; + } +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/LapsedReinstatementRecoveryResp.java b/ga-core/src/main/java/com/ga/core/vo/commission/LapsedReinstatementRecoveryResp.java new file mode 100644 index 0000000..e932a9a --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/LapsedReinstatementRecoveryResp.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/LapsedReinstatementRecoverySaveReq.java b/ga-core/src/main/java/com/ga/core/vo/commission/LapsedReinstatementRecoverySaveReq.java new file mode 100644 index 0000000..5af63e0 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/LapsedReinstatementRecoverySaveReq.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/LapsedReinstatementRecoverySearchParam.java b/ga-core/src/main/java/com/ga/core/vo/commission/LapsedReinstatementRecoverySearchParam.java new file mode 100644 index 0000000..9775d42 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/LapsedReinstatementRecoverySearchParam.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/LapsedReinstatementRecoveryVO.java b/ga-core/src/main/java/com/ga/core/vo/commission/LapsedReinstatementRecoveryVO.java new file mode 100644 index 0000000..13c3435 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/LapsedReinstatementRecoveryVO.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/ReconInStatus.java b/ga-core/src/main/java/com/ga/core/vo/commission/ReconInStatus.java new file mode 100644 index 0000000..826c79b --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/ReconInStatus.java @@ -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; + } +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/ReconciliationVarianceVO.java b/ga-core/src/main/java/com/ga/core/vo/commission/ReconciliationVarianceVO.java new file mode 100644 index 0000000..19436e9 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/ReconciliationVarianceVO.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/RecruiterCommissionLedgerResp.java b/ga-core/src/main/java/com/ga/core/vo/commission/RecruiterCommissionLedgerResp.java new file mode 100644 index 0000000..9d7db73 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/RecruiterCommissionLedgerResp.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/RecruiterCommissionLedgerSaveReq.java b/ga-core/src/main/java/com/ga/core/vo/commission/RecruiterCommissionLedgerSaveReq.java new file mode 100644 index 0000000..e334921 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/RecruiterCommissionLedgerSaveReq.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/RecruiterCommissionLedgerSearchParam.java b/ga-core/src/main/java/com/ga/core/vo/commission/RecruiterCommissionLedgerSearchParam.java new file mode 100644 index 0000000..bf32b15 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/RecruiterCommissionLedgerSearchParam.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/RecruiterCommissionLedgerVO.java b/ga-core/src/main/java/com/ga/core/vo/commission/RecruiterCommissionLedgerVO.java new file mode 100644 index 0000000..cade36b --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/RecruiterCommissionLedgerVO.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/RecruiterCommissionRuleResp.java b/ga-core/src/main/java/com/ga/core/vo/commission/RecruiterCommissionRuleResp.java new file mode 100644 index 0000000..3393040 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/RecruiterCommissionRuleResp.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/RecruiterCommissionRuleSaveReq.java b/ga-core/src/main/java/com/ga/core/vo/commission/RecruiterCommissionRuleSaveReq.java new file mode 100644 index 0000000..52ff666 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/RecruiterCommissionRuleSaveReq.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/RecruiterCommissionRuleSearchParam.java b/ga-core/src/main/java/com/ga/core/vo/commission/RecruiterCommissionRuleSearchParam.java new file mode 100644 index 0000000..d65bfe8 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/RecruiterCommissionRuleSearchParam.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/RecruiterCommissionRuleVO.java b/ga-core/src/main/java/com/ga/core/vo/commission/RecruiterCommissionRuleVO.java new file mode 100644 index 0000000..9d71a75 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/RecruiterCommissionRuleVO.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/RecruiterType.java b/ga-core/src/main/java/com/ga/core/vo/commission/RecruiterType.java new file mode 100644 index 0000000..07c1967 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/RecruiterType.java @@ -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; + } +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/StatuteConfigResp.java b/ga-core/src/main/java/com/ga/core/vo/commission/StatuteConfigResp.java new file mode 100644 index 0000000..ded181c --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/StatuteConfigResp.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/StatuteConfigSaveReq.java b/ga-core/src/main/java/com/ga/core/vo/commission/StatuteConfigSaveReq.java new file mode 100644 index 0000000..f4337ed --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/StatuteConfigSaveReq.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/StatuteConfigVO.java b/ga-core/src/main/java/com/ga/core/vo/commission/StatuteConfigVO.java new file mode 100644 index 0000000..322ccbd --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/StatuteConfigVO.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/WaiverStatus.java b/ga-core/src/main/java/com/ga/core/vo/commission/WaiverStatus.java new file mode 100644 index 0000000..a1d1acc --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/WaiverStatus.java @@ -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; + } +} diff --git a/ga-core/src/main/java/com/ga/core/vo/product/ContractResp.java b/ga-core/src/main/java/com/ga/core/vo/product/ContractResp.java index 0628dea..9073bf4 100644 --- a/ga-core/src/main/java/com/ga/core/vo/product/ContractResp.java +++ b/ga-core/src/main/java/com/ga/core/vo/product/ContractResp.java @@ -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; } diff --git a/ga-core/src/main/java/com/ga/core/vo/product/ContractSaveReq.java b/ga-core/src/main/java/com/ga/core/vo/product/ContractSaveReq.java index d37b97d..3c4d508 100644 --- a/ga-core/src/main/java/com/ga/core/vo/product/ContractSaveReq.java +++ b/ga-core/src/main/java/com/ga/core/vo/product/ContractSaveReq.java @@ -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; } diff --git a/ga-core/src/main/java/com/ga/core/vo/product/ContractVO.java b/ga-core/src/main/java/com/ga/core/vo/product/ContractVO.java index f70712e..3f4dea1 100644 --- a/ga-core/src/main/java/com/ga/core/vo/product/ContractVO.java +++ b/ga-core/src/main/java/com/ga/core/vo/product/ContractVO.java @@ -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; } diff --git a/ga-core/src/main/resources/mapper/commission/AdvancePaymentMapper.xml b/ga-core/src/main/resources/mapper/commission/AdvancePaymentMapper.xml new file mode 100644 index 0000000..c987d69 --- /dev/null +++ b/ga-core/src/main/resources/mapper/commission/AdvancePaymentMapper.xml @@ -0,0 +1,92 @@ + + + + + + + + + + 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 + + + + 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 + + + + + + + + + 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()) + + + + + UPDATE advance_payment + SET recovered_amount = #{recoveredAmount}, + balance = #{balance}, + status = #{status}, + memo = #{memo}, + updated_at = NOW() + WHERE advance_id = #{advanceId} + + + + DELETE FROM advance_payment WHERE advance_id = #{advanceId} + + + + + + + + + diff --git a/ga-core/src/main/resources/mapper/commission/AdvanceRecoveryLedgerMapper.xml b/ga-core/src/main/resources/mapper/commission/AdvanceRecoveryLedgerMapper.xml new file mode 100644 index 0000000..1551f70 --- /dev/null +++ b/ga-core/src/main/resources/mapper/commission/AdvanceRecoveryLedgerMapper.xml @@ -0,0 +1,40 @@ + + + + + + + + INSERT INTO advance_recovery_ledger + (advance_id, settle_month, recovery_amount, created_by, created_at) + VALUES + (#{advanceId}, #{settleMonth}, #{recoveryAmount}, #{createdBy}, NOW()) + + + + + DELETE FROM advance_recovery_ledger + WHERE advance_id = #{advanceId} + AND settle_month = #{settleMonth} + + + + + + + diff --git a/ga-core/src/main/resources/mapper/commission/CarrierReconciliationInMapper.xml b/ga-core/src/main/resources/mapper/commission/CarrierReconciliationInMapper.xml new file mode 100644 index 0000000..5282b7d --- /dev/null +++ b/ga-core/src/main/resources/mapper/commission/CarrierReconciliationInMapper.xml @@ -0,0 +1,95 @@ + + + + + + + + + + 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 + + + + FROM carrier_reconciliation_in cr + LEFT JOIN insurance_company ic ON ic.company_id = cr.company_id + + + + + + + + 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()) + + + + 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} + + + + + 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() + + + diff --git a/ga-core/src/main/resources/mapper/commission/ChargebackWaiverRequestMapper.xml b/ga-core/src/main/resources/mapper/commission/ChargebackWaiverRequestMapper.xml new file mode 100644 index 0000000..009d517 --- /dev/null +++ b/ga-core/src/main/resources/mapper/commission/ChargebackWaiverRequestMapper.xml @@ -0,0 +1,71 @@ + + + + + + + + + + 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 + + + + 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 + + + + + + + + + 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()) + + + + + UPDATE chargeback_waiver_request + SET approval_status = #{approvalStatus}, + approver_id = #{approverId}, + approved_at = #{approvedAt}, + updated_at = NOW() + WHERE request_id = #{requestId} + + + diff --git a/ga-core/src/main/resources/mapper/commission/ClawbackInstallmentPlanMapper.xml b/ga-core/src/main/resources/mapper/commission/ClawbackInstallmentPlanMapper.xml new file mode 100644 index 0000000..db6eff4 --- /dev/null +++ b/ga-core/src/main/resources/mapper/commission/ClawbackInstallmentPlanMapper.xml @@ -0,0 +1,82 @@ + + + + + + + + + + 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 + + + + 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 + + + + + + + + + + + + INSERT INTO clawback_installment_plan + (receivable_id, installment_no, installment_amount, due_month, + status, created_by, created_at) + VALUES + + (#{p.receivableId}, #{p.installmentNo}, #{p.installmentAmount}, #{p.dueMonth}, + COALESCE(#{p.status}, 'SCHEDULED'), #{p.createdBy}, NOW()) + + + + + + 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} + + + diff --git a/ga-core/src/main/resources/mapper/commission/ClawbackReceivableMapper.xml b/ga-core/src/main/resources/mapper/commission/ClawbackReceivableMapper.xml index 1e56a5e..10c16ac 100644 --- a/ga-core/src/main/resources/mapper/commission/ClawbackReceivableMapper.xml +++ b/ga-core/src/main/resources/mapper/commission/ClawbackReceivableMapper.xml @@ -194,4 +194,30 @@ + + + + + + + UPDATE clawback_receivable + SET balance = 0, + status = 'WRITTEN_OFF', + writeoff_reason = #{reason}, + written_off_at = NOW(), + updated_at = NOW() + WHERE origin_settle_month <= #{beforeMonth} + AND status IN ('OUTSTANDING', 'RECOVERING') + + diff --git a/ga-core/src/main/resources/mapper/commission/LapsedReinstatementRecoveryMapper.xml b/ga-core/src/main/resources/mapper/commission/LapsedReinstatementRecoveryMapper.xml new file mode 100644 index 0000000..8a809b9 --- /dev/null +++ b/ga-core/src/main/resources/mapper/commission/LapsedReinstatementRecoveryMapper.xml @@ -0,0 +1,78 @@ + + + + + + + + + + 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 + + + + 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 + + + + + + + + + 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()) + + + + + UPDATE lapsed_reinstatement_recovery + SET status = #{status}, + repaid_amount = #{repaidAmount}, + updated_at = NOW() + WHERE recovery_id = #{recoveryId} + + + + + + diff --git a/ga-core/src/main/resources/mapper/commission/ReconciliationVarianceMapper.xml b/ga-core/src/main/resources/mapper/commission/ReconciliationVarianceMapper.xml new file mode 100644 index 0000000..b865ad2 --- /dev/null +++ b/ga-core/src/main/resources/mapper/commission/ReconciliationVarianceMapper.xml @@ -0,0 +1,33 @@ + + + + + + + + + INSERT INTO reconciliation_variance + (recon_id, policy_no, expected_amount, reported_amount, + variance_reason, created_by, created_at) + VALUES + + (#{v.reconId}, #{v.policyNo}, #{v.expectedAmount}, #{v.reportedAmount}, + #{v.varianceReason}, #{v.createdBy}, NOW()) + + + + + + + diff --git a/ga-core/src/main/resources/mapper/commission/RecruiterCommissionLedgerMapper.xml b/ga-core/src/main/resources/mapper/commission/RecruiterCommissionLedgerMapper.xml new file mode 100644 index 0000000..c582c01 --- /dev/null +++ b/ga-core/src/main/resources/mapper/commission/RecruiterCommissionLedgerMapper.xml @@ -0,0 +1,83 @@ + + + + + + + + + + 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 + + + + 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 + + + + + + + + + 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()) + + + + UPDATE recruiter_commission_ledger + SET status = #{status}, updated_at = NOW() + WHERE ledger_id = #{ledgerId} + + + + DELETE FROM recruiter_commission_ledger WHERE ledger_id = #{ledgerId} + + + + + + diff --git a/ga-core/src/main/resources/mapper/commission/RecruiterCommissionRuleMapper.xml b/ga-core/src/main/resources/mapper/commission/RecruiterCommissionRuleMapper.xml new file mode 100644 index 0000000..59a6e5b --- /dev/null +++ b/ga-core/src/main/resources/mapper/commission/RecruiterCommissionRuleMapper.xml @@ -0,0 +1,65 @@ + + + + + + + + + + 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 + + + + + + + + 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()) + + + + 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} + + + + DELETE FROM recruiter_commission_rule WHERE rule_id = #{ruleId} + + + diff --git a/ga-core/src/main/resources/mapper/commission/StatuteConfigMapper.xml b/ga-core/src/main/resources/mapper/commission/StatuteConfigMapper.xml new file mode 100644 index 0000000..dff8ab4 --- /dev/null +++ b/ga-core/src/main/resources/mapper/commission/StatuteConfigMapper.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + INSERT INTO statute_config + (entity_type, expiration_months, is_active, created_by, created_at) + VALUES + (#{entityType}, #{expirationMonths}, COALESCE(#{isActive}, 'Y'), + #{createdBy}, NOW()) + + + + UPDATE statute_config + SET expiration_months = #{expirationMonths}, + is_active = #{isActive}, + updated_at = NOW() + WHERE config_id = #{configId} + + + diff --git a/ga-core/src/main/resources/mapper/product/ContractMapper.xml b/ga-core/src/main/resources/mapper/product/ContractMapper.xml index 1857a00..89e538e 100644 --- a/ga-core/src/main/resources/mapper/product/ContractMapper.xml +++ b/ga-core/src/main/resources/mapper/product/ContractMapper.xml @@ -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 @@ -65,7 +66,8 @@ AND commission_year = #{year} AND effective_from <= #{baseDate} AND (effective_to IS NULL OR effective_to >= #{baseDate}) - ORDER BY effective_from DESC, version DESC + AND (sales_channel = #{salesChannel} OR sales_channel IS NULL) + ORDER BY (sales_channel IS NULL) ASC, effective_from DESC, version DESC LIMIT 1 diff --git a/ga-frontend/src/App.tsx b/ga-frontend/src/App.tsx index 09f58c7..9863ee0 100644 --- a/ga-frontend/src/App.tsx +++ b/ga-frontend/src/App.tsx @@ -30,6 +30,15 @@ const GuaranteeInsurance = React.lazy(() => import('@/pages/org/GuaranteeInsur const ReserveFund = React.lazy(() => import('@/pages/commission/ReserveFund')); const CommissionSimulator = React.lazy(() => import('@/pages/commission/CommissionSimulator')); +// MOD-1~7: 신규 수수료 모듈 +const RecruiterCommission = React.lazy(() => import('@/pages/commission/RecruiterCommission')); +const AdvancePayment = React.lazy(() => import('@/pages/settle/AdvancePayment')); +const ClawbackInstallment = React.lazy(() => import('@/pages/commission/ClawbackInstallment')); +const LapsedReinstatement = React.lazy(() => import('@/pages/commission/LapsedReinstatement')); +const CarrierReconIn = React.lazy(() => import('@/pages/settle/CarrierReconIn')); +const StatuteConfig = React.lazy(() => import('@/pages/commission/StatuteConfig')); +const ChargebackWaiver = React.lazy(() => import('@/pages/commission/ChargebackWaiver')); + // P9: 환수채권 const ClawbackReceivable = React.lazy(() => import('@/pages/commission/ClawbackReceivable')); @@ -233,6 +242,15 @@ export default function App() { } /> } /> + {/* MOD-1~7: 신규 수수료 모듈 */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* P9: 환수채권 */} } /> diff --git a/ga-frontend/src/api/commission.ts b/ga-frontend/src/api/commission.ts index 7872368..45bf4ce 100644 --- a/ga-frontend/src/api/commission.ts +++ b/ga-frontend/src/api/commission.ts @@ -318,6 +318,102 @@ export interface BranchAllowanceLedgerRow extends Record { status: string; } +// ── MOD-1: RecruiterCommission ──────────────────── +export interface RecruiterRuleRow extends Record { + ruleId: number; + recruiterType: string; + calcType: string; + commissionRate?: number; + fixedAmount?: number; + effectiveFrom: string; + effectiveTo?: string; +} + +export interface RecruiterLedgerRow extends Record { + ledgerId: number; + recruiterAgentId: number; + recruiterAgentName: string; + recruitAgentId: number; + recruitAgentName: string; + settleMonth: string; + recruiterType: string; + baseAmount: number; + commissionAmount: number; + status: string; +} + +// ── MOD-3: ClawbackInstallment ──────────────────── +export interface ClawbackInstallmentRow extends Record { + planId: number; + receivableId: number; + installmentNo: number; + installmentAmount: number; + dueMonth: string; + status: string; + recoveredAt?: string; +} + +export interface ClawbackInstallmentGenerateReq { + receivableId: number; + installmentCount: number; + startMonth: string; +} + +// ── MOD-4: LapsedReinstatement ──────────────────── +export interface LapsedReinstatementRow extends Record { + recoveryId: number; + contractId: number; + contractNo?: string; + agentId: number; + agentName?: string; + lapsedMonth: string; + reinstatedMonth: string; + withheldAmount: number; + repaidAmount: number; + status: string; +} + +export interface LapsedReinstatementSaveReq { + contractId: number; + agentId: number; + lapsedMonth: string; + reinstatedMonth: string; + withheldAmount: number; +} + +// ── MOD-6: StatuteConfig ────────────────────────── +export interface StatuteConfigRow extends Record { + configId: number; + entityType: string; + expirationMonths: number; + isActive: string; +} + +// ── MOD-7: ChargebackWaiver ─────────────────────── +export interface ChargebackWaiverRow extends Record { + requestId: number; + contractId: number; + contractNo?: string; + agentId: number; + agentName?: string; + settleMonth: string; + originalChargeback: number; + waiverAmount: number; + reason?: string; + approvalStatus: string; + approverId?: number; + approvedAt?: string; +} + +export interface ChargebackWaiverSaveReq { + contractId: number; + agentId: number; + settleMonth: string; + originalChargeback: number; + waiverAmount: number; + reason?: string; +} + // ── API ─────────────────────────────────────────── export const commissionApi = { // CommissionMaster @@ -513,4 +609,62 @@ export const commissionApi = { unwrap>( api.get('/api/branch-allowances/ledgers', { params: p }), ), + + // ── MOD-1: RecruiterCommission ──────────────────── + recruiterRules: (p?: Partial) => + unwrap>( + api.get('/api/recruiter-commissions/rules', { params: p }), + ), + recruiterRuleCreate: (body: Partial) => + unwrap(api.post('/api/recruiter-commissions/rules', body)), + recruiterRuleUpdate: (id: number, body: Partial) => + unwrap(api.put(`/api/recruiter-commissions/rules/${id}`, body)), + recruiterRuleDelete: (id: number) => + unwrap(api.delete(`/api/recruiter-commissions/rules/${id}`)), + recruiterLedgers: (p: CommissionSearchParam) => + unwrap>( + api.get('/api/recruiter-commissions/ledgers', { params: p }), + ), + + // ── MOD-3: ClawbackInstallment ──────────────────── + clawbackInstallmentList: (p?: Partial) => + unwrap>( + api.get('/api/clawback-installments', { params: p }), + ), + clawbackInstallmentGenerate: (body: ClawbackInstallmentGenerateReq) => + unwrap(api.post('/api/clawback-installments/generate', body)), + clawbackInstallmentCancel: (planId: number) => + unwrap(api.put(`/api/clawback-installments/${planId}/cancel`)), + + // ── MOD-4: LapsedReinstatement ──────────────────── + lapsedReinstatementList: (p?: Partial) => + unwrap>( + api.get('/api/lapsed-reinstatements', { params: p }), + ), + lapsedReinstatementCreate: (body: LapsedReinstatementSaveReq) => + unwrap(api.post('/api/lapsed-reinstatements', body)), + lapsedReinstatementRepay: (recoveryId: number) => + unwrap(api.put(`/api/lapsed-reinstatements/${recoveryId}/repay`)), + lapsedReinstatementCancel: (recoveryId: number) => + unwrap(api.put(`/api/lapsed-reinstatements/${recoveryId}/cancel`)), + + // ── MOD-6: StatuteConfig ────────────────────────── + statuteConfigs: () => + unwrap>(api.get('/api/statute/configs')), + statuteConfigUpdate: (configId: number, body: Partial) => + unwrap(api.put(`/api/statute/configs/${configId}`, body)), + statuteExpire: (baseMonth: string) => + unwrap<{ expiredCount: number }>(api.post('/api/statute/expire', null, { params: { baseMonth } })), + + // ── MOD-7: ChargebackWaiver ─────────────────────── + chargebackWaiverList: (p?: Partial) => + unwrap>( + api.get('/api/chargeback-waivers', { params: p }), + ), + chargebackWaiverCreate: (body: ChargebackWaiverSaveReq) => + unwrap(api.post('/api/chargeback-waivers', body)), + chargebackWaiverApprove: (requestId: number) => + unwrap(api.put(`/api/chargeback-waivers/${requestId}/approve`)), + chargebackWaiverReject: (requestId: number, reason?: string) => + unwrap(api.put(`/api/chargeback-waivers/${requestId}/reject`, { reason })), }; diff --git a/ga-frontend/src/api/contract.ts b/ga-frontend/src/api/contract.ts index 77aef5c..e595d31 100644 --- a/ga-frontend/src/api/contract.ts +++ b/ga-frontend/src/api/contract.ts @@ -19,6 +19,8 @@ export interface ContractResp { status: string; statusName?: string; contractDate: string; + salesChannel?: string; + salesChannelName?: string; } export interface ContractSearchParam { @@ -45,6 +47,7 @@ export interface ContractSaveReq { coveragePeriod?: number; contractDate?: string; effectiveDate?: string; + salesChannel?: string; } export const contractApi = { diff --git a/ga-frontend/src/api/settle.ts b/ga-frontend/src/api/settle.ts index d9b0ac3..4c17aff 100644 --- a/ga-frontend/src/api/settle.ts +++ b/ga-frontend/src/api/settle.ts @@ -45,6 +45,105 @@ export const settleApi = { unwrap(api.put(`/api/settle/${settleId}/release`)), }; +// ── MOD-2: AdvancePayment ───────────────────────── +export interface AdvancePaymentRow extends Record { + advanceId: number; + agentId: number; + agentName?: string; + advanceMonth: string; + principalAmount: number; + recoveredAmount: number; + balance: number; + status: string; + memo?: string; +} + +export interface AdvancePaymentSaveReq { + agentId: number; + advanceMonth: string; + principalAmount: number; + memo?: string; +} + +export interface AdvanceRecoveryRow extends Record { + recoveryId: number; + advanceId: number; + settleMonth: string; + recoveryAmount: number; +} + +export interface AdvancePaymentSearchParam { + pageNum?: number; + pageSize?: number; + agentId?: number; + agentName?: string; + status?: string; + advanceMonth?: string; +} + +// ── MOD-5: CarrierReconIn ───────────────────────── +export interface CarrierReconInRow extends Record { + reconId: number; + companyId: number; + companyName?: string; + settleMonth: string; + fileName?: string; + recordCount: number; + reportedTotal: number; + systemTotal: number; + varianceAmount: number; + status: string; +} + +export interface CarrierReconInSaveReq { + companyId: number; + settleMonth: string; + fileName?: string; + recordCount: number; + reportedTotal: number; +} + +export interface ReconciliationVarianceRow extends Record { + varianceId: number; + reconId: number; + policyNo?: string; + expectedAmount?: number; + reportedAmount?: number; + varianceReason?: string; +} + +export interface CarrierReconSearchParam { + pageNum?: number; + pageSize?: number; + settleMonth?: string; + companyId?: number; + status?: string; +} + +export const advancePaymentApi = { + list: (p: AdvancePaymentSearchParam) => + unwrap>(api.get('/api/advance-payments', { params: p })), + create: (body: AdvancePaymentSaveReq) => + unwrap(api.post('/api/advance-payments', body)), + waive: (advanceId: number) => + unwrap(api.put(`/api/advance-payments/${advanceId}/waive`)), + recoveries: (advanceId: number) => + unwrap(api.get(`/api/advance-payments/${advanceId}/recoveries`)), +}; + +export const carrierReconInApi = { + list: (p: CarrierReconSearchParam) => + unwrap>(api.get('/api/carrier-recon-in', { params: p })), + create: (body: CarrierReconInSaveReq) => + unwrap(api.post('/api/carrier-recon-in', body)), + reconcile: (reconId: number) => + unwrap(api.post(`/api/carrier-recon-in/${reconId}/reconcile`)), + approve: (reconId: number) => + unwrap(api.put(`/api/carrier-recon-in/${reconId}/approve`)), + variances: (reconId: number) => + unwrap(api.get(`/api/carrier-recon-in/${reconId}/variances`)), +}; + export interface BatchJobLog { jobId: number; jobName: string; diff --git a/ga-frontend/src/pages/commission/ChargebackWaiver.tsx b/ga-frontend/src/pages/commission/ChargebackWaiver.tsx new file mode 100644 index 0000000..41c22d7 --- /dev/null +++ b/ga-frontend/src/pages/commission/ChargebackWaiver.tsx @@ -0,0 +1,221 @@ +import { useState } from 'react'; +import { Alert, Form, Input, InputNumber, Modal, Select, Space, message } from 'antd'; +import { ProCard } from '@ant-design/pro-components'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { ColDef } from '@ag-grid-community/core'; +import dayjs from 'dayjs'; +import PageContainer from '@/components/common/PageContainer'; +import SearchForm from '@/components/common/SearchForm'; +import DataGrid from '@/components/common/DataGrid'; +import PermissionButton from '@/components/common/PermissionButton'; +import { + commissionApi, + ChargebackWaiverRow, + ChargebackWaiverSaveReq, + CommissionSearchParam, +} from '@/api/commission'; +import { GRAY, RADIUS, SHADOW } from '@/theme/tokens'; + +const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-'; + +const STATUS_OPTIONS = [ + { value: 'REQUESTED', label: '신청' }, + { value: 'APPROVED', label: '승인' }, + { value: 'REJECTED', label: '반려' }, +]; + +const COLS: ColDef[] = [ + { field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' }, + { field: 'agentName', headerName: '설계사', width: 130 }, + { field: 'contractNo', headerName: '증권번호', width: 140 }, + { field: 'originalChargeback', headerName: '원환수액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, + { field: 'waiverAmount', headerName: '감면액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, + { field: 'reason', headerName: '사유', flex: 1 }, + { field: 'approvalStatus', headerName: '승인상태', width: 110 }, + { field: 'approvedAt', headerName: '승인일시', width: 160 }, +]; + +export default function ChargebackWaiver() { + const qc = useQueryClient(); + const [searchParams, setSearchParams] = useState({ + settleMonth: dayjs().format('YYYYMM'), pageSize: 200, + }); + const [createOpen, setCreateOpen] = useState(false); + const [rejectTarget, setRejectTarget] = useState(null); + const [rejectReason, setRejectReason] = useState(''); + const [form] = Form.useForm(); + + const { data: listData, isLoading, isError } = useQuery({ + queryKey: ['chargebackWaiver', 'list', searchParams], + queryFn: () => commissionApi.chargebackWaiverList(searchParams), + retry: false, + }); + + const rows = listData?.list ?? []; + + const createMutation = useMutation({ + mutationFn: (body: ChargebackWaiverSaveReq) => commissionApi.chargebackWaiverCreate(body), + onSuccess: () => { + message.success('신청 완료'); + qc.invalidateQueries({ queryKey: ['chargebackWaiver', 'list'] }); + setCreateOpen(false); + form.resetFields(); + }, + }); + + const approveMutation = useMutation({ + mutationFn: (requestId: number) => commissionApi.chargebackWaiverApprove(requestId), + onSuccess: () => { + message.success('승인 완료'); + qc.invalidateQueries({ queryKey: ['chargebackWaiver', 'list'] }); + }, + }); + + const rejectMutation = useMutation({ + mutationFn: ({ requestId, reason }: { requestId: number; reason: string }) => + commissionApi.chargebackWaiverReject(requestId, reason), + onSuccess: () => { + message.success('반려 완료'); + qc.invalidateQueries({ queryKey: ['chargebackWaiver', 'list'] }); + setRejectTarget(null); + setRejectReason(''); + }, + }); + + const handleCreate = async () => { + const values = await form.validateFields(); + createMutation.mutate(values); + }; + + const handleApprove = (row: ChargebackWaiverRow) => Modal.confirm({ + title: '승인', + content: `${row.agentName} 감면 신청(감면액: ${fmt(row.waiverAmount)})을 승인하시겠습니까?`, + onOk: () => approveMutation.mutateAsync(row.requestId), + }); + + const openReject = (row: ChargebackWaiverRow) => { + setRejectTarget(row); + setRejectReason(''); + }; + + const handleReject = () => { + if (!rejectReason.trim()) { message.warning('반려 사유를 입력하세요'); return; } + rejectMutation.mutate({ requestId: rejectTarget!.requestId, reason: rejectReason }); + }; + + const pinnedRow = rows.length > 0 ? { + agentName: '합계', + originalChargeback: rows.reduce((s, r) => s + (r.originalChargeback ?? 0), 0), + waiverAmount: rows.reduce((s, r) => s + (r.waiverAmount ?? 0), 0), + } as Partial : undefined; + + const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }; + + return ( + + {isError && ( + + )} + + setSearchParams({ ...v as typeof searchParams, pageSize: 200 })} + onReset={() => setSearchParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })} + /> + + +
+ setCreateOpen(true)}> + + 감면 신청 + +
+ + rows={rows} + columns={[ + ...COLS, + { + headerName: '액션', width: 180, pinned: 'right', + cellRenderer: (p: { data: ChargebackWaiverRow }) => ( + + handleApprove(p.data)} + > + 승인 + + openReject(p.data)} + > + 반려 + + + ), + }, + ]} + loading={isLoading} + height={520} + rowKey="requestId" + pinnedBottomRow={pinnedRow} + /> +
+ + {/* 신청 모달 */} + { setCreateOpen(false); form.resetFields(); }} + okText="신청" + cancelText="취소" + confirmLoading={createMutation.isPending} + > +
+ + + + + + + + + + + + + + + + + + +
+
+ + {/* 반려 사유 모달 */} + { setRejectTarget(null); setRejectReason(''); }} + okText="반려" + okButtonProps={{ danger: true }} + cancelText="취소" + confirmLoading={rejectMutation.isPending} + > + setRejectReason(e.target.value)} + /> + +
+ ); +} diff --git a/ga-frontend/src/pages/commission/ClawbackInstallment.tsx b/ga-frontend/src/pages/commission/ClawbackInstallment.tsx new file mode 100644 index 0000000..7a2bd81 --- /dev/null +++ b/ga-frontend/src/pages/commission/ClawbackInstallment.tsx @@ -0,0 +1,156 @@ +import { useState } from 'react'; +import { Alert, Form, Input, InputNumber, Modal, Space, message } from 'antd'; +import { ProCard } from '@ant-design/pro-components'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { ColDef } from '@ag-grid-community/core'; +import PageContainer from '@/components/common/PageContainer'; +import SearchForm from '@/components/common/SearchForm'; +import DataGrid from '@/components/common/DataGrid'; +import PermissionButton from '@/components/common/PermissionButton'; +import { + commissionApi, + ClawbackInstallmentRow, + ClawbackInstallmentGenerateReq, +} from '@/api/commission'; +import { GRAY, RADIUS, SHADOW } from '@/theme/tokens'; + +const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-'; + +interface SearchParam { + receivableId?: number; + dueMonth?: string; + pageSize?: number; +} + +const COLS: ColDef[] = [ + { field: 'receivableId', headerName: '채권ID', width: 110, pinned: 'left' }, + { field: 'installmentNo', headerName: '회차', width: 80, type: 'numericColumn' }, + { field: 'installmentAmount', headerName: '분할금액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, + { field: 'dueMonth', headerName: '납부월', width: 110 }, + { field: 'status', headerName: '상태', width: 110 }, + { field: 'recoveredAt', headerName: '회수일시', width: 160 }, +]; + +export default function ClawbackInstallment() { + const qc = useQueryClient(); + const [searchParams, setSearchParams] = useState({ pageSize: 200 }); + const [generateOpen, setGenerateOpen] = useState(false); + const [form] = Form.useForm(); + + const { data: listData, isLoading, isError } = useQuery({ + queryKey: ['clawbackInstallment', 'list', searchParams], + queryFn: () => commissionApi.clawbackInstallmentList(searchParams), + retry: false, + }); + + const rows = listData?.list ?? []; + + const generateMutation = useMutation({ + mutationFn: (body: ClawbackInstallmentGenerateReq) => + commissionApi.clawbackInstallmentGenerate(body), + onSuccess: (result) => { + message.success(`분할 계획 ${result.length}건 생성 완료`); + qc.invalidateQueries({ queryKey: ['clawbackInstallment', 'list'] }); + setGenerateOpen(false); + form.resetFields(); + }, + }); + + const cancelMutation = useMutation({ + mutationFn: (planId: number) => commissionApi.clawbackInstallmentCancel(planId), + onSuccess: () => { + message.success('취소 완료'); + qc.invalidateQueries({ queryKey: ['clawbackInstallment', 'list'] }); + }, + }); + + const handleGenerate = async () => { + const values = await form.validateFields(); + generateMutation.mutate(values); + }; + + const handleCancel = (row: ClawbackInstallmentRow) => Modal.confirm({ + title: '회차 취소', + content: `채권 ${row.receivableId} / ${row.installmentNo}회차를 취소하시겠습니까?`, + onOk: () => cancelMutation.mutateAsync(row.planId), + }); + + const pinnedRow = rows.length > 0 ? { + installmentNo: undefined, + installmentAmount: rows.reduce((s, r) => s + (r.installmentAmount ?? 0), 0), + dueMonth: '합계', + } as Partial : undefined; + + const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }; + + return ( + + {isError && ( + + )} + + setSearchParams({ ...v as SearchParam, pageSize: 200 })} + onReset={() => setSearchParams({ pageSize: 200 })} + /> + + +
+ setGenerateOpen(true)}> + + 분할 계획 생성 + +
+ + rows={rows} + columns={[ + ...COLS, + { + headerName: '액션', width: 100, pinned: 'right', + cellRenderer: (p: { data: ClawbackInstallmentRow }) => ( + + handleCancel(p.data)} + > + 취소 + + + ), + }, + ]} + loading={isLoading} + height={520} + rowKey="planId" + pinnedBottomRow={pinnedRow} + /> +
+ + { setGenerateOpen(false); form.resetFields(); }} + okText="생성" + cancelText="취소" + confirmLoading={generateMutation.isPending} + > +
+ + + + + + + + + +
+
+
+ ); +} diff --git a/ga-frontend/src/pages/commission/LapsedReinstatement.tsx b/ga-frontend/src/pages/commission/LapsedReinstatement.tsx new file mode 100644 index 0000000..18f9f48 --- /dev/null +++ b/ga-frontend/src/pages/commission/LapsedReinstatement.tsx @@ -0,0 +1,188 @@ +import { useState } from 'react'; +import { Alert, Form, Input, InputNumber, Modal, Select, Space, Tabs, message } from 'antd'; +import { ProCard } from '@ant-design/pro-components'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { ColDef } from '@ag-grid-community/core'; +import dayjs from 'dayjs'; +import PageContainer from '@/components/common/PageContainer'; +import SearchForm from '@/components/common/SearchForm'; +import DataGrid from '@/components/common/DataGrid'; +import PermissionButton from '@/components/common/PermissionButton'; +import { + commissionApi, + LapsedReinstatementRow, + LapsedReinstatementSaveReq, + CommissionSearchParam, +} from '@/api/commission'; +import { GRAY, RADIUS, SHADOW } from '@/theme/tokens'; + +const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-'; + +const STATUS_OPTIONS = [ + { value: 'PENDING', label: '대기' }, + { value: 'REPAID', label: '재지급완료' }, + { value: 'CANCELLED', label: '취소' }, +]; + +const COLS: ColDef[] = [ + { field: 'reinstatedMonth', headerName: '부활월', width: 110, pinned: 'left' }, + { field: 'agentName', headerName: '설계사', width: 130 }, + { field: 'contractNo', headerName: '증권번호', width: 140 }, + { field: 'lapsedMonth', headerName: '실효월', width: 110 }, + { field: 'withheldAmount', headerName: '보류액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, + { field: 'repaidAmount', headerName: '재지급액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, + { field: 'status', headerName: '상태', width: 110 }, +]; + +export default function LapsedReinstatement() { + const qc = useQueryClient(); + const [searchParams, setSearchParams] = useState({ + settleMonth: dayjs().format('YYYYMM'), pageSize: 200, + }); + const [createOpen, setCreateOpen] = useState(false); + const [form] = Form.useForm(); + + const { data: listData, isLoading, isError } = useQuery({ + queryKey: ['lapsedReinstatement', 'list', searchParams], + queryFn: () => commissionApi.lapsedReinstatementList(searchParams), + retry: false, + }); + + const rows = listData?.list ?? []; + + const createMutation = useMutation({ + mutationFn: (body: LapsedReinstatementSaveReq) => commissionApi.lapsedReinstatementCreate(body), + onSuccess: () => { + message.success('등록 완료'); + qc.invalidateQueries({ queryKey: ['lapsedReinstatement', 'list'] }); + setCreateOpen(false); + form.resetFields(); + }, + }); + + const repayMutation = useMutation({ + mutationFn: (recoveryId: number) => commissionApi.lapsedReinstatementRepay(recoveryId), + onSuccess: () => { + message.success('재지급 처리 완료'); + qc.invalidateQueries({ queryKey: ['lapsedReinstatement', 'list'] }); + }, + }); + + const cancelMutation = useMutation({ + mutationFn: (recoveryId: number) => commissionApi.lapsedReinstatementCancel(recoveryId), + onSuccess: () => { + message.success('취소 완료'); + qc.invalidateQueries({ queryKey: ['lapsedReinstatement', 'list'] }); + }, + }); + + const handleCreate = async () => { + const values = await form.validateFields(); + createMutation.mutate(values); + }; + + const handleRepay = (row: LapsedReinstatementRow) => Modal.confirm({ + title: '재지급 처리', + content: `${row.agentName} 보류액 ${fmt(row.withheldAmount)}원을 재지급 처리하시겠습니까?`, + onOk: () => repayMutation.mutateAsync(row.recoveryId), + }); + + const handleCancel = (row: LapsedReinstatementRow) => Modal.confirm({ + title: '취소', + content: '해당 부활 재지급 건을 취소하시겠습니까?', + onOk: () => cancelMutation.mutateAsync(row.recoveryId), + }); + + const pinnedRow = rows.length > 0 ? { + agentName: '합계', + withheldAmount: rows.reduce((s, r) => s + (r.withheldAmount ?? 0), 0), + repaidAmount: rows.reduce((s, r) => s + (r.repaidAmount ?? 0), 0), + } as Partial : undefined; + + const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }; + + return ( + + {isError && ( + + )} + + setSearchParams({ ...v as typeof searchParams, pageSize: 200 })} + onReset={() => setSearchParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })} + /> + + +
+ setCreateOpen(true)}> + + 부활 등록 + +
+ + rows={rows} + columns={[ + ...COLS, + { + headerName: '액션', width: 180, pinned: 'right', + cellRenderer: (p: { data: LapsedReinstatementRow }) => ( + + handleRepay(p.data)} + > + 재지급 + + handleCancel(p.data)} + > + 취소 + + + ), + }, + ]} + loading={isLoading} + height={520} + rowKey="recoveryId" + pinnedBottomRow={pinnedRow} + /> +
+ + { setCreateOpen(false); form.resetFields(); }} + okText="등록" + cancelText="취소" + confirmLoading={createMutation.isPending} + > +
+ + + + + + + + + + + + + + + +
+
+
+ ); +} diff --git a/ga-frontend/src/pages/commission/RecruiterCommission.tsx b/ga-frontend/src/pages/commission/RecruiterCommission.tsx new file mode 100644 index 0000000..207bc10 --- /dev/null +++ b/ga-frontend/src/pages/commission/RecruiterCommission.tsx @@ -0,0 +1,210 @@ +import { useState } from 'react'; +import { Alert, Form, Input, InputNumber, Modal, Select, Space, Tabs, message } from 'antd'; +import { ProCard } from '@ant-design/pro-components'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { ColDef } from '@ag-grid-community/core'; +import dayjs from 'dayjs'; +import PageContainer from '@/components/common/PageContainer'; +import SearchForm from '@/components/common/SearchForm'; +import DataGrid from '@/components/common/DataGrid'; +import PermissionButton from '@/components/common/PermissionButton'; +import { + commissionApi, + RecruiterRuleRow, + RecruiterLedgerRow, + CommissionSearchParam, +} from '@/api/commission'; +import { GRAY, RADIUS, SHADOW } from '@/theme/tokens'; + +const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-'; + +const RECRUITER_TYPE_OPTIONS = [ + { value: 'NEW_RECRUIT', label: '신인도입' }, + { value: 'DEVELOPMENT', label: '육성' }, + { value: 'TRAINING', label: '교육' }, +]; + +const RULE_COLS: ColDef[] = [ + { field: 'recruiterType', headerName: '유형', width: 120 }, + { field: 'calcType', headerName: '계산방식', width: 110 }, + { field: 'commissionRate', headerName: '수수료율', width: 110, type: 'numericColumn' }, + { field: 'fixedAmount', headerName: '정액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, + { field: 'effectiveFrom', headerName: '적용시작', width: 120 }, + { field: 'effectiveTo', headerName: '적용종료', width: 120 }, +]; + +const LEDGER_COLS: ColDef[] = [ + { field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' }, + { field: 'recruiterAgentName', headerName: '도입자', width: 130 }, + { field: 'recruitAgentName', headerName: '피도입자', width: 130 }, + { field: 'recruiterType', headerName: '유형', width: 110 }, + { field: 'baseAmount', headerName: '기준금액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, + { field: 'commissionAmount', headerName: '수수료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, + { field: 'status', headerName: '상태', width: 110 }, +]; + +export default function RecruiterCommission() { + const qc = useQueryClient(); + const [ledgerParams, setLedgerParams] = useState({ + settleMonth: dayjs().format('YYYYMM'), pageSize: 200, + }); + const [ruleModalOpen, setRuleModalOpen] = useState(false); + const [editingRule, setEditingRule] = useState(null); + const [form] = Form.useForm(); + + const { data: rulesData, isLoading: rulesLoading, isError: rulesError } = useQuery({ + queryKey: ['recruiterCommission', 'rules'], + queryFn: () => commissionApi.recruiterRules(), + retry: false, + }); + + const { data: ledgersData, isLoading: ledgersLoading } = useQuery({ + queryKey: ['recruiterCommission', 'ledgers', ledgerParams], + queryFn: () => commissionApi.recruiterLedgers(ledgerParams), + retry: false, + }); + + const rules = rulesData?.list ?? []; + const ledgers = ledgersData?.list ?? []; + + const saveMutation = useMutation({ + mutationFn: async (values: Partial) => { + if (editingRule) { + return commissionApi.recruiterRuleUpdate(editingRule.ruleId, values); + } + return commissionApi.recruiterRuleCreate(values); + }, + onSuccess: () => { + message.success(editingRule ? '수정 완료' : '등록 완료'); + qc.invalidateQueries({ queryKey: ['recruiterCommission', 'rules'] }); + setRuleModalOpen(false); + }, + }); + + const openCreate = () => { setEditingRule(null); form.resetFields(); setRuleModalOpen(true); }; + const openEdit = (row: RecruiterRuleRow) => { setEditingRule(row); form.setFieldsValue(row); setRuleModalOpen(true); }; + + const handleSave = async () => { + const values = await form.validateFields(); + saveMutation.mutate(values); + }; + + const handleDelete = (id: number) => Modal.confirm({ + title: '룰 삭제', content: '삭제하시겠습니까?', + onOk: async () => { + await commissionApi.recruiterRuleDelete(id); + message.success('삭제 완료'); + qc.invalidateQueries({ queryKey: ['recruiterCommission', 'rules'] }); + }, + }); + + const pinnedLedger = ledgers.length > 0 ? { + recruiterAgentName: '합계', + baseAmount: ledgers.reduce((s, r) => s + (r.baseAmount ?? 0), 0), + commissionAmount: ledgers.reduce((s, r) => s + (r.commissionAmount ?? 0), 0), + } as Partial : undefined; + + const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }; + + return ( + + {rulesError && ( + + )} + + +
+ + + 룰 등록 + +
+ + rows={rules} + columns={[ + ...RULE_COLS, + { + headerName: '액션', width: 160, pinned: 'right', + cellRenderer: (p: { data: RecruiterRuleRow }) => ( + + openEdit(p.data)}>수정 + handleDelete(p.data.ruleId)}>삭제 + + ), + }, + ]} + loading={rulesLoading} + height={520} + rowKey="ruleId" + /> + + ), + }, + { + key: 'ledgers', + label: '원장', + children: ( + <> + setLedgerParams({ ...v as CommissionSearchParam, pageSize: 200 })} + onReset={() => setLedgerParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })} + /> + + + rows={ledgers} + columns={LEDGER_COLS} + loading={ledgersLoading} + height={520} + rowKey="ledgerId" + pinnedBottomRow={pinnedLedger} + /> + + + ), + }, + ]} + /> + + setRuleModalOpen(false)} + okText="저장" + cancelText="취소" + confirmLoading={saveMutation.isPending} + > +
+ + + + + + + + + + + + + + + +
+
+
+ ); +} diff --git a/ga-frontend/src/pages/commission/StatuteConfig.tsx b/ga-frontend/src/pages/commission/StatuteConfig.tsx new file mode 100644 index 0000000..c3bdc97 --- /dev/null +++ b/ga-frontend/src/pages/commission/StatuteConfig.tsx @@ -0,0 +1,153 @@ +import { useState } from 'react'; +import { Alert, Descriptions, Form, Input, InputNumber, Modal, Space, message } from 'antd'; +import { ProCard } from '@ant-design/pro-components'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { ColDef } from '@ag-grid-community/core'; +import dayjs from 'dayjs'; +import PageContainer from '@/components/common/PageContainer'; +import DataGrid from '@/components/common/DataGrid'; +import PermissionButton from '@/components/common/PermissionButton'; +import { commissionApi, StatuteConfigRow } from '@/api/commission'; +import { GRAY, RADIUS, SHADOW } from '@/theme/tokens'; + +const COLS: ColDef[] = [ + { field: 'entityType', headerName: '대상유형', width: 200 }, + { field: 'expirationMonths', headerName: '시효(개월)', width: 130, type: 'numericColumn' }, + { field: 'isActive', headerName: '활성', width: 80 }, +]; + +export default function StatuteConfig() { + const qc = useQueryClient(); + const [editTarget, setEditTarget] = useState(null); + const [expireMonth, setExpireMonth] = useState(dayjs().format('YYYYMM')); + const [expireResult, setExpireResult] = useState<{ expiredCount: number } | null>(null); + const [form] = Form.useForm>(); + + const { data: configData, isLoading, isError } = useQuery({ + queryKey: ['statuteConfig', 'list'], + queryFn: () => commissionApi.statuteConfigs(), + retry: false, + }); + + const rows = configData?.list ?? []; + + const updateMutation = useMutation({ + mutationFn: ({ configId, values }: { configId: number; values: Partial }) => + commissionApi.statuteConfigUpdate(configId, values), + onSuccess: () => { + message.success('설정 수정 완료'); + qc.invalidateQueries({ queryKey: ['statuteConfig', 'list'] }); + setEditTarget(null); + }, + }); + + const expireMutation = useMutation({ + mutationFn: (baseMonth: string) => commissionApi.statuteExpire(baseMonth), + onSuccess: (result) => { + setExpireResult(result); + message.success(`만료 처리 완료 — ${result.expiredCount}건`); + qc.invalidateQueries({ queryKey: ['statuteConfig', 'list'] }); + }, + }); + + const handleUpdate = async () => { + const values = await form.validateFields(); + updateMutation.mutate({ configId: editTarget!.configId, values }); + }; + + const openEdit = (row: StatuteConfigRow) => { + setEditTarget(row); + form.setFieldsValue({ + expirationMonths: row.expirationMonths, + isActive: row.isActive, + }); + }; + + const handleExpire = () => Modal.confirm({ + title: '만료 실행', + content: `기준월 ${expireMonth} 기준으로 시효 만료된 채권을 WRITTEN_OFF 처리합니다. 계속하시겠습니까?`, + okType: 'danger', + onOk: () => expireMutation.mutateAsync(expireMonth), + }); + + const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }; + + return ( + + {isError && ( + + )} + + + + rows={rows} + columns={[ + ...COLS, + { + headerName: '액션', width: 100, pinned: 'right', + cellRenderer: (p: { data: StatuteConfigRow }) => ( + openEdit(p.data)} + > + 수정 + + ), + }, + ]} + loading={isLoading} + height={200} + rowKey="configId" + /> + + + + + + 기준월: + setExpireMonth(e.target.value)} + placeholder="YYYYMM" + style={{ width: 120 }} + /> + + 만료 실행 + + + {expireResult && ( + + + {expireResult.expiredCount}건이 WRITTEN_OFF 처리되었습니다. + + + )} + + + + setEditTarget(null)} + okText="저장" + cancelText="취소" + confirmLoading={updateMutation.isPending} + > +
+ + + + + + +
+
+
+ ); +} diff --git a/ga-frontend/src/pages/product/ContractList.tsx b/ga-frontend/src/pages/product/ContractList.tsx index c496563..db6dec6 100644 --- a/ga-frontend/src/pages/product/ContractList.tsx +++ b/ga-frontend/src/pages/product/ContractList.tsx @@ -18,9 +18,10 @@ import { agentApi } from '@/api/agent'; export default function ContractList() { const actionRef = useRef(); const [open, setOpen] = useState(false); - const { data: insuranceCodes = [] } = useCommonCodes('INSURANCE_TYPE'); - const { data: statusCodes = [] } = useCommonCodes('CONTRACT_STATUS'); - const { data: payCycleCodes = [] } = useCommonCodes('PAY_CYCLE'); + const { data: insuranceCodes = [] } = useCommonCodes('INSURANCE_TYPE'); + const { data: statusCodes = [] } = useCommonCodes('CONTRACT_STATUS'); + const { data: payCycleCodes = [] } = useCommonCodes('PAY_CYCLE'); + const { data: salesChannelCodes = [] } = useCommonCodes('SALES_CHANNEL'); // 등록 모달용 - 상품/설계사 옵션 const { data: products } = useQuery({ @@ -74,6 +75,10 @@ export default function ContractList() { render: (_, r) => , }, { title: '계약일', dataIndex: 'contractDate', width: 110, search: false }, + { + title: '판매채널', dataIndex: 'salesChannel', width: 110, search: false, + render: (_, r) => r.salesChannelName ?? r.salesChannel ?? '-', + }, ]; return ( @@ -138,6 +143,13 @@ export default function ContractList() { + ({ value: c.code, label: c.codeName }))} + placeholder="선택 안 함(직판 기본)" + /> ); diff --git a/ga-frontend/src/pages/settle/AdvancePayment.tsx b/ga-frontend/src/pages/settle/AdvancePayment.tsx new file mode 100644 index 0000000..cd8a7e3 --- /dev/null +++ b/ga-frontend/src/pages/settle/AdvancePayment.tsx @@ -0,0 +1,209 @@ +import { useState } from 'react'; +import { Alert, Descriptions, Drawer, Form, Input, InputNumber, Modal, Select, Space, message } from 'antd'; +import { ProCard } from '@ant-design/pro-components'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { ColDef } from '@ag-grid-community/core'; +import PageContainer from '@/components/common/PageContainer'; +import SearchForm from '@/components/common/SearchForm'; +import DataGrid from '@/components/common/DataGrid'; +import PermissionButton from '@/components/common/PermissionButton'; +import { + advancePaymentApi, + AdvancePaymentRow, + AdvancePaymentSaveReq, + AdvanceRecoveryRow, + AdvancePaymentSearchParam, +} from '@/api/settle'; +import { GRAY, RADIUS, SHADOW } from '@/theme/tokens'; + +const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-'; + +const STATUS_OPTIONS = [ + { value: 'OUTSTANDING', label: '미상환' }, + { value: 'PARTIAL', label: '부분상환' }, + { value: 'CLEARED', label: '완료' }, + { value: 'WAIVED', label: '면제' }, +]; + +const LIST_COLS: ColDef[] = [ + { field: 'advanceMonth', headerName: '선지급월', width: 110, pinned: 'left' }, + { field: 'agentName', headerName: '설계사', width: 130 }, + { field: 'principalAmount', headerName: '원금', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, + { field: 'recoveredAmount', headerName: '회수액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, + { field: 'balance', headerName: '잔액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, + { field: 'status', headerName: '상태', width: 110 }, + { field: 'memo', headerName: '메모', flex: 1 }, +]; + +const RECOVERY_COLS: ColDef[] = [ + { field: 'settleMonth', headerName: '정산월', width: 120 }, + { field: 'recoveryAmount', headerName: '상계액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, +]; + +export default function AdvancePayment() { + const qc = useQueryClient(); + const [searchParams, setSearchParams] = useState({ pageSize: 200 }); + const [createOpen, setCreateOpen] = useState(false); + const [drawerTarget, setDrawerTarget] = useState(null); + const [form] = Form.useForm(); + + const { data: listData, isLoading, isError } = useQuery({ + queryKey: ['advancePayment', 'list', searchParams], + queryFn: () => advancePaymentApi.list(searchParams), + retry: false, + }); + + const { data: recoveries = [], isLoading: recLoading } = useQuery({ + queryKey: ['advancePayment', 'recoveries', drawerTarget?.advanceId], + queryFn: () => advancePaymentApi.recoveries(drawerTarget!.advanceId), + enabled: !!drawerTarget, + }); + + const rows = listData?.list ?? []; + + const createMutation = useMutation({ + mutationFn: (body: AdvancePaymentSaveReq) => advancePaymentApi.create(body), + onSuccess: () => { + message.success('등록 완료'); + qc.invalidateQueries({ queryKey: ['advancePayment', 'list'] }); + setCreateOpen(false); + form.resetFields(); + }, + }); + + const waiveMutation = useMutation({ + mutationFn: (advanceId: number) => advancePaymentApi.waive(advanceId), + onSuccess: () => { + message.success('면제 처리 완료'); + qc.invalidateQueries({ queryKey: ['advancePayment', 'list'] }); + }, + }); + + const handleCreate = async () => { + const values = await form.validateFields(); + createMutation.mutate(values); + }; + + const handleWaive = (row: AdvancePaymentRow) => Modal.confirm({ + title: '면제 처리', + content: `${row.agentName} 의 선지급 잔액 ${fmt(row.balance)}원을 면제하시겠습니까?`, + onOk: () => waiveMutation.mutateAsync(row.advanceId), + }); + + const pinnedRow = rows.length > 0 ? { + agentName: '합계', + principalAmount: rows.reduce((s, r) => s + (r.principalAmount ?? 0), 0), + recoveredAmount: rows.reduce((s, r) => s + (r.recoveredAmount ?? 0), 0), + balance: rows.reduce((s, r) => s + (r.balance ?? 0), 0), + } as Partial : undefined; + + const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }; + + return ( + + {isError && ( + + )} + + setSearchParams({ ...v as AdvancePaymentSearchParam, pageSize: 200 })} + onReset={() => setSearchParams({ pageSize: 200 })} + /> + + +
+ setCreateOpen(true)}> + + 선지급 등록 + +
+ + rows={rows} + columns={[ + ...LIST_COLS, + { + headerName: '액션', width: 180, pinned: 'right', + cellRenderer: (p: { data: AdvancePaymentRow }) => ( + + setDrawerTarget(p.data)} + > + 상계내역 + + handleWaive(p.data)} + > + 면제 + + + ), + }, + ]} + loading={isLoading} + height={520} + rowKey="advanceId" + pinnedBottomRow={pinnedRow} + /> +
+ + {/* 등록 모달 */} + { setCreateOpen(false); form.resetFields(); }} + okText="등록" + cancelText="취소" + confirmLoading={createMutation.isPending} + > +
+ + + + + + + + + + + + +
+
+ + {/* 상계내역 Drawer */} + setDrawerTarget(null)} + width={480} + > + {drawerTarget && ( + <> + + {fmt(drawerTarget.principalAmount)} + {fmt(drawerTarget.recoveredAmount)} + {fmt(drawerTarget.balance)} + {drawerTarget.status} + + + rows={recoveries} + columns={RECOVERY_COLS} + loading={recLoading} + height={300} + rowKey="recoveryId" + /> + + )} + +
+ ); +} diff --git a/ga-frontend/src/pages/settle/CarrierReconIn.tsx b/ga-frontend/src/pages/settle/CarrierReconIn.tsx new file mode 100644 index 0000000..8f58ebd --- /dev/null +++ b/ga-frontend/src/pages/settle/CarrierReconIn.tsx @@ -0,0 +1,238 @@ +import { useState } from 'react'; +import { Alert, Descriptions, Drawer, Form, Input, InputNumber, Modal, Space, message } from 'antd'; +import { ProCard } from '@ant-design/pro-components'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { ColDef } from '@ag-grid-community/core'; +import dayjs from 'dayjs'; +import PageContainer from '@/components/common/PageContainer'; +import SearchForm from '@/components/common/SearchForm'; +import DataGrid from '@/components/common/DataGrid'; +import PermissionButton from '@/components/common/PermissionButton'; +import { + carrierReconInApi, + CarrierReconInRow, + CarrierReconInSaveReq, + ReconciliationVarianceRow, + CarrierReconSearchParam, +} from '@/api/settle'; +import { GRAY, RADIUS, SHADOW } from '@/theme/tokens'; + +const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-'; + +const STATUS_OPTIONS = [ + { value: 'RECEIVED', label: '입수' }, + { value: 'MATCHED', label: '일치' }, + { value: 'VARIANCE', label: '차이' }, + { value: 'APPROVED', label: '승인' }, +]; + +const LIST_COLS: ColDef[] = [ + { field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' }, + { field: 'companyName', headerName: '보험사', width: 130 }, + { field: 'fileName', headerName: '파일명', flex: 1 }, + { field: 'recordCount', headerName: '건수', width: 80, type: 'numericColumn' }, + { field: 'reportedTotal', headerName: '보고합계', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, + { field: 'systemTotal', headerName: '시스템합계', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, + { field: 'varianceAmount', headerName: '차이금액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, + { field: 'status', headerName: '상태', width: 110 }, +]; + +const VARIANCE_COLS: ColDef[] = [ + { field: 'policyNo', headerName: '증권번호', width: 140 }, + { field: 'expectedAmount', headerName: '시스템금액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, + { field: 'reportedAmount', headerName: '보고금액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, + { field: 'varianceReason', headerName: '차이사유', flex: 1 }, +]; + +export default function CarrierReconIn() { + const qc = useQueryClient(); + const [searchParams, setSearchParams] = useState({ + settleMonth: dayjs().format('YYYYMM'), pageSize: 200, + }); + const [createOpen, setCreateOpen] = useState(false); + const [drawerTarget, setDrawerTarget] = useState(null); + const [form] = Form.useForm(); + + const { data: listData, isLoading, isError } = useQuery({ + queryKey: ['carrierReconIn', 'list', searchParams], + queryFn: () => carrierReconInApi.list(searchParams), + retry: false, + }); + + const { data: variances = [], isLoading: varLoading } = useQuery({ + queryKey: ['carrierReconIn', 'variances', drawerTarget?.reconId], + queryFn: () => carrierReconInApi.variances(drawerTarget!.reconId), + enabled: !!drawerTarget, + }); + + const rows = listData?.list ?? []; + + const createMutation = useMutation({ + mutationFn: (body: CarrierReconInSaveReq) => carrierReconInApi.create(body), + onSuccess: () => { + message.success('대사 등록 완료'); + qc.invalidateQueries({ queryKey: ['carrierReconIn', 'list'] }); + setCreateOpen(false); + form.resetFields(); + }, + }); + + const reconcileMutation = useMutation({ + mutationFn: (reconId: number) => carrierReconInApi.reconcile(reconId), + onSuccess: () => { + message.success('대사 실행 완료'); + qc.invalidateQueries({ queryKey: ['carrierReconIn', 'list'] }); + }, + }); + + const approveMutation = useMutation({ + mutationFn: (reconId: number) => carrierReconInApi.approve(reconId), + onSuccess: () => { + message.success('승인 완료'); + qc.invalidateQueries({ queryKey: ['carrierReconIn', 'list'] }); + }, + }); + + const handleCreate = async () => { + const values = await form.validateFields(); + createMutation.mutate(values); + }; + + const handleReconcile = (row: CarrierReconInRow) => Modal.confirm({ + title: '대사 실행', + content: `${row.companyName} / ${row.settleMonth} 대사를 실행하시겠습니까?`, + onOk: () => reconcileMutation.mutateAsync(row.reconId), + }); + + const handleApprove = (row: CarrierReconInRow) => Modal.confirm({ + title: '승인', + content: '해당 대사 결과를 승인하시겠습니까?', + onOk: () => approveMutation.mutateAsync(row.reconId), + }); + + const pinnedRow = rows.length > 0 ? { + companyName: '합계', + reportedTotal: rows.reduce((s, r) => s + (r.reportedTotal ?? 0), 0), + systemTotal: rows.reduce((s, r) => s + (r.systemTotal ?? 0), 0), + varianceAmount: rows.reduce((s, r) => s + (r.varianceAmount ?? 0), 0), + } as Partial : undefined; + + const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }; + + return ( + + {isError && ( + + )} + + setSearchParams({ ...v as CarrierReconSearchParam, pageSize: 200 })} + onReset={() => setSearchParams({ settleMonth: dayjs().format('YYYYMM'), pageSize: 200 })} + /> + + +
+ setCreateOpen(true)}> + + 대사 등록 + +
+ + rows={rows} + columns={[ + ...LIST_COLS, + { + headerName: '액션', width: 220, pinned: 'right', + cellRenderer: (p: { data: CarrierReconInRow }) => ( + + handleReconcile(p.data)} + > + 대사실행 + + handleApprove(p.data)} + > + 승인 + + setDrawerTarget(p.data)} + > + 차이내역 + + + ), + }, + ]} + loading={isLoading} + height={520} + rowKey="reconId" + pinnedBottomRow={pinnedRow} + /> +
+ + {/* 등록 모달 */} + { setCreateOpen(false); form.resetFields(); }} + okText="등록" + cancelText="취소" + confirmLoading={createMutation.isPending} + > +
+ + + + + + + + + + + + + + + +
+
+ + {/* 차이내역 Drawer */} + setDrawerTarget(null)} + width={560} + > + {drawerTarget && ( + <> + + {fmt(drawerTarget.reportedTotal)} + {fmt(drawerTarget.systemTotal)} + {fmt(drawerTarget.varianceAmount)} + {drawerTarget.status} + + + rows={variances} + columns={VARIANCE_COLS} + loading={varLoading} + height={380} + rowKey="varianceId" + /> + + )} + +
+ ); +} diff --git a/progress.txt b/progress.txt new file mode 100644 index 0000000..a354bc9 --- /dev/null +++ b/progress.txt @@ -0,0 +1,29 @@ +RALPH PROGRESS — GA 수수료 공식 갭 보완 + 8종 신규모듈 (세션 930c39aa, 2026-06-06) + +[BASELINE] +- 캐노니컬 레포 ga-commission-system, schema V115, ./gradlew build+test GREEN (47 test files). +- 수수료 공식 레퍼런스: docs/수수료_계산공식_전체레퍼런스.md (25+종 구현 확인). + +[갭 감사 — 5 에이전트 병렬] +- 확정 버그 3건(아래 GAP-1/2/3), 오탐 반려(BranchAllowance net=SQL이 이미 차감, Referral RATE우선=정상, scale-0환수=의도적). +- 개념적 빠진 모듈 8종 → 사용자 "전부 풀스택" 결정. + +[작업 로그] +- GAP-1/2/3 PL 직접수정+테스트 완료(:ga-batch:test/:ga-api:test GREEN). PRD passes=true. +- 8종 모듈 5-Wave 위임: + - Wave1 DBA: V116~V123 작성(dba-architect). 실명 검증(commission_rate/payout_rule, company_id, clawback WRITTEN_OFF 기존존재). + - Wave2 CORE: VO/Mapper/Enum 40+파일(core-architect). :ga-core 컴파일 GREEN. + - Wave3 API+BATCH 병렬: api 14파일(:ga-api GREEN), batch AggregateStep 11종집계+선지급상계+MOD-8채널요율. + **PL 버그수정**: 선지급 멱등 reverse가 no-op였음(reverseAdvanceRecoveryForAgent 미호출) → 월단위 selectAdvanceIdsBySettleMonth 추가 후 reverseAdvanceRecovery 정상 구현, dead code 제거. :ga-batch:test GREEN(CommissionCalculator/CalcRecruit/Scenario 무손상). + - Wave4 FRONTEND: 7페이지+7라우트+MOD-8 ContractList 채널필드(frontend-developer). tsc 0오류. +- Wave5: full ./gradlew build(test) GREEN. Flyway V116~V123 운영DB 적용 성공(schema v123). 라이브 스모크: 8 GET=200, /api/statute 시드노출, MOD-1 원장 RATE공식 실측 50000.00(룰권위), MOD-6 expire=200, MOD-7 create+검증=200 → 5xx 0건(테스트데이터 psql 정리). +- 적대적 리뷰(general-purpose) → BLOCKING 1건: MOD-8 채널요율이 계산기에 미연결(컬럼만 추가, RuleMapper SQL/CommissionCalculator 미반영=inert). 나머지(GAP-1/2/3, MOD-2 멱등, 정산무결성, 마이그레이션) CORRECT 판정. + **PL 수정**: RuleMapper.xml findCompanyRate/findPayoutRate에 `(sales_channel=#{x} OR sales_channel IS NULL) ORDER BY (sales_channel IS NULL) ASC`(findChargebackRate 패턴) + RuleMapper.java salesChannel param + CommissionCalculator c.getSalesChannel() 전달 + 테스트스텁 갱신 + recruit_passesSalesChannel 신규테스트(채널요율 실적용 40000 검증). full build GREEN. 재리뷰 진행중. +- 재리뷰 **APPROVED**(MOD-8 채널 NULL폴백 backward-compat·pass-through·무회귀 확인). deslop 클린(데드코드 1건 이미 제거, 디버그/TODO/console.log 0). post-deslop 회귀 build GREEN. +- ✅ PRD 11/11 passes. 미커밋(사용자 커밋/푸시 지시 대기). 마이그레이션 V116~V123 운영DB 적용됨. + +[학습/패턴] +- 9종 지급성 수수료는 *Mapper.xml aggregateByAgent에서 net을 SQL로 계산(award/life/settling=net_amount, branch=allowance-clawback, reinstatement=net_commission). VO에 net 컬럼 없어도 정상. +- 1200% 한도: BatchRegulatoryLimitChecker.checkContractTotal(TOTAL_RATIO)/checkFirstYear(FIRST_YEAR_RATIO) 둘 다 존재. resolveLimitKrw가 PERCENT를 연환산보험료×율/100 KRW로 환산. +- 분급: fullSchedule(비율합100%)이면 마지막 회차가 잔차 흡수. +- 풀스택 패턴: dba(V+메뉴+공통코드+권한) → core(VO/Mapper/Enum/MapStruct) → api(Service/Controller @RequirePermission/@DataChangeLog/ApiResponse/PageResponse/PageHelper) → batch(필요시 Step) → frontend(AG Grid/DataTable+api+App.tsx 라우트).