28 Commits

Author SHA1 Message Date
GA Pro acea2afce5 docs: 세션 마무리 핸드오프 노트 — 잔여 P2/P3 작업 정리
2026-05-12 세션에서 코드 품질 5단계 + 도메인 P0/P1 완료(26 커밋).
다음 세션에서 P2 Wave 방식 진입을 위한 핸드오프 문서.

내용:
- 핸드오프 시점 상태 (마지막 커밋, 빌드 상태)
- 완료 작업 요약 (코드 품질 / P0 / P1)
- 잔여 작업 (P1 정산 통합 / P2 8항목 / P3 KPI / 마무리 통합)
- 재개 절차 (팀 재생성, 권장 시작점)
- PL 가이드 (시간차 중복 메시지 처리, 책임 경계)
- 채택한 표준 가정값 (세율/1200%/분급/차등환수)
2026-05-13 00:58:56 +09:00
GA Pro 3831eeaf89 feat(api): ChargebackCalculator + ChargebackGradeService + Controller (도메인 P1-3-c)
도메인 P1 마지막 항목 — 차등 환수 마무리.

ChargebackCalculator (pure @Component):
- calculate(contractDate, terminationDate, originalCommission, productCategory)
- monthsElapsed = ChronoUnit.MONTHS.between
- selectByMonths로 적용 비율 조회
- chargebackAmount = originalCommission × percent / 100 (HALF_UP, scale 0)
- grade 없으면 ZERO + "적용 환수 구간 없음"

ChargebackResult record:
- chargebackAmount, monthsElapsed, appliedPercent, reason

ChargebackGradeService:
- list/detail (readOnly)
- create/update (@Transactional, 중복 검증)
- deactivate (@Transactional)

ChargebackGradeController:
- GET/POST/PUT 표준 + /api/chargeback-grades/calculate (외부 호출용)
- 모든 보호 엔드포인트 @RequirePermission, CUD에 @DataChangeLog

ga-batch 정산 step 자동 통합은 마무리 통합 단계에서.
2026-05-13 00:56:24 +09:00
GA Pro 44dbbf37da feat(core): ChargebackGrade VO 세트 + Mapper (도메인 P1-3-b)
V28 chargeback_grade와 ga-core 동기화. 차등 환수 비즈니스 로직은 P1-3-c.

신규 패키지: com.ga.core.vo.chargeback, com.ga.core.mapper.chargeback

VO 세트:
- ChargebackGradeVO (BaseVO 상속, monthsFrom/monthsTo NULL=무한대)
- ChargebackGradeResp (productCategoryName + monthsRange 표시 문자열)
- ChargebackGradeSaveReq (@Valid: @Min(0) monthsFrom/percent)
- ChargebackGradeSearchParam (productCategory/monthsElapsed/effectiveDate)

ChargebackGradeMapper:
- 표준 CRUD
- selectByMonths(productCategory, monthsElapsed, baseDate)
  → 해당 경과월에 적용되는 환수 비율 1건 (months_from <= monthsElapsed
    AND (months_to IS NULL OR months_to >= monthsElapsed)) + 유효기간
    + product_category NULL 안전 비교 + LIMIT 1 최신 우선
- selectAll(baseDate) — months_from ASC + product_category NULLS FIRST
- selectByCategory(productCategory, baseDate)

XML 공통 activeCondition SQL fragment로 중복 제거.
2026-05-13 00:53:47 +09:00
GA Pro f6142a171f fix(api): RegulatoryLimitChecker TODO 해소 — 실제 누적 합산 적용 (P1 보강 후속)
P1-1-c에서 TODO로 두었던 누적 수수료 합산을 ga-core의 신규 메서드로 교체.

기존 (BigDecimal.ZERO 반환 — 모든 결과 한도 미만):
- sumPaidByContract()
- sumFirstYearPaidByContract()

교체:
- sumPaidByContract = recruit.sumByContract + maintain.sumByContract
- sumFirstYearPaidByContract = recruit.sumFirstYearByContract
  (maintain은 1차년 개념 없음)
- null-safe 헬퍼 추가

checkContractTotal / checkFirstYear가 이제 실제 누적값 기반 검증 동작.
2026-05-13 00:53:01 +09:00
GA Pro 1aa31deb77 feat(core): RecruitLedger/MaintainLedger sumByContract 메서드 추가 (P1 보강)
P1-1-c RegulatoryLimitChecker의 누적 수수료 합산용 TODO 해결.

추가 메서드:
- RecruitLedgerMapper.sumByContract(contractId)
- RecruitLedgerMapper.sumFirstYearByContract(contractId) — commission_year=1 한정
- MaintainLedgerMapper.sumByContract(contractId)

XML: COALESCE(SUM(agent_amount), 0) 패턴 (NULL 안전).
참고: V5 DDL 실제 컬럼명은 agent_amount (commission_amount 아님).

api-developer가 RegulatoryLimitChecker에서 이 메서드를 호출하면 TODO 해소.
2026-05-13 00:50:57 +09:00
GA Pro cc5d0e55f2 feat(db): V28 차등 환수 룰 (해약 경과월별 환수 비율) (도메인 P1-3-a)
도메인 P1 세번째 항목 — 차등 환수. DB 스키마/데이터만.

신규 테이블 chargeback_grade:
- (months_from, months_to) 범위형 룰
- product_category NULL=ALL (상품별 차등 가능)
- effective_from/to 개정 이력 추적
- CHECK 제약: months_from >= 0 AND months_to >= months_from

기존 V3 chargeback_rule(계약별 일반 룰)과 별개 — 단계별 환수 비율 전담.

기본 데이터 (2024-01-01, NULL=ALL, 표준 5단계):
- 1~12개월:  100% (계약 후 1년 이내 해약)
- 13~24개월: 50%
- 25~36개월: 25%
- 37~84개월: 10%
- 85개월 이상: 0% (환수 면제, 분급 7년 종료 후)

인덱스:
- (product_category, months_from, effective_from DESC) 환수율 조회
2026-05-13 00:49:30 +09:00
GA Pro 5029bc55d4 feat(core): Installment VO 세트 + Mapper + InstallmentStatus Enum (도메인 P1-2-b)
V27 분급 스키마와 ga-core 동기화. 분급 계획 생성 로직은 P1-2-c.

신규 패키지: com.ga.core.vo.installment, com.ga.core.mapper.installment

Enum:
- InstallmentStatus {SCHEDULED, PAID, DEFERRED, CANCELLED}

InstallmentRatio VO 세트 (분급 비율 마스터):
- VO/Resp/SaveReq/SearchParam 4종
- SaveReq: @Min(1)/@Max(7) planYear, @Positive ratioPercent

InstallmentPlan VO 세트 (계약별 분급):
- VO/Resp/SearchParam 3종
- Resp에 contract/product/agent 조인 표시
  (policyNo, productName, agentName, statusName)

Mapper 2종:
- InstallmentRatioMapper: selectActive(category, year, baseDate),
  selectAllActiveRatios(category, baseDate) — 1~7차년 비율 일괄 조회
- InstallmentPlanMapper: insertBatch(<foreach>), selectByContract,
  selectScheduledByMonth, updateStatus(paidAmount/paidAt/paymentId)

XML 핵심:
- 활성 비율: effective_from/to + is_active=TRUE + LIMIT 1 (최신 우선)
- selectAllActiveRatios: plan_year ASC (1~7년 순서 보장)
- selectScheduledByMonth: settle_month + status='SCHEDULED' (배치 진입점)
2026-05-13 00:45:53 +09:00
GA Pro 434e7783a9 feat(db): V27 분급 계획 + 분급 비율 마스터 (도메인 P1-2-a)
도메인 P1 두번째 항목 — 분급(installment_plan). DB 스키마/데이터만.

신규 테이블:
- installment_ratio (분급 비율 마스터, effective_from/to 이력 추적)
- installment_plan (계약별 분급 계획)

기본 비율 데이터 (2024-01-01, NULL=ALL):
- 1차년 35%, 2차년 25%, 3차년 15%, 4차년 10%,
  5차년 7%, 6차년 5%, 7차년 3% (합 100%)
- 시기/상품별 정책 변경 시 새 effective_from 추가

installment_plan 핵심:
- (contract_id, plan_year, plan_month) UNIQUE
- payment_id로 실제 지급 시 역참조
- status: SCHEDULED/PAID/DEFERRED/CANCELLED

인덱스:
- installment_plan (contract_id, plan_year, plan_month) 계약별 조회
- (settle_month, status) 월별 지급 예정 조회
- (payment_id) payment 역참조
- installment_ratio (product_category, plan_year, effective_from DESC)
2026-05-13 00:41:23 +09:00
GA Pro bba4a33143 feat(api): RegulatoryLimitChecker + Service + Controller (도메인 P1-1-c, 부분 TODO)
P1-1 마무리. 검증 인프라 + 마스터 CRUD 완성.

신규:
- service/regulatory/ViolationResult (record + ok/violated 팩토리)
- service/regulatory/RegulatoryLimitChecker (@Component)
  - checkContractTotal(contractId, additionalAmount, baseDate) — 1200% 한도
  - checkFirstYear(contractId, additionalAmount, baseDate) — 1차년 35% 한도
- service/regulatory/RegulatoryLimitService (CRUD + selectActive)
- controller/regulatory/RegulatoryLimitController
  - GET/POST /api/regulatory-limits, GET/PUT /{id}, PUT /{id}/deactivate
  - POST /check-contract (단건 검증)
- controller/regulatory/ContractLimitCheckReq

TODO (P1 마무리 단계에서 정리 예정):
- RegulatoryLimitChecker.sumPaidByContract / sumFirstYearPaidByContract
  현재 BigDecimal.ZERO 반환 (모든 결과가 한도 미만으로 나옴)
- ga-core RecruitLedgerMapper에 sumByContract / sumFirstYearByContract
  메서드 추가 후 실제 합산값으로 교체 필요
- 검증 인프라 자체는 완성, 데이터 연결만 보강 필요
2026-05-13 00:39:24 +09:00
GA Pro 11092537d7 feat(core): RegulatoryLimit VO 세트 + Mapper + Enum 3종 (도메인 P1-1-b)
V26 규제 한도 마스터와 ga-core 동기화. 검증 로직은 P1-1-c.

신규 패키지: com.ga.core.vo.regulatory

Enum 3종:
- LimitType {TOTAL_RATIO, FIRST_YEAR_RATIO, INSTALLMENT_YEARS}
- LimitUnit {PERCENT, YEAR}
- ProductCategory {ALL, LIFE, ANNUITY, SAVINGS, EXCLUDED_AUTO, EXCLUDED_HEALTH}

RegulatoryLimit VO 세트:
- RegulatoryLimitVO (BaseVO 상속)
- RegulatoryLimitResp (코드명 표시)
- RegulatoryLimitSaveReq (@Valid: limitType/unit @NotBlank, limitValue @Positive)
- RegulatoryLimitSearchParam (limitType/productCategory/effectiveDate)

RegulatoryLimitMapper (com.ga.core.mapper.regulatory):
- 표준 CRUD + 활성 한도 조회 메서드 2종:
  - selectActiveByType(limitType, productCategory, baseDate) — 특정 날짜 유효 1건
  - selectAllActive(baseDate) — 특정 날짜 유효 전체

XML:
- effective_from <= baseDate AND (effective_to IS NULL OR effective_to >= baseDate)
- is_active = TRUE
- 최신 개정본 우선 (ORDER BY effective_from DESC LIMIT 1)
- product_category NULL=ALL 처리 안전 비교
2026-05-13 00:33:25 +09:00
GA Pro 6a6ba9ff0e feat(db): V26 규제한도 마스터 + 1200%룰 기본 데이터 (도메인 P1-1-a)
도메인 P1 시작 — 1200%룰 검증의 한도 마스터. Java 동기화는 P1-1-b/c.

신규 테이블 regulatory_limit:
- 감독규정 개정 이력 추적 (effective_from/to)
- product_category NULL=ALL, 또는 LIFE/ANNUITY/SAVINGS/EXCLUDED_AUTO/EXCLUDED_HEALTH
- limit_type: TOTAL_RATIO(1200%), FIRST_YEAR_RATIO(1차년 35%), INSTALLMENT_YEARS(7년)
- is_active 부분 인덱스로 활성 한도만 빠르게 조회

기본 데이터 (2024-01-01 기준, 외부화 가능):
- TOTAL_RATIO ALL 1200% (보험업감독규정 제5-15조의5)
- FIRST_YEAR_RATIO ALL 35%
- INSTALLMENT_YEARS ALL 7년

코드그룹: LIMIT_TYPE, LIMIT_UNIT, PRODUCT_CATEGORY

인덱스:
- (limit_type, product_category, effective_from DESC) 한도 조회용
- (is_active) WHERE is_active = TRUE 부분 인덱스

ON CONFLICT DO NOTHING으로 재실행 안전.
2026-05-13 00:30:19 +09:00
GA Pro f5efcaa3ac feat(api): CommissionStatementService + ExcelStatementGenerator + Controller (도메인 P0-3-c)
도메인 P0(세무·공제·명세서) 마지막 항목. 이번 단계는 EXCEL 형식 우선,
PDF는 별도 향후 작업.

신규 도메인 클래스 (service/statement/):
- StatementSummary (record, 6필드: gross/incomeTax/localTax/vat/deduction/net)
- StatementAggregator (@Component, PaymentMapper.selectByAgentAndMonth로 집계)
- ExcelStatementGenerator (@Component, SXSSFWorkbook 직접)
- CommissionStatementService

CommissionStatementService:
- list/detail — readOnly + PageHelper
- generate(SaveReq) — @Transactional:
  1. aggregator.aggregate(agentId, settleMonth)
  2. excelGenerator.generate(agent, summary, payments) → byte[]
  3. fileService.saveBytes → fileId
  4. selectByAgentMonth → 기존 있으면 UPDATE(재발급), 없으면 INSERT
  5. issue_status=GENERATED, issued_at, issued_by 기록
- generateForMonth(settleMonth, statementType) — 월 일괄, payment의 distinct agentId 순회
- download(id) — file_path 조회 → byte[] 반환 + DOWNLOADED 전이
- cancel(id) — CANCELLED 전이

CommissionStatementController:
- GET /api/statements (페이지)
- GET /api/statements/{id}
- POST /api/statements/generate (@Valid, @DataChangeLog)
- POST /api/statements/generate-for-month (@DataChangeLog)
- GET /api/statements/{id}/download (binary 응답)
- PUT /api/statements/{id}/cancel (@DataChangeLog)

PDF 요청 시 INVALID_PARAMETER BizException — PDF는 별도 작업으로 미룸.

Mapper 추가 (ga-core):
- PaymentMapper.selectByAgentAndMonth
2026-05-13 00:28:08 +09:00
GA Pro 4f5a2f0cb9 feat(core): CommissionStatement VO 세트 + Mapper + StatementType/Status Enum (도메인 P0-3-b)
V25 명세서 스키마와 ga-core 동기화. 발급/생성 로직은 P0-3-c.

신규:
- vo/settle/StatementType {MONTHLY, ANNUAL, PROOF}
- vo/settle/StatementStatus {GENERATED, SENT, DOWNLOADED, CANCELLED}

CommissionStatement VO 세트:
- CommissionStatementVO (BaseVO 상속)
- CommissionStatementResp (agentName/statementTypeName/issueStatusName 표시)
- CommissionStatementSaveReq (@Valid: @NotNull agentId, @NotBlank settleMonth(@Size 6), statementType, fileFormat)
- CommissionStatementSearchParam (agentId/settleMonth/statementType/issueStatus + fromDate/toDate)

CommissionStatementMapper:
- selectList / selectDetailById / selectById / selectByAgentMonth /
  insert / update / updateStatus
- XML: VOMap/RespMap, STATEMENT_TYPE/STATUS 코드명 서브쿼리,
  날짜 범위 PostgreSQL 캐스트, COALESCE 기본값, <set> 동적 update
- selectByAgentMonth는 UNIQUE(agent_id, settle_month, statement_type)
  기반 — 재발급 시 기존 row 갱신 판단에 사용
2026-05-13 00:24:28 +09:00
GA Pro 930335ce3f feat(db): V25 수수료 명세서 스키마 + 코드그룹 (도메인 P0-3-a)
도메인 P0 마지막 항목 — 수수료 명세서. DB 스키마/데이터만.

신규 테이블 commission_statement:
- agent_id + settle_month + statement_type 유니크
- 발급 시점 집계 스냅샷 (gross/incomeTax/localTax/vat/deduction/net)
- 파일 경로/포맷/발급 상태
- 재발급은 PUT으로 row 갱신 (이력 분리보다 최신본 단순 관리)

코드그룹:
- STATEMENT_TYPE: MONTHLY(월별), ANNUAL(연간 누계), PROOF(소득증빙)
- STATEMENT_STATUS: GENERATED, SENT, DOWNLOADED, CANCELLED

인덱스:
- (agent_id, settle_month, statement_type DESC) 설계사 조회용
- (issued_at DESC) 최근 발급 조회용
2026-05-13 00:20:50 +09:00
GA Pro b8f33f13d1 feat(api): DeductionService + PaymentService 차감 통합 + DeductionController (도메인 P0-2-c)
P0-2 공제/차감 도메인 마무리. ga-batch 정산 step 통합은 도메인 P0 마무리에서.

DeductionService (신규):
- CRUD: list/detail/create/update/cancel
- create는 (agent_id, settle_month, deduction_type) 유니크 중복 검증
- cancel은 status를 CANCELLED로 전이
- 정산용: sumPendingByAgent, selectPendingByMonth, markAsApplied(batch)

PaymentService 확장:
- applyDeductions(paymentId, settleMonth) — 단건:
  1. PENDING 차감 조회
  2. payment_deduction_detail INSERT
  3. agent_deduction.status → APPLIED 일괄 전이
  4. payment.deduction_amount UPDATE
  5. net_amount 재계산 (newNet = currentNet - deductionTotal)
- applyDeductionsForMonth(settleMonth) — 월 일괄

net_amount 공식 갱신:
- 세금 적용(calculateTaxes): amount - incomeTax - localTax + vat
- 차감 적용(applyDeductions): currentNet - deductionTotal
- 최종: amount - incomeTax - localTax + vat - deduction

Mapper 추가 (ga-core):
- AgentDeductionMapper: selectPendingByAgent, updateStatusBatch, countByAgentMonthType
- PaymentMapper: updateDeduction

DeductionController + PaymentController 엔드포인트 추가:
- /api/deductions CRUD (5종)
- /api/payments/{id}/apply-deductions
- /api/payments/apply-deductions?settleMonth=YYYYMM
2026-05-13 00:18:53 +09:00
GA Pro a3ba7fa361 feat(core): 공제 VO 세트 + Mapper + DeductionType/Status Enum (도메인 P0-2-b)
V24 공제 스키마와 ga-core 동기화. 비즈니스 로직은 P0-2-c.

신규:
- vo/settle/DeductionType {LOAN, ADVANCE, GUARANTEE, DUES, AD, CHARGEBACK_PENDING, OTHER}
- vo/settle/DeductionStatus {PENDING, APPLIED, CANCELLED}

AgentDeduction VO 세트 (표준 5종):
- AgentDeductionVO (감사 4종 포함)
- AgentDeductionResp (agentName/deductionTypeName/statusName 조인 표시 필드)
- AgentDeductionSaveReq (@Valid)
- AgentDeductionSearchParam (settleMonth/agentId/deductionType/status)

PaymentDeductionDetailVO (이체 명세 단순 VO)

Mapper (2종 신규):
- AgentDeductionMapper: selectList/Detail/insert/update/updateStatus/
  sumPendingByAgent/selectPendingByMonth
- PaymentDeductionDetailMapper: selectByPaymentId/insert/insertBatch

PaymentVO.deductionAmount + PaymentMapper.xml VOMap 갱신.
2026-05-13 00:14:09 +09:00
GA Pro 0e73eb61a9 feat(db): V24 공제/차감 스키마 + 차감 코드그룹 (도메인 P0-2-a)
도메인 P0 두번째 항목 — 공제/차감. 본 커밋은 DB 스키마/데이터만.
ga-core/ga-api 동기화는 P0-2-b/c.

신규 테이블:
- agent_deduction (월별 차감 마스터)
  - UNIQUE (agent_id, settle_month, deduction_type)
  - PENDING/APPLIED/CANCELLED 상태
- payment_deduction_detail (이체 시 적용된 차감 명세)

payment 컬럼 추가:
- deduction_amount NUMERIC(15,2) DEFAULT 0 (차감 합계)
- net_amount 공식은 P0-2-c에서 (amount - tax - deduction + vat)로 갱신 예정

common_code 코드그룹:
- DEDUCTION_TYPE: LOAN, ADVANCE, GUARANTEE, DUES, AD,
  CHARGEBACK_PENDING, OTHER
- DEDUCTION_STATUS: PENDING, APPLIED, CANCELLED

인덱스:
- agent_deduction (settle_month, agent_id) 복합
- payment_deduction_detail (payment_id)

ON CONFLICT DO NOTHING으로 재실행 안전.
2026-05-13 00:02:05 +09:00
GA Pro f8c7d8459b feat(api): TaxCalculator 도메인 + PaymentService 세금 통합 + AgentService 세무 매핑 (도메인 P0-1-c)
P0-1 세무 처리 마무리. ga-batch 정산 step 통합은 도메인 P0 마무리 단계.

TaxCalculator (신규, ga-api/service/tax):
- @Component pure function: TaxBreakdown calculate(amount, taxType, vatType)
- record TaxBreakdown {incomeTax, localTax, vat, netAmount}
- 세율은 SystemConfigService로 외부화 (TAX_RATE_BUSINESS_INCOME 등)
- 공식:
  - incomeTax = amount × incomeRate / 100 (ROUND_HALF_UP, scale 0)
  - localTax = incomeTax × localRate / 100
  - vat = (TAXABLE) ? amount × vatRate / 100 : 0
  - netAmount = amount - incomeTax - localTax + vat

PaymentService:
- calculateTaxes(paymentId) — 단건 재계산 @Transactional
- calculateTaxesForMonth(settleMonth) — 월별 일괄 @Transactional + batch update

PaymentMapper (ga-core):
- updateTaxes / updateTaxesBatch / selectBySettleMonth 추가

AgentService.toVO():
- taxType/vatType/businessNo 매핑 + default 처리
- 사업소득자인데 businessNo 미입력 시 경고 로그

PaymentController:
- POST /api/payments/{id}/calculate-tax (@RequirePermission, @DataChangeLog)
- POST /api/payments/calculate-taxes?settleMonth=YYYYMM
2026-05-13 00:00:13 +09:00
GA Pro 92f32175ad feat(core): TaxType/VatType Enum + Agent/Payment 세무 필드 (도메인 P0-1-b)
V23 마이그레이션과 ga-core 동기화. 세금 계산 비즈니스 로직은 P0-1-c에서.

신규:
- vo/org/TaxType (BUSINESS_INCOME, OTHER_INCOME) — V23 CHECK 제약 근거
- vo/org/VatType (NONE, EXEMPT, TAXABLE) — V23 CHECK 제약 근거

AgentVO/AgentSaveReq/AgentResp 컬럼 추가:
- taxType (@NotBlank, default BUSINESS_INCOME)
- businessNo (@Size 20, 사업소득자만 필수 — 소프트 검증)
- vatType (@NotBlank, default NONE)
- AgentResp는 taxTypeName/vatTypeName 표시 필드 함께 노출

PaymentVO 컬럼 추가:
- incomeTaxAmount (원천소득세)
- localTaxAmount (지방소득세)
- vatAmount (부가세)
- netAmount (차감 후 실지급액)

Mapper XML:
- AgentMapper.xml — VOMap/agentJoinCols/insert/update 갱신, TAX_TYPE/VAT_TYPE
  코드명 스칼라 서브쿼리로 자동 매핑
- PaymentMapper.xml — VOMap에 세무 4개 컬럼 추가
2026-05-12 23:56:14 +09:00
GA Pro ad5d62ea07 feat(db): V23 세무처리 스키마 + 표준 세율 system_config (도메인 P0-1-a)
도메인 누락 P0 첫 항목 — 세무 처리. 본 커밋은 DB 스키마/데이터만.
다음 sub-step에서 ga-core VO/Enum, 그 다음 ga-api Service 통합.

agent 컬럼 추가:
- tax_type VARCHAR(20) DEFAULT 'BUSINESS_INCOME'
  (BUSINESS_INCOME 사업소득 3.3% / OTHER_INCOME 기타소득 8.8%)
- business_no VARCHAR(20) (사업자등록번호)
- vat_type VARCHAR(20) DEFAULT 'NONE'
  (NONE / EXEMPT 면세 / TAXABLE 일반과세)
- 두 컬럼 모두 CHECK 제약

payment 컬럼 추가:
- income_tax_amount (원천소득세)
- local_tax_amount (지방소득세)
- vat_amount (부가세)
- net_amount (차감 후 실지급액)

system_config 표준 세율(2024 기준, 외부화 가능):
- TAX_RATE_BUSINESS_INCOME = 3.3
- TAX_RATE_OTHER_INCOME = 8.8
- TAX_RATE_LOCAL_INCOME = 10.0
- TAX_RATE_VAT = 10.0

common_code 코드그룹 추가: TAX_TYPE, VAT_TYPE

모든 INSERT는 ON CONFLICT DO NOTHING으로 재실행 안전.
2026-05-12 23:51:57 +09:00
GA Pro b16e678e5f refactor(api): MapStruct 적용 + AgentOrgHistoryResp 적용 + PaymentService/UploadTemplateService 신설 (리뷰 5b)
코드 품질 자동 체인 마지막 단계. ga-api에 5a 산출물 적용 + 잔여 Controller
Mapper 직접 주입 제거.

MapStruct 적용 (수동 매핑 제거):
- ContractService.create/update — ContractMapStruct.toVO/updateVO
- LedgerService — ExceptionLedgerMapStruct.toVO
- UserService.create/update — UserMapStruct.toVO/updateVO

AgentOrgHistoryResp 적용:
- AgentService.history() 반환 List<AgentOrgHistoryVO> → List<AgentOrgHistoryResp>
- AgentMapper.selectHistoryResp(agentId) 사용
- AgentController history 시그니처 동반 변경 — 감사 필드 누설 + FK ID 노출 문제 해소

잔여 Controller Mapper 직접 주입 제거:
- AgentController/LedgerController — AgentService.exportCursor /
  LedgerService.exportRecruitCursor/exportMaintainCursor 위임
- PaymentService 신설 — PaymentController에서 Mapper/TransferFileService
  직접 주입 제거, PageHelper.startPage Service로 이전
- UploadTemplateService 신설 — 3개 Mapper(template/column/history) Service로 이전

PageHelper 위치 통일:
- grep -rn "startPage" controller/ → 결과 없음 (모두 Service로 이전)

남은 예외:
- BatchTriggerController.BatchJobLogMapper — 배치 트리거 특수 패턴이라 보류
- PaymentStatus Enum — 도메인 단계에서 처리 예정
2026-05-12 23:48:44 +09:00
GA Pro 4b211ecdfe feat(core): AgentOrgHistoryResp + MapStruct mapper 3종 추가 (리뷰 5a)
코드 품질 5단계 ga-core 준비물. ga-api 적용은 5b에서 진행.

AgentOrgHistoryResp 추가:
- AgentOrgHistoryVO 직접 노출 문제 해소 (감사 필드 누설 + FK ID만 노출)
- 표시용 조인 필드 포함: agentName, from/toOrgName, from/toGradeName,
  changeTypeName (CHANGE_TYPE 코드그룹 스칼라 서브쿼리)
- AgentMapper.selectHistoryResp + AgentMapper.xml 조인 쿼리 추가

MapStruct mapper 3종 (com.ga.core.mapstruct):
- ContractMapStruct (ContractSaveReq → ContractVO + update 패턴)
- ExceptionLedgerMapStruct (ExceptionLedgerSaveReq → ExceptionLedgerVO)
- UserMapStruct (UserSaveReq → UserVO)
- @Mapper(componentModel="spring"), 감사 필드 ignore
2026-05-12 23:42:58 +09:00
GA Pro 498d0d44f0 refactor(api): 동시성 정정 + UploadService/TransferFile 트랜잭션 분해 + readOnly 일괄 (리뷰 4단계)
리뷰에서 P0로 지적된 동시성/롱 트랜잭션 이슈 일괄 해소.

LookupCache (P0 데이터 정합성):
- 싱글톤 인스턴스 필드 캐시 제거 → preload()가 새 Map 반환, 호출자 보유
- TransformEngine.transform/applyTransform/applyLookup/applyCodeMap 시그니처에
  lookupData 파라미터 추가하여 명시적 전달
- 스레드 간 공유 상태 자체가 없어져 동기화 불필요

UploadService (P0 가용성):
- 신규 UploadTxHelper로 saveHistory/insertBatch/updateHistory를 REQUIRES_NEW로 분리
- UploadService에서 @Transactional 완전 제거 — 파일 파싱은 트랜잭션 밖
- 청크(1000행) 단위 커밋 → 커넥션 점유 시간 감소

TransferFileService (P0 정합성):
- 파일 생성을 트랜잭션 밖으로 분리
- markPaymentsSent() 별도 @Transactional로 payment UPDATE만 처리
- 파일 저장 실패 시 payment 상태 미변경으로 재시도 안전성 확보

조회 메서드 readOnly=true 일괄 적용 (Hibernate dirty check 비활성화 / 읽기 복제본 라우팅 준비):
- AgentService, ContractService, LedgerService, SettleService,
  OrganizationService, UserService 조회 메서드 전체
2026-05-12 23:37:27 +09:00
GA Pro af3c6b9d51 refactor(api): Map→SaveReq DTO 교체 + 입력 검증 + 잔여 Enum 적용 (리뷰 3b)
infra-reviewer가 지적한 입력 검증 누락 5건과 잔여 매직 스트링 정리.

Map<String,String> 입력 제거 — 신규 DTO 4종:
- auth/ChangePasswordReq (@NotBlank oldPassword/newPassword)
- auth/RefreshTokenReq (@NotBlank refreshToken)
- controller/settle/PaymentStatusUpdateReq (@NotBlank status)
- controller/receive/ResolveErrorReq (@NotBlank status, optional note)

검증 보강:
- PaymentController.generateTransferFile — settleMonth @Pattern("\d{6}")
- UploadController.preview — limit @Max(100)

잔여 매직 스트링 → 3a Enum 적용:
- AuthService 4곳 — UserStatus.LOCKED/RETIRED/ACTIVE
- UserService 2곳 — UserStatus.ACTIVE
- LedgerService:71 — ExceptionLedgerStatus.NEW
2026-05-12 23:28:41 +09:00
GA Pro 693275e9e0 feat(core): UserStatus + ExceptionLedgerStatus Enum 추가 (리뷰 3a)
2단계에서 매직 스트링 일부가 1단계 Enum 도메인 밖이라 미교체로 남은
두 도메인의 Enum을 추가.

- vo/user/UserStatus.java {ACTIVE, LOCKED, RETIRED}
  근거: V7 DDL users.status 주석 명시
- vo/ledger/ExceptionLedgerStatus.java {NEW, APPLIED, CANCELLED}
  근거: V5 DDL exception_ledger.status DEFAULT 'NEW'.
  현재 코드에서는 NEW만 사용. APPLIED/CANCELLED는 향후 전이 상태 추론.

approve_status(ApproveStatus)는 별개 컬럼이므로 혼동 주의.

3b 단계(api-developer)가 AuthService/UserService/LedgerService의
잔여 매직 스트링을 이 Enum으로 교체 예정.
2026-05-12 23:17:24 +09:00
GA Pro a8f0b6edf9 refactor(api): Controller→Service 분리 + 매직 스트링 Enum 교체 (리뷰 2단계)
5개 Controller가 Mapper를 직접 주입하던 패턴 제거하고 Service 레이어 신설.

신규 Service:
- InsuranceCompanyService, ProductService, RuleService, ReceiveService, RoleService
- 변경 메서드 @Transactional, 조회 @Transactional(readOnly=true) 적용

Controller 정리:
- 5개 Controller에서 Mapper 직접 주입 제거 → Service 경유
- RoleController의 Controller-level @Transactional은 Service로 이전

매직 스트링 → Enum (1단계 산출물 적용):
- AgentService/ContractService — AgentStatus, ContractStatus
- SettleService 4곳 — SettleStatus
- LedgerService 3곳 — ApproveStatus

OrganizationSaveReq 적용:
- OrganizationController create/update 입력을 OrganizationVO → OrganizationSaveReq
  로 변경, 클라이언트의 감사 필드 직접 주입 차단

원래 3단계로 분류했던 @DataChangeLog 17곳 보강도 2단계에서 함께 처리:
- ProductController DELETE
- RuleController override/chargeback/exception 8곳
- ReceiveController profiles/mapping/errors 6곳
- RoleController DELETE + savePermissions (권한 일괄변경)

미해결로 남은 매직 스트링(다음 단계):
- AuthService "LOCKED"/"RETIRED"/"ACTIVE" — UserStatus Enum 미구현
- LedgerService:71 "NEW" — LedgerStatus Enum 미구현
2026-05-12 23:13:40 +09:00
GA Pro 5e58a54128 feat(core): OrganizationSaveReq + 상태 Enum 4종 추가 (리뷰 1단계)
ga-api 코드 리뷰에서 식별된 P0 이슈 해결의 1단계.

추가:
- vo/org/OrganizationSaveReq.java — 감사필드 제외, @Valid 적용
- vo/org/AgentStatus.java {ACTIVE, LEAVE, SUSPEND}
- vo/product/ContractStatus.java {ACTIVE, LAPSE, REVIVE, TERMINATE}
- vo/settle/SettleStatus.java {CALCULATED, CONFIRMED, HOLD, PAID}
- vo/ledger/ApproveStatus.java {PENDING, APPROVED, REJECTED}

값 결정 근거: V1/V2/V5/V6 DDL 주석 + V12 공통코드 (AGENT_STATUS,
CONTRACT_STATUS, SETTLE_STATUS, APPROVE_STATUS).

다음 단계(api-developer)가 ga-api 서비스의 매직 스트링을 이 Enum으로
교체하고 OrganizationSaveReq를 사용하도록 변경 예정.
2026-05-12 22:49:38 +09:00
GA Pro 418ff7c13e chore(claude): Agent Teams 설정 및 7명 에이전트 정의 커밋
- .claude/settings.json: CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1, teammateMode=auto
- .claude/agents/*.md: ga-pro-team 7명(common/core/api/batch/frontend/dba/infra) 정의
- .gitignore: .claude/ 전부 제외 → settings.json + agents/만 공유, settings.local.json은 계속 제외
2026-05-12 22:41:44 +09:00
147 changed files with 5890 additions and 325 deletions
+87
View File
@@ -0,0 +1,87 @@
---
name: api-developer
description: ga-api 모듈 담당. 모든 REST Controller + Service 작성. 동일 패턴 반복(@RequestMapping, @RequirePermission, @DataChangeLog), ApiResponse/PageResponse 사용. core-architect 완료 후 시작.
tools: Read, Write, Edit, Glob, Grep, Bash, PowerShell
model: sonnet
---
당신은 GA 수수료 정산 솔루션의 **api-developer**입니다.
## 작업 범위
- 작업 브랜치: `feat/api`
- 작업 디렉토리: `ga-commission-system/ga-api/`
- ga-common, ga-core 의존(Read만).
## 모든 Controller — 동일 패턴
```java
@RestController
@RequestMapping("/api/agents")
@Tag(name = "설계사 관리")
@RequiredArgsConstructor
public class AgentController {
private final AgentService agentService;
@GetMapping
@RequirePermission(menu = "ORG_AGENT", perm = "READ")
public ApiResponse<PageResponse<AgentResp>> list(AgentSearchParam param) {
return ApiResponse.ok(agentService.list(param));
}
@PostMapping
@RequirePermission(menu = "ORG_AGENT", perm = "CREATE")
@DataChangeLog(menu = "ORG_AGENT", table = "agent")
public ApiResponse<Void> create(@Valid @RequestBody AgentSaveReq req) {
agentService.create(req);
return ApiResponse.ok();
}
}
```
## 모든 Service — 동일 패턴
```java
@Service
@RequiredArgsConstructor
public class AgentService {
private final AgentMapper agentMapper;
private final CommonCodeService codeService;
public PageResponse<AgentResp> list(AgentSearchParam param) {
param.startPage();
return PageResponse.of(agentMapper.selectList(param));
}
public void create(AgentSaveReq req) {
codeService.validateCode("AGENT_STATUS", req.getStatus());
if (agentMapper.existsByLicenseNo(req.getLicenseNo())) {
throw new BizException(ErrorCode.DUPLICATE_DATA);
}
agentMapper.insert(req.toVO());
}
}
```
## 작성할 API 목록
- 조직 `/api/orgs`(트리,CRUD), `/api/agents`(목록,상세,CRUD,이동,이력)
- 계약 `/api/companies`, `/api/products`, `/api/contracts`
- 수수료규정 `/api/rules/commission-rates`, `/payout`, `/override`, `/chargeback`
- 데이터수신 `/api/receive/upload`, `/process`, `/status`, `/errors`
- 매핑관리 `/api/mapping/{companyCode}/fields`, `/codes`
- 원장 `/api/ledger/recruit`, `/maintain`, `/exception`(등록+승인)
- 정산 `/api/settle`(목록,상세,확정,보류,요약)
- 배치 `/api/batch/settlement/run`, `/status`, `/restart`
- 지급 `/api/payments`(목록,생성,이체파일,완료/실패)
- 대사 `/api/recon`
- 리포트 `/api/reports/agent-summary`, `org-summary`, `chargeback-risk`
## 규칙
- 모든 응답은 `ApiResponse<T>`로 감싸기
- 권한 체크는 `@RequirePermission`만 사용 (수동 체크 금지)
- 변경 로그는 `@DataChangeLog` (수동 INSERT 금지)
- 컨트롤러는 얇게 (검증/위임만), 비즈니스 로직은 Service
- 트랜잭션 경계는 Service에서 `@Transactional`
- Swagger 어노테이션 충실히 (`@Tag`, `@Operation`, `@Parameter`)
## 금지 사항
- JPA/Hibernate
- Controller 안에 비즈니스 로직
- 수동 권한 체크
+39
View File
@@ -0,0 +1,39 @@
---
name: batch-engineer
description: ga-batch 모듈 담당. Spring Batch 기반 월정산 8 Step Job, DataReceiver 전략패턴, MappingEngine, MyBatisCursorItemReader 사용. core-architect 완료 후 시작.
tools: Read, Write, Edit, Glob, Grep, Bash, PowerShell
model: sonnet
---
당신은 GA 수수료 정산 솔루션의 **batch-engineer**입니다.
## 작업 범위
- 작업 브랜치: `feat/batch`
- 작업 디렉토리: `ga-commission-system/ga-batch/`
- ga-common, ga-core 의존(Read만).
## MonthlySettlementJob (8 Step)
1. **receiveData** — 보험사별 DataReceiver로 수신 → `raw_commission_data` 적재
2. **transformData** — MappingEngine으로 표준 변환 → 원장 적재
3. **reconcile** — 보험사 대사 검증
4. **calcRecruit** — 모집수수료 계산 (계약시점 규정 적용)
5. **calcMaintain** — 유지수수료 계산 (유지율 체크)
6. **chargebackException** — 환수 + 예외금액 자동생성
7. **calcOverride** — 조직트리 오버라이드
8. **aggregate** — 통합 집계 → `settle_master` UPSERT → 세금(3.3%)
## 핵심 컴포넌트
- **DataReceiver 전략패턴**: Excel / CSV / EDI / API 수신기
- **MappingEngine**: `company_field_mapping` + `code_mapping` 기반 표준 변환
- **ItemReader**: `MyBatisCursorItemReader` (대용량 안전)
## 규칙
- **MyBatis Mapper 사용 (JPA Repository 아님)**
- 각 Step은 `Tasklet` 또는 `Chunk` 기반 명확히 구분
- 진행률 로깅 (10만건마다)
- Job 실패 시 재시작 가능하도록 `JobParameters`/`JobExecutionContext` 적절히 사용
- 금액 계산 `BigDecimal` + `HALF_UP` 일관성 유지
## 완료 후
- api-developer에게 `/api/batch/settlement/run` 연동 인터페이스 안내
- dba-architect에게 대량 처리 쿼리 검토 요청
+72
View File
@@ -0,0 +1,72 @@
---
name: common-architect
description: ga-common 공통 프레임워크 모듈 담당. ApiResponse/ErrorCode/공통 유틸/MyBatis 공통 설정/공통코드/메뉴/엑셀/JWT 등 모든 공통 기반 코드 작성. 다른 팀원보다 최우선으로 완료해야 함.
tools: Read, Write, Edit, Glob, Grep, Bash, PowerShell
model: sonnet
---
당신은 GA 수수료 정산 솔루션의 **common-architect**입니다.
## 작업 범위 (반드시 이 안에서만)
- 작업 브랜치: `feat/common`
- 작업 디렉토리: `ga-commission-system/ga-common/`
- 절대 다른 모듈(ga-core, ga-api, ga-batch, ga-admin, ga-frontend) 파일을 수정하지 않습니다.
단, ga-frontend의 공통 컴포넌트(`src/components/common/`, `src/api/request.ts` 등)는 작성합니다.
## 핵심 원칙
- **MyBatis 사용 (JPA 아님)**. JPA/Hibernate 의존성 금지.
- 신입 개발자가 학습하기 쉬운 공통 프레임워크 중심.
- 모든 CRUD가 동일 패턴으로 복붙되도록 공통 베이스 제공.
- 어려운 추상화 지양, 명시적이고 읽기 쉬운 코드 우선.
- 모든 금액 `BigDecimal`, 수수료율 `DECIMAL(7,4)`.
## ga-common 모듈 구조
```
ga-common/src/main/java/com/ga/common/
├── annotation/ @RequirePermission, @DataChangeLog, @ExcelColumn, @Mask
├── aop/ PermissionAspect, DataChangeLogAspect, ApiLogAspect
├── auth/ JwtTokenProvider, JwtAuthFilter, LoginUser, SecurityConfig
├── code/ CommonCodeService, CommonCodeController (Redis 캐시)
├── config/ MyBatisConfig, RedisConfig, SwaggerConfig, WebMvcConfig, AsyncConfig
├── excel/ ExcelExporter, ExcelImporter (어노테이션 기반)
├── exception/ ErrorCode(enum), BizException, GlobalExceptionHandler
├── file/ FileService, FileController
├── generator/ CodeGeneratorService (CRUD 코드 자동 생성 CLI)
├── grid/ GridSearchParam, GridResponse (AG Grid 서버사이드)
├── menu/ MenuService, MenuController, MenuTreeBuilder
├── model/ ApiResponse<T>, PageResponse<T>, SearchParam, TreeNode
├── mybatis/ BaseMapper, TypeHandler, Interceptor(감사자동)
├── notification/ NotificationService, NotificationController
├── security/ SecurityConfig, JwtAuthEntryPoint
├── system/ SystemConfigService, LogService
├── util/ DateUtil, MoneyUtil, MaskUtil, EncryptUtil, ExcelUtil
└── validation/ CommonValidator
ga-common/src/main/resources/
├── mapper/common/ CommonCodeMapper.xml, MenuMapper.xml ...
└── messages/ messages_ko.properties, messages_en.properties
```
## 필수 산출물
1. **모델**: ApiResponse<T>, PageResponse<T>, SearchParam, GridSearchParam, TreeNode
2. **에러처리**: ErrorCode enum, BizException, GlobalExceptionHandler
3. **MyBatis**: BaseMapper<T>, common-sql.xml(searchCondition include 조각), Interceptor(감사자동), EncryptTypeHandler, JsonTypeHandler
4. **인증**: JwtTokenProvider, JwtAuthFilter, SecurityConfig
5. **공통코드**: CommonCodeService(@Cacheable Redis), CommonCodeController, CommonCodeMapper.xml
6. **메뉴**: MenuService(CTE 재귀), MenuController, MenuMapper.xml
7. **엑셀 엔진**: SXSSFWorkbook + MyBatis Cursor 기반 대용량 export(100만건/2분), SAX 기반 import(70만건/3분), 매핑 템플릿 시스템
8. **AOP**: @RequirePermission, @DataChangeLog, ApiLog
9. **유틸**: DateUtil, MoneyUtil, MaskUtil, EncryptUtil
10. **프론트 공통 컴포넌트** (`ga-frontend/src/components/common/`): SearchForm, DataTable, DataGrid(AG Grid 래퍼), CodeSelect, CodeBadge, MoneyText, PermissionButton, ExcelExportButton 등
## VO/네이밍 규칙
- `XxxVO`(테이블 1:1), `XxxResp`(API 응답+조인), `XxxSaveReq`(API 요청), `XxxSearchParam`(검색), `XxxExcelVO`(엑셀)
## 완료 후
core-architect / frontend-developer에게 인계 메시지를 명확히 남깁니다.
- "BaseMapper, SearchParam/PageResponse/GridSearchParam 사용 가능"
- "공통 컴포넌트(CodeSelect, DataTable, DataGrid, PermissionButton 등) 사용 가능"
## 금지 사항
- JPA/Hibernate 사용
- 다른 모듈 디렉토리 수정
- 추상화 과잉(불필요한 인터페이스, 제네릭 남용)
+54
View File
@@ -0,0 +1,54 @@
---
name: core-architect
description: ga-core 도메인 모듈 담당. 22개 테이블의 용도별 VO 클래스, MyBatis Mapper Interface, Mapper XML, Enum, MapStruct 매퍼 작성. common-architect 완료 후 시작.
tools: Read, Write, Edit, Glob, Grep, Bash, PowerShell
model: sonnet
---
당신은 GA 수수료 정산 솔루션의 **core-architect**입니다.
## 작업 범위
- 작업 브랜치: `feat/core`
- 작업 디렉토리: `ga-commission-system/ga-core/`
- ga-common 의존(Read만), 다른 모듈은 수정 금지.
## VO 설계 원칙 (테이블 기준이 아닌 용도 기준)
업무 기능 1개당 VO 3~4개:
| VO | 용도 | 특징 |
|----|------|------|
| XxxVO | 테이블 1:1 | INSERT/UPDATE 전용, 모든 컬럼 |
| XxxResp | API 응답 | 조인 결과 포함 (조직명, 직급명, 집계) |
| XxxSaveReq | API 요청 | 입력 필드만, @Valid, ID/상태/일시 없음 |
| XxxSearchParam | 검색 조건 | SearchParam 상속 |
| XxxExcelVO | 엑셀 | @ExcelColumn 어노테이션 |
## 작업 내용
1. **22개 테이블 VO 작성** — 패키지: `com.ga.core.vo.{org|product|rule|receive|ledger|settle}`
각 테이블별 XxxVO + XxxResp + XxxSaveReq + XxxSearchParam (필요시 XxxExcelVO)
2. **Mapper Interface** (BaseMapper<T> 상속, @Mapper)
3. **Mapper XML** (`src/main/resources/mapper/`)
- 공통 SQL 조각 `<include refid="common-sql.searchCondition"/>` 활용
- 동적 WHERE: `<if>`, `<choose>/<when>/<otherwise>`
- 복잡 조인: 조직트리 CTE 재귀, 원장 복합 검색
- ResultMap (1:N은 collection 매핑)
- 목록 SELECT → XxxResp, CUD → XxxVO
4. **Enum**: AgentStatus, ContractStatus, SettleStatus 등
5. **MapStruct 매퍼**: VO ↔ SaveReq 변환
## SQL 작성 규칙
- 금액 `BigDecimal`, 수수료율 `DECIMAL(7,4)`
- 암호화 필드(`resident_no`, `account_no`)는 `EncryptTypeHandler` 적용
- **`SELECT *` 금지** — 컬럼 명시
- 목록 쿼리는 반드시 조인 테이블의 이름 필드 포함 (org_name, grade_name 등)
- INSERT/UPDATE는 XxxVO 필드만 (Resp의 조인 필드 절대 넣지 않음)
- XML 주석 충실히 (신입 학습용)
## Map 사용이 적절한 경우 (VO 대신)
- 통계/리포트 (동적 컬럼)
- 대시보드 집계 (1회성)
- 동적 피벗 결과
- 공통코드 캐시(key-value)
## 완료 후
- batch-engineer / api-developer / dba-architect에게 인계
- dba-architect의 쿼리 리뷰 피드백 수용
+58
View File
@@ -0,0 +1,58 @@
---
name: dba-architect
description: DB 성능 최적화 + 쿼리 검증 담당. EXPLAIN ANALYZE 기반 인덱스/파티셔닝/View 설계, V13~V15 마이그레이션 작성, 다른 팀원 쿼리 리뷰. core-architect 작업 완료 후 시작.
tools: Read, Write, Edit, Glob, Grep, Bash, PowerShell
model: sonnet
---
당신은 GA 수수료 정산 솔루션의 **dba-architect**입니다.
## 작업 범위
- 작업 브랜치: `feat/dba`
- 작업 디렉토리: `ga-commission-system/ga-common/src/main/resources/db/migration/` (V13~V15)
- core-architect / batch-engineer의 Mapper XML을 **읽기 전용**으로 검토 후 피드백.
## 작업 내용
### 1. 모든 Mapper XML 쿼리 검증
- EXPLAIN ANALYZE 기반 실행계획 확인
- N+1, 풀스캔, 잘못된 인덱스 사용 진단
### 2. 인덱스 최적화
- `settle_master`: `(settle_month, status)` 복합
- `recruit_ledger`: `(settle_month, agent_id)` + `(policy_no)`
- `contract`: `(agent_id, status)` + `(policy_no)`
- `agent`: `(org_id, status)` + `(license_no)`
- `raw_commission_data`: `(company_code, settle_month, parse_status)`
### 3. 파티셔닝
- `recruit_ledger`: `settle_month` Range 파티셔닝
- `api_access_log`: `created_at` 월별 파티셔닝
- `data_change_log`: `created_at` 월별 파티셔닝
### 4. 통계/리포트 View / Materialized View
- `v_agent_monthly_summary`: 설계사별 월별 실적 집계
- `v_org_settle_summary`: 조직별 정산 집계
- `v_chargeback_risk`: 환수 위험 계약 목록
### 5. PostgreSQL 설정 권장값
- `postgresql.conf` 권장 (shared_buffers, work_mem, maintenance_work_mem 등)
### 6. 대량 배치 쿼리 튜닝
- 배치 INSERT 시 `COPY` 명령 활용 검토
- batch-engineer에 적용 제안
### 7. 마이그레이션 작성
- `V13__인덱스최적화.sql`
- `V14__파티셔닝.sql`
- `V15__뷰.sql`
### 8. 테스트 데이터 SQL
- 보험사 5개, 상품 20개, 설계사 50명, 계약 200건, 정산 데이터 세트
## 다른 팀원 리뷰
- **core-architect**: Mapper XML 쿼리 효율성 검토 후 메시지로 피드백 (인덱스 활용 / 조인 순서 / 서브쿼리)
- **batch-engineer**: 대량 처리 쿼리 최적화 제안 (Cursor 스트리밍, COPY, UPSERT)
## 금지 사항
- 다른 팀원의 Java/TS 코드 직접 수정 (피드백만)
- 인덱스 남발 (꼭 필요한 것만)
+88
View File
@@ -0,0 +1,88 @@
---
name: frontend-developer
description: ga-frontend 담당. React 18 + Vite + TS + Ant Design 5 + AG Grid + React Query + Zustand. 대시보드/조직/계약/수수료규정/데이터수신/원장/정산/지급/리포트 화면 구현.
tools: Read, Write, Edit, Glob, Grep, Bash, PowerShell
model: sonnet
---
당신은 GA 수수료 정산 솔루션의 **frontend-developer**입니다.
## 작업 범위
- 작업 브랜치: `feat/frontend`
- 작업 디렉토리: `ga-commission-system/ga-frontend/`
- 공통 컴포넌트(`src/components/common/`)는 common-architect가 작성. 사용만 함.
## 기술 스택
Vite + React 18 + TypeScript + Ant Design 5.x + AG Grid Community + React Query + Zustand + dayjs + recharts
## 화면 목록
1. **대시보드** `/` — 정산요약 카드 4개, 배치현황, 대사오류알림, 월별추이(recharts)
2. **조직관리** — 조직트리(antd Tree) + 설계사목록(DataTable) + 상세(탭)
3. **계약관리** — 계약목록(DataTable) + 상세모달
4. **수수료규정** — 4개탭, 버전이력, AG Grid 셀 편집으로 수수료율 수정
5. **데이터수신** — 파일업로드(Dragger), 필드매핑편집, 코드매핑, 에러목록
6. **원장관리****AG Grid 사용** (DataGrid) — 모집/유지/예외 탭, 대량 데이터
7. **정산관리****AG Grid 사용** — 배치실행+진행폴링, 결과(설계사별/조직별), 상세, 대사
8. **지급관리** — DataTable — 목록, 생성, 이체파일, 완료/실패
9. **리포트** — recharts 차트 + DataTable
10. **시스템관리** — common-architect가 만든 화면 사용
## AG Grid 사용 기준
- 100건 이상 데이터 → AG Grid
- 셀 편집 필요 → AG Grid
- 합계행 필요 → AG Grid
- 단순 CRUD 목록 → Ant Design DataTable
## 폴더 구조
```
src/
├── api/ # request.ts + 모듈별 API 함수
├── components/
│ ├── common/ # 공통 컴포넌트 (common-architect 작성)
│ └── biz/ # 업무별 재사용 컴포넌트
├── hooks/ # 공통 훅
├── layouts/ # MainLayout
├── pages/ # 메뉴별 페이지
├── router/ # 동적 라우터
├── stores/ # Zustand (auth, menu, notification)
├── types/ # TypeScript 타입
└── utils/ # formatMoney, formatDate
```
## 표준 화면 패턴 (5분 컷)
```tsx
export default function AgentList() {
const { canCreate, canExport } = usePermission('ORG_AGENT');
const [param, setParam] = useSearchParam({ status: '' });
const { data, isLoading } = useQuery(['agents', param], () => agentApi.list(param));
return (
<>
<SearchForm conditions={[
{ type: 'text', name: 'keyword', label: '설계사명' },
{ type: 'code', name: 'status', label: '상태', groupCode: 'AGENT_STATUS' },
]} onSearch={setParam} />
<PermissionButton permCode="CREATE" type="primary"
onClick={() => navigate('/org/agents/new')}></PermissionButton>
<DataTable
loading={isLoading}
dataSource={data?.list}
pagination={{ total: data?.total, current: param.pageNum }}
columns={[...]}
/>
</>
);
}
```
## 규칙
- 공통 컴포넌트(SearchForm/DataTable/DataGrid/CodeSelect/PermissionButton 등) 우선 사용, 직접 구현 금지
- 데이터 페치는 React Query, 전역상태는 Zustand
- 메뉴/권한은 `useQuery`로 받아 stores/menu에 저장 후 동적 라우팅
- 금액은 `<MoneyText/>` 컴포넌트, 코드값은 `<CodeBadge/>`로 표시
- 한국어 라벨, dayjs로 날짜 처리
## 금지 사항
- 공통 컴포넌트 우회하고 antd 직접 사용 (이유 있을 때만)
- inline style 남발
- 페이지 컴포넌트 안에 비즈니스 로직 덩어리
+43
View File
@@ -0,0 +1,43 @@
---
name: infra-reviewer
description: Docker/nginx/문서/통합검증 담당. docker-compose, Dockerfile, nginx.conf, README, DEVELOPER_GUIDE 작성 + 모든 팀원 작업 후 통합 빌드/실행 검증. 마지막에 합류.
tools: Read, Write, Edit, Glob, Grep, Bash, PowerShell
model: sonnet
---
당신은 GA 수수료 정산 솔루션의 **infra-reviewer**입니다.
## 작업 범위
- 작업 브랜치: `feat/infra`
- 작업 디렉토리: 프로젝트 루트 (`docker-compose.yml`, `Dockerfile`, `nginx.conf`, `README.md`, `DEVELOPER_GUIDE.md`)
## 작업 내용
### 1. Docker
- `docker-compose.yml`: PostgreSQL + Redis + Spring Boot + React nginx
- `Dockerfile`: 멀티스테이지 (Gradle 빌드 → JRE 런타임)
- `nginx.conf`: React SPA + `/api``backend:8080` 프록시
### 2. 문서
- `README.md`: 실행방법, API 목록, 아키텍처, 폴더 구조
- `DEVELOPER_GUIDE.md`: 신입 개발자 온보딩
1. 환경 설정 (JDK 17, Node 18, PostgreSQL, Redis, IDE)
2. 프로젝트 구조 설명
3. 새 화면 만들기 Step-by-Step (메뉴 등록 → VO → Mapper → Service → Controller → React 페이지)
4. 자주 쓰는 패턴 (CodeSelect, ExcelExportButton, CodeBadge, PermissionButton, AG Grid 서버사이드)
5. 코드 컨벤션 (VO 네이밍, Mapper XML, API URL kebab-case)
6. 트러블슈팅 FAQ (권한 에러 / 캐시 / MyBatis 매핑)
### 3. 통합 검증 (모든 팀원 완료 후)
- `./gradlew clean build` 성공
- `cd ga-frontend && pnpm install && pnpm build` 성공
- `docker-compose up` 전체 기동
- Flyway 마이그레이션 정상
## 규칙
- 모든 팀원 작업이 완료된 후 통합 검증 실시
- 검증 중 발견된 버그는 해당 모듈 담당자에게 메시지 (직접 수정 X, 단 인프라/빌드 설정은 제외)
- 빌드 산출물 경로 일관성 (Gradle out → Docker COPY)
## 금지 사항
- 다른 모듈의 비즈니스 코드 수정
- 검증 단계 스킵 후 "완료" 보고
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"env": {
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
},
"teammateMode": "auto"
}
+5 -2
View File
@@ -36,8 +36,11 @@ dist/
out/
target/
# Claude Code 개인 설정 (Agent Teams 등)
.claude/
# Claude Code: 팀 공용 파일만 커밋, 개인 설정/로컬 데이터는 제외
.claude/*
!.claude/settings.json
!.claude/agents/
.claude/settings.local.json
# vite/tsc 빌드 산출물
ga-frontend/tsconfig.tsbuildinfo
+148
View File
@@ -0,0 +1,148 @@
# Handoff Note — 2026-05-12
다음 세션에서 이어가기 위한 핸드오프 문서. Agent Teams 기반 자동 체인으로 진행 중인 작업의 현재 상태와 잔여 작업, 재개 절차를 기록한다.
## 1. 핸드오프 시점 상태
- **마지막 커밋**: P1-3-c (ChargebackCalculator + ChargebackGradeService + Controller)
- **빌드 상태**: `./gradlew :ga-common:compileJava :ga-core:compileJava :ga-api:compileJava` 모두 BUILD SUCCESSFUL
- **이번 세션 누적 커밋 수**: 26개 (Agent Teams 설정 1 + 코드 품질 7 + 도메인 P0 9 + 도메인 P1 11)
- **변경 사항 없음** (working tree clean)
## 2. 완료된 작업
### 2-1. 코드 품질 자동 체인 (7 커밋)
초기 ga-api 5명 리뷰 결과를 바탕으로 P0 이슈 일괄 해결.
| 단계 | 커밋 | 내용 |
|---|---|---|
| 1 | `5e58a54` | OrganizationSaveReq + 상태 Enum 4종 (Agent/Contract/Settle/Approve) |
| 2 | `a8f0b6e` | Controller→Service 분리 5개 + 매직 스트링 Enum 교체 + @DataChangeLog 17곳 |
| 3a | `693275e` | UserStatus + ExceptionLedgerStatus Enum |
| 3b | `af3c6b9` | Map→SaveReq DTO 4종 + 입력 검증 + 잔여 Enum 적용 |
| 4 | `498d0d4` | LookupCache 동시성 + UploadService/TransferFile 트랜잭션 분해 + readOnly 일괄 |
| 5a | `4b211ec` | AgentOrgHistoryResp + MapStruct mapper 3종 + AgentMapper.xml 조인 |
| 5b | `b16e678` | MapStruct 적용 + Resp 적용 + PaymentService/UploadTemplateService 신설 + PageHelper 통일 |
### 2-2. 도메인 P0 — 세무·공제·명세서 (9 커밋)
| 도메인 | a (DB) | b (core) | c (api) |
|---|---|---|---|
| P0-1 세무 | `ad5d62e` V23 | `92f3217` | `f8c7d84` TaxCalculator |
| P0-2 공제 | `0e73eb6` V24 | `a3ba7fa` | `b8f33f1` DeductionService |
| P0-3 명세서 | `930335c` V25 | `4f5a2f0` | `f5efcaa` ExcelStatementGenerator |
핵심 산출물:
- 표준 세율 system_config 외부화 (3.3%/8.8%/10%/10%)
- `agent`에 tax_type/business_no/vat_type 컬럼
- `payment`에 income_tax_amount/local_tax_amount/vat_amount/net_amount
- `agent_deduction` (월별 차감 마스터) + `payment_deduction_detail` (이체 명세)
- `commission_statement` (명세서 발급 마스터, agent+settle_month+statement_type UNIQUE)
- TaxCalculator pure 도메인 + ExcelStatementGenerator (PDF는 향후)
### 2-3. 도메인 P1 — 1200%룰·분급·차등환수 (11 커밋)
| 도메인 | a (DB) | b (core) | c (api) |
|---|---|---|---|
| P1-1 1200%룰 | `6a6ba9f` V26 | `1109253` | `bba4a33` RegulatoryLimitChecker |
| P1-2 분급 | `434e778` V27 | `5029bc5` | (P1-2-c 커밋) InstallmentPlanGenerator |
| P1-3 차등환수 | `cc5d0e5` V28 | `44dbbf3` | (P1-3-c 커밋) ChargebackCalculator |
**P1 보강 (병렬 트랙)**:
- `1aa31de` RecruitLedger/MaintainLedger sumByContract 메서드 추가
- `f6142a1` RegulatoryLimitChecker TODO 해소 (실제 누적값 사용)
핵심 산출물:
- `regulatory_limit` 마스터 (1200%/35%/7년 한도, effective_from/to 이력 추적)
- `installment_ratio` + `installment_plan` (분급 7년 비율 35/25/15/10/7/5/3)
- `chargeback_grade` (5단계: 1~12개월 100% → 85개월~ 0%)
- RegulatoryLimitChecker / InstallmentPlanGenerator / ChargebackCalculator pure 도메인
### 2-4. 병렬화 적용 (P1 후반부)
초반 직렬 a→b→c 패턴에서 후반에 도메인 간 sub-step 병렬로 전환.
- P1-2-c(api) + P1-3-a(dba) + P1 보강(core) 3명 동시 활성화
- P1-3-b(core) + P1-1-c TODO 교체(api) 2명 동시
- 모듈이 다른 사람끼리 묶어 머지 충돌 없음
## 3. 잔여 작업
### 3-1. P1 정산 통합 (보류 권장)
- ga-batch의 8 Step 정산 잡에 TaxCalculator/DeductionService/RegulatoryLimitChecker/InstallmentService/ChargebackCalculator 통합
- batch-engineer 작업
- 운영 비즈니스 룰 의존이 커 **인프라만 완성하고 통합은 사용자 결정 후** 진행 권장
- 보류 가능 — 현 시점에 인프라/CRUD/API는 모두 동작
### 3-2. 도메인 P2 — 8개 항목 (Wave 방식)
| # | 도메인 | 마이그레이션 | 핵심 |
|---|---|---|---|
| P2-1 | 마감 (Period Close) | V29 | period_close 테이블 + 마감 후 수정 차단 AOP |
| P2-2 | 정정 (Correction) | V30 | settle_correction + 원본 참조 + 정정 사유 |
| P2-3 | 이의 신청 (Dispute) | V31 | commission_dispute + OPEN/IN_REVIEW/APPROVED/REJECTED |
| P2-4 | 보류 사유 (Hold Reasons) | V32 | settle_master.hold_reason_code + HOLD_REASON 코드그룹 |
| P2-5 | 시책·인센티브 | V33 | incentive_program + incentive_payment |
| P2-6 | 해촉 정산 (Termination) | V34 | termination_settlement (agent_org_history 연계) |
| P2-7 | 세금계산서 (Tax Invoice) | V35 | tax_invoice 발행 이력 (SUPPLY/PURCHASE) |
| P2-8 | 외부 회신 | V36 | carrier_reconciliation_out (보험사 회신 인터페이스) |
**Wave 디스패치 계획**:
- Wave 1 (dba): V29~V36 8개 마이그레이션 한 번에
- Wave 2 (core): 8개 도메인 VO/Mapper 한 번에
- Wave 3 (api): 8개 도메인 Service/Controller 한 번에
- 디스패치 24 → 3으로 축소
### 3-3. 도메인 P3 — KPI 대시보드
| 작업 | 담당 |
|---|---|
| V37 Materialized View 4종 (월별/조직별/유지율/누계) | dba-architect |
| KPI VO/Mapper | core-architect |
| KpiService + KpiController | api-developer |
| KPI 대시보드 화면 (AG Grid 합계행, 차트) | frontend-developer |
### 3-4. 마무리 통합
| 작업 | 담당 |
|---|---|
| ga-batch 8 Step에 도메인 로직 통합 (TaxCalculator/Deduction/Regulatory/Installment/Chargeback) | batch-engineer |
| 통합 테스트 | infra-reviewer |
| README/DEVELOPER_GUIDE 업데이트 | infra-reviewer |
## 4. 재개 절차 (다음 세션)
1. **Claude Code 시작 시 cwd 확인**: `C:\Users\kyu\Desktop\ga_pro` (현재 세션과 동일)
2. **Agent Teams 활성화 확인**: `.claude/settings.json` 이미 커밋되어 있음 (`418ff7c`)
3. **팀 재생성**: 새 세션은 팀이 없음. 다음 명령 흐름:
- PL 역할로 `TeamCreate(team_name="ga-pro-team", agent_type="pl", description="...")` 호출
- 7명 팀원 spawn (common/core/api/batch/frontend/dba/infra) — 첫 세션과 동일
4. **HANDOFF.md 읽기**: 본 문서 + 최근 git log로 컨텍스트 확보
5. **권장 시작 지점**: P2 Wave 1 디스패치 (dba-architect에게 V29~V36 8개 마이그레이션)
6. **사용자 결정 필요 항목**:
- P1 정산 통합 진행 여부 (Wave 작업 전 또는 P3 후)
- P2-1 마감 도메인의 마감 후 수정 차단 AOP 구현 방식 (전역 AOP vs Service-level 가드)
## 5. PL 가이드 (다음 세션 유의사항)
- **시간차 중복 메시지**: 팀원이 SendMessage로 같은 보고를 2~3번 보내는 경우 있음. 짧게 acknowledge만.
- **idle 알림**: 팀원이 작업 끝낼 때마다 idle 알림 보냄. 정상. 액션 필요 없음.
- **책임 경계**: dba=Flyway/SQL, core=VO/Mapper/Enum, api=Service/Controller. api-developer가 가끔 ga-core Mapper에 메서드 추가하는데 작은 변경이면 통과 가능, 큰 변경은 core-architect에 위임.
- **TODO 잔존**: api-developer가 부족한 ga-core 메서드 발견 시 TODO로 남기고 보고 → PL이 후속 보강 디스패치. 같은 단계에서 멈추지 말고 흐름 유지.
- **자동 정책**:
- 각 단계 완료 후 자동 커밋 (push 안 함)
- 빌드/테스트 실패 시 담당 팀원에 재지시
- 컴파일은 PL이 직접 검증, 테스트는 환경 문제(SqlSessionFactory)로 통과 안 되는 게 알려진 상태
## 6. 표준 가정 (도메인 작업 진행 시 채택한 값)
도메인 룰은 한국 보험업감독규정 표준 가정을 외부화 가능한 구조로 등록. 실제 회사 정책과 다르면 system_config / regulatory_limit / installment_ratio / chargeback_grade의 데이터만 수정하면 됨.
| 항목 | 표준값 | 근거 |
|---|---|---|
| 사업소득 원천징수 | 3.3% | 한국 표준 |
| 기타소득 원천징수 | 8.8% | 한국 표준 |
| 지방소득세 | 원천세의 10% | 한국 표준 |
| 부가세 | 10% | 한국 표준 |
| 1200% 룰 총 한도 | 1200% | 보험업감독규정 제5-15조의5 |
| 1차년 한도 | 35% | 2024년 기준 |
| 분급 연한 | 7년 | 2024년 기준 |
| 분급 비율 | 35/25/15/10/7/5/3 (합 100%) | 가정값 |
| 차등 환수 | 1~12: 100%, 13~24: 50%, 25~36: 25%, 37~84: 10%, 85~: 0% | 가정값 |
가정값과 실제가 다르면 운영 데이터 수정으로 해결 가능. 코드 변경 불필요.
@@ -8,8 +8,6 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Tag(name = "인증")
@RestController
@RequestMapping("/api/auth")
@@ -24,8 +22,8 @@ public class AuthController {
}
@PostMapping("/refresh")
public ApiResponse<LoginResp> refresh(@RequestBody Map<String, String> body) {
return ApiResponse.ok(authService.refresh(body.get("refreshToken")));
public ApiResponse<LoginResp> refresh(@Valid @RequestBody RefreshTokenReq req) {
return ApiResponse.ok(authService.refresh(req.getRefreshToken()));
}
@GetMapping("/me")
@@ -34,8 +32,8 @@ public class AuthController {
}
@PostMapping("/password")
public ApiResponse<Void> changePassword(@RequestBody Map<String, String> body) {
authService.changePassword(body.get("oldPassword"), body.get("newPassword"));
public ApiResponse<Void> changePassword(@Valid @RequestBody ChangePasswordReq req) {
authService.changePassword(req.getOldPassword(), req.getNewPassword());
return ApiResponse.ok();
}
@@ -8,6 +8,7 @@ import com.ga.common.system.SystemLogMapper;
import com.ga.common.util.SecurityUtil;
import com.ga.core.mapper.user.UserMapper;
import com.ga.core.vo.user.UserResp;
import com.ga.core.vo.user.UserStatus;
import com.ga.core.vo.user.UserVO;
import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
@@ -38,18 +39,18 @@ public class AuthService {
saveLoginLog(null, req.getLoginId(), "FAIL", "USER_NOT_FOUND", ipAddress, userAgent);
throw new BizException(ErrorCode.LOGIN_FAIL);
}
if ("LOCKED".equals(user.getStatus())) {
if (UserStatus.LOCKED.name().equals(user.getStatus())) {
saveLoginLog(user.getUserId(), req.getLoginId(), "FAIL", "ACCOUNT_LOCKED", ipAddress, userAgent);
throw new BizException(ErrorCode.ACCOUNT_LOCKED);
}
if ("RETIRED".equals(user.getStatus())) {
if (UserStatus.RETIRED.name().equals(user.getStatus())) {
throw new BizException(ErrorCode.ACCOUNT_RETIRED);
}
if (!passwordEncoder.matches(req.getPassword(), user.getPassword())) {
userMapper.incrementFailCount(user.getUserId());
int newFail = (user.getFailCount() == null ? 0 : user.getFailCount()) + 1;
if (newFail >= failLimit) {
userMapper.updateStatus(user.getUserId(), "LOCKED");
userMapper.updateStatus(user.getUserId(), UserStatus.LOCKED.name());
}
saveLoginLog(user.getUserId(), req.getLoginId(), "FAIL", "BAD_PASSWORD", ipAddress, userAgent);
throw new BizException(ErrorCode.LOGIN_FAIL);
@@ -80,7 +81,7 @@ public class AuthService {
}
Long userId = claims.get("uid", Long.class);
UserVO user = userMapper.selectById(userId);
if (user == null || !"ACTIVE".equals(user.getStatus())) {
if (user == null || !UserStatus.ACTIVE.name().equals(user.getStatus())) {
throw new BizException(ErrorCode.UNAUTHORIZED);
}
List<String> roles = userMapper.selectRoleCodes(userId);
@@ -0,0 +1,16 @@
package com.ga.api.auth;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class ChangePasswordReq {
@NotBlank
private String oldPassword;
@NotBlank
private String newPassword;
}
@@ -0,0 +1,13 @@
package com.ga.api.auth;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class RefreshTokenReq {
@NotBlank
private String refreshToken;
}
@@ -0,0 +1,24 @@
package com.ga.api.controller.chargeback;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
@Data
public class ChargebackCalculateReq {
@NotNull
private LocalDate contractDate;
@NotNull
private LocalDate terminationDate;
@NotNull
@Positive
private BigDecimal originalCommission;
private String productCategory;
}
@@ -0,0 +1,75 @@
package com.ga.api.controller.chargeback;
import com.ga.api.service.chargeback.ChargebackCalculator;
import com.ga.api.service.chargeback.ChargebackGradeService;
import com.ga.api.service.chargeback.ChargebackResult;
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.chargeback.ChargebackGradeResp;
import com.ga.core.vo.chargeback.ChargebackGradeSaveReq;
import com.ga.core.vo.chargeback.ChargebackGradeSearchParam;
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-grades")
@RequiredArgsConstructor
public class ChargebackGradeController {
private final ChargebackGradeService gradeService;
private final ChargebackCalculator calculator;
@GetMapping
@RequirePermission(menu = "CHARGEBACK_GRADE", perm = "READ")
public ApiResponse<PageResponse<ChargebackGradeResp>> list(ChargebackGradeSearchParam param) {
return ApiResponse.ok(gradeService.list(param));
}
@GetMapping("/{gradeId}")
@RequirePermission(menu = "CHARGEBACK_GRADE", perm = "READ")
public ApiResponse<ChargebackGradeResp> detail(@PathVariable Long gradeId) {
return ApiResponse.ok(gradeService.detail(gradeId));
}
@PostMapping
@RequirePermission(menu = "CHARGEBACK_GRADE", perm = "CREATE")
@DataChangeLog(menu = "CHARGEBACK_GRADE", table = "chargeback_grade")
public ApiResponse<Void> create(@Valid @RequestBody ChargebackGradeSaveReq req) {
gradeService.create(req);
return ApiResponse.ok();
}
@PutMapping("/{gradeId}")
@RequirePermission(menu = "CHARGEBACK_GRADE", perm = "UPDATE")
@DataChangeLog(menu = "CHARGEBACK_GRADE", table = "chargeback_grade")
public ApiResponse<Void> update(@PathVariable Long gradeId,
@Valid @RequestBody ChargebackGradeSaveReq req) {
gradeService.update(gradeId, req);
return ApiResponse.ok();
}
@PutMapping("/{gradeId}/deactivate")
@RequirePermission(menu = "CHARGEBACK_GRADE", perm = "UPDATE")
@DataChangeLog(menu = "CHARGEBACK_GRADE", table = "chargeback_grade")
public ApiResponse<Void> deactivate(@PathVariable Long gradeId) {
gradeService.deactivate(gradeId);
return ApiResponse.ok();
}
@PostMapping("/calculate")
@RequirePermission(menu = "CHARGEBACK_GRADE", perm = "READ")
public ApiResponse<ChargebackResult> calculate(@Valid @RequestBody ChargebackCalculateReq req) {
ChargebackResult result = calculator.calculate(
req.getContractDate(),
req.getTerminationDate(),
req.getOriginalCommission(),
req.getProductCategory()
);
return ApiResponse.ok(result);
}
}
@@ -0,0 +1,59 @@
package com.ga.api.controller.deduction;
import com.ga.api.service.deduction.DeductionService;
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.settle.AgentDeductionResp;
import com.ga.core.vo.settle.AgentDeductionSaveReq;
import com.ga.core.vo.settle.AgentDeductionSearchParam;
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/deductions")
@RequiredArgsConstructor
public class DeductionController {
private final DeductionService deductionService;
@GetMapping
@RequirePermission(menu = "DEDUCTION", perm = "READ")
public ApiResponse<PageResponse<AgentDeductionResp>> list(@ModelAttribute AgentDeductionSearchParam param) {
return ApiResponse.ok(deductionService.list(param));
}
@GetMapping("/{deductionId}")
@RequirePermission(menu = "DEDUCTION", perm = "READ")
public ApiResponse<AgentDeductionResp> detail(@PathVariable Long deductionId) {
return ApiResponse.ok(deductionService.detail(deductionId));
}
@PostMapping
@RequirePermission(menu = "DEDUCTION", perm = "CREATE")
@DataChangeLog(menu = "DEDUCTION", table = "agent_deduction")
public ApiResponse<Long> create(@Valid @RequestBody AgentDeductionSaveReq req) {
return ApiResponse.ok(deductionService.create(req));
}
@PutMapping("/{deductionId}")
@RequirePermission(menu = "DEDUCTION", perm = "UPDATE")
@DataChangeLog(menu = "DEDUCTION", table = "agent_deduction")
public ApiResponse<Void> update(@PathVariable Long deductionId,
@Valid @RequestBody AgentDeductionSaveReq req) {
deductionService.update(deductionId, req);
return ApiResponse.ok();
}
@PutMapping("/{deductionId}/cancel")
@RequirePermission(menu = "DEDUCTION", perm = "UPDATE")
@DataChangeLog(menu = "DEDUCTION", table = "agent_deduction")
public ApiResponse<Void> cancel(@PathVariable Long deductionId) {
deductionService.cancel(deductionId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,24 @@
package com.ga.api.controller.installment;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
@Data
public class GeneratePlanReq {
@NotNull
private Long contractId;
@NotNull
@Positive
private BigDecimal totalCommission;
@NotNull
private LocalDate contractDate;
private String productCategory;
}
@@ -0,0 +1,82 @@
package com.ga.api.controller.installment;
import com.ga.api.service.installment.InstallmentService;
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.installment.InstallmentPlanResp;
import com.ga.core.vo.installment.InstallmentPlanSearchParam;
import com.ga.core.vo.installment.InstallmentPlanVO;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "분급 계획 관리")
@RestController
@RequestMapping("/api/installment-plans")
@RequiredArgsConstructor
public class InstallmentController {
private final InstallmentService installmentService;
@GetMapping
@RequirePermission(menu = "INSTALLMENT", perm = "READ")
public ApiResponse<PageResponse<InstallmentPlanResp>> list(InstallmentPlanSearchParam param) {
return ApiResponse.ok(installmentService.list(param));
}
@GetMapping("/{planId}")
@RequirePermission(menu = "INSTALLMENT", perm = "READ")
public ApiResponse<InstallmentPlanResp> detail(@PathVariable Long planId) {
return ApiResponse.ok(installmentService.detail(planId));
}
@PostMapping("/generate")
@RequirePermission(menu = "INSTALLMENT", perm = "CREATE")
@DataChangeLog(menu = "INSTALLMENT", table = "installment_plan")
public ApiResponse<Void> generate(@Valid @RequestBody GeneratePlanReq req) {
installmentService.createPlan(
req.getContractId(),
req.getTotalCommission(),
req.getContractDate(),
req.getProductCategory()
);
return ApiResponse.ok();
}
@PutMapping("/{planId}/pay")
@RequirePermission(menu = "INSTALLMENT", perm = "UPDATE")
@DataChangeLog(menu = "INSTALLMENT", table = "installment_plan")
public ApiResponse<Void> pay(@PathVariable Long planId,
@Valid @RequestBody PayPlanReq req) {
installmentService.payPlan(planId, req.getPaymentId(), req.getPaidAmount());
return ApiResponse.ok();
}
@PutMapping("/{planId}/defer")
@RequirePermission(menu = "INSTALLMENT", perm = "UPDATE")
@DataChangeLog(menu = "INSTALLMENT", table = "installment_plan")
public ApiResponse<Void> defer(@PathVariable Long planId) {
installmentService.deferPlan(planId);
return ApiResponse.ok();
}
@PutMapping("/{planId}/cancel")
@RequirePermission(menu = "INSTALLMENT", perm = "UPDATE")
@DataChangeLog(menu = "INSTALLMENT", table = "installment_plan")
public ApiResponse<Void> cancel(@PathVariable Long planId) {
installmentService.cancelPlan(planId);
return ApiResponse.ok();
}
@GetMapping("/scheduled")
@RequirePermission(menu = "INSTALLMENT", perm = "READ")
public ApiResponse<List<InstallmentPlanVO>> scheduled(@RequestParam String settleMonth) {
return ApiResponse.ok(installmentService.selectScheduledForMonth(settleMonth));
}
}
@@ -0,0 +1,37 @@
package com.ga.api.controller.installment;
import com.ga.api.service.installment.InstallmentService;
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.installment.InstallmentRatioResp;
import com.ga.core.vo.installment.InstallmentRatioSaveReq;
import com.ga.core.vo.installment.InstallmentRatioSearchParam;
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/installment-ratios")
@RequiredArgsConstructor
public class InstallmentRatioController {
private final InstallmentService installmentService;
@GetMapping
@RequirePermission(menu = "INSTALLMENT", perm = "READ")
public ApiResponse<PageResponse<InstallmentRatioResp>> list(InstallmentRatioSearchParam param) {
return ApiResponse.ok(installmentService.listRatios(param));
}
@PostMapping
@RequirePermission(menu = "INSTALLMENT", perm = "CREATE")
@DataChangeLog(menu = "INSTALLMENT", table = "installment_ratio")
public ApiResponse<Void> create(@Valid @RequestBody InstallmentRatioSaveReq req) {
installmentService.createRatio(req);
return ApiResponse.ok();
}
}
@@ -0,0 +1,18 @@
package com.ga.api.controller.installment;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class PayPlanReq {
@NotNull
private Long paymentId;
@NotNull
@Positive
private BigDecimal paidAmount;
}
@@ -6,8 +6,6 @@ import com.ga.common.annotation.RequirePermission;
import com.ga.common.excel.ExcelService;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.ledger.MaintainLedgerMapper;
import com.ga.core.mapper.ledger.RecruitLedgerMapper;
import com.ga.core.vo.ledger.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
@@ -24,8 +22,6 @@ import java.util.Map;
public class LedgerController {
private final LedgerService service;
private final RecruitLedgerMapper recruitMapper;
private final MaintainLedgerMapper maintainMapper;
private final ExcelService excelService;
// ===== 모집 =====
@@ -45,7 +41,7 @@ public class LedgerController {
@RequirePermission(menu = "LEDGER_RECRUIT", perm = "EXPORT")
public void exportRecruit(@ModelAttribute LedgerSearchParam param, HttpServletResponse res) {
excelService.exportLargeExcel("모집수수료원장", LedgerExcelVO.class,
() -> recruitMapper.selectCursorForExcel(param), res);
() -> service.exportRecruitCursor(param), res);
}
// ===== 유지 =====
@@ -65,7 +61,7 @@ public class LedgerController {
@RequirePermission(menu = "LEDGER_MAINTAIN", perm = "EXPORT")
public void exportMaintain(@ModelAttribute LedgerSearchParam param, HttpServletResponse res) {
excelService.exportLargeExcel("유지수수료원장", LedgerExcelVO.class,
() -> maintainMapper.selectCursorForExcel(param), res);
() -> service.exportMaintainCursor(param), res);
}
// ===== 예외 =====
@@ -6,7 +6,6 @@ import com.ga.common.annotation.RequirePermission;
import com.ga.common.excel.ExcelService;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.org.AgentMapper;
import com.ga.core.vo.org.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
@@ -24,7 +23,6 @@ import java.util.Map;
public class AgentController {
private final AgentService agentService;
private final AgentMapper agentMapper;
private final ExcelService excelService;
@GetMapping
@@ -64,7 +62,7 @@ public class AgentController {
@GetMapping("/{agentId}/history")
@RequirePermission(menu = "ORG_AGENT", perm = "READ")
public ApiResponse<List<AgentOrgHistoryVO>> history(@PathVariable Long agentId) {
public ApiResponse<List<AgentOrgHistoryResp>> history(@PathVariable Long agentId) {
return ApiResponse.ok(agentService.history(agentId));
}
@@ -72,6 +70,6 @@ public class AgentController {
@RequirePermission(menu = "ORG_AGENT", perm = "EXPORT")
public void export(@ModelAttribute AgentSearchParam param, HttpServletResponse response) {
excelService.exportLargeExcel("설계사목록", AgentExcelVO.class,
() -> agentMapper.selectCursorForExcel(param), response);
() -> agentService.exportCursor(param), response);
}
}
@@ -5,7 +5,9 @@ import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.core.vo.org.OrganizationResp;
import com.ga.core.vo.org.OrganizationSaveReq;
import com.ga.core.vo.org.OrganizationVO;
import jakarta.validation.Valid;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@@ -43,15 +45,15 @@ public class OrganizationController {
@PostMapping
@RequirePermission(menu = "ORG_TREE", perm = "CREATE")
@DataChangeLog(menu = "ORG_TREE", table = "organization")
public ApiResponse<Long> create(@RequestBody OrganizationVO vo) {
return ApiResponse.ok(service.create(vo));
public ApiResponse<Long> create(@Valid @RequestBody OrganizationSaveReq req) {
return ApiResponse.ok(service.create(req));
}
@PutMapping("/{orgId}")
@RequirePermission(menu = "ORG_TREE", perm = "UPDATE")
@DataChangeLog(menu = "ORG_TREE", table = "organization")
public ApiResponse<Void> update(@PathVariable Long orgId, @RequestBody OrganizationVO vo) {
service.update(orgId, vo);
public ApiResponse<Void> update(@PathVariable Long orgId, @Valid @RequestBody OrganizationSaveReq req) {
service.update(orgId, req);
return ApiResponse.ok();
}
@@ -1,9 +1,9 @@
package com.ga.api.controller.product;
import com.ga.api.service.product.InsuranceCompanyService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.core.mapper.product.InsuranceCompanyMapper;
import com.ga.core.vo.product.InsuranceCompanyVO;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
@@ -17,34 +17,32 @@ import java.util.List;
@RequiredArgsConstructor
public class CompanyController {
private final InsuranceCompanyMapper mapper;
private final InsuranceCompanyService companyService;
@GetMapping
@RequirePermission(menu = "COMPANY", perm = "READ")
public ApiResponse<List<InsuranceCompanyVO>> list(@RequestParam(required = false, defaultValue = "false") boolean activeOnly) {
return ApiResponse.ok(activeOnly ? mapper.selectActive() : mapper.selectAll());
return ApiResponse.ok(companyService.list(activeOnly));
}
@GetMapping("/{companyId}")
@RequirePermission(menu = "COMPANY", perm = "READ")
public ApiResponse<InsuranceCompanyVO> detail(@PathVariable Long companyId) {
return ApiResponse.ok(mapper.selectById(companyId));
return ApiResponse.ok(companyService.detail(companyId));
}
@PostMapping
@RequirePermission(menu = "COMPANY", perm = "CREATE")
@DataChangeLog(menu = "COMPANY", table = "insurance_company")
public ApiResponse<Long> create(@RequestBody InsuranceCompanyVO vo) {
mapper.insert(vo);
return ApiResponse.ok(vo.getCompanyId());
return ApiResponse.ok(companyService.create(vo));
}
@PutMapping("/{companyId}")
@RequirePermission(menu = "COMPANY", perm = "UPDATE")
@DataChangeLog(menu = "COMPANY", table = "insurance_company")
public ApiResponse<Void> update(@PathVariable Long companyId, @RequestBody InsuranceCompanyVO vo) {
vo.setCompanyId(companyId);
mapper.update(vo);
companyService.update(companyId, vo);
return ApiResponse.ok();
}
}
@@ -1,11 +1,9 @@
package com.ga.api.controller.product;
import com.ga.api.service.product.ProductService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.ApiResponse;
import com.ga.core.mapper.product.ProductMapper;
import com.ga.core.vo.product.ProductResp;
import com.ga.core.vo.product.ProductVO;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -20,7 +18,7 @@ import java.util.List;
@RequiredArgsConstructor
public class ProductController {
private final ProductMapper mapper;
private final ProductService productService;
@GetMapping
@RequirePermission(menu = "PRODUCT", perm = "READ")
@@ -28,41 +26,35 @@ public class ProductController {
@RequestParam(required = false) String insuranceType,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String isActive) {
return ApiResponse.ok(mapper.selectList(companyId, insuranceType, keyword, isActive));
return ApiResponse.ok(productService.list(companyId, insuranceType, keyword, isActive));
}
@GetMapping("/{productId}")
@RequirePermission(menu = "PRODUCT", perm = "READ")
public ApiResponse<ProductResp> detail(@PathVariable Long productId) {
ProductResp r = mapper.selectDetailById(productId);
if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
return ApiResponse.ok(r);
return ApiResponse.ok(productService.detail(productId));
}
@PostMapping
@RequirePermission(menu = "PRODUCT", perm = "CREATE")
@DataChangeLog(menu = "PRODUCT", table = "product")
public ApiResponse<Long> create(@RequestBody ProductVO vo) {
if (mapper.existsByCode(vo.getCompanyId(), vo.getProductCode()) > 0) {
throw new BizException(ErrorCode.DUPLICATE_DATA);
}
mapper.insert(vo);
return ApiResponse.ok(vo.getProductId());
return ApiResponse.ok(productService.create(vo));
}
@PutMapping("/{productId}")
@RequirePermission(menu = "PRODUCT", perm = "UPDATE")
@DataChangeLog(menu = "PRODUCT", table = "product")
public ApiResponse<Void> update(@PathVariable Long productId, @RequestBody ProductVO vo) {
vo.setProductId(productId);
mapper.update(vo);
productService.update(productId, vo);
return ApiResponse.ok();
}
@DeleteMapping("/{productId}")
@RequirePermission(menu = "PRODUCT", perm = "DELETE")
@DataChangeLog(menu = "PRODUCT", table = "product")
public ApiResponse<Void> delete(@PathVariable Long productId) {
mapper.deleteById(productId);
productService.delete(productId);
return ApiResponse.ok();
}
}
@@ -1,15 +1,16 @@
package com.ga.api.controller.receive;
import com.ga.api.service.receive.ReceiveService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.core.mapper.receive.ReceiveMapper;
import com.ga.core.vo.receive.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@Tag(name = "데이터 수신")
@RestController
@@ -17,21 +18,20 @@ import java.util.Map;
@RequiredArgsConstructor
public class ReceiveController {
private final ReceiveMapper mapper;
private final ReceiveService receiveService;
// 보험사 프로파일
@GetMapping("/profiles")
@RequirePermission(menu = "RECEIVE_MAPPING", perm = "READ")
public ApiResponse<List<CompanyProfileVO>> profiles() {
return ApiResponse.ok(mapper.selectAllProfiles());
return ApiResponse.ok(receiveService.listProfiles());
}
@PutMapping("/profiles/{companyCode}")
@RequirePermission(menu = "RECEIVE_MAPPING", perm = "UPDATE")
@DataChangeLog(menu = "RECEIVE_MAPPING", table = "company_profile")
public ApiResponse<Void> updateProfile(@PathVariable String companyCode, @RequestBody CompanyProfileVO vo) {
vo.setCompanyCode(companyCode);
if (mapper.selectProfile(companyCode) == null) mapper.insertProfile(vo);
else mapper.updateProfile(vo);
receiveService.saveProfile(companyCode, vo);
return ApiResponse.ok();
}
@@ -40,29 +40,29 @@ public class ReceiveController {
@RequirePermission(menu = "RECEIVE_MAPPING", perm = "READ")
public ApiResponse<List<CompanyFieldMappingVO>> fields(@PathVariable String companyCode,
@RequestParam(required = false) String targetTable) {
return ApiResponse.ok(mapper.selectFieldMappings(companyCode, targetTable));
return ApiResponse.ok(receiveService.listFields(companyCode, targetTable));
}
@PostMapping("/mapping/{companyCode}/fields")
@RequirePermission(menu = "RECEIVE_MAPPING", perm = "CREATE")
@DataChangeLog(menu = "RECEIVE_MAPPING", table = "company_field_mapping")
public ApiResponse<Long> createField(@PathVariable String companyCode, @RequestBody CompanyFieldMappingVO vo) {
vo.setCompanyCode(companyCode);
mapper.insertFieldMapping(vo);
return ApiResponse.ok(vo.getMappingId());
return ApiResponse.ok(receiveService.createField(companyCode, vo));
}
@PutMapping("/mapping/fields/{mappingId}")
@RequirePermission(menu = "RECEIVE_MAPPING", perm = "UPDATE")
@DataChangeLog(menu = "RECEIVE_MAPPING", table = "company_field_mapping")
public ApiResponse<Void> updateField(@PathVariable Long mappingId, @RequestBody CompanyFieldMappingVO vo) {
vo.setMappingId(mappingId);
mapper.updateFieldMapping(vo);
receiveService.updateField(mappingId, vo);
return ApiResponse.ok();
}
@DeleteMapping("/mapping/fields/{mappingId}")
@RequirePermission(menu = "RECEIVE_MAPPING", perm = "DELETE")
@DataChangeLog(menu = "RECEIVE_MAPPING", table = "company_field_mapping")
public ApiResponse<Void> deleteField(@PathVariable Long mappingId) {
mapper.deleteFieldMapping(mappingId);
receiveService.deleteField(mappingId);
return ApiResponse.ok();
}
@@ -71,15 +71,14 @@ public class ReceiveController {
@RequirePermission(menu = "RECEIVE_MAPPING", perm = "READ")
public ApiResponse<List<CodeMappingVO>> codes(@PathVariable String companyCode,
@RequestParam(required = false) String codeType) {
return ApiResponse.ok(mapper.selectCodeMappings(companyCode, codeType));
return ApiResponse.ok(receiveService.listCodes(companyCode, codeType));
}
@PostMapping("/mapping/{companyCode}/codes")
@RequirePermission(menu = "RECEIVE_MAPPING", perm = "CREATE")
@DataChangeLog(menu = "RECEIVE_MAPPING", table = "code_mapping")
public ApiResponse<Long> createCode(@PathVariable String companyCode, @RequestBody CodeMappingVO vo) {
vo.setCompanyCode(companyCode);
mapper.insertCodeMapping(vo);
return ApiResponse.ok(vo.getCodeId());
return ApiResponse.ok(receiveService.createCode(companyCode, vo));
}
// 원본 데이터 + 에러
@@ -88,23 +87,21 @@ public class ReceiveController {
public ApiResponse<List<RawCommissionDataVO>> rawList(@RequestParam(required = false) String companyCode,
@RequestParam(required = false) String settleMonth,
@RequestParam(required = false) String parseStatus) {
return ApiResponse.ok(mapper.selectRawList(companyCode, settleMonth, parseStatus));
return ApiResponse.ok(receiveService.listRaw(companyCode, settleMonth, parseStatus));
}
@GetMapping("/errors")
@RequirePermission(menu = "RECEIVE_DATA", perm = "READ")
public ApiResponse<List<ParseErrorLogVO>> errors(@RequestParam(required = false) String companyCode,
@RequestParam(required = false) String resolveStatus) {
return ApiResponse.ok(mapper.selectErrors(companyCode, resolveStatus));
return ApiResponse.ok(receiveService.listErrors(companyCode, resolveStatus));
}
@PutMapping("/errors/{errorId}/resolve")
@RequirePermission(menu = "RECEIVE_DATA", perm = "UPDATE")
public ApiResponse<Void> resolveError(@PathVariable Long errorId, @RequestBody Map<String, String> body) {
mapper.resolveError(errorId,
body.getOrDefault("status", "RESOLVED"),
body.get("note"),
com.ga.common.util.SecurityUtil.getCurrentUserId());
@DataChangeLog(menu = "RECEIVE_DATA", table = "parse_error_log")
public ApiResponse<Void> resolveError(@PathVariable Long errorId, @Valid @RequestBody ResolveErrorReq req) {
receiveService.resolveError(errorId, req.getStatus(), req.getNote());
return ApiResponse.ok();
}
}
@@ -0,0 +1,15 @@
package com.ga.api.controller.receive;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class ResolveErrorReq {
@NotBlank
private String status;
private String note;
}
@@ -0,0 +1,26 @@
package com.ga.api.controller.regulatory;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
@Data
public class ContractLimitCheckReq {
@NotNull
private Long contractId;
@NotNull
@Positive
private BigDecimal additionalAmount;
/** 기준일 (null이면 오늘) */
private LocalDate baseDate;
/** TOTAL_RATIO | FIRST_YEAR_RATIO */
@NotNull
private String limitType;
}
@@ -0,0 +1,78 @@
package com.ga.api.controller.regulatory;
import com.ga.api.service.regulatory.RegulatoryLimitChecker;
import com.ga.api.service.regulatory.RegulatoryLimitService;
import com.ga.api.service.regulatory.ViolationResult;
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.regulatory.RegulatoryLimitResp;
import com.ga.core.vo.regulatory.RegulatoryLimitSaveReq;
import com.ga.core.vo.regulatory.RegulatoryLimitSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
@Tag(name = "규제 한도 관리")
@RestController
@RequestMapping("/api/regulatory-limits")
@RequiredArgsConstructor
public class RegulatoryLimitController {
private final RegulatoryLimitService limitService;
private final RegulatoryLimitChecker limitChecker;
@GetMapping
@RequirePermission(menu = "REGULATORY_LIMIT", perm = "READ")
public ApiResponse<PageResponse<RegulatoryLimitResp>> list(RegulatoryLimitSearchParam param) {
return ApiResponse.ok(limitService.list(param));
}
@GetMapping("/{limitId}")
@RequirePermission(menu = "REGULATORY_LIMIT", perm = "READ")
public ApiResponse<RegulatoryLimitResp> detail(@PathVariable Long limitId) {
return ApiResponse.ok(limitService.detail(limitId));
}
@PostMapping
@RequirePermission(menu = "REGULATORY_LIMIT", perm = "CREATE")
@DataChangeLog(menu = "REGULATORY_LIMIT", table = "regulatory_limit")
public ApiResponse<Void> create(@Valid @RequestBody RegulatoryLimitSaveReq req) {
limitService.create(req);
return ApiResponse.ok();
}
@PutMapping("/{limitId}")
@RequirePermission(menu = "REGULATORY_LIMIT", perm = "UPDATE")
@DataChangeLog(menu = "REGULATORY_LIMIT", table = "regulatory_limit")
public ApiResponse<Void> update(@PathVariable Long limitId,
@Valid @RequestBody RegulatoryLimitSaveReq req) {
limitService.update(limitId, req);
return ApiResponse.ok();
}
@PutMapping("/{limitId}/deactivate")
@RequirePermission(menu = "REGULATORY_LIMIT", perm = "UPDATE")
@DataChangeLog(menu = "REGULATORY_LIMIT", table = "regulatory_limit")
public ApiResponse<Void> deactivate(@PathVariable Long limitId) {
limitService.deactivate(limitId);
return ApiResponse.ok();
}
@PostMapping("/check-contract")
@RequirePermission(menu = "REGULATORY_LIMIT", perm = "READ")
public ApiResponse<ViolationResult> checkContract(@Valid @RequestBody ContractLimitCheckReq req) {
LocalDate baseDate = req.getBaseDate() != null ? req.getBaseDate() : LocalDate.now();
ViolationResult result;
if ("FIRST_YEAR_RATIO".equals(req.getLimitType())) {
result = limitChecker.checkFirstYear(req.getContractId(), req.getAdditionalAmount(), baseDate);
} else {
result = limitChecker.checkContractTotal(req.getContractId(), req.getAdditionalAmount(), baseDate);
}
return ApiResponse.ok(result);
}
}
@@ -1,9 +1,9 @@
package com.ga.api.controller.rule;
import com.ga.api.service.rule.RuleService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.core.mapper.rule.RuleMapper;
import com.ga.core.vo.rule.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
@@ -17,37 +17,36 @@ import java.util.List;
@RequiredArgsConstructor
public class RuleController {
private final RuleMapper mapper;
private final RuleService ruleService;
// ========== commission_rate ==========
@GetMapping("/commission-rates")
@RequirePermission(menu = "RULE_COMMISSION", perm = "READ")
public ApiResponse<List<CommissionRateVO>> listCommissionRates(@RequestParam(required = false) Long productId,
@RequestParam(required = false) Integer year) {
return ApiResponse.ok(mapper.selectCommissionRates(productId, year));
return ApiResponse.ok(ruleService.listCommissionRates(productId, year));
}
@PostMapping("/commission-rates")
@RequirePermission(menu = "RULE_COMMISSION", perm = "CREATE")
@DataChangeLog(menu = "RULE_COMMISSION", table = "commission_rate")
public ApiResponse<Long> createCommissionRate(@RequestBody CommissionRateVO vo) {
mapper.insertCommissionRate(vo);
return ApiResponse.ok(vo.getRateId());
return ApiResponse.ok(ruleService.createCommissionRate(vo));
}
@PutMapping("/commission-rates/{rateId}")
@RequirePermission(menu = "RULE_COMMISSION", perm = "UPDATE")
@DataChangeLog(menu = "RULE_COMMISSION", table = "commission_rate")
public ApiResponse<Void> updateCommissionRate(@PathVariable Long rateId, @RequestBody CommissionRateVO vo) {
vo.setRateId(rateId);
mapper.updateCommissionRate(vo);
ruleService.updateCommissionRate(rateId, vo);
return ApiResponse.ok();
}
@DeleteMapping("/commission-rates/{rateId}")
@RequirePermission(menu = "RULE_COMMISSION", perm = "DELETE")
@DataChangeLog(menu = "RULE_COMMISSION", table = "commission_rate")
public ApiResponse<Void> deleteCommissionRate(@PathVariable Long rateId) {
mapper.deleteCommissionRate(rateId);
ruleService.deleteCommissionRate(rateId);
return ApiResponse.ok();
}
@@ -56,30 +55,29 @@ public class RuleController {
@RequirePermission(menu = "RULE_PAYOUT", perm = "READ")
public ApiResponse<List<PayoutRuleVO>> listPayoutRules(@RequestParam(required = false) Long gradeId,
@RequestParam(required = false) String insuranceType) {
return ApiResponse.ok(mapper.selectPayoutRules(gradeId, insuranceType));
return ApiResponse.ok(ruleService.listPayoutRules(gradeId, insuranceType));
}
@PostMapping("/payout")
@RequirePermission(menu = "RULE_PAYOUT", perm = "CREATE")
@DataChangeLog(menu = "RULE_PAYOUT", table = "payout_rule")
public ApiResponse<Long> createPayoutRule(@RequestBody PayoutRuleVO vo) {
mapper.insertPayoutRule(vo);
return ApiResponse.ok(vo.getRuleId());
return ApiResponse.ok(ruleService.createPayoutRule(vo));
}
@PutMapping("/payout/{ruleId}")
@RequirePermission(menu = "RULE_PAYOUT", perm = "UPDATE")
@DataChangeLog(menu = "RULE_PAYOUT", table = "payout_rule")
public ApiResponse<Void> updatePayoutRule(@PathVariable Long ruleId, @RequestBody PayoutRuleVO vo) {
vo.setRuleId(ruleId);
mapper.updatePayoutRule(vo);
ruleService.updatePayoutRule(ruleId, vo);
return ApiResponse.ok();
}
@DeleteMapping("/payout/{ruleId}")
@RequirePermission(menu = "RULE_PAYOUT", perm = "DELETE")
@DataChangeLog(menu = "RULE_PAYOUT", table = "payout_rule")
public ApiResponse<Void> deletePayoutRule(@PathVariable Long ruleId) {
mapper.deletePayoutRule(ruleId);
ruleService.deletePayoutRule(ruleId);
return ApiResponse.ok();
}
@@ -87,28 +85,29 @@ public class RuleController {
@GetMapping("/override")
@RequirePermission(menu = "RULE_OVERRIDE", perm = "READ")
public ApiResponse<List<OverrideRuleVO>> listOverrideRules() {
return ApiResponse.ok(mapper.selectOverrideRules());
return ApiResponse.ok(ruleService.listOverrideRules());
}
@PostMapping("/override")
@RequirePermission(menu = "RULE_OVERRIDE", perm = "CREATE")
@DataChangeLog(menu = "RULE_OVERRIDE", table = "override_rule")
public ApiResponse<Long> createOverrideRule(@RequestBody OverrideRuleVO vo) {
mapper.insertOverrideRule(vo);
return ApiResponse.ok(vo.getOverrideId());
return ApiResponse.ok(ruleService.createOverrideRule(vo));
}
@PutMapping("/override/{overrideId}")
@RequirePermission(menu = "RULE_OVERRIDE", perm = "UPDATE")
@DataChangeLog(menu = "RULE_OVERRIDE", table = "override_rule")
public ApiResponse<Void> updateOverrideRule(@PathVariable Long overrideId, @RequestBody OverrideRuleVO vo) {
vo.setOverrideId(overrideId);
mapper.updateOverrideRule(vo);
ruleService.updateOverrideRule(overrideId, vo);
return ApiResponse.ok();
}
@DeleteMapping("/override/{overrideId}")
@RequirePermission(menu = "RULE_OVERRIDE", perm = "DELETE")
@DataChangeLog(menu = "RULE_OVERRIDE", table = "override_rule")
public ApiResponse<Void> deleteOverrideRule(@PathVariable Long overrideId) {
mapper.deleteOverrideRule(overrideId);
ruleService.deleteOverrideRule(overrideId);
return ApiResponse.ok();
}
@@ -116,28 +115,29 @@ public class RuleController {
@GetMapping("/chargeback")
@RequirePermission(menu = "RULE_CHARGEBACK", perm = "READ")
public ApiResponse<List<ChargebackRuleVO>> listChargebackRules(@RequestParam(required = false) String companyCode) {
return ApiResponse.ok(mapper.selectChargebackRules(companyCode));
return ApiResponse.ok(ruleService.listChargebackRules(companyCode));
}
@PostMapping("/chargeback")
@RequirePermission(menu = "RULE_CHARGEBACK", perm = "CREATE")
@DataChangeLog(menu = "RULE_CHARGEBACK", table = "chargeback_rule")
public ApiResponse<Long> createChargebackRule(@RequestBody ChargebackRuleVO vo) {
mapper.insertChargebackRule(vo);
return ApiResponse.ok(vo.getCbRuleId());
return ApiResponse.ok(ruleService.createChargebackRule(vo));
}
@PutMapping("/chargeback/{cbRuleId}")
@RequirePermission(menu = "RULE_CHARGEBACK", perm = "UPDATE")
@DataChangeLog(menu = "RULE_CHARGEBACK", table = "chargeback_rule")
public ApiResponse<Void> updateChargebackRule(@PathVariable Long cbRuleId, @RequestBody ChargebackRuleVO vo) {
vo.setCbRuleId(cbRuleId);
mapper.updateChargebackRule(vo);
ruleService.updateChargebackRule(cbRuleId, vo);
return ApiResponse.ok();
}
@DeleteMapping("/chargeback/{cbRuleId}")
@RequirePermission(menu = "RULE_CHARGEBACK", perm = "DELETE")
@DataChangeLog(menu = "RULE_CHARGEBACK", table = "chargeback_rule")
public ApiResponse<Void> deleteChargebackRule(@PathVariable Long cbRuleId) {
mapper.deleteChargebackRule(cbRuleId);
ruleService.deleteChargebackRule(cbRuleId);
return ApiResponse.ok();
}
@@ -145,22 +145,23 @@ public class RuleController {
@GetMapping("/exception-codes")
@RequirePermission(menu = "RULE_EXCEPTION", perm = "READ")
public ApiResponse<List<ExceptionTypeCodeVO>> listExceptionCodes() {
return ApiResponse.ok(mapper.selectExceptionCodes());
return ApiResponse.ok(ruleService.listExceptionCodes());
}
@PostMapping("/exception-codes")
@RequirePermission(menu = "RULE_EXCEPTION", perm = "CREATE")
@DataChangeLog(menu = "RULE_EXCEPTION", table = "exception_type_code")
public ApiResponse<Void> createExceptionCode(@RequestBody ExceptionTypeCodeVO vo) {
mapper.insertExceptionCode(vo);
ruleService.createExceptionCode(vo);
return ApiResponse.ok();
}
@PutMapping("/exception-codes/{exceptionCode}")
@RequirePermission(menu = "RULE_EXCEPTION", perm = "UPDATE")
@DataChangeLog(menu = "RULE_EXCEPTION", table = "exception_type_code")
public ApiResponse<Void> updateExceptionCode(@PathVariable String exceptionCode,
@RequestBody ExceptionTypeCodeVO vo) {
vo.setExceptionCode(exceptionCode);
mapper.updateExceptionCode(vo);
ruleService.updateExceptionCode(exceptionCode, vo);
return ApiResponse.ok();
}
}
@@ -1,42 +1,41 @@
package com.ga.api.controller.settle;
import com.ga.api.service.transfer.TransferFileService;
import com.ga.api.service.settle.PaymentService;
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.mapper.settle.PaymentMapper;
import com.ga.core.vo.settle.PaymentResp;
import com.ga.core.vo.settle.SettleSearchParam;
import com.ga.core.vo.settle.TransferFileResp;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Pattern;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Tag(name = "지급 관리")
@Validated
@RestController
@RequestMapping("/api/payments")
@RequiredArgsConstructor
public class PaymentController {
private final PaymentMapper mapper;
private final TransferFileService transferFileService;
private final PaymentService paymentService;
@GetMapping
@RequirePermission(menu = "PAYMENT", perm = "READ")
public ApiResponse<PageResponse<PaymentResp>> list(@ModelAttribute SettleSearchParam param) {
param.startPage();
return ApiResponse.ok(PageResponse.of(mapper.selectList(param)));
return ApiResponse.ok(paymentService.list(param));
}
@PutMapping("/{paymentId}/status")
@RequirePermission(menu = "PAYMENT", perm = "UPDATE")
@DataChangeLog(menu = "PAYMENT", table = "payment")
public ApiResponse<Void> updateStatus(@PathVariable Long paymentId, @RequestBody Map<String, String> body) {
mapper.updateStatus(paymentId, body.get("status"), body.get("failReason"));
public ApiResponse<Void> updateStatus(@PathVariable Long paymentId, @Valid @RequestBody PaymentStatusUpdateReq req) {
paymentService.updateStatus(paymentId, req.getStatus(), req.getFailReason());
return ApiResponse.ok();
}
@@ -45,8 +44,48 @@ public class PaymentController {
@RequirePermission(menu = "PAYMENT", perm = "EXPORT")
@DataChangeLog(menu = "PAYMENT", table = "payment")
public ApiResponse<TransferFileResp> generateTransferFile(
@RequestParam String settleMonth,
@Pattern(regexp = "\\d{6}", message = "settleMonth는 YYYYMM 형식이어야 합니다") @RequestParam String settleMonth,
@RequestParam String bankCode) {
return ApiResponse.ok(transferFileService.generate(settleMonth, bankCode));
return ApiResponse.ok(paymentService.generateTransferFile(settleMonth, bankCode));
}
@Operation(summary = "세금 단건 재계산")
@PostMapping("/{paymentId}/calculate-tax")
@RequirePermission(menu = "PAYMENT", perm = "UPDATE")
@DataChangeLog(menu = "PAYMENT", table = "payment")
public ApiResponse<Void> calculateTax(@PathVariable Long paymentId) {
paymentService.calculateTaxes(paymentId);
return ApiResponse.ok();
}
@Operation(summary = "세금 월별 일괄 재계산")
@PostMapping("/calculate-taxes")
@RequirePermission(menu = "PAYMENT", perm = "UPDATE")
@DataChangeLog(menu = "PAYMENT", table = "payment")
public ApiResponse<Void> calculateTaxesForMonth(
@Pattern(regexp = "\\d{6}", message = "settleMonth는 YYYYMM 형식이어야 합니다") @RequestParam String settleMonth) {
paymentService.calculateTaxesForMonth(settleMonth);
return ApiResponse.ok();
}
@Operation(summary = "차감 단건 적용")
@PostMapping("/{paymentId}/apply-deductions")
@RequirePermission(menu = "PAYMENT", perm = "UPDATE")
@DataChangeLog(menu = "PAYMENT", table = "payment")
public ApiResponse<Void> applyDeductions(
@PathVariable Long paymentId,
@Pattern(regexp = "\\d{6}", message = "settleMonth는 YYYYMM 형식이어야 합니다") @RequestParam String settleMonth) {
paymentService.applyDeductions(paymentId, settleMonth);
return ApiResponse.ok();
}
@Operation(summary = "차감 월별 일괄 적용")
@PostMapping("/apply-deductions")
@RequirePermission(menu = "PAYMENT", perm = "UPDATE")
@DataChangeLog(menu = "PAYMENT", table = "payment")
public ApiResponse<Void> applyDeductionsForMonth(
@Pattern(regexp = "\\d{6}", message = "settleMonth는 YYYYMM 형식이어야 합니다") @RequestParam String settleMonth) {
paymentService.applyDeductionsForMonth(settleMonth);
return ApiResponse.ok();
}
}
@@ -0,0 +1,15 @@
package com.ga.api.controller.settle;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class PaymentStatusUpdateReq {
@NotBlank
private String status;
private String failReason;
}
@@ -0,0 +1,85 @@
package com.ga.api.controller.statement;
import com.ga.api.service.statement.CommissionStatementService;
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.settle.CommissionStatementResp;
import com.ga.core.vo.settle.CommissionStatementSaveReq;
import com.ga.core.vo.settle.CommissionStatementSearchParam;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Pattern;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
@Tag(name = "수수료 명세서")
@Validated
@RestController
@RequestMapping("/api/statements")
@RequiredArgsConstructor
public class CommissionStatementController {
private final CommissionStatementService statementService;
@GetMapping
@RequirePermission(menu = "STATEMENT", perm = "READ")
public ApiResponse<PageResponse<CommissionStatementResp>> list(@ModelAttribute CommissionStatementSearchParam param) {
return ApiResponse.ok(statementService.list(param));
}
@GetMapping("/{statementId}")
@RequirePermission(menu = "STATEMENT", perm = "READ")
public ApiResponse<CommissionStatementResp> detail(@PathVariable Long statementId) {
return ApiResponse.ok(statementService.detail(statementId));
}
@Operation(summary = "명세서 단건 발급")
@PostMapping("/generate")
@RequirePermission(menu = "STATEMENT", perm = "CREATE")
@DataChangeLog(menu = "STATEMENT", table = "commission_statement")
public ApiResponse<Long> generate(@Valid @RequestBody CommissionStatementSaveReq req) {
return ApiResponse.ok(statementService.generate(req));
}
@Operation(summary = "명세서 월별 일괄 발급")
@PostMapping("/generate-for-month")
@RequirePermission(menu = "STATEMENT", perm = "CREATE")
@DataChangeLog(menu = "STATEMENT", table = "commission_statement")
public ApiResponse<Void> generateForMonth(
@Pattern(regexp = "\\d{6}", message = "settleMonth는 YYYYMM 형식이어야 합니다") @RequestParam String settleMonth,
@RequestParam String statementType) {
statementService.generateForMonth(settleMonth, statementType);
return ApiResponse.ok();
}
@Operation(summary = "명세서 다운로드")
@GetMapping("/{statementId}/download")
@RequirePermission(menu = "STATEMENT", perm = "READ")
public void download(@PathVariable Long statementId, HttpServletResponse response) throws IOException {
byte[] content = statementService.download(statementId);
String encoded = URLEncoder.encode("수수료명세서_" + statementId + ".xlsx", StandardCharsets.UTF_8)
.replace("+", "%20");
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encoded);
response.setContentLength(content.length);
response.getOutputStream().write(content);
}
@Operation(summary = "명세서 취소")
@PutMapping("/{statementId}/cancel")
@RequirePermission(menu = "STATEMENT", perm = "UPDATE")
@DataChangeLog(menu = "STATEMENT", table = "commission_statement")
public ApiResponse<Void> cancel(@PathVariable Long statementId) {
statementService.cancel(statementId);
return ApiResponse.ok();
}
}
@@ -1,13 +1,12 @@
package com.ga.api.controller.system;
import com.ga.api.service.system.RoleService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.core.mapper.user.RoleMapper;
import com.ga.core.vo.user.RoleVO;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@@ -19,63 +18,55 @@ import java.util.Map;
@RequiredArgsConstructor
public class RoleController {
private final RoleMapper mapper;
private final RoleService roleService;
@GetMapping
@RequirePermission(menu = "SYSTEM_ROLE", perm = "READ")
public ApiResponse<List<RoleVO>> list() {
return ApiResponse.ok(mapper.selectAll());
return ApiResponse.ok(roleService.list());
}
@GetMapping("/{roleId}")
@RequirePermission(menu = "SYSTEM_ROLE", perm = "READ")
public ApiResponse<RoleVO> detail(@PathVariable Long roleId) {
return ApiResponse.ok(mapper.selectById(roleId));
return ApiResponse.ok(roleService.detail(roleId));
}
@GetMapping("/{roleId}/permissions")
@RequirePermission(menu = "SYSTEM_ROLE", perm = "READ")
public ApiResponse<List<Map<String, Object>>> permissions(@PathVariable Long roleId) {
return ApiResponse.ok(mapper.selectRolePermissions(roleId));
return ApiResponse.ok(roleService.permissions(roleId));
}
@PostMapping
@RequirePermission(menu = "SYSTEM_ROLE", perm = "CREATE")
@DataChangeLog(menu = "SYSTEM_ROLE", table = "role")
public ApiResponse<Long> create(@RequestBody RoleVO vo) {
mapper.insert(vo);
return ApiResponse.ok(vo.getRoleId());
return ApiResponse.ok(roleService.create(vo));
}
@PutMapping("/{roleId}")
@RequirePermission(menu = "SYSTEM_ROLE", perm = "UPDATE")
@DataChangeLog(menu = "SYSTEM_ROLE", table = "role")
public ApiResponse<Void> update(@PathVariable Long roleId, @RequestBody RoleVO vo) {
vo.setRoleId(roleId);
mapper.update(vo);
roleService.update(roleId, vo);
return ApiResponse.ok();
}
@DeleteMapping("/{roleId}")
@RequirePermission(menu = "SYSTEM_ROLE", perm = "DELETE")
@DataChangeLog(menu = "SYSTEM_ROLE", table = "role")
public ApiResponse<Void> delete(@PathVariable Long roleId) {
mapper.deleteById(roleId);
roleService.delete(roleId);
return ApiResponse.ok();
}
/** 권한 매트릭스 일괄 저장 */
@PutMapping("/{roleId}/permissions")
@RequirePermission(menu = "SYSTEM_ROLE", perm = "UPDATE")
@Transactional
@DataChangeLog(menu = "SYSTEM_ROLE", table = "role_permission")
public ApiResponse<Void> savePermissions(@PathVariable Long roleId,
@RequestBody List<Map<String, Object>> permissions) {
mapper.deleteRolePermissions(roleId);
for (Map<String, Object> p : permissions) {
Long menuId = ((Number) p.get("menuId")).longValue();
String permCode = (String) p.get("permCode");
String isGranted = (String) p.getOrDefault("isGranted", "Y");
mapper.insertRolePermission(roleId, menuId, permCode, isGranted);
}
roleService.savePermissions(roleId, permissions);
return ApiResponse.ok();
}
}
@@ -9,7 +9,9 @@ import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.Max;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@@ -17,6 +19,7 @@ import java.util.List;
import java.util.Map;
@Tag(name = "엑셀 업로드")
@Validated
@RestController
@RequiredArgsConstructor
public class UploadController {
@@ -41,7 +44,7 @@ public class UploadController {
public ApiResponse<List<Map<String, Object>>> preview(
@PathVariable String templateCode,
@RequestParam("file") MultipartFile file,
@RequestParam(value = "limit", defaultValue = "5") int limit) {
@Max(100) @RequestParam(value = "limit", defaultValue = "5") int limit) {
List<Map<String, Object>> rows = uploadService.preview(templateCode, file, limit);
return ApiResponse.ok(rows);
}
@@ -1,21 +1,15 @@
package com.ga.api.controller.upload;
import com.ga.api.service.upload.UploadTemplateService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.common.util.SecurityUtil;
import com.ga.core.mapper.upload.UploadColumnMapper;
import com.ga.core.mapper.upload.UploadHistoryMapper;
import com.ga.core.mapper.upload.UploadTemplateMapper;
import com.ga.core.vo.upload.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@@ -26,68 +20,37 @@ import java.util.List;
@RequiredArgsConstructor
public class UploadTemplateController {
private final UploadTemplateMapper templateMapper;
private final UploadColumnMapper columnMapper;
private final UploadHistoryMapper historyMapper;
private final UploadTemplateService templateService;
@Operation(summary = "템플릿 목록 조회")
@GetMapping
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ")
public ApiResponse<PageResponse<UploadTemplateResp>> list(@ModelAttribute UploadTemplateSearchParam param) {
param.startPage();
return ApiResponse.ok(PageResponse.of(templateMapper.selectList(param)));
return ApiResponse.ok(templateService.list(param));
}
@Operation(summary = "템플릿 상세 조회 (컬럼 포함)")
@GetMapping("/{templateId}")
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ")
public ApiResponse<UploadTemplateResp> detail(@PathVariable Long templateId) {
UploadTemplateResp resp = templateMapper.selectById(templateId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
resp.setColumns(columnMapper.selectByTemplateId(templateId));
return ApiResponse.ok(resp);
return ApiResponse.ok(templateService.detail(templateId));
}
@Operation(summary = "템플릿 등록")
@PostMapping
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "CREATE")
@DataChangeLog(menu = "SYSTEM_UPLOAD_TEMPLATE", table = "upload_template")
@Transactional
public ApiResponse<Long> create(@Valid @RequestBody UploadTemplateSaveReq req) {
if (templateMapper.existsByCode(req.getTemplateCode()) > 0) {
throw new BizException(ErrorCode.DUPLICATE_DATA, "이미 존재하는 템플릿 코드입니다: " + req.getTemplateCode());
}
UploadTemplateVO vo = req.toVO();
vo.setCreatedBy(SecurityUtil.getCurrentUserId());
templateMapper.insert(vo);
if (req.getColumns() != null && !req.getColumns().isEmpty()) {
columnMapper.insertBatch(vo.getTemplateId(), req.getColumns());
}
return ApiResponse.ok(vo.getTemplateId());
return ApiResponse.ok(templateService.create(req));
}
@Operation(summary = "템플릿 수정")
@PutMapping("/{templateId}")
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "UPDATE")
@DataChangeLog(menu = "SYSTEM_UPLOAD_TEMPLATE", table = "upload_template")
@Transactional
public ApiResponse<Void> update(@PathVariable Long templateId,
@Valid @RequestBody UploadTemplateSaveReq req) {
if (templateMapper.selectById(templateId) == null) {
throw new BizException(ErrorCode.NOT_FOUND);
}
UploadTemplateVO vo = req.toVO();
vo.setTemplateId(templateId);
vo.setUpdatedBy(SecurityUtil.getCurrentUserId());
templateMapper.update(vo);
if (req.getColumns() != null) {
columnMapper.deleteByTemplateId(templateId);
if (!req.getColumns().isEmpty()) {
columnMapper.insertBatch(templateId, req.getColumns());
}
}
templateService.update(templateId, req);
return ApiResponse.ok();
}
@@ -95,13 +58,8 @@ public class UploadTemplateController {
@DeleteMapping("/{templateId}")
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "DELETE")
@DataChangeLog(menu = "SYSTEM_UPLOAD_TEMPLATE", table = "upload_template")
@Transactional
public ApiResponse<Void> delete(@PathVariable Long templateId) {
if (templateMapper.selectById(templateId) == null) {
throw new BizException(ErrorCode.NOT_FOUND);
}
columnMapper.deleteByTemplateId(templateId);
templateMapper.delete(templateId);
templateService.delete(templateId);
return ApiResponse.ok();
}
@@ -109,23 +67,16 @@ public class UploadTemplateController {
@GetMapping("/{templateId}/columns")
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ")
public ApiResponse<List<UploadTemplateColumnVO>> columns(@PathVariable Long templateId) {
return ApiResponse.ok(columnMapper.selectByTemplateId(templateId));
return ApiResponse.ok(templateService.columns(templateId));
}
@Operation(summary = "템플릿 컬럼 전체 갱신 (기존 삭제 후 재등록)")
@PutMapping("/{templateId}/columns")
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "UPDATE")
@DataChangeLog(menu = "SYSTEM_UPLOAD_TEMPLATE", table = "upload_template_column")
@Transactional
public ApiResponse<Void> updateColumns(@PathVariable Long templateId,
@RequestBody List<UploadTemplateColumnVO> columns) {
if (templateMapper.selectById(templateId) == null) {
throw new BizException(ErrorCode.NOT_FOUND);
}
columnMapper.deleteByTemplateId(templateId);
if (columns != null && !columns.isEmpty()) {
columnMapper.insertBatch(templateId, columns);
}
templateService.updateColumns(templateId, columns);
return ApiResponse.ok();
}
@@ -134,7 +85,6 @@ public class UploadTemplateController {
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ")
public ApiResponse<PageResponse<UploadHistoryResp>> history(@PathVariable Long templateId,
@ModelAttribute UploadTemplateSearchParam param) {
param.startPage();
return ApiResponse.ok(PageResponse.of(historyMapper.selectByTemplate(templateId, param)));
return ApiResponse.ok(templateService.history(templateId, param));
}
}
@@ -0,0 +1,45 @@
package com.ga.api.service.chargeback;
import com.ga.core.mapper.chargeback.ChargebackGradeMapper;
import com.ga.core.vo.chargeback.ChargebackGradeVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
@Component
@RequiredArgsConstructor
public class ChargebackCalculator {
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final ChargebackGradeMapper gradeMapper;
public ChargebackResult calculate(LocalDate contractDate, LocalDate terminationDate,
BigDecimal originalCommission, String productCategory) {
int monthsElapsed = (int) ChronoUnit.MONTHS.between(contractDate, terminationDate);
String today = LocalDate.now().format(DATE_FMT);
ChargebackGradeVO grade = gradeMapper.selectByMonths(productCategory, monthsElapsed, today);
if (grade == null) {
return new ChargebackResult(BigDecimal.ZERO, monthsElapsed, BigDecimal.ZERO,
"적용 환수 구간 없음");
}
BigDecimal chargebackAmount = originalCommission
.multiply(grade.getChargebackPercent())
.divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP);
return new ChargebackResult(
chargebackAmount,
monthsElapsed,
grade.getChargebackPercent(),
String.format("경과월 %d개월 → 환수율 %s%%", monthsElapsed, grade.getChargebackPercent())
);
}
}
@@ -0,0 +1,86 @@
package com.ga.api.service.chargeback;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.chargeback.ChargebackGradeMapper;
import com.ga.core.vo.chargeback.ChargebackGradeResp;
import com.ga.core.vo.chargeback.ChargebackGradeSaveReq;
import com.ga.core.vo.chargeback.ChargebackGradeSearchParam;
import com.ga.core.vo.chargeback.ChargebackGradeVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
@Service
@RequiredArgsConstructor
public class ChargebackGradeService {
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final ChargebackGradeMapper gradeMapper;
@Transactional(readOnly = true)
public PageResponse<ChargebackGradeResp> list(ChargebackGradeSearchParam param) {
param.startPage();
return PageResponse.of(gradeMapper.selectList(param));
}
@Transactional(readOnly = true)
public ChargebackGradeResp detail(Long gradeId) {
ChargebackGradeResp resp = gradeMapper.selectDetailById(gradeId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public void create(ChargebackGradeSaveReq req) {
String today = LocalDate.now().format(DATE_FMT);
ChargebackGradeVO existing = gradeMapper.selectByMonths(
req.getProductCategory(), req.getMonthsFrom(), today);
if (existing != null) {
throw new BizException(ErrorCode.DUPLICATE_DATA);
}
gradeMapper.insert(toVO(req));
}
@Transactional
public void update(Long gradeId, ChargebackGradeSaveReq req) {
ChargebackGradeVO existing = gradeMapper.selectById(gradeId);
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
ChargebackGradeVO vo = toVO(req);
vo.setGradeId(gradeId);
vo.setIsActive(existing.getIsActive());
gradeMapper.update(vo);
}
@Transactional
public void deactivate(Long gradeId) {
ChargebackGradeVO existing = gradeMapper.selectById(gradeId);
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
if (Boolean.FALSE.equals(existing.getIsActive())) {
throw new BizException(ErrorCode.INVALID_PARAMETER);
}
gradeMapper.updateStatus(gradeId, false);
}
@Transactional(readOnly = true)
public ChargebackGradeVO selectByMonths(String productCategory, Integer monthsElapsed, String baseDate) {
return gradeMapper.selectByMonths(productCategory, monthsElapsed, baseDate);
}
private ChargebackGradeVO toVO(ChargebackGradeSaveReq req) {
ChargebackGradeVO vo = new ChargebackGradeVO();
vo.setProductCategory(req.getProductCategory());
vo.setMonthsFrom(req.getMonthsFrom());
vo.setMonthsTo(req.getMonthsTo());
vo.setChargebackPercent(req.getChargebackPercent());
vo.setEffectiveFrom(req.getEffectiveFrom());
vo.setEffectiveTo(req.getEffectiveTo());
vo.setIsActive(true);
return vo;
}
}
@@ -0,0 +1,10 @@
package com.ga.api.service.chargeback;
import java.math.BigDecimal;
public record ChargebackResult(
BigDecimal chargebackAmount,
int monthsElapsed,
BigDecimal appliedPercent,
String reason
) {}
@@ -0,0 +1,99 @@
package com.ga.api.service.deduction;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.settle.AgentDeductionMapper;
import com.ga.core.vo.settle.AgentDeductionResp;
import com.ga.core.vo.settle.AgentDeductionSaveReq;
import com.ga.core.vo.settle.AgentDeductionSearchParam;
import com.ga.core.vo.settle.AgentDeductionVO;
import com.ga.core.vo.settle.DeductionStatus;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class DeductionService {
private final AgentDeductionMapper mapper;
@Transactional(readOnly = true)
public PageResponse<AgentDeductionResp> list(AgentDeductionSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
@Transactional(readOnly = true)
public AgentDeductionResp detail(Long deductionId) {
AgentDeductionResp resp = mapper.selectDetailById(deductionId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public Long create(AgentDeductionSaveReq req) {
if (mapper.countByAgentMonthType(req.getAgentId(), req.getSettleMonth(), req.getDeductionType()) > 0) {
throw new BizException(ErrorCode.DUPLICATE_DATA,
"동일 설계사/정산월/차감유형 조합이 이미 존재합니다");
}
AgentDeductionVO vo = new AgentDeductionVO();
vo.setAgentId(req.getAgentId());
vo.setSettleMonth(req.getSettleMonth());
vo.setDeductionType(req.getDeductionType());
vo.setAmount(req.getAmount());
vo.setDescription(req.getDescription());
vo.setStatus(DeductionStatus.PENDING.name());
mapper.insert(vo);
return vo.getDeductionId();
}
@Transactional
public void update(Long deductionId, AgentDeductionSaveReq req) {
AgentDeductionVO vo = mapper.selectById(deductionId);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!DeductionStatus.PENDING.name().equals(vo.getStatus())) {
throw new BizException(ErrorCode.INVALID_PARAMETER, "PENDING 상태만 수정 가능합니다");
}
vo.setAmount(req.getAmount());
vo.setDescription(req.getDescription());
mapper.update(vo);
}
@Transactional
public void cancel(Long deductionId) {
AgentDeductionVO vo = mapper.selectById(deductionId);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
if (DeductionStatus.APPLIED.name().equals(vo.getStatus())) {
throw new BizException(ErrorCode.INVALID_PARAMETER, "이미 적용된 차감은 취소할 수 없습니다");
}
mapper.updateStatus(deductionId, DeductionStatus.CANCELLED.name());
}
@Transactional(readOnly = true)
public BigDecimal sumPendingByAgent(Long agentId, String settleMonth) {
BigDecimal sum = mapper.sumPendingByAgent(agentId, settleMonth);
return sum != null ? sum : BigDecimal.ZERO;
}
@Transactional(readOnly = true)
public List<AgentDeductionVO> selectPendingByMonth(String settleMonth) {
return mapper.selectPendingByMonth(settleMonth);
}
@Transactional(readOnly = true)
public List<AgentDeductionVO> selectPendingByAgent(Long agentId, String settleMonth) {
return mapper.selectPendingByAgent(agentId, settleMonth);
}
@Transactional
public void markAsApplied(List<Long> deductionIds) {
if (deductionIds.isEmpty()) return;
mapper.updateStatusBatch(deductionIds, DeductionStatus.APPLIED.name());
}
}
@@ -0,0 +1,51 @@
package com.ga.api.service.installment;
import com.ga.core.mapper.installment.InstallmentRatioMapper;
import com.ga.core.vo.installment.InstallmentPlanVO;
import com.ga.core.vo.installment.InstallmentRatioVO;
import com.ga.core.vo.installment.InstallmentStatus;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
@Component
@RequiredArgsConstructor
public class InstallmentPlanGenerator {
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final DateTimeFormatter MONTH_FMT = DateTimeFormatter.ofPattern("yyyyMM");
private final InstallmentRatioMapper ratioMapper;
/**
* 계약 체결일 기준 1~7차년 분급 계획 생성.
* ratioPercent 합이 100% 되는 경우 연차별 비율만큼 지급.
*/
public List<InstallmentPlanVO> generate(Long contractId, BigDecimal totalCommission,
LocalDate contractDate, String productCategory) {
String baseDate = contractDate.format(DATE_FMT);
List<InstallmentRatioVO> ratios = ratioMapper.selectAllActiveRatios(productCategory, baseDate);
List<InstallmentPlanVO> plans = new ArrayList<>();
for (InstallmentRatioVO ratio : ratios) {
InstallmentPlanVO plan = new InstallmentPlanVO();
plan.setContractId(contractId);
plan.setPlanYear(ratio.getPlanYear());
plan.setPlanMonth(1); // 단순화: 1회 지급 (plan_month=1)
plan.setSettleMonth(contractDate.plusYears(ratio.getPlanYear()).format(MONTH_FMT));
plan.setExpectedAmount(
totalCommission.multiply(ratio.getRatioPercent())
.divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP)
);
plan.setStatus(InstallmentStatus.SCHEDULED.code());
plans.add(plan);
}
return plans;
}
}
@@ -0,0 +1,136 @@
package com.ga.api.service.installment;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.installment.InstallmentPlanMapper;
import com.ga.core.mapper.installment.InstallmentRatioMapper;
import com.ga.core.vo.installment.InstallmentPlanResp;
import com.ga.core.vo.installment.InstallmentPlanSearchParam;
import com.ga.core.vo.installment.InstallmentPlanVO;
import com.ga.core.vo.installment.InstallmentRatioResp;
import com.ga.core.vo.installment.InstallmentRatioSaveReq;
import com.ga.core.vo.installment.InstallmentRatioSearchParam;
import com.ga.core.vo.installment.InstallmentRatioVO;
import com.ga.core.vo.installment.InstallmentStatus;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@Service
@RequiredArgsConstructor
public class InstallmentService {
private final InstallmentPlanMapper planMapper;
private final InstallmentRatioMapper ratioMapper;
private final InstallmentPlanGenerator generator;
// ---- Plan 조회 ----
@Transactional(readOnly = true)
public PageResponse<InstallmentPlanResp> list(InstallmentPlanSearchParam param) {
param.startPage();
return PageResponse.of(planMapper.selectList(param));
}
@Transactional(readOnly = true)
public InstallmentPlanResp detail(Long planId) {
InstallmentPlanResp resp = planMapper.selectDetailById(planId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional(readOnly = true)
public List<InstallmentPlanVO> selectScheduledForMonth(String settleMonth) {
return planMapper.selectScheduledByMonth(settleMonth);
}
// ---- Ratio 조회 ----
@Transactional(readOnly = true)
public PageResponse<InstallmentRatioResp> listRatios(InstallmentRatioSearchParam param) {
param.startPage();
return PageResponse.of(ratioMapper.selectList(param));
}
// ---- Plan 생성 ----
@Transactional
public void createPlan(Long contractId, BigDecimal totalCommission,
LocalDate contractDate, String productCategory) {
List<InstallmentPlanVO> plans = generator.generate(contractId, totalCommission, contractDate, productCategory);
if (!plans.isEmpty()) {
planMapper.insertBatch(plans);
}
}
// ---- Plan 상태 전이 ----
@Transactional
public void payPlan(Long planId, Long paymentId, BigDecimal paidAmount) {
InstallmentPlanVO plan = requirePlan(planId);
if (!InstallmentStatus.SCHEDULED.code().equals(plan.getStatus())
&& !InstallmentStatus.DEFERRED.code().equals(plan.getStatus())) {
throw new BizException(ErrorCode.INVALID_PARAMETER, "지급 처리 가능한 상태가 아닙니다");
}
plan.setStatus(InstallmentStatus.PAID.code());
plan.setPaidAmount(paidAmount);
plan.setPaidAt(LocalDateTime.now());
plan.setPaymentId(paymentId);
planMapper.update(plan);
}
@Transactional
public void deferPlan(Long planId) {
InstallmentPlanVO plan = requirePlan(planId);
if (!InstallmentStatus.SCHEDULED.code().equals(plan.getStatus())) {
throw new BizException(ErrorCode.INVALID_PARAMETER, "SCHEDULED 상태만 유예 가능합니다");
}
planMapper.updateStatus(planId, InstallmentStatus.DEFERRED.code());
}
@Transactional
public void cancelPlan(Long planId) {
InstallmentPlanVO plan = requirePlan(planId);
if (InstallmentStatus.PAID.code().equals(plan.getStatus())) {
throw new BizException(ErrorCode.INVALID_PARAMETER, "지급 완료된 분급은 취소할 수 없습니다");
}
planMapper.updateStatus(planId, InstallmentStatus.CANCELLED.code());
}
@Transactional
public void payScheduledForMonth(String settleMonth) {
List<InstallmentPlanVO> scheduled = planMapper.selectScheduledByMonth(settleMonth);
for (InstallmentPlanVO plan : scheduled) {
plan.setStatus(InstallmentStatus.PAID.code());
plan.setPaidAmount(plan.getExpectedAmount());
plan.setPaidAt(LocalDateTime.now());
planMapper.update(plan);
}
}
// ---- Ratio 생성 ----
@Transactional
public void createRatio(InstallmentRatioSaveReq req) {
InstallmentRatioVO vo = new InstallmentRatioVO();
vo.setProductCategory(req.getProductCategory());
vo.setPlanYear(req.getPlanYear());
vo.setRatioPercent(req.getRatioPercent());
vo.setEffectiveFrom(req.getEffectiveFrom());
vo.setEffectiveTo(req.getEffectiveTo());
vo.setIsActive(true);
ratioMapper.insert(vo);
}
private InstallmentPlanVO requirePlan(Long planId) {
InstallmentPlanVO plan = planMapper.selectById(planId);
if (plan == null) throw new BizException(ErrorCode.NOT_FOUND);
return plan;
}
}
@@ -7,7 +7,11 @@ import com.ga.common.util.SecurityUtil;
import com.ga.core.mapper.ledger.ExceptionLedgerMapper;
import com.ga.core.mapper.ledger.MaintainLedgerMapper;
import com.ga.core.mapper.ledger.RecruitLedgerMapper;
import com.ga.core.mapstruct.ExceptionLedgerMapStruct;
import com.ga.core.vo.ledger.*;
import com.ga.core.vo.ledger.ApproveStatus;
import com.ga.core.vo.ledger.ExceptionLedgerStatus;
import org.apache.ibatis.cursor.Cursor;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -19,34 +23,41 @@ public class LedgerService {
private final RecruitLedgerMapper recruitMapper;
private final MaintainLedgerMapper maintainMapper;
private final ExceptionLedgerMapper exceptionMapper;
private final ExceptionLedgerMapStruct exceptionLedgerMapStruct;
@Transactional(readOnly = true)
public PageResponse<LedgerResp> listRecruit(LedgerSearchParam param) {
param.startPage();
return PageResponse.of(recruitMapper.selectList(param));
}
@Transactional(readOnly = true)
public PageResponse<LedgerResp> listMaintain(LedgerSearchParam param) {
param.startPage();
return PageResponse.of(maintainMapper.selectList(param));
}
@Transactional(readOnly = true)
public PageResponse<ExceptionLedgerResp> listException(LedgerSearchParam param) {
param.startPage();
return PageResponse.of(exceptionMapper.selectList(param));
}
@Transactional(readOnly = true)
public LedgerResp detailRecruit(Long ledgerId) {
LedgerResp r = recruitMapper.selectDetailById(ledgerId);
if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
return r;
}
@Transactional(readOnly = true)
public LedgerResp detailMaintain(Long ledgerId) {
LedgerResp r = maintainMapper.selectDetailById(ledgerId);
if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
return r;
}
@Transactional(readOnly = true)
public ExceptionLedgerResp detailException(Long ledgerId) {
ExceptionLedgerResp r = exceptionMapper.selectDetailById(ledgerId);
if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
@@ -55,27 +66,25 @@ public class LedgerService {
@Transactional
public Long createException(ExceptionLedgerSaveReq req) {
ExceptionLedgerVO vo = new ExceptionLedgerVO();
vo.setAgentId(req.getAgentId());
vo.setExceptionCode(req.getExceptionCode());
vo.setSettleMonth(req.getSettleMonth());
vo.setAmount(req.getAmount());
vo.setPolicyNo(req.getPolicyNo());
vo.setCompanyCode(req.getCompanyCode());
vo.setInsuranceType(req.getInsuranceType());
vo.setDescription(req.getDescription());
vo.setRecurringYn(req.getRecurringYn());
vo.setRecurringMonths(req.getRecurringMonths());
ExceptionLedgerVO vo = exceptionLedgerMapStruct.toVO(req);
vo.setRecurringLeft(req.getRecurringMonths());
vo.setApproveStatus("PENDING");
vo.setStatus("NEW");
vo.setApproveStatus(ApproveStatus.PENDING.name());
vo.setStatus(ExceptionLedgerStatus.NEW.name());
exceptionMapper.insert(vo);
return vo.getLedgerId();
}
public Cursor<LedgerExcelVO> exportRecruitCursor(LedgerSearchParam param) {
return recruitMapper.selectCursorForExcel(param);
}
public Cursor<LedgerExcelVO> exportMaintainCursor(LedgerSearchParam param) {
return maintainMapper.selectCursorForExcel(param);
}
@Transactional
public void approveException(Long ledgerId, String status) {
if (!"APPROVED".equals(status) && !"REJECTED".equals(status)) {
if (!ApproveStatus.APPROVED.name().equals(status) && !ApproveStatus.REJECTED.name().equals(status)) {
throw new BizException(ErrorCode.INVALID_PARAMETER, "status는 APPROVED/REJECTED만 가능합니다");
}
Long approverId = SecurityUtil.getCurrentUserId();
@@ -6,10 +6,16 @@ import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.org.AgentMapper;
import com.ga.core.vo.org.*;
import com.ga.core.vo.org.AgentStatus;
import com.ga.core.vo.org.TaxType;
import com.ga.core.vo.org.VatType;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.cursor.Cursor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class AgentService {
@@ -17,11 +23,13 @@ public class AgentService {
private final AgentMapper mapper;
private final CommonCodeService codeService;
@Transactional(readOnly = true)
public PageResponse<AgentResp> list(AgentSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
@Transactional(readOnly = true)
public AgentResp detail(Long agentId) {
AgentResp resp = mapper.selectDetailById(agentId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
@@ -34,7 +42,7 @@ public class AgentService {
throw new BizException(ErrorCode.DUPLICATE_DATA, "이미 등록된 사번입니다");
}
AgentVO vo = toVO(req, null);
vo.setStatus("ACTIVE");
vo.setStatus(AgentStatus.ACTIVE.name());
mapper.insert(vo);
// 입사 이력 기록
AgentOrgHistoryVO h = new AgentOrgHistoryVO();
@@ -76,8 +84,13 @@ public class AgentService {
mapper.updateStatus(agentId, status);
}
public java.util.List<AgentOrgHistoryVO> history(Long agentId) {
return mapper.selectHistory(agentId);
@Transactional(readOnly = true)
public java.util.List<AgentOrgHistoryResp> history(Long agentId) {
return mapper.selectHistoryResp(agentId);
}
public Cursor<AgentExcelVO> exportCursor(AgentSearchParam param) {
return mapper.selectCursorForExcel(param);
}
private AgentVO toVO(AgentSaveReq req, Long agentId) {
@@ -93,6 +106,17 @@ public class AgentService {
vo.setBankCode(req.getBankCode());
vo.setAccountNo(req.getAccountNo());
vo.setJoinDate(req.getJoinDate());
String taxType = req.getTaxType() != null ? req.getTaxType() : TaxType.BUSINESS_INCOME.name();
String vatType = req.getVatType() != null ? req.getVatType() : VatType.NONE.name();
vo.setTaxType(taxType);
vo.setVatType(vatType);
vo.setBusinessNo(req.getBusinessNo());
if (TaxType.BUSINESS_INCOME.name().equals(taxType)
&& (req.getBusinessNo() == null || req.getBusinessNo().isBlank())) {
log.warn("agentId={} taxType=BUSINESS_INCOME이지만 businessNo가 비어있습니다", agentId);
}
return vo;
}
}
@@ -4,6 +4,7 @@ import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.core.mapper.org.OrganizationMapper;
import com.ga.core.vo.org.OrganizationResp;
import com.ga.core.vo.org.OrganizationSaveReq;
import com.ga.core.vo.org.OrganizationVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -17,15 +18,18 @@ public class OrganizationService {
private final OrganizationMapper mapper;
@Transactional(readOnly = true)
public List<OrganizationResp> getTree() {
List<OrganizationResp> flat = mapper.selectAllForTree();
return buildTree(flat);
}
@Transactional(readOnly = true)
public List<OrganizationResp> list(String keyword, String orgType, String isActive) {
return mapper.selectList(keyword, orgType, isActive);
}
@Transactional(readOnly = true)
public OrganizationVO get(Long orgId) {
OrganizationVO vo = mapper.selectById(orgId);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
@@ -33,18 +37,33 @@ public class OrganizationService {
}
@Transactional
public Long create(OrganizationVO vo) {
public Long create(OrganizationSaveReq req) {
OrganizationVO vo = toVO(req, null);
mapper.insert(vo);
return vo.getOrgId();
}
@Transactional
public void update(Long orgId, OrganizationVO vo) {
public void update(Long orgId, OrganizationSaveReq req) {
get(orgId);
vo.setOrgId(orgId);
OrganizationVO vo = toVO(req, orgId);
mapper.update(vo);
}
private OrganizationVO toVO(OrganizationSaveReq req, Long orgId) {
OrganizationVO vo = new OrganizationVO();
vo.setOrgId(orgId);
vo.setOrgName(req.getOrgName());
vo.setParentOrgId(req.getParentOrgId());
vo.setOrgType(req.getOrgType());
vo.setOrgLevel(req.getOrgLevel());
vo.setRegion(req.getRegion());
vo.setEffectiveFrom(req.getEffectiveFrom());
vo.setEffectiveTo(req.getEffectiveTo());
vo.setIsActive(req.getIsActive());
return vo;
}
@Transactional
public void delete(Long orgId) {
if (mapper.countChildren(orgId) > 0) {
@@ -4,9 +4,11 @@ import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.product.ContractMapper;
import com.ga.core.mapstruct.ContractMapStruct;
import com.ga.core.vo.product.ContractResp;
import com.ga.core.vo.product.ContractSaveReq;
import com.ga.core.vo.product.ContractSearchParam;
import com.ga.core.vo.product.ContractStatus;
import com.ga.core.vo.product.ContractVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -17,12 +19,15 @@ import org.springframework.transaction.annotation.Transactional;
public class ContractService {
private final ContractMapper mapper;
private final ContractMapStruct contractMapStruct;
@Transactional(readOnly = true)
public PageResponse<ContractResp> list(ContractSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
@Transactional(readOnly = true)
public ContractResp detail(Long contractId) {
ContractResp resp = mapper.selectDetailById(contractId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
@@ -34,19 +39,8 @@ public class ContractService {
if (mapper.existsByPolicyNo(req.getPolicyNo()) > 0) {
throw new BizException(ErrorCode.DUPLICATE_DATA, "이미 등록된 증권번호입니다");
}
ContractVO vo = new ContractVO();
vo.setAgentId(req.getAgentId());
vo.setProductId(req.getProductId());
vo.setPolicyNo(req.getPolicyNo());
vo.setContractorName(req.getContractorName());
vo.setInsuredName(req.getInsuredName());
vo.setPremium(req.getPremium());
vo.setPayCycle(req.getPayCycle());
vo.setPayPeriod(req.getPayPeriod());
vo.setCoveragePeriod(req.getCoveragePeriod());
vo.setStatus("ACTIVE");
vo.setContractDate(req.getContractDate());
vo.setEffectiveDate(req.getEffectiveDate());
ContractVO vo = contractMapStruct.toVO(req);
vo.setStatus(ContractStatus.ACTIVE.name());
mapper.insert(vo);
return vo.getContractId();
}
@@ -55,15 +49,7 @@ public class ContractService {
public void update(Long contractId, ContractSaveReq req) {
ContractVO vo = mapper.selectById(contractId);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
vo.setAgentId(req.getAgentId());
vo.setProductId(req.getProductId());
vo.setContractorName(req.getContractorName());
vo.setInsuredName(req.getInsuredName());
vo.setPremium(req.getPremium());
vo.setPayCycle(req.getPayCycle());
vo.setPayPeriod(req.getPayPeriod());
vo.setCoveragePeriod(req.getCoveragePeriod());
vo.setEffectiveDate(req.getEffectiveDate());
contractMapStruct.updateVO(req, vo);
mapper.update(vo);
}
@@ -0,0 +1,43 @@
package com.ga.api.service.product;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.core.mapper.product.InsuranceCompanyMapper;
import com.ga.core.vo.product.InsuranceCompanyVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
public class InsuranceCompanyService {
private final InsuranceCompanyMapper mapper;
@Transactional(readOnly = true)
public List<InsuranceCompanyVO> list(boolean activeOnly) {
return activeOnly ? mapper.selectActive() : mapper.selectAll();
}
@Transactional(readOnly = true)
public InsuranceCompanyVO detail(Long companyId) {
InsuranceCompanyVO vo = mapper.selectById(companyId);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
return vo;
}
@Transactional
public Long create(InsuranceCompanyVO vo) {
mapper.insert(vo);
return vo.getCompanyId();
}
@Transactional
public void update(Long companyId, InsuranceCompanyVO vo) {
if (mapper.selectById(companyId) == null) throw new BizException(ErrorCode.NOT_FOUND);
vo.setCompanyId(companyId);
mapper.update(vo);
}
}
@@ -0,0 +1,53 @@
package com.ga.api.service.product;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.core.mapper.product.ProductMapper;
import com.ga.core.vo.product.ProductResp;
import com.ga.core.vo.product.ProductVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductMapper mapper;
@Transactional(readOnly = true)
public List<ProductResp> list(Long companyId, String insuranceType, String keyword, String isActive) {
return mapper.selectList(companyId, insuranceType, keyword, isActive);
}
@Transactional(readOnly = true)
public ProductResp detail(Long productId) {
ProductResp r = mapper.selectDetailById(productId);
if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
return r;
}
@Transactional
public Long create(ProductVO vo) {
if (mapper.existsByCode(vo.getCompanyId(), vo.getProductCode()) > 0) {
throw new BizException(ErrorCode.DUPLICATE_DATA);
}
mapper.insert(vo);
return vo.getProductId();
}
@Transactional
public void update(Long productId, ProductVO vo) {
if (mapper.selectById(productId) == null) throw new BizException(ErrorCode.NOT_FOUND);
vo.setProductId(productId);
mapper.update(vo);
}
@Transactional
public void delete(Long productId) {
if (mapper.selectById(productId) == null) throw new BizException(ErrorCode.NOT_FOUND);
mapper.deleteById(productId);
}
}
@@ -0,0 +1,87 @@
package com.ga.api.service.receive;
import com.ga.common.util.SecurityUtil;
import com.ga.core.mapper.receive.ReceiveMapper;
import com.ga.core.vo.receive.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
public class ReceiveService {
private final ReceiveMapper mapper;
// === company_profile ===
@Transactional(readOnly = true)
public List<CompanyProfileVO> listProfiles() {
return mapper.selectAllProfiles();
}
@Transactional
public void saveProfile(String companyCode, CompanyProfileVO vo) {
vo.setCompanyCode(companyCode);
if (mapper.selectProfile(companyCode) == null) mapper.insertProfile(vo);
else mapper.updateProfile(vo);
}
// === field_mapping ===
@Transactional(readOnly = true)
public List<CompanyFieldMappingVO> listFields(String companyCode, String targetTable) {
return mapper.selectFieldMappings(companyCode, targetTable);
}
@Transactional
public Long createField(String companyCode, CompanyFieldMappingVO vo) {
vo.setCompanyCode(companyCode);
mapper.insertFieldMapping(vo);
return vo.getMappingId();
}
@Transactional
public void updateField(Long mappingId, CompanyFieldMappingVO vo) {
vo.setMappingId(mappingId);
mapper.updateFieldMapping(vo);
}
@Transactional
public void deleteField(Long mappingId) {
mapper.deleteFieldMapping(mappingId);
}
// === code_mapping ===
@Transactional(readOnly = true)
public List<CodeMappingVO> listCodes(String companyCode, String codeType) {
return mapper.selectCodeMappings(companyCode, codeType);
}
@Transactional
public Long createCode(String companyCode, CodeMappingVO vo) {
vo.setCompanyCode(companyCode);
mapper.insertCodeMapping(vo);
return vo.getCodeId();
}
// === raw + errors ===
@Transactional(readOnly = true)
public List<RawCommissionDataVO> listRaw(String companyCode, String settleMonth, String parseStatus) {
return mapper.selectRawList(companyCode, settleMonth, parseStatus);
}
@Transactional(readOnly = true)
public List<ParseErrorLogVO> listErrors(String companyCode, String resolveStatus) {
return mapper.selectErrors(companyCode, resolveStatus);
}
@Transactional
public void resolveError(Long errorId, String status, String note) {
mapper.resolveError(errorId, status, note, SecurityUtil.getCurrentUserId());
}
}
@@ -0,0 +1,85 @@
package com.ga.api.service.regulatory;
import com.ga.core.mapper.ledger.MaintainLedgerMapper;
import com.ga.core.mapper.ledger.RecruitLedgerMapper;
import com.ga.core.mapper.regulatory.RegulatoryLimitMapper;
import com.ga.core.vo.regulatory.RegulatoryLimitVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
@Component
@RequiredArgsConstructor
public class RegulatoryLimitChecker {
private static final String TOTAL_RATIO = "TOTAL_RATIO";
private static final String FIRST_YEAR_RATIO = "FIRST_YEAR_RATIO";
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final RegulatoryLimitMapper limitMapper;
private final RecruitLedgerMapper recruitLedgerMapper;
private final MaintainLedgerMapper maintainLedgerMapper;
/** 누적 수수료율 한도 체크 (1200%). 신계약 + 유지 원장 합산. */
public ViolationResult checkContractTotal(Long contractId, BigDecimal additionalAmount, LocalDate baseDate) {
String baseDateStr = baseDate.format(DATE_FMT);
RegulatoryLimitVO limit = limitMapper.selectActiveByType(TOTAL_RATIO, null, baseDateStr);
if (limit == null) {
return ViolationResult.ok(additionalAmount, null, TOTAL_RATIO);
}
BigDecimal currentTotal = sumPaidByContract(contractId);
BigDecimal projected = currentTotal.add(additionalAmount);
if (projected.compareTo(limit.getLimitValue()) > 0) {
return ViolationResult.violated(
currentTotal,
limit.getLimitValue(),
TOTAL_RATIO,
String.format("누적 수수료 한도 초과: 현재 %s, 추가 %s, 한도 %s %s",
currentTotal, additionalAmount, limit.getLimitValue(), limit.getUnit())
);
}
return ViolationResult.ok(currentTotal, limit.getLimitValue(), TOTAL_RATIO);
}
/** 1차년 수수료율 한도 체크. 신계약 원장 1차년도만 (유지는 1차년 개념 없음). */
public ViolationResult checkFirstYear(Long contractId, BigDecimal additionalAmount, LocalDate baseDate) {
String baseDateStr = baseDate.format(DATE_FMT);
RegulatoryLimitVO limit = limitMapper.selectActiveByType(FIRST_YEAR_RATIO, null, baseDateStr);
if (limit == null) {
return ViolationResult.ok(additionalAmount, null, FIRST_YEAR_RATIO);
}
BigDecimal currentTotal = sumFirstYearPaidByContract(contractId);
BigDecimal projected = currentTotal.add(additionalAmount);
if (projected.compareTo(limit.getLimitValue()) > 0) {
return ViolationResult.violated(
currentTotal,
limit.getLimitValue(),
FIRST_YEAR_RATIO,
String.format("1차년 수수료 한도 초과: 현재 %s, 추가 %s, 한도 %s %s",
currentTotal, additionalAmount, limit.getLimitValue(), limit.getUnit())
);
}
return ViolationResult.ok(currentTotal, limit.getLimitValue(), FIRST_YEAR_RATIO);
}
private BigDecimal sumPaidByContract(Long contractId) {
BigDecimal recruit = recruitLedgerMapper.sumByContract(contractId);
BigDecimal maintain = maintainLedgerMapper.sumByContract(contractId);
return nullSafe(recruit).add(nullSafe(maintain));
}
private BigDecimal sumFirstYearPaidByContract(Long contractId) {
return nullSafe(recruitLedgerMapper.sumFirstYearByContract(contractId));
}
private BigDecimal nullSafe(BigDecimal value) {
return value != null ? value : BigDecimal.ZERO;
}
}
@@ -0,0 +1,104 @@
package com.ga.api.service.regulatory;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.regulatory.RegulatoryLimitMapper;
import com.ga.core.vo.regulatory.RegulatoryLimitResp;
import com.ga.core.vo.regulatory.RegulatoryLimitSaveReq;
import com.ga.core.vo.regulatory.RegulatoryLimitSearchParam;
import com.ga.core.vo.regulatory.RegulatoryLimitVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
@Service
@RequiredArgsConstructor
public class RegulatoryLimitService {
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final RegulatoryLimitMapper limitMapper;
@Transactional(readOnly = true)
public PageResponse<RegulatoryLimitResp> list(RegulatoryLimitSearchParam param) {
param.startPage();
return PageResponse.of(limitMapper.selectList(param));
}
@Transactional(readOnly = true)
public RegulatoryLimitResp detail(Long limitId) {
RegulatoryLimitResp resp = limitMapper.selectDetailById(limitId);
if (resp == null) {
throw new BizException(ErrorCode.NOT_FOUND);
}
return resp;
}
@Transactional
public void create(RegulatoryLimitSaveReq req) {
// 동일 limitType + productCategory + 기간 중복 체크
String today = LocalDate.now().format(DATE_FMT);
RegulatoryLimitVO existing = limitMapper.selectActiveByType(
req.getLimitType(), req.getProductCategory(), today);
if (existing != null) {
throw new BizException(ErrorCode.DUPLICATE_DATA);
}
RegulatoryLimitVO vo = toVO(req);
vo.setIsActive(true);
limitMapper.insert(vo);
}
@Transactional
public void update(Long limitId, RegulatoryLimitSaveReq req) {
RegulatoryLimitVO existing = limitMapper.selectById(limitId);
if (existing == null) {
throw new BizException(ErrorCode.NOT_FOUND);
}
RegulatoryLimitVO vo = toVO(req);
vo.setLimitId(limitId);
vo.setIsActive(existing.getIsActive());
limitMapper.update(vo);
}
@Transactional
public void deactivate(Long limitId) {
RegulatoryLimitVO existing = limitMapper.selectById(limitId);
if (existing == null) {
throw new BizException(ErrorCode.NOT_FOUND);
}
if (Boolean.FALSE.equals(existing.getIsActive())) {
throw new BizException(ErrorCode.INVALID_PARAMETER);
}
limitMapper.updateStatus(limitId, false);
}
@Transactional(readOnly = true)
public RegulatoryLimitVO selectActiveByType(String limitType, String productCategory, String baseDate) {
return limitMapper.selectActiveByType(limitType, productCategory, baseDate);
}
@Transactional(readOnly = true)
public List<RegulatoryLimitVO> selectAllActive(String baseDate) {
return limitMapper.selectAllActive(baseDate);
}
private RegulatoryLimitVO toVO(RegulatoryLimitSaveReq req) {
RegulatoryLimitVO vo = new RegulatoryLimitVO();
vo.setLimitType(req.getLimitType());
vo.setProductCategory(req.getProductCategory());
vo.setLimitValue(req.getLimitValue());
vo.setUnit(req.getUnit());
vo.setEffectiveFrom(req.getEffectiveFrom());
vo.setEffectiveTo(req.getEffectiveTo());
vo.setRegulationRef(req.getRegulationRef());
vo.setDescription(req.getDescription());
return vo;
}
}
@@ -0,0 +1,19 @@
package com.ga.api.service.regulatory;
import java.math.BigDecimal;
public record ViolationResult(
boolean violated,
BigDecimal currentTotal,
BigDecimal limitAmount,
String limitType,
String message
) {
public static ViolationResult ok(BigDecimal currentTotal, BigDecimal limitAmount, String limitType) {
return new ViolationResult(false, currentTotal, limitAmount, limitType, null);
}
public static ViolationResult violated(BigDecimal currentTotal, BigDecimal limitAmount, String limitType, String message) {
return new ViolationResult(true, currentTotal, limitAmount, limitType, message);
}
}
@@ -0,0 +1,135 @@
package com.ga.api.service.rule;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.core.mapper.rule.RuleMapper;
import com.ga.core.vo.rule.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
public class RuleService {
private final RuleMapper mapper;
// === commission_rate ===
@Transactional(readOnly = true)
public List<CommissionRateVO> listCommissionRates(Long productId, Integer year) {
return mapper.selectCommissionRates(productId, year);
}
@Transactional
public Long createCommissionRate(CommissionRateVO vo) {
mapper.insertCommissionRate(vo);
return vo.getRateId();
}
@Transactional
public void updateCommissionRate(Long rateId, CommissionRateVO vo) {
vo.setRateId(rateId);
mapper.updateCommissionRate(vo);
}
@Transactional
public void deleteCommissionRate(Long rateId) {
mapper.deleteCommissionRate(rateId);
}
// === payout_rule ===
@Transactional(readOnly = true)
public List<PayoutRuleVO> listPayoutRules(Long gradeId, String insuranceType) {
return mapper.selectPayoutRules(gradeId, insuranceType);
}
@Transactional
public Long createPayoutRule(PayoutRuleVO vo) {
mapper.insertPayoutRule(vo);
return vo.getRuleId();
}
@Transactional
public void updatePayoutRule(Long ruleId, PayoutRuleVO vo) {
vo.setRuleId(ruleId);
mapper.updatePayoutRule(vo);
}
@Transactional
public void deletePayoutRule(Long ruleId) {
mapper.deletePayoutRule(ruleId);
}
// === override_rule ===
@Transactional(readOnly = true)
public List<OverrideRuleVO> listOverrideRules() {
return mapper.selectOverrideRules();
}
@Transactional
public Long createOverrideRule(OverrideRuleVO vo) {
mapper.insertOverrideRule(vo);
return vo.getOverrideId();
}
@Transactional
public void updateOverrideRule(Long overrideId, OverrideRuleVO vo) {
vo.setOverrideId(overrideId);
mapper.updateOverrideRule(vo);
}
@Transactional
public void deleteOverrideRule(Long overrideId) {
mapper.deleteOverrideRule(overrideId);
}
// === chargeback_rule ===
@Transactional(readOnly = true)
public List<ChargebackRuleVO> listChargebackRules(String companyCode) {
return mapper.selectChargebackRules(companyCode);
}
@Transactional
public Long createChargebackRule(ChargebackRuleVO vo) {
mapper.insertChargebackRule(vo);
return vo.getCbRuleId();
}
@Transactional
public void updateChargebackRule(Long cbRuleId, ChargebackRuleVO vo) {
vo.setCbRuleId(cbRuleId);
mapper.updateChargebackRule(vo);
}
@Transactional
public void deleteChargebackRule(Long cbRuleId) {
mapper.deleteChargebackRule(cbRuleId);
}
// === exception_type_code ===
@Transactional(readOnly = true)
public List<ExceptionTypeCodeVO> listExceptionCodes() {
return mapper.selectExceptionCodes();
}
@Transactional
public void createExceptionCode(ExceptionTypeCodeVO vo) {
if (mapper.selectExceptionCode(vo.getExceptionCode()) != null) {
throw new BizException(ErrorCode.DUPLICATE_DATA);
}
mapper.insertExceptionCode(vo);
}
@Transactional
public void updateExceptionCode(String exceptionCode, ExceptionTypeCodeVO vo) {
vo.setExceptionCode(exceptionCode);
mapper.updateExceptionCode(vo);
}
}
@@ -0,0 +1,151 @@
package com.ga.api.service.settle;
import com.ga.api.service.deduction.DeductionService;
import com.ga.api.service.tax.TaxBreakdown;
import com.ga.api.service.tax.TaxCalculator;
import com.ga.api.service.transfer.TransferFileService;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.org.AgentMapper;
import com.ga.core.mapper.settle.PaymentDeductionDetailMapper;
import com.ga.core.mapper.settle.PaymentMapper;
import com.ga.core.vo.org.AgentVO;
import com.ga.core.vo.org.TaxType;
import com.ga.core.vo.org.VatType;
import com.ga.core.vo.settle.AgentDeductionVO;
import com.ga.core.vo.settle.PaymentDeductionDetailVO;
import com.ga.core.vo.settle.PaymentResp;
import com.ga.core.vo.settle.PaymentVO;
import com.ga.core.vo.settle.SettleSearchParam;
import com.ga.core.vo.settle.TransferFileResp;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentMapper mapper;
private final AgentMapper agentMapper;
private final PaymentDeductionDetailMapper deductionDetailMapper;
private final TransferFileService transferFileService;
private final TaxCalculator taxCalculator;
private final DeductionService deductionService;
@Transactional(readOnly = true)
public PageResponse<PaymentResp> list(SettleSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
@Transactional
public void updateStatus(Long paymentId, String status, String failReason) {
mapper.updateStatus(paymentId, status, failReason);
}
public TransferFileResp generateTransferFile(String settleMonth, String bankCode) {
return transferFileService.generate(settleMonth, bankCode);
}
@Transactional
public void calculateTaxes(Long paymentId) {
PaymentVO payment = mapper.selectById(paymentId);
if (payment == null) throw new BizException(ErrorCode.NOT_FOUND);
applyTaxes(payment);
mapper.updateTaxes(payment.getPaymentId(),
payment.getIncomeTaxAmount(), payment.getLocalTaxAmount(),
payment.getVatAmount(), payment.getNetAmount());
}
@Transactional
public void calculateTaxesForMonth(String settleMonth) {
List<PaymentVO> payments = mapper.selectBySettleMonth(settleMonth);
for (PaymentVO payment : payments) {
applyTaxes(payment);
}
if (!payments.isEmpty()) {
mapper.updateTaxesBatch(payments);
}
}
@Transactional
public void applyDeductions(Long paymentId, String settleMonth) {
PaymentVO payment = mapper.selectById(paymentId);
if (payment == null) throw new BizException(ErrorCode.NOT_FOUND);
List<AgentDeductionVO> pending = deductionService.selectPendingByAgent(payment.getAgentId(), settleMonth);
if (pending.isEmpty()) return;
// 명세 기록
List<PaymentDeductionDetailVO> details = pending.stream().map(d -> {
PaymentDeductionDetailVO detail = new PaymentDeductionDetailVO();
detail.setPaymentId(paymentId);
detail.setDeductionType(d.getDeductionType());
detail.setAmount(d.getAmount());
detail.setDescription(d.getDescription());
detail.setAppliedAt(LocalDateTime.now());
return detail;
}).collect(Collectors.toList());
deductionDetailMapper.insertBatch(details);
// 상태 전이
List<Long> ids = pending.stream().map(AgentDeductionVO::getDeductionId).collect(Collectors.toList());
deductionService.markAsApplied(ids);
// deduction_amount + net_amount UPDATE
// net_amount = amount incomeTax localTax + vat deductionAmount
BigDecimal deductionTotal = pending.stream()
.map(AgentDeductionVO::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal currentNet = payment.getNetAmount() != null ? payment.getNetAmount() : payment.getPayAmount();
BigDecimal newNet = currentNet.subtract(deductionTotal);
mapper.updateDeduction(paymentId, deductionTotal, newNet);
}
@Transactional
public void applyDeductionsForMonth(String settleMonth) {
List<PaymentVO> payments = mapper.selectBySettleMonth(settleMonth);
for (PaymentVO payment : payments) {
applyDeductions(payment.getPaymentId(), settleMonth);
}
}
private void applyTaxes(PaymentVO payment) {
AgentVO agent = agentMapper.selectById(payment.getAgentId());
TaxType taxType = parseTaxType(agent);
VatType vatType = parseVatType(agent);
if (taxType == TaxType.BUSINESS_INCOME && (agent == null || agent.getBusinessNo() == null || agent.getBusinessNo().isBlank())) {
log.warn("agentId={} taxType=BUSINESS_INCOME이지만 businessNo가 비어있습니다", payment.getAgentId());
}
TaxBreakdown breakdown = taxCalculator.calculate(payment.getPayAmount(), taxType, vatType);
payment.setIncomeTaxAmount(breakdown.incomeTax());
payment.setLocalTaxAmount(breakdown.localTax());
payment.setVatAmount(breakdown.vat());
payment.setNetAmount(breakdown.netAmount());
}
private TaxType parseTaxType(AgentVO agent) {
if (agent != null && agent.getTaxType() != null) {
try { return TaxType.valueOf(agent.getTaxType()); } catch (IllegalArgumentException ignored) {}
}
return TaxType.BUSINESS_INCOME;
}
private VatType parseVatType(AgentVO agent) {
if (agent != null && agent.getVatType() != null) {
try { return VatType.valueOf(agent.getVatType()); } catch (IllegalArgumentException ignored) {}
}
return VatType.NONE;
}
}
@@ -8,6 +8,7 @@ import com.ga.core.mapper.settle.SettleMasterMapper;
import com.ga.core.vo.settle.SettleMasterResp;
import com.ga.core.vo.settle.SettleMasterVO;
import com.ga.core.vo.settle.SettleSearchParam;
import com.ga.core.vo.settle.SettleStatus;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -21,21 +22,25 @@ public class SettleService {
private final SettleMasterMapper masterMapper;
@Transactional(readOnly = true)
public PageResponse<SettleMasterResp> list(SettleSearchParam param) {
param.startPage();
return PageResponse.of(masterMapper.selectList(param));
}
@Transactional(readOnly = true)
public SettleMasterResp detail(Long agentId, String settleMonth) {
SettleMasterResp r = masterMapper.selectDetail(agentId, settleMonth);
if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
return r;
}
@Transactional(readOnly = true)
public Map<String, Object> summary(String settleMonth) {
return masterMapper.selectMonthSummary(settleMonth);
}
@Transactional(readOnly = true)
public List<Map<String, Object>> orgSummary(String settleMonth) {
return masterMapper.selectOrgSummary(settleMonth);
}
@@ -44,17 +49,17 @@ public class SettleService {
public void confirm(Long settleId) {
SettleMasterVO vo = masterMapper.selectById(settleId);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
if ("CONFIRMED".equals(vo.getStatus()) || "PAID".equals(vo.getStatus())) {
if (SettleStatus.CONFIRMED.name().equals(vo.getStatus()) || SettleStatus.PAID.name().equals(vo.getStatus())) {
throw new BizException(ErrorCode.ALREADY_CONFIRMED);
}
masterMapper.updateStatus(settleId, "CONFIRMED", SecurityUtil.getCurrentUserId());
masterMapper.updateStatus(settleId, SettleStatus.CONFIRMED.name(), SecurityUtil.getCurrentUserId());
}
@Transactional
public void hold(Long settleId, String reason) {
SettleMasterVO vo = masterMapper.selectById(settleId);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
if ("PAID".equals(vo.getStatus())) {
if (SettleStatus.PAID.name().equals(vo.getStatus())) {
throw new BizException(ErrorCode.NOT_PERMITTED, "지급완료된 정산은 보류할 수 없습니다");
}
masterMapper.updateHold(settleId, reason);
@@ -64,9 +69,9 @@ public class SettleService {
public void release(Long settleId) {
SettleMasterVO vo = masterMapper.selectById(settleId);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!"HOLD".equals(vo.getStatus())) {
if (!SettleStatus.HOLD.name().equals(vo.getStatus())) {
throw new BizException(ErrorCode.INVALID_STATUS, "보류 상태가 아닙니다");
}
masterMapper.updateStatus(settleId, "CALCULATED", SecurityUtil.getCurrentUserId());
masterMapper.updateStatus(settleId, SettleStatus.CALCULATED.name(), SecurityUtil.getCurrentUserId());
}
}
@@ -0,0 +1,155 @@
package com.ga.api.service.statement;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.file.FileService;
import com.ga.common.model.PageResponse;
import com.ga.common.util.SecurityUtil;
import com.ga.core.mapper.org.AgentMapper;
import com.ga.core.mapper.settle.CommissionStatementMapper;
import com.ga.core.mapper.settle.PaymentMapper;
import com.ga.core.vo.org.AgentResp;
import com.ga.core.vo.settle.CommissionStatementResp;
import com.ga.core.vo.settle.CommissionStatementSaveReq;
import com.ga.core.vo.settle.CommissionStatementSearchParam;
import com.ga.core.vo.settle.CommissionStatementVO;
import com.ga.core.vo.settle.PaymentVO;
import com.ga.core.vo.settle.StatementStatus;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class CommissionStatementService {
private final CommissionStatementMapper statementMapper;
private final AgentMapper agentMapper;
private final PaymentMapper paymentMapper;
private final StatementAggregator aggregator;
private final ExcelStatementGenerator excelGenerator;
private final FileService fileService;
@Transactional(readOnly = true)
public PageResponse<CommissionStatementResp> list(CommissionStatementSearchParam param) {
param.startPage();
return PageResponse.of(statementMapper.selectList(param));
}
@Transactional(readOnly = true)
public CommissionStatementResp detail(Long statementId) {
CommissionStatementResp resp = statementMapper.selectDetailById(statementId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public Long generate(CommissionStatementSaveReq req) {
if ("PDF".equalsIgnoreCase(req.getFileFormat())) {
throw new BizException(ErrorCode.INVALID_PARAMETER, "PDF 형식은 아직 지원되지 않습니다");
}
AgentResp agent = agentMapper.selectDetailById(req.getAgentId());
if (agent == null) throw new BizException(ErrorCode.NOT_FOUND, "설계사를 찾을 수 없습니다");
// 1. 집계
StatementSummary summary = aggregator.aggregate(req.getAgentId(), req.getSettleMonth());
// 2. 엑셀 생성
List<PaymentVO> payments = paymentMapper.selectByAgentAndMonth(req.getAgentId(), req.getSettleMonth());
byte[] excelBytes = excelGenerator.generate(agent, summary, payments);
// 3. 파일 저장
String fileName = "statement_" + req.getAgentId() + "_" + req.getSettleMonth() + ".xlsx";
Long fileId = fileService.saveBytes(excelBytes, fileName, "COMMISSION_STATEMENT",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
// 4. INSERT 또는 UPDATE (재발급)
CommissionStatementVO existing = statementMapper.selectByAgentMonth(
req.getAgentId(), req.getSettleMonth(), req.getStatementType());
Long issuedBy = SecurityUtil.getCurrentUserId();
LocalDateTime now = LocalDateTime.now();
if (existing == null) {
CommissionStatementVO vo = new CommissionStatementVO();
vo.setAgentId(req.getAgentId());
vo.setSettleMonth(req.getSettleMonth());
vo.setStatementType(req.getStatementType());
vo.setGrossAmount(summary.grossAmount());
vo.setIncomeTaxAmount(summary.incomeTaxAmount());
vo.setLocalTaxAmount(summary.localTaxAmount());
vo.setVatAmount(summary.vatAmount());
vo.setDeductionAmount(summary.deductionAmount());
vo.setNetAmount(summary.netAmount());
vo.setFilePath(String.valueOf(fileId));
vo.setFileFormat("EXCEL");
vo.setIssueStatus(StatementStatus.GENERATED.name());
vo.setIssuedAt(now);
vo.setIssuedBy(issuedBy != null ? issuedBy.toString() : null);
statementMapper.insert(vo);
return vo.getStatementId();
} else {
existing.setGrossAmount(summary.grossAmount());
existing.setIncomeTaxAmount(summary.incomeTaxAmount());
existing.setLocalTaxAmount(summary.localTaxAmount());
existing.setVatAmount(summary.vatAmount());
existing.setDeductionAmount(summary.deductionAmount());
existing.setNetAmount(summary.netAmount());
existing.setFilePath(String.valueOf(fileId));
existing.setFileFormat("EXCEL");
existing.setIssueStatus(StatementStatus.GENERATED.name());
existing.setIssuedAt(now);
existing.setIssuedBy(issuedBy != null ? issuedBy.toString() : null);
statementMapper.update(existing);
return existing.getStatementId();
}
}
@Transactional
public void generateForMonth(String settleMonth, String statementType) {
List<PaymentVO> allPayments = paymentMapper.selectBySettleMonth(settleMonth);
List<Long> agentIds = allPayments.stream()
.map(PaymentVO::getAgentId)
.distinct()
.collect(Collectors.toList());
for (Long agentId : agentIds) {
CommissionStatementSaveReq req = new CommissionStatementSaveReq();
req.setAgentId(agentId);
req.setSettleMonth(settleMonth);
req.setStatementType(statementType);
req.setFileFormat("EXCEL");
generate(req);
}
}
@Transactional
public byte[] download(Long statementId) {
CommissionStatementVO vo = statementMapper.selectById(statementId);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
Long fileId = Long.parseLong(vo.getFilePath());
com.ga.common.file.FileVO fileVO = fileService.download(fileId);
// DOWNLOADED 상태 전이
statementMapper.updateStatus(statementId, StatementStatus.DOWNLOADED.name());
try {
return java.nio.file.Files.readAllBytes(java.nio.file.Paths.get(fileVO.getFilePath()));
} catch (java.io.IOException e) {
throw new BizException(ErrorCode.FILE_NOT_FOUND, "파일을 읽을 수 없습니다");
}
}
@Transactional
public void cancel(Long statementId) {
CommissionStatementVO vo = statementMapper.selectById(statementId);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
statementMapper.updateStatus(statementId, StatementStatus.CANCELLED.name());
}
}
@@ -0,0 +1,106 @@
package com.ga.api.service.statement;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.core.vo.org.AgentResp;
import com.ga.core.vo.settle.PaymentVO;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.springframework.stereotype.Component;
import java.io.ByteArrayOutputStream;
import java.math.BigDecimal;
import java.text.DecimalFormat;
import java.util.List;
@Slf4j
@Component
public class ExcelStatementGenerator {
private static final DecimalFormat AMOUNT_FMT = new DecimalFormat("#,##0");
public byte[] generate(AgentResp agent, StatementSummary summary, List<PaymentVO> payments) {
try (SXSSFWorkbook wb = new SXSSFWorkbook(100)) {
wb.setCompressTempFiles(true);
Sheet sheet = wb.createSheet("수수료명세서");
int rowNum = 0;
// 상단 헤더 정보
rowNum = writeHeaderSection(sheet, wb, agent, summary, rowNum);
// 공백
sheet.createRow(rowNum++);
// 지급 테이블 헤더
Row tableHeader = sheet.createRow(rowNum++);
CellStyle boldStyle = boldStyle(wb);
String[] cols = {"지급ID", "지급금액", "원천세", "지방소득세", "부가세", "공제금액", "실수령액"};
for (int i = 0; i < cols.length; i++) {
Cell c = tableHeader.createCell(i);
c.setCellValue(cols[i]);
c.setCellStyle(boldStyle);
sheet.setColumnWidth(i, 18 * 256);
}
// 지급 데이터
for (PaymentVO p : payments) {
Row row = sheet.createRow(rowNum++);
row.createCell(0).setCellValue(p.getPaymentId() != null ? p.getPaymentId() : 0L);
row.createCell(1).setCellValue(fmt(p.getPayAmount()));
row.createCell(2).setCellValue(fmt(p.getIncomeTaxAmount()));
row.createCell(3).setCellValue(fmt(p.getLocalTaxAmount()));
row.createCell(4).setCellValue(fmt(p.getVatAmount()));
row.createCell(5).setCellValue(fmt(p.getDeductionAmount()));
row.createCell(6).setCellValue(fmt(p.getNetAmount()));
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
wb.write(out);
wb.dispose();
return out.toByteArray();
} catch (Exception e) {
log.error("ExcelStatementGenerator error", e);
throw new BizException(ErrorCode.INTERNAL_ERROR, "명세서 엑셀 생성 실패");
}
}
private int writeHeaderSection(Sheet sheet, SXSSFWorkbook wb, AgentResp agent,
StatementSummary summary, int rowNum) {
CellStyle bold = boldStyle(wb);
writeKv(sheet, rowNum++, bold, "설계사명", agent.getAgentName());
writeKv(sheet, rowNum++, bold, "사업자번호", agent.getBusinessNo());
writeKv(sheet, rowNum++, bold, "소득세유형", agent.getTaxType());
writeKv(sheet, rowNum++, bold, "부가세유형", agent.getVatType());
sheet.createRow(rowNum++);
writeKv(sheet, rowNum++, bold, "총 지급액", fmt(summary.grossAmount()));
writeKv(sheet, rowNum++, bold, "원천세", fmt(summary.incomeTaxAmount()));
writeKv(sheet, rowNum++, bold, "지방소득세", fmt(summary.localTaxAmount()));
writeKv(sheet, rowNum++, bold, "부가세", fmt(summary.vatAmount()));
writeKv(sheet, rowNum++, bold, "공제금액", fmt(summary.deductionAmount()));
writeKv(sheet, rowNum++, bold, "실수령액", fmt(summary.netAmount()));
return rowNum;
}
private void writeKv(Sheet sheet, int rowNum, CellStyle keyStyle, String key, String value) {
Row row = sheet.createRow(rowNum);
Cell k = row.createCell(0);
k.setCellValue(key);
k.setCellStyle(keyStyle);
row.createCell(1).setCellValue(value != null ? value : "");
}
private CellStyle boldStyle(SXSSFWorkbook wb) {
CellStyle style = wb.createCellStyle();
Font font = wb.createFont();
font.setBold(true);
style.setFont(font);
return style;
}
private String fmt(BigDecimal val) {
if (val == null) return "0";
return AMOUNT_FMT.format(val);
}
}
@@ -0,0 +1,35 @@
package com.ga.api.service.statement;
import com.ga.core.mapper.settle.PaymentMapper;
import com.ga.core.vo.settle.PaymentVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.List;
@Component
@RequiredArgsConstructor
public class StatementAggregator {
private final PaymentMapper paymentMapper;
public StatementSummary aggregate(Long agentId, String settleMonth) {
List<PaymentVO> payments = paymentMapper.selectByAgentAndMonth(agentId, settleMonth);
BigDecimal grossAmount = sum(payments, PaymentVO::getPayAmount);
BigDecimal incomeTax = sum(payments, PaymentVO::getIncomeTaxAmount);
BigDecimal localTax = sum(payments, PaymentVO::getLocalTaxAmount);
BigDecimal vat = sum(payments, PaymentVO::getVatAmount);
BigDecimal deduction = sum(payments, PaymentVO::getDeductionAmount);
BigDecimal netAmount = sum(payments, PaymentVO::getNetAmount);
return new StatementSummary(grossAmount, incomeTax, localTax, vat, deduction, netAmount);
}
private BigDecimal sum(List<PaymentVO> payments, java.util.function.Function<PaymentVO, BigDecimal> getter) {
return payments.stream()
.map(p -> getter.apply(p) != null ? getter.apply(p) : BigDecimal.ZERO)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
@@ -0,0 +1,12 @@
package com.ga.api.service.statement;
import java.math.BigDecimal;
public record StatementSummary(
BigDecimal grossAmount,
BigDecimal incomeTaxAmount,
BigDecimal localTaxAmount,
BigDecimal vatAmount,
BigDecimal deductionAmount,
BigDecimal netAmount
) {}
@@ -0,0 +1,66 @@
package com.ga.api.service.system;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.core.mapper.user.RoleMapper;
import com.ga.core.vo.user.RoleVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class RoleService {
private final RoleMapper mapper;
@Transactional(readOnly = true)
public List<RoleVO> list() {
return mapper.selectAll();
}
@Transactional(readOnly = true)
public RoleVO detail(Long roleId) {
RoleVO vo = mapper.selectById(roleId);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
return vo;
}
@Transactional(readOnly = true)
public List<Map<String, Object>> permissions(Long roleId) {
return mapper.selectRolePermissions(roleId);
}
@Transactional
public Long create(RoleVO vo) {
mapper.insert(vo);
return vo.getRoleId();
}
@Transactional
public void update(Long roleId, RoleVO vo) {
if (mapper.selectById(roleId) == null) throw new BizException(ErrorCode.NOT_FOUND);
vo.setRoleId(roleId);
mapper.update(vo);
}
@Transactional
public void delete(Long roleId) {
if (mapper.selectById(roleId) == null) throw new BizException(ErrorCode.NOT_FOUND);
mapper.deleteById(roleId);
}
@Transactional
public void savePermissions(Long roleId, List<Map<String, Object>> permissions) {
mapper.deleteRolePermissions(roleId);
for (Map<String, Object> p : permissions) {
Long menuId = ((Number) p.get("menuId")).longValue();
String permCode = (String) p.get("permCode");
String isGranted = (String) p.getOrDefault("isGranted", "Y");
mapper.insertRolePermission(roleId, menuId, permCode, isGranted);
}
}
}
@@ -5,9 +5,11 @@ import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.common.system.SystemConfigService;
import com.ga.core.mapper.user.UserMapper;
import com.ga.core.mapstruct.UserMapStruct;
import com.ga.core.vo.user.UserResp;
import com.ga.core.vo.user.UserSaveReq;
import com.ga.core.vo.user.UserSearchParam;
import com.ga.core.vo.user.UserStatus;
import com.ga.core.vo.user.UserVO;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
@@ -23,12 +25,15 @@ public class UserService {
private final UserMapper mapper;
private final PasswordEncoder passwordEncoder;
private final SystemConfigService configService;
private final UserMapStruct userMapStruct;
@Transactional(readOnly = true)
public PageResponse<UserResp> list(UserSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
@Transactional(readOnly = true)
public UserResp detail(Long userId) {
UserResp r = mapper.selectDetailById(userId);
if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
@@ -45,14 +50,9 @@ public class UserService {
if (initialPwd == null || initialPwd.isBlank()) {
initialPwd = "init1234!"; // 임시 로그인 변경 강제
}
UserVO vo = new UserVO();
vo.setLoginId(req.getLoginId());
UserVO vo = userMapStruct.toVO(req);
vo.setPassword(passwordEncoder.encode(initialPwd));
vo.setUserName(req.getUserName());
vo.setEmail(req.getEmail());
vo.setPhone(req.getPhone());
vo.setAgentId(req.getAgentId());
vo.setStatus(req.getStatus() != null ? req.getStatus() : "ACTIVE");
vo.setStatus(req.getStatus() != null ? req.getStatus() : UserStatus.ACTIVE.name());
mapper.insert(vo);
if (req.getRoleIds() != null) {
@@ -63,15 +63,10 @@ public class UserService {
@Transactional
public void update(Long userId, UserSaveReq req) {
UserVO old = mapper.selectById(userId);
if (old == null) throw new BizException(ErrorCode.NOT_FOUND);
UserVO vo = new UserVO();
UserVO vo = mapper.selectById(userId);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
userMapStruct.updateVO(req, vo);
vo.setUserId(userId);
vo.setUserName(req.getUserName());
vo.setEmail(req.getEmail());
vo.setPhone(req.getPhone());
vo.setAgentId(req.getAgentId());
vo.setStatus(req.getStatus());
mapper.update(vo);
if (req.getRoleIds() != null) {
@@ -91,9 +86,10 @@ public class UserService {
@Transactional
public void unlock(Long userId) {
mapper.resetFailCount(userId);
mapper.updateStatus(userId, "ACTIVE");
mapper.updateStatus(userId, UserStatus.ACTIVE.name());
}
@Transactional(readOnly = true)
public List<String> roleCodes(Long userId) {
return mapper.selectRoleCodes(userId);
}
@@ -0,0 +1,10 @@
package com.ga.api.service.tax;
import java.math.BigDecimal;
public record TaxBreakdown(
BigDecimal incomeTax,
BigDecimal localTax,
BigDecimal vat,
BigDecimal netAmount
) {}
@@ -0,0 +1,41 @@
package com.ga.api.service.tax;
import com.ga.common.system.SystemConfigService;
import com.ga.core.vo.org.TaxType;
import com.ga.core.vo.org.VatType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
@Component
@RequiredArgsConstructor
public class TaxCalculator {
private final SystemConfigService configService;
public TaxBreakdown calculate(BigDecimal amount, TaxType taxType, VatType vatType) {
BigDecimal incomeRate = resolveIncomeRate(taxType);
BigDecimal localRate = configService.getDecimal("TAX_RATE_LOCAL_INCOME", new BigDecimal("10.0"));
BigDecimal vatRate = configService.getDecimal("TAX_RATE_VAT", new BigDecimal("10.0"));
BigDecimal incomeTax = amount.multiply(incomeRate)
.divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP);
BigDecimal localTax = incomeTax.multiply(localRate)
.divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP);
BigDecimal vat = VatType.TAXABLE.equals(vatType)
? amount.multiply(vatRate).divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP)
: BigDecimal.ZERO;
BigDecimal netAmount = amount.subtract(incomeTax).subtract(localTax).add(vat);
return new TaxBreakdown(incomeTax, localTax, vat, netAmount);
}
private BigDecimal resolveIncomeRate(TaxType taxType) {
if (TaxType.OTHER_INCOME.equals(taxType)) {
return configService.getDecimal("TAX_RATE_OTHER_INCOME", new BigDecimal("8.8"));
}
return configService.getDecimal("TAX_RATE_BUSINESS_INCOME", new BigDecimal("3.3"));
}
}
@@ -26,34 +26,35 @@ public class TransferFileService {
private final FileService fileService;
private final EncryptUtil encryptUtil;
@Transactional
public TransferFileResp generate(String settleMonth, String bankCode) {
// 1) 해당 정산월 + 은행의 PENDING payment 조회 (accountNo는 EncryptTypeHandler로 자동복호화됨)
// 1) PENDING payment 조회 readOnly 트랜잭션 (accountNo는 EncryptTypeHandler로 자동복호화됨)
List<PaymentVO> payments = paymentMapper.selectPendingByMonthAndBank(settleMonth, bankCode);
if (payments.isEmpty()) {
throw new BizException(ErrorCode.NOT_FOUND, "이체 대상 지급 건이 없습니다 (settleMonth=" + settleMonth + ", bankCode=" + bankCode + ")");
}
// 2) 은행별 생성기로 byte[] 생성 (accountNo는 이미 복호화된 상태)
// 2) 파일 byte 생성 + 저장 트랜잭션 (IO 작업이므로 커넥션 점유 불필요)
BankTransferFileGenerator generator = factory.get(bankCode);
byte[] fileContent = generator.generate(payments, settleMonth);
// 3) FileService로 file_storage 적재 fileId 반환
String fileName = "transfer_" + settleMonth + "_" + bankCode + generator.getFileExtension();
Long fileId = fileService.saveBytes(fileContent, fileName, "TRANSFER_FILE", "application/octet-stream");
// 4) payment들의 transfer_file_id 갱신 + status=SENT
// 3) payment 상태 갱신 파일 저장 성공 별도 트랜잭션 (파일만 잔존하는 역방향 불일치 방지)
List<Long> paymentIds = payments.stream()
.map(PaymentVO::getPaymentId)
.collect(Collectors.toList());
paymentMapper.updateTransferFile(paymentIds, fileId);
markPaymentsSent(paymentIds, fileId);
// 5) 합계 금액 계산
BigDecimal totalAmount = payments.stream()
.map(p -> p.getPayAmount() != null ? p.getPayAmount() : BigDecimal.ZERO)
.reduce(BigDecimal.ZERO, BigDecimal::add);
return new TransferFileResp(fileId, fileName, payments.size(), totalAmount);
}
@Transactional
public void markPaymentsSent(List<Long> paymentIds, Long fileId) {
paymentMapper.updateTransferFile(paymentIds, fileId);
}
}
@@ -16,8 +16,8 @@ import java.util.regex.Pattern;
/**
* LOOKUP 변환 규칙 최적화 캐시.
*
* processUpload() 시작 LOOKUP 컬럼의 참조 테이블을 번에 로드하여
* 처리마다 DB 조회하는 방지한다 (70만건 × DB 조회 = 지옥 HashMap.get() = 즉시).
* preload() Map 인스턴스를 반환한다 싱글톤 상태를 보유하지 않으므로 thread-safe.
* 호출자(UploadService) 반환된 Map 보유하고 TransformEngine에 전달한다.
*
* 캐시 구조: Map< "table:sourceField:targetField" Map<sourceValue, targetValue> >
*/
@@ -40,14 +40,12 @@ public class LookupCache {
private static final Pattern FIELD_PATTERN =
Pattern.compile("^[A-Za-z][A-Za-z0-9_]{0,63}$");
// 요청 범위 캐시 (processUpload 번에만 유효)
private final Map<String, Map<String, Object>> cache = new HashMap<>();
/**
* LOOKUP 컬럼 목록에서 필요한 참조 데이터를 번에 preload.
* LOOKUP 컬럼 목록에서 필요한 참조 데이터를 번에 로드하여 Map으로 반환.
* 반환된 Map은 요청 범위에서만 사용되므로 동기화 불필요.
*/
public void preload(List<UploadTemplateColumnVO> columns) {
cache.clear();
public Map<String, Map<String, Object>> preload(List<UploadTemplateColumnVO> columns) {
Map<String, Map<String, Object>> cache = new HashMap<>();
columns.stream()
.filter(c -> "LOOKUP".equalsIgnoreCase(c.getTransformRule()))
@@ -71,12 +69,15 @@ public class LookupCache {
log.debug("LookupCache preloaded: {} → {} entries", key, map.size());
}
});
return cache;
}
/**
* O(1) 조회.
* O(1) 조회. cache는 preload() 반환값이어야 한다.
*/
public Object lookup(String table, String sourceField, String value, String targetField) {
public Object lookup(Map<String, Map<String, Object>> cache,
String table, String sourceField, String value, String targetField) {
if (value == null) return null;
String key = buildKey(table, sourceField, targetField);
Map<String, Object> map = cache.get(key);
@@ -37,17 +37,19 @@ public class TransformEngine {
/**
* 엑셀 Row 템플릿 컬럼 정의에 따라 변환하여 Map 으로 반환.
*
* @param row xlsx-streamer Row
* @param columns 템플릿 컬럼 정의
* @param row xlsx-streamer Row
* @param columns 템플릿 컬럼 정의
* @param lookupData preload() 반환한 요청 범위 LOOKUP 캐시
* @return { targetField 변환된 }
*/
public Map<String, Object> transform(Row row, List<UploadTemplateColumnVO> columns) {
public Map<String, Object> transform(Row row, List<UploadTemplateColumnVO> columns,
Map<String, Map<String, Object>> lookupData) {
Map<String, Object> result = new LinkedHashMap<>();
for (UploadTemplateColumnVO col : columns) {
try {
Object rawValue = extractRaw(row, col);
Object transformed = applyTransform(rawValue, col, row, columns);
Object transformed = applyTransform(rawValue, col, row, columns, lookupData);
result.put(col.getTargetField(), transformed);
} catch (BizException e) {
throw e;
@@ -64,7 +66,8 @@ public class TransformEngine {
* 단일 컬럼에 변환 규칙을 적용.
*/
public Object applyTransform(Object value, UploadTemplateColumnVO col,
Row row, List<UploadTemplateColumnVO> allColumns) {
Row row, List<UploadTemplateColumnVO> allColumns,
Map<String, Map<String, Object>> lookupData) {
String rule = col.getTransformRule();
// FIXED 원본 무시
@@ -88,8 +91,8 @@ public class TransformEngine {
case "DATE" -> applyDate(value, col);
case "DIVIDE" -> applyDivide(value, col);
case "MULTIPLY" -> applyMultiply(value, col);
case "LOOKUP" -> applyLookup(value, col);
case "CODE_MAP" -> applyCodeMap(value, col);
case "LOOKUP" -> applyLookup(value, col, lookupData);
case "CODE_MAP" -> applyCodeMap(value, col, lookupData);
case "REPLACE" -> applyReplace(value, col);
case "SUBSTRING" -> applySubstring(value, col);
case "CONCAT" -> applyConcat(row, col);
@@ -180,9 +183,11 @@ public class TransformEngine {
return bd.multiply(BigDecimal.valueOf(multiplier)).stripTrailingZeros();
}
private Object applyLookup(Object value, UploadTemplateColumnVO col) {
private Object applyLookup(Object value, UploadTemplateColumnVO col,
Map<String, Map<String, Object>> lookupData) {
if (value == null) return null;
Object result = lookupCache.lookup(
lookupData,
col.getLookupTable(),
col.getLookupSourceField(),
value.toString(),
@@ -193,10 +198,11 @@ public class TransformEngine {
return result;
}
private Object applyCodeMap(Object value, UploadTemplateColumnVO col) {
private Object applyCodeMap(Object value, UploadTemplateColumnVO col,
Map<String, Map<String, Object>> lookupData) {
if (value == null) return null;
Object mapped = lookupCache.lookup(
"common_code", "code", value.toString(), "code_name");
lookupData, "common_code", "code", value.toString(), "code_name");
return mapped != null ? mapped : (col.getDefaultValue() != null ? col.getDefaultValue() : value);
}
@@ -6,7 +6,6 @@ import com.ga.common.exception.ErrorCode;
import com.ga.common.file.FileService;
import com.ga.common.util.SecurityUtil;
import com.ga.core.mapper.upload.UploadColumnMapper;
import com.ga.core.mapper.upload.UploadHistoryMapper;
import com.ga.core.mapper.upload.UploadTemplateMapper;
import com.ga.core.vo.upload.UploadHistoryVO;
import com.ga.core.vo.upload.UploadTemplateColumnVO;
@@ -19,7 +18,6 @@ import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.lang.NonNull;
@@ -36,6 +34,10 @@ import java.util.regex.Pattern;
*
* processUpload: SAX 파싱 + 변환 + 검증 + 배치 INSERT upload_history 저장
* preview: N행 변환 결과만 반환 (DB INSERT 없음)
*
* 트랜잭션 경계: 파일 파싱은 트랜잭션 . history 저장/갱신과 배치 INSERT는
* UploadTxHelper를 통해 REQUIRES_NEW 독립 트랜잭션으로 처리하여
* 트랜잭션으로 인한 커넥션 고갈을 방지한다.
*/
@Slf4j
@Service
@@ -44,11 +46,10 @@ public class UploadService {
private final UploadTemplateMapper templateMapper;
private final UploadColumnMapper columnMapper;
private final UploadHistoryMapper historyMapper;
private final TransformEngine transformEngine;
private final LookupCache lookupCache;
private final DynamicInsertMapper dynamicInsertMapper;
private final FileService fileService;
private final UploadTxHelper txHelper;
/** target_table whitelist — SQL Injection 방지 */
private static final Set<String> ALLOWED_TARGET_TABLES = Set.of(
@@ -61,7 +62,6 @@ public class UploadService {
// 메인: 업로드 처리
// ============================================================
@Transactional
public UploadResultResp processUpload(String templateCode, MultipartFile file) {
long startMs = System.currentTimeMillis();
@@ -74,12 +74,12 @@ public class UploadService {
validateTargetTable(template.getTargetTable());
// 2. LOOKUP 캐시 사전 로드 (70만건 × DB 조회 방지)
lookupCache.preload(columns);
// 2. LOOKUP 캐시 사전 로드 (요청 범위 Map thread-safe)
Map<String, Map<String, Object>> lookupData = lookupCache.preload(columns);
// 3. history 레코드 생성 (PROCESSING 상태)
// 3. history 레코드 생성 (PROCESSING 상태) 독립 트랜잭션
UploadHistoryVO history = initHistory(template, file);
historyMapper.insert(history);
txHelper.saveHistory(history);
List<ExcelErrorRow> errors = new ArrayList<>();
int successCount = 0;
@@ -92,6 +92,7 @@ public class UploadService {
List<Map<String, Object>> batch = new ArrayList<>(batchSize);
// 4. 파일 파싱 트랜잭션 밖에서 실행
try (InputStream is = file.getInputStream();
Workbook wb = StreamingReader.builder()
.rowCacheSize(100)
@@ -118,7 +119,7 @@ public class UploadService {
}
try {
Map<String, Object> mapped = transformEngine.transform(row, columns);
Map<String, Object> mapped = transformEngine.transform(row, columns, lookupData);
validateRow(mapped, columns, rowNum, errors);
final int currentRow = rowNum;
@@ -126,8 +127,8 @@ public class UploadService {
if (!rowHasError) {
batch.add(mapped);
if (batch.size() >= batchSize) {
dynamicInsertMapper.insertBatch(template.getTargetTable(), batch);
successCount += batch.size();
// 청크 단위 독립 트랜잭션으로 커밋
successCount += txHelper.insertBatch(template.getTargetTable(), new ArrayList<>(batch));
batch.clear();
log.debug("Batch flush: {} rows processed so far", totalCount);
}
@@ -140,17 +141,18 @@ public class UploadService {
}
if (!batch.isEmpty()) {
dynamicInsertMapper.insertBatch(template.getTargetTable(), batch);
successCount += batch.size();
successCount += txHelper.insertBatch(template.getTargetTable(), new ArrayList<>(batch));
batch.clear();
}
} catch (BizException e) {
updateHistoryFail(history.getHistoryId(), e.getDisplayMessage());
txHelper.updateHistory(history.getHistoryId(), "FAIL", e.getDisplayMessage(),
0, 0, 0, 0, null, 0);
throw e;
} catch (Exception e) {
log.error("Upload processing error: templateCode={}", templateCode, e);
updateHistoryFail(history.getHistoryId(), e.getMessage());
txHelper.updateHistory(history.getHistoryId(), "FAIL", e.getMessage(),
0, 0, 0, 0, null, 0);
throw new BizException(ErrorCode.EXCEL_PARSE_FAIL, e.getMessage());
}
@@ -163,7 +165,8 @@ public class UploadService {
errorFileId = createErrorFile(errors, file.getOriginalFilename());
}
historyMapper.updateStatus(history.getHistoryId(), status, null,
// 5. history 완료 갱신 독립 트랜잭션
txHelper.updateHistory(history.getHistoryId(), status, null,
successCount, errorCount, skipCount, totalCount, errorFileId, durationSec);
return UploadResultResp.builder()
@@ -186,7 +189,7 @@ public class UploadService {
UploadTemplateVO template = loadTemplate(templateCode);
List<UploadTemplateColumnVO> columns = columnMapper.selectByTemplateId(template.getTemplateId());
lookupCache.preload(columns);
Map<String, Map<String, Object>> lookupData = lookupCache.preload(columns);
List<Map<String, Object>> result = new ArrayList<>();
int dataStartRow = template.getDataStartRow() != null ? template.getDataStartRow() : 2;
@@ -207,7 +210,7 @@ public class UploadService {
if ("Y".equalsIgnoreCase(template.getSkipEmptyRow()) && isEmptyRow(row)) continue;
try {
result.add(transformEngine.transform(row, columns));
result.add(transformEngine.transform(row, columns, lookupData));
} catch (Exception e) {
result.add(Map.of("_error", "" + rowNum + ": " + e.getMessage()));
}
@@ -337,11 +340,6 @@ public class UploadService {
return h;
}
private void updateHistoryFail(Long historyId, String message) {
historyMapper.updateStatus(historyId, "FAIL", message,
0, 0, 0, 0, null, 0);
}
private void validateRow(Map<String, Object> row, List<UploadTemplateColumnVO> columns,
int rowNum, List<ExcelErrorRow> errors) {
for (UploadTemplateColumnVO col : columns) {
@@ -0,0 +1,100 @@
package com.ga.api.service.upload;
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.upload.UploadColumnMapper;
import com.ga.core.mapper.upload.UploadHistoryMapper;
import com.ga.core.mapper.upload.UploadTemplateMapper;
import com.ga.core.vo.upload.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
public class UploadTemplateService {
private final UploadTemplateMapper templateMapper;
private final UploadColumnMapper columnMapper;
private final UploadHistoryMapper historyMapper;
@Transactional(readOnly = true)
public PageResponse<UploadTemplateResp> list(UploadTemplateSearchParam param) {
param.startPage();
return PageResponse.of(templateMapper.selectList(param));
}
@Transactional(readOnly = true)
public UploadTemplateResp detail(Long templateId) {
UploadTemplateResp resp = templateMapper.selectById(templateId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
resp.setColumns(columnMapper.selectByTemplateId(templateId));
return resp;
}
@Transactional
public Long create(UploadTemplateSaveReq req) {
if (templateMapper.existsByCode(req.getTemplateCode()) > 0) {
throw new BizException(ErrorCode.DUPLICATE_DATA, "이미 존재하는 템플릿 코드입니다: " + req.getTemplateCode());
}
UploadTemplateVO vo = req.toVO();
vo.setCreatedBy(SecurityUtil.getCurrentUserId());
templateMapper.insert(vo);
if (req.getColumns() != null && !req.getColumns().isEmpty()) {
columnMapper.insertBatch(vo.getTemplateId(), req.getColumns());
}
return vo.getTemplateId();
}
@Transactional
public void update(Long templateId, UploadTemplateSaveReq req) {
if (templateMapper.selectById(templateId) == null) {
throw new BizException(ErrorCode.NOT_FOUND);
}
UploadTemplateVO vo = req.toVO();
vo.setTemplateId(templateId);
vo.setUpdatedBy(SecurityUtil.getCurrentUserId());
templateMapper.update(vo);
if (req.getColumns() != null) {
columnMapper.deleteByTemplateId(templateId);
if (!req.getColumns().isEmpty()) {
columnMapper.insertBatch(templateId, req.getColumns());
}
}
}
@Transactional
public void delete(Long templateId) {
if (templateMapper.selectById(templateId) == null) {
throw new BizException(ErrorCode.NOT_FOUND);
}
columnMapper.deleteByTemplateId(templateId);
templateMapper.delete(templateId);
}
@Transactional(readOnly = true)
public List<UploadTemplateColumnVO> columns(Long templateId) {
return columnMapper.selectByTemplateId(templateId);
}
@Transactional
public void updateColumns(Long templateId, List<UploadTemplateColumnVO> columns) {
if (templateMapper.selectById(templateId) == null) {
throw new BizException(ErrorCode.NOT_FOUND);
}
columnMapper.deleteByTemplateId(templateId);
if (columns != null && !columns.isEmpty()) {
columnMapper.insertBatch(templateId, columns);
}
}
@Transactional(readOnly = true)
public PageResponse<UploadHistoryResp> history(Long templateId, UploadTemplateSearchParam param) {
param.startPage();
return PageResponse.of(historyMapper.selectByTemplate(templateId, param));
}
}
@@ -0,0 +1,49 @@
package com.ga.api.service.upload;
import com.ga.core.mapper.upload.UploadHistoryMapper;
import com.ga.core.vo.upload.UploadHistoryVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
/**
* UploadService의 트랜잭션 경계를 분리한 헬퍼.
*
* UploadService 내에서 self-invocation으로는 프록시가 적용되지 않으므로
* 별도 Bean으로 분리하여 REQUIRES_NEW 트랜잭션을 보장한다.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class UploadTxHelper {
private final UploadHistoryMapper historyMapper;
private final DynamicInsertMapper dynamicInsertMapper;
/** history INSERT를 독립 트랜잭션으로 커밋 — 본 작업 실패 시에도 기록이 남는다. */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveHistory(UploadHistoryVO history) {
historyMapper.insert(history);
}
/** 배치 INSERT를 독립 트랜잭션으로 커밋 — 청크 단위 커밋으로 커넥션 점유 시간 단축. */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public int insertBatch(String targetTable, List<Map<String, Object>> batch) {
dynamicInsertMapper.insertBatch(targetTable, batch);
return batch.size();
}
/** history 상태 갱신을 독립 트랜잭션으로 커밋. */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void updateHistory(Long historyId, String status, String errorMessage,
int successCount, int errorCount, int skipCount,
int totalCount, Long errorFileId, int durationSec) {
historyMapper.updateStatus(historyId, status, errorMessage,
successCount, errorCount, skipCount, totalCount, errorFileId, durationSec);
}
}
@@ -0,0 +1,54 @@
-- V23: 세무 처리 스키마 확장 + 세율 설정 + 공통코드
-- ============================================================
-- 1. agent 테이블 세무 컬럼 추가
-- ============================================================
ALTER TABLE agent
ADD COLUMN tax_type VARCHAR(20) NOT NULL DEFAULT 'BUSINESS_INCOME',
ADD COLUMN business_no VARCHAR(20),
ADD COLUMN vat_type VARCHAR(20) NOT NULL DEFAULT 'NONE';
ALTER TABLE agent
ADD CONSTRAINT chk_agent_tax_type
CHECK (tax_type IN ('BUSINESS_INCOME', 'OTHER_INCOME')),
ADD CONSTRAINT chk_agent_vat_type
CHECK (vat_type IN ('NONE', 'EXEMPT', 'TAXABLE'));
-- ============================================================
-- 2. payment 테이블 세무 컬럼 추가
-- ============================================================
ALTER TABLE payment
ADD COLUMN income_tax_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
ADD COLUMN local_tax_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
ADD COLUMN vat_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
ADD COLUMN net_amount NUMERIC(15,2);
-- ============================================================
-- 3. 표준 세율 system_config 추가
-- ============================================================
INSERT INTO system_config (config_key, config_value, config_group, value_type, description) VALUES
('TAX_RATE_BUSINESS_INCOME', '3.3', 'TAX', 'NUMBER', '사업소득 원천징수율(%) — 2024년 기준'),
('TAX_RATE_OTHER_INCOME', '8.8', 'TAX', 'NUMBER', '기타소득 원천징수율(%) — 2024년 기준'),
('TAX_RATE_LOCAL_INCOME', '10.0', 'TAX', 'NUMBER', '지방소득세율(%) — 원천세 기준'),
('TAX_RATE_VAT', '10.0', 'TAX', 'NUMBER', '부가세율(%) — 일반과세자 적용')
ON CONFLICT (config_key) DO NOTHING;
-- ============================================================
-- 4. 공통코드 그룹 TAX_TYPE / VAT_TYPE 추가
-- ============================================================
INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
('TAX_TYPE', '소득세유형', 'BUSINESS_INCOME(사업소득) / OTHER_INCOME(기타소득)', 'Y', 190),
('VAT_TYPE', '부가세유형', 'NONE / EXEMPT(면세) / TAXABLE(일반과세)', 'Y', 200)
ON CONFLICT (group_code) DO NOTHING;
INSERT INTO common_code (group_code, code, code_name, code_desc, sort_order) VALUES
('TAX_TYPE', 'BUSINESS_INCOME', '사업소득', '원천징수 3.3% 적용', 10),
('TAX_TYPE', 'OTHER_INCOME', '기타소득', '원천징수 8.8% 적용', 20),
('VAT_TYPE', 'NONE', '미지정', '부가세 처리 없음', 10),
('VAT_TYPE', 'EXEMPT', '면세사업자','부가세 면세 — 계산서 발행', 20),
('VAT_TYPE', 'TAXABLE', '일반과세자','부가세 10% — 세금계산서', 30)
ON CONFLICT (group_code, code) DO NOTHING;
@@ -0,0 +1,66 @@
-- V24: 공제/차감 도메인 (agent_deduction, payment_deduction_detail, payment 확장, 공통코드)
-- ============================================================
-- 1. agent_deduction — 월별 차감 마스터
-- ============================================================
CREATE TABLE agent_deduction (
deduction_id BIGSERIAL PRIMARY KEY,
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
settle_month CHAR(6) NOT NULL,
deduction_type VARCHAR(20) NOT NULL,
amount NUMERIC(15,2) NOT NULL DEFAULT 0,
description VARCHAR(255),
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
updated_by BIGINT,
UNIQUE (agent_id, settle_month, deduction_type)
);
CREATE INDEX idx_ad_month_agent ON agent_deduction(settle_month, agent_id);
-- ============================================================
-- 2. payment_deduction_detail — 이체 시 적용된 차감 명세
-- ============================================================
CREATE TABLE payment_deduction_detail (
detail_id BIGSERIAL PRIMARY KEY,
payment_id BIGINT NOT NULL REFERENCES payment(payment_id),
deduction_type VARCHAR(20) NOT NULL,
amount NUMERIC(15,2) NOT NULL,
description VARCHAR(255),
applied_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_pdd_payment ON payment_deduction_detail(payment_id);
-- ============================================================
-- 3. payment 테이블 차감 합계 컬럼 추가
-- ============================================================
ALTER TABLE payment
ADD COLUMN deduction_amount NUMERIC(15,2) NOT NULL DEFAULT 0;
-- ============================================================
-- 4. 공통코드 그룹 DEDUCTION_TYPE 추가
-- ============================================================
INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
('DEDUCTION_TYPE', '차감유형', 'LOAN/ADVANCE/GUARANTEE/DUES/AD/CHARGEBACK_PENDING/OTHER', 'Y', 210),
('DEDUCTION_STATUS', '차감상태', 'PENDING/APPLIED/CANCELLED', 'Y', 220)
ON CONFLICT (group_code) DO NOTHING;
INSERT INTO common_code (group_code, code, code_name, code_desc, sort_order) VALUES
('DEDUCTION_TYPE', 'LOAN', '대출잔액', '설계사 대출 미상환 잔액', 10),
('DEDUCTION_TYPE', 'ADVANCE', '가불금', '선지급 후 정산 차감', 20),
('DEDUCTION_TYPE', 'GUARANTEE', '신원보증보험료', '신원보증보험 납부액', 30),
('DEDUCTION_TYPE', 'DUES', '협회비', '보험협회 등 협회비', 40),
('DEDUCTION_TYPE', 'AD', '광고비/판촉비', '광고 및 판촉 활동 비용', 50),
('DEDUCTION_TYPE', 'CHARGEBACK_PENDING', '미회수환수금', '환수 분할 미처리 잔액', 60),
('DEDUCTION_TYPE', 'OTHER', '기타', '기타 차감 항목', 70),
('DEDUCTION_STATUS', 'PENDING', '대기', '차감 등록 후 미적용 상태', 10),
('DEDUCTION_STATUS', 'APPLIED', '적용됨', '이체 시 차감 완료', 20),
('DEDUCTION_STATUS', 'CANCELLED', '취소', '차감 취소 처리됨', 30)
ON CONFLICT (group_code, code) DO NOTHING;
@@ -0,0 +1,61 @@
-- V25: 수수료 명세서 (commission_statement, 공통코드)
-- ============================================================
-- 1. commission_statement — 명세서 발급 마스터
--
-- 재발급 정책: UNIQUE(agent_id, settle_month, statement_type) 유지.
-- 재발급은 기존 row를 PUT으로 갱신(issued_at, file_path 등 업데이트)
-- — 재발급 빈도가 낮고 명세서 이력 추적보다 최신본이 중요하기 때문.
-- ============================================================
CREATE TABLE commission_statement (
statement_id BIGSERIAL PRIMARY KEY,
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
settle_month CHAR(6) NOT NULL,
statement_type VARCHAR(20) NOT NULL,
-- 발급 시점 집계 스냅샷 (동결값)
gross_amount NUMERIC(15,2) NOT NULL,
income_tax_amount NUMERIC(15,2) NOT NULL,
local_tax_amount NUMERIC(15,2) NOT NULL,
vat_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
deduction_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
net_amount NUMERIC(15,2) NOT NULL,
-- 발급 정보
issued_at TIMESTAMP NOT NULL,
issued_by VARCHAR(50),
file_path VARCHAR(500),
file_format VARCHAR(10),
issue_status VARCHAR(20) NOT NULL DEFAULT 'GENERATED',
-- 감사 4종
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
updated_by BIGINT,
UNIQUE (agent_id, settle_month, statement_type)
);
CREATE INDEX idx_cs_agent_month ON commission_statement(agent_id, settle_month, statement_type DESC);
CREATE INDEX idx_cs_issued_at ON commission_statement(issued_at DESC);
-- ============================================================
-- 2. 공통코드 그룹 STATEMENT_TYPE / STATEMENT_STATUS 추가
-- ============================================================
INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
('STATEMENT_TYPE', '명세서유형', 'MONTHLY/ANNUAL/PROOF', 'Y', 230),
('STATEMENT_STATUS', '명세서상태', 'GENERATED/SENT/DOWNLOADED/CANCELLED', 'Y', 240)
ON CONFLICT (group_code) DO NOTHING;
INSERT INTO common_code (group_code, code, code_name, code_desc, sort_order) VALUES
('STATEMENT_TYPE', 'MONTHLY', '월별', '월별 수수료 명세서', 10),
('STATEMENT_TYPE', 'ANNUAL', '연간누계', '연간 수수료 누계 명세서', 20),
('STATEMENT_TYPE', 'PROOF', '소득증빙', '소득증빙용 발급 명세서', 30),
('STATEMENT_STATUS', 'GENERATED', '생성됨', '명세서 파일 생성 완료', 10),
('STATEMENT_STATUS', 'SENT', '발송', '이메일 등으로 발송 완료', 20),
('STATEMENT_STATUS', 'DOWNLOADED', '다운로드됨', '설계사 다운로드 확인', 30),
('STATEMENT_STATUS', 'CANCELLED', '취소', '명세서 취소 처리', 40)
ON CONFLICT (group_code, code) DO NOTHING;
@@ -0,0 +1,65 @@
-- V26: 규제 한도 마스터 + 1200%룰 기본 데이터 (보험업감독규정 2024년 기준)
-- ============================================================
-- 1. regulatory_limit — 감독규정 한도 마스터 (개정 이력 추적)
-- ============================================================
CREATE TABLE regulatory_limit (
limit_id BIGSERIAL PRIMARY KEY,
limit_type VARCHAR(30) NOT NULL,
product_category VARCHAR(30), -- NULL = ALL 상품 적용
limit_value NUMERIC(10,2) NOT NULL,
unit VARCHAR(10) NOT NULL, -- PERCENT / YEAR
effective_from DATE NOT NULL,
effective_to DATE, -- NULL = 현재 유효
regulation_ref VARCHAR(100),
description VARCHAR(500),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
updated_by BIGINT
);
CREATE INDEX idx_rl_type_category ON regulatory_limit(limit_type, product_category, effective_from DESC);
CREATE INDEX idx_rl_active ON regulatory_limit(is_active) WHERE is_active = TRUE;
-- ============================================================
-- 2. 기본 데이터 INSERT — 2024년 한국 보험업감독규정 표준
-- ============================================================
INSERT INTO regulatory_limit
(limit_type, product_category, limit_value, unit, effective_from, effective_to, regulation_ref, description)
VALUES
('TOTAL_RATIO', NULL, 1200, 'PERCENT', '2024-01-01', NULL,
'보험업감독규정 제5-15조의5', '총 모집수수료 한도 — 첫 1년 영업보험료의 1200% 이내'),
('FIRST_YEAR_RATIO', NULL, 35, 'PERCENT', '2024-01-01', NULL,
'보험업감독규정 제5-15조의5', '1차년도 지급 한도'),
('INSTALLMENT_YEARS', NULL, 7, 'YEAR', '2024-01-01', NULL,
'보험업감독규정 제5-15조의5', '분급 연한');
-- ============================================================
-- 3. 공통코드 그룹
-- ============================================================
INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
('LIMIT_TYPE', '한도유형', 'TOTAL_RATIO/FIRST_YEAR_RATIO/INSTALLMENT_YEARS', 'Y', 250),
('LIMIT_UNIT', '한도단위', 'PERCENT/YEAR', 'Y', 260),
('PRODUCT_CATEGORY', '상품카테고리', 'ALL/LIFE/ANNUITY/SAVINGS/EXCLUDED_AUTO/EXCLUDED_HEALTH', 'Y', 270)
ON CONFLICT (group_code) DO NOTHING;
INSERT INTO common_code (group_code, code, code_name, code_desc, sort_order) VALUES
('LIMIT_TYPE', 'TOTAL_RATIO', '총한도비율', '총 모집수수료 한도율 (1200%)', 10),
('LIMIT_TYPE', 'FIRST_YEAR_RATIO', '1차년도한도', '1차년도 지급 한도율 (35%)', 20),
('LIMIT_TYPE', 'INSTALLMENT_YEARS', '분급연한', '수수료 분급 기간 (7년)', 30),
('LIMIT_UNIT', 'PERCENT', '퍼센트', '%', 10),
('LIMIT_UNIT', 'YEAR', '', '연도', 20),
('PRODUCT_CATEGORY', 'ALL', '전체', '모든 상품 공통 적용', 10),
('PRODUCT_CATEGORY', 'LIFE', '종신', '종신보험', 20),
('PRODUCT_CATEGORY', 'ANNUITY', '연금', '연금보험', 30),
('PRODUCT_CATEGORY', 'SAVINGS', '저축', '저축보험', 40),
('PRODUCT_CATEGORY', 'EXCLUDED_AUTO', '자동차제외', '1200%룰 적용 제외 — 자동차보험', 50),
('PRODUCT_CATEGORY', 'EXCLUDED_HEALTH', '실손제외', '1200%룰 적용 제외 — 실손의료비', 60)
ON CONFLICT (group_code, code) DO NOTHING;
@@ -0,0 +1,77 @@
-- V27: 분급 계획 (installment_ratio, installment_plan, 공통코드)
-- ============================================================
-- 1. installment_ratio — 분급 비율 마스터 (개정 이력 추적)
-- ============================================================
CREATE TABLE installment_ratio (
ratio_id BIGSERIAL PRIMARY KEY,
product_category VARCHAR(30), -- NULL = ALL 상품
plan_year INT NOT NULL, -- 1~7
ratio_percent NUMERIC(5,2) NOT NULL,
effective_from DATE NOT NULL,
effective_to DATE, -- NULL = 현재 유효
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
updated_by BIGINT,
UNIQUE (product_category, plan_year, effective_from)
);
CREATE INDEX idx_ir_category_year ON installment_ratio(product_category, plan_year, effective_from DESC);
-- ============================================================
-- 2. installment_plan — 계약별 분급 계획
-- ============================================================
CREATE TABLE installment_plan (
plan_id BIGSERIAL PRIMARY KEY,
contract_id BIGINT NOT NULL REFERENCES contract(contract_id),
plan_year INT NOT NULL, -- 1~7
plan_month INT NOT NULL, -- 1~12
settle_month CHAR(6), -- YYYYMM, 지급 예정월
expected_amount NUMERIC(15,2) NOT NULL,
paid_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
paid_at TIMESTAMP,
payment_id BIGINT REFERENCES payment(payment_id),
status VARCHAR(20) NOT NULL DEFAULT 'SCHEDULED',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
updated_by BIGINT,
UNIQUE (contract_id, plan_year, plan_month)
);
CREATE INDEX idx_ip_contract ON installment_plan(contract_id, plan_year, plan_month);
CREATE INDEX idx_ip_settle ON installment_plan(settle_month, status);
CREATE INDEX idx_ip_payment ON installment_plan(payment_id);
-- ============================================================
-- 3. 기본 분급 비율 데이터 (2024-01-01, NULL=ALL 상품)
-- ============================================================
INSERT INTO installment_ratio (product_category, plan_year, ratio_percent, effective_from) VALUES
(NULL, 1, 35, '2024-01-01'),
(NULL, 2, 25, '2024-01-01'),
(NULL, 3, 15, '2024-01-01'),
(NULL, 4, 10, '2024-01-01'),
(NULL, 5, 7, '2024-01-01'),
(NULL, 6, 5, '2024-01-01'),
(NULL, 7, 3, '2024-01-01')
ON CONFLICT (product_category, plan_year, effective_from) DO NOTHING;
-- ============================================================
-- 4. 공통코드 INSTALLMENT_STATUS
-- ============================================================
INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
('INSTALLMENT_STATUS', '분급상태', 'SCHEDULED/PAID/DEFERRED/CANCELLED', 'Y', 280)
ON CONFLICT (group_code) DO NOTHING;
INSERT INTO common_code (group_code, code, code_name, code_desc, sort_order) VALUES
('INSTALLMENT_STATUS', 'SCHEDULED', '지급예정', '분급 지급 예정 상태', 10),
('INSTALLMENT_STATUS', 'PAID', '지급완료', '분급 지급 완료', 20),
('INSTALLMENT_STATUS', 'DEFERRED', '이월', '당월 미지급 — 다음 월로 이월', 30),
('INSTALLMENT_STATUS', 'CANCELLED', '취소', '분급 계획 취소', 40)
ON CONFLICT (group_code, code) DO NOTHING;
@@ -0,0 +1,38 @@
-- V28: 차등 환수 룰 (chargeback_grade — 해약 경과월별 단계별 환수 비율)
-- 기존 chargeback_rule(V3)은 계약별 일반 룰 — 본 테이블은 해약 시점별 차등 비율 마스터
-- ============================================================
-- 1. chargeback_grade — 해약 경과월별 환수 비율 마스터
-- ============================================================
CREATE TABLE chargeback_grade (
grade_id BIGSERIAL PRIMARY KEY,
product_category VARCHAR(30), -- NULL = ALL 상품
months_from INT NOT NULL, -- 해약 경과월 시작 (이상)
months_to INT, -- 해약 경과월 종료 (이하), NULL = 무한대
chargeback_percent NUMERIC(5,2) NOT NULL, -- 환수율 %
effective_from DATE NOT NULL,
effective_to DATE, -- NULL = 현재 유효
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
updated_by BIGINT,
CHECK (months_from >= 0 AND (months_to IS NULL OR months_to >= months_from))
);
CREATE INDEX idx_cbg_category_months ON chargeback_grade(product_category, months_from, effective_from DESC);
-- ============================================================
-- 2. 기본 데이터 (2024-01-01, NULL=ALL 상품, 표준 차등 환수 룰)
-- ============================================================
INSERT INTO chargeback_grade
(product_category, months_from, months_to, chargeback_percent, effective_from)
VALUES
(NULL, 1, 12, 100, '2024-01-01'), -- 1~12개월: 전액 환수
(NULL, 13, 24, 50, '2024-01-01'), -- 13~24개월: 50%
(NULL, 25, 36, 25, '2024-01-01'), -- 25~36개월: 25%
(NULL, 37, 84, 10, '2024-01-01'), -- 37~84개월: 10%
(NULL, 85, NULL, 0, '2024-01-01') -- 85개월 이상: 환수 없음
ON CONFLICT DO NOTHING;
@@ -0,0 +1,37 @@
package com.ga.core.mapper.chargeback;
import com.ga.core.vo.chargeback.ChargebackGradeResp;
import com.ga.core.vo.chargeback.ChargebackGradeSearchParam;
import com.ga.core.vo.chargeback.ChargebackGradeVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface ChargebackGradeMapper {
List<ChargebackGradeResp> selectList(ChargebackGradeSearchParam param);
ChargebackGradeResp selectDetailById(@Param("gradeId") Long gradeId);
ChargebackGradeVO selectById(@Param("gradeId") Long gradeId);
/** 해약 경과월에 적용되는 환수 비율 구간 1건 조회 (차등 환수 계산 핵심) */
ChargebackGradeVO selectByMonths(@Param("productCategory") String productCategory,
@Param("monthsElapsed") Integer monthsElapsed,
@Param("baseDate") String baseDate);
/** 특정 시점 활성 전체 구간 조회 (months_from ASC) */
List<ChargebackGradeVO> selectAll(@Param("baseDate") String baseDate);
/** 카테고리별 활성 구간 조회 */
List<ChargebackGradeVO> selectByCategory(@Param("productCategory") String productCategory,
@Param("baseDate") String baseDate);
int insert(ChargebackGradeVO vo);
int update(ChargebackGradeVO vo);
int updateStatus(@Param("gradeId") Long gradeId, @Param("isActive") Boolean isActive);
}
@@ -0,0 +1,34 @@
package com.ga.core.mapper.installment;
import com.ga.core.vo.installment.InstallmentPlanResp;
import com.ga.core.vo.installment.InstallmentPlanSearchParam;
import com.ga.core.vo.installment.InstallmentPlanVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface InstallmentPlanMapper {
List<InstallmentPlanResp> selectList(InstallmentPlanSearchParam param);
InstallmentPlanResp selectDetailById(@Param("planId") Long planId);
InstallmentPlanVO selectById(@Param("planId") Long planId);
/** 계약의 전체 분급 계획 조회 (계약 상세 화면용) */
List<InstallmentPlanVO> selectByContract(@Param("contractId") Long contractId);
/** 정산월의 SCHEDULED 분급 계획 조회 (배치 지급 처리용) */
List<InstallmentPlanVO> selectScheduledByMonth(@Param("settleMonth") String settleMonth);
int insert(InstallmentPlanVO vo);
/** 7년치 분급 계획 일괄 생성 */
int insertBatch(@Param("list") List<InstallmentPlanVO> list);
int update(InstallmentPlanVO vo);
int updateStatus(@Param("planId") Long planId, @Param("status") String status);
}
@@ -0,0 +1,32 @@
package com.ga.core.mapper.installment;
import com.ga.core.vo.installment.InstallmentRatioResp;
import com.ga.core.vo.installment.InstallmentRatioSearchParam;
import com.ga.core.vo.installment.InstallmentRatioVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface InstallmentRatioMapper {
List<InstallmentRatioResp> selectList(InstallmentRatioSearchParam param);
InstallmentRatioResp selectDetailById(@Param("ratioId") Long ratioId);
InstallmentRatioVO selectById(@Param("ratioId") Long ratioId);
/** 특정 날짜에 유효한 분급 비율 1건 (planYear + productCategory 조합) */
InstallmentRatioVO selectActive(@Param("productCategory") String productCategory,
@Param("planYear") Integer planYear,
@Param("baseDate") String baseDate);
/** 특정 날짜에 유효한 1~7년 차 전체 비율 조회 (분급 계획 일괄 생성 시 사용) */
List<InstallmentRatioVO> selectAllActiveRatios(@Param("productCategory") String productCategory,
@Param("baseDate") String baseDate);
int insert(InstallmentRatioVO vo);
int update(InstallmentRatioVO vo);
}
@@ -26,4 +26,7 @@ public interface MaintainLedgerMapper {
BigDecimal sumByMonth(@Param("settleMonth") String settleMonth);
BigDecimal sumByAgentMonth(@Param("agentId") Long agentId,
@Param("settleMonth") String settleMonth);
/** 계약별 유지 원장 수수료 합산 (1200%룰 누적 검증용) */
BigDecimal sumByContract(@Param("contractId") Long contractId);
}
@@ -30,4 +30,10 @@ public interface RecruitLedgerMapper {
/** 정산월 + 설계사 합계 */
BigDecimal sumByAgentMonth(@Param("agentId") Long agentId,
@Param("settleMonth") String settleMonth);
/** 계약별 신계약 원장 수수료 합산 (1200%룰 누적 검증용) */
BigDecimal sumByContract(@Param("contractId") Long contractId);
/** 계약별 1차년도 수수료 합산 (1차년도 한도 검증용) */
BigDecimal sumFirstYearByContract(@Param("contractId") Long contractId);
}
@@ -1,6 +1,7 @@
package com.ga.core.mapper.org;
import com.ga.core.vo.org.AgentExcelVO;
import com.ga.core.vo.org.AgentOrgHistoryResp;
import com.ga.core.vo.org.AgentOrgHistoryVO;
import com.ga.core.vo.org.AgentResp;
import com.ga.core.vo.org.AgentSearchParam;
@@ -28,5 +29,6 @@ public interface AgentMapper {
/** 이력 */
List<AgentOrgHistoryVO> selectHistory(@Param("agentId") Long agentId);
List<AgentOrgHistoryResp> selectHistoryResp(@Param("agentId") Long agentId);
int insertHistory(AgentOrgHistoryVO vo);
}
@@ -0,0 +1,34 @@
package com.ga.core.mapper.regulatory;
import com.ga.core.vo.regulatory.RegulatoryLimitResp;
import com.ga.core.vo.regulatory.RegulatoryLimitSearchParam;
import com.ga.core.vo.regulatory.RegulatoryLimitVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface RegulatoryLimitMapper {
List<RegulatoryLimitResp> selectList(RegulatoryLimitSearchParam param);
RegulatoryLimitResp selectDetailById(@Param("limitId") Long limitId);
RegulatoryLimitVO selectById(@Param("limitId") Long limitId);
/** 특정 날짜에 유효한 한도 1건 조회 (limitType + productCategory 조합) */
RegulatoryLimitVO selectActiveByType(@Param("limitType") String limitType,
@Param("productCategory") String productCategory,
@Param("baseDate") String baseDate);
/** 특정 날짜에 유효한 모든 활성 한도 조회 (배치/검증용) */
List<RegulatoryLimitVO> selectAllActive(@Param("baseDate") String baseDate);
int insert(RegulatoryLimitVO vo);
int update(RegulatoryLimitVO vo);
/** is_active 플래그 변경 */
int updateStatus(@Param("limitId") Long limitId, @Param("isActive") Boolean isActive);
}
@@ -0,0 +1,41 @@
package com.ga.core.mapper.settle;
import com.ga.core.vo.settle.AgentDeductionResp;
import com.ga.core.vo.settle.AgentDeductionSearchParam;
import com.ga.core.vo.settle.AgentDeductionVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.math.BigDecimal;
import java.util.List;
@Mapper
public interface AgentDeductionMapper {
List<AgentDeductionResp> selectList(AgentDeductionSearchParam param);
AgentDeductionResp selectDetailById(@Param("deductionId") Long deductionId);
AgentDeductionVO selectById(@Param("deductionId") Long deductionId);
int insert(AgentDeductionVO vo);
int update(AgentDeductionVO vo);
int updateStatus(@Param("deductionId") Long deductionId, @Param("status") String status);
/** 정산월 + 설계사의 PENDING 차감 합계 */
BigDecimal sumPendingByAgent(@Param("agentId") Long agentId,
@Param("settleMonth") String settleMonth);
/** 정산월 전체 PENDING 목록 (배치용) */
List<AgentDeductionVO> selectPendingByMonth(@Param("settleMonth") String settleMonth);
/** 설계사의 PENDING 목록 (단건 차감 적용용) */
List<AgentDeductionVO> selectPendingByAgent(@Param("agentId") Long agentId,
@Param("settleMonth") String settleMonth);
/** 일괄 상태 전이 */
int updateStatusBatch(@Param("deductionIds") List<Long> deductionIds,
@Param("status") String status);
/** 중복 검증 */
int countByAgentMonthType(@Param("agentId") Long agentId,
@Param("settleMonth") String settleMonth,
@Param("deductionType") String deductionType);
}
@@ -0,0 +1,30 @@
package com.ga.core.mapper.settle;
import com.ga.core.vo.settle.CommissionStatementResp;
import com.ga.core.vo.settle.CommissionStatementSearchParam;
import com.ga.core.vo.settle.CommissionStatementVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface CommissionStatementMapper {
List<CommissionStatementResp> selectList(CommissionStatementSearchParam param);
CommissionStatementResp selectDetailById(@Param("statementId") Long statementId);
CommissionStatementVO selectById(@Param("statementId") Long statementId);
/** UNIQUE(agent_id, settle_month, statement_type) 조회 — 재발급 판단용 */
CommissionStatementVO selectByAgentMonth(@Param("agentId") Long agentId,
@Param("settleMonth") String settleMonth,
@Param("statementType") String statementType);
int insert(CommissionStatementVO vo);
int update(CommissionStatementVO vo);
int updateStatus(@Param("statementId") Long statementId, @Param("issueStatus") String issueStatus);
}
@@ -0,0 +1,14 @@
package com.ga.core.mapper.settle;
import com.ga.core.vo.settle.PaymentDeductionDetailVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface PaymentDeductionDetailMapper {
List<PaymentDeductionDetailVO> selectByPaymentId(@Param("paymentId") Long paymentId);
int insert(PaymentDeductionDetailVO vo);
int insertBatch(@Param("list") List<PaymentDeductionDetailVO> list);
}
@@ -6,6 +6,7 @@ import com.ga.core.vo.settle.SettleSearchParam;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.math.BigDecimal;
import java.util.List;
@Mapper
@@ -19,7 +20,20 @@ public interface PaymentMapper {
@Param("failReason") String failReason);
int updateTransferFile(@Param("paymentIds") List<Long> paymentIds,
@Param("transferFileId") Long transferFileId);
int updateTaxes(@Param("paymentId") Long paymentId,
@Param("incomeTaxAmount") BigDecimal incomeTaxAmount,
@Param("localTaxAmount") BigDecimal localTaxAmount,
@Param("vatAmount") BigDecimal vatAmount,
@Param("netAmount") BigDecimal netAmount);
int updateTaxesBatch(@Param("list") List<PaymentVO> list);
int updateDeduction(@Param("paymentId") Long paymentId,
@Param("deductionAmount") BigDecimal deductionAmount,
@Param("netAmount") BigDecimal netAmount);
List<PaymentVO> selectPendingByMonthAndBank(@Param("settleMonth") String settleMonth,
@Param("bankCode") String bankCode);
List<PaymentVO> selectBySettleMonth(@Param("settleMonth") String settleMonth);
List<PaymentVO> selectByAgentAndMonth(@Param("agentId") Long agentId,
@Param("settleMonth") String settleMonth);
}
@@ -0,0 +1,27 @@
package com.ga.core.mapstruct;
import com.ga.core.vo.product.ContractSaveReq;
import com.ga.core.vo.product.ContractVO;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
@Mapper(componentModel = "spring")
public interface ContractMapStruct {
@Mapping(target = "contractId", ignore = true)
@Mapping(target = "status", ignore = true)
@Mapping(target = "lapseDate", ignore = true)
@Mapping(target = "revivalDate", ignore = true)
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "updatedAt", ignore = true)
ContractVO toVO(ContractSaveReq req);
@Mapping(target = "contractId", ignore = true)
@Mapping(target = "status", ignore = true)
@Mapping(target = "lapseDate", ignore = true)
@Mapping(target = "revivalDate", ignore = true)
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "updatedAt", ignore = true)
void updateVO(ContractSaveReq req, @MappingTarget ContractVO vo);
}
@@ -0,0 +1,24 @@
package com.ga.core.mapstruct;
import com.ga.core.vo.ledger.ExceptionLedgerSaveReq;
import com.ga.core.vo.ledger.ExceptionLedgerVO;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "spring")
public interface ExceptionLedgerMapStruct {
@Mapping(target = "ledgerId", ignore = true)
@Mapping(target = "contractId", ignore = true)
@Mapping(target = "commissionYear", ignore = true)
@Mapping(target = "taxAmount", ignore = true)
@Mapping(target = "approveStatus", ignore = true)
@Mapping(target = "approverId", ignore = true)
@Mapping(target = "approveDate", ignore = true)
@Mapping(target = "recurringLeft", ignore = true)
@Mapping(target = "status", ignore = true)
@Mapping(target = "version", ignore = true)
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "updatedAt", ignore = true)
ExceptionLedgerVO toVO(ExceptionLedgerSaveReq req);
}
@@ -0,0 +1,34 @@
package com.ga.core.mapstruct;
import com.ga.core.vo.user.UserSaveReq;
import com.ga.core.vo.user.UserVO;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
@Mapper(componentModel = "spring")
public interface UserMapStruct {
@Mapping(target = "userId", ignore = true)
@Mapping(target = "password", ignore = true)
@Mapping(target = "failCount", ignore = true)
@Mapping(target = "lastLoginAt", ignore = true)
@Mapping(target = "pwdChangedAt", ignore = true)
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "createdBy", ignore = true)
@Mapping(target = "updatedAt", ignore = true)
@Mapping(target = "updatedBy", ignore = true)
UserVO toVO(UserSaveReq req);
@Mapping(target = "userId", ignore = true)
@Mapping(target = "loginId", ignore = true)
@Mapping(target = "password", ignore = true)
@Mapping(target = "failCount", ignore = true)
@Mapping(target = "lastLoginAt", ignore = true)
@Mapping(target = "pwdChangedAt", ignore = true)
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "createdBy", ignore = true)
@Mapping(target = "updatedAt", ignore = true)
@Mapping(target = "updatedBy", ignore = true)
void updateVO(UserSaveReq req, @MappingTarget UserVO vo);
}
@@ -0,0 +1,20 @@
package com.ga.core.vo.chargeback;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
@Data
public class ChargebackGradeResp {
private Long gradeId;
private String productCategory;
private String productCategoryName;
private Integer monthsFrom;
private Integer monthsTo;
private String monthsRange; // "1~12개월", "85개월 이상" 형태 표시용
private BigDecimal chargebackPercent;
private LocalDate effectiveFrom;
private LocalDate effectiveTo;
private Boolean isActive;
}
@@ -0,0 +1,30 @@
package com.ga.core.vo.chargeback;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
@Data
public class ChargebackGradeSaveReq {
private String productCategory; // NULL = 전체 상품
@NotNull(message = "경과월 시작은 필수입니다")
@Min(value = 0, message = "경과월 시작은 0 이상이어야 합니다")
private Integer monthsFrom;
private Integer monthsTo; // NULL = 무한대
@NotNull(message = "환수율은 필수입니다")
@Min(value = 0, message = "환수율은 0 이상이어야 합니다")
private BigDecimal chargebackPercent;
@NotNull(message = "적용 시작일은 필수입니다")
private LocalDate effectiveFrom;
private LocalDate effectiveTo;
}
@@ -0,0 +1,13 @@
package com.ga.core.vo.chargeback;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class ChargebackGradeSearchParam extends SearchParam {
private String productCategory;
private Integer monthsElapsed; // 해당 경과월에 해당하는 구간 필터
private String effectiveDate; // YYYY-MM-DD 해당 날짜 유효 구간 필터
}
@@ -0,0 +1,24 @@
package com.ga.core.vo.chargeback;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
public class ChargebackGradeVO {
private Long gradeId;
private String productCategory; // NULL = ALL 상품
private Integer monthsFrom; // 해약 경과월 시작 (이상)
private Integer monthsTo; // 해약 경과월 종료 (이하), NULL = 무한대
private BigDecimal chargebackPercent;
private LocalDate effectiveFrom;
private LocalDate effectiveTo; // NULL = 현재 유효
private Boolean isActive;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
private Long updatedBy;
}
@@ -0,0 +1,25 @@
package com.ga.core.vo.installment;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
public class InstallmentPlanResp {
private Long planId;
private Long contractId;
private String policyNo;
private String productName;
private Long agentId;
private String agentName;
private Integer planYear;
private Integer planMonth;
private String settleMonth;
private BigDecimal expectedAmount;
private BigDecimal paidAmount;
private LocalDateTime paidAt;
private Long paymentId;
private String status;
private String statusName;
}
@@ -0,0 +1,14 @@
package com.ga.core.vo.installment;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class InstallmentPlanSearchParam extends SearchParam {
private Long contractId;
private String settleMonth;
private String status;
private Integer planYear;
}
@@ -0,0 +1,25 @@
package com.ga.core.vo.installment;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
public class InstallmentPlanVO {
private Long planId;
private Long contractId;
private Integer planYear; // 1~7
private Integer planMonth; // 1~12
private String settleMonth; // YYYYMM
private BigDecimal expectedAmount;
private BigDecimal paidAmount;
private LocalDateTime paidAt;
private Long paymentId;
private String status;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
private Long updatedBy;
}
@@ -0,0 +1,18 @@
package com.ga.core.vo.installment;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
@Data
public class InstallmentRatioResp {
private Long ratioId;
private String productCategory;
private String productCategoryName;
private Integer planYear;
private BigDecimal ratioPercent;
private LocalDate effectiveFrom;
private LocalDate effectiveTo;
private Boolean isActive;
}
@@ -0,0 +1,30 @@
package com.ga.core.vo.installment;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
@Data
public class InstallmentRatioSaveReq {
private String productCategory; // NULL = 전체 상품
@NotNull(message = "분급 연차는 필수입니다")
@Min(value = 1, message = "분급 연차는 1 이상이어야 합니다")
@Max(value = 7, message = "분급 연차는 7 이하여야 합니다")
private Integer planYear;
@NotNull(message = "비율은 필수입니다")
@Positive(message = "비율은 양수여야 합니다")
private BigDecimal ratioPercent;
@NotNull(message = "적용 시작일은 필수입니다")
private LocalDate effectiveFrom;
private LocalDate effectiveTo;
}
@@ -0,0 +1,13 @@
package com.ga.core.vo.installment;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class InstallmentRatioSearchParam extends SearchParam {
private String productCategory;
private Integer planYear;
private String effectiveDate; // YYYY-MM-DD 해당 날짜 유효 비율 필터
}

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