From 17ab1098ec793cfa51b200031ea77b5bc6cf5534 Mon Sep 17 00:00:00 2001 From: GA Pro Date: Fri, 12 Jun 2026 23:40:28 +0900 Subject: [PATCH] =?UTF-8?q?feat:=201200%=EB=A3=B0=20=EC=9D=B4=EC=97=B0=20?= =?UTF-8?q?=EB=B6=84=EA=B8=89=20=EB=8F=84=EB=9E=98=EC=9B=94=20=EC=A7=80?= =?UTF-8?q?=EA=B8=89(PayInstallmentStep)=20+=20=EC=BD=94=EB=93=9C=EB=B2=A0?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=8B=A0=EC=9E=85=EA=B5=90=EC=9C=A1=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PayInstallmentStep(Step 4.5): 1200%룰로 이연된 분급(installment_plan SCHEDULED)을 도래월(settle_month=정산월)에 recruit_ledger로 편입해 실제 지급. 이전엔 이연만 되고 도래월 지급 절차가 없어 차액이 영구 미지급되던 갭을 메움. - BatchConfig job flow에 step4b(calcRecruit→payInstallment→calcMaintain) 등록 - InstallmentPlanMapper.resetPaidToScheduledByMonth(+XML)로 재실행 멱등 (진입 시 PAID→SCHEDULED 역산, Step4 deleteBySettleMonth가 원장 정리) - RecruitLedgerMapper insert에 reconcile_status 컬럼, SettlementContext.installmentPaidCount - 단위테스트 3건(정상지급/멱등/계약미존재) GREEN 신입교육 주석: 컨트롤러/서비스/공통/프론트 전반에 도메인·코드 설명 주석 추가 (동작 변경 없음 — 빌드/테스트/타입체크 전부 GREEN으로 무회귀 확인). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../accounting/AccountingCloseController.java | 9 ++ .../accounting/CloseJournalReq.java | 4 + .../ContractAccountingController.java | 10 ++ .../accounting/GenerateEntriesReq.java | 4 + .../api/controller/approval/AdvanceReq.java | 8 ++ .../approval/ApprovalController.java | 25 ++++ .../attachment/AttachmentController.java | 15 ++ .../batch/BatchTriggerController.java | 3 + .../chargeback/ChargebackCalculateReq.java | 9 ++ .../chargeback/ChargebackGradeController.java | 12 ++ .../commission/AdvancePaymentController.java | 14 ++ .../commission/AwardClawbackReq.java | 7 +- .../commission/AwardController.java | 22 ++- .../commission/BranchAllowanceController.java | 20 ++- .../CarrierReconciliationInController.java | 14 ++ .../ChargebackWaiverController.java | 14 ++ .../ClawbackInstallmentController.java | 19 ++- .../ClawbackReceivableController.java | 15 ++ .../CollectionCommissionController.java | 20 ++- .../CommissionMasterController.java | 15 ++ .../CommissionSimulationController.java | 11 ++ .../commission/GradeEvaluationController.java | 23 ++- .../GuaranteeInsuranceController.java | 13 ++ .../commission/HqSettleController.java | 14 ++ .../IncomeCommissionController.java | 24 +++- .../commission/JointContractController.java | 13 ++ .../LapsedReinstatementController.java | 15 ++ .../commission/LifeSupportController.java | 21 ++- .../commission/OverrideLedgerController.java | 14 ++ .../PersistencyBonusController.java | 17 +++ .../RecognizedPerformanceController.java | 22 ++- .../RecruiterCommissionController.java | 21 ++- .../ReferralCommissionController.java | 21 ++- .../ReinstatementCommissionController.java | 17 +++ .../RenewalCommissionController.java | 17 +++ .../commission/ReserveFundController.java | 11 ++ .../commission/SettlingSupportController.java | 21 ++- .../commission/StatuteController.java | 17 ++- .../commission/StepCloseController.java | 15 ++ .../complaint/ComplaintController.java | 15 ++ .../contract/AgentContractController.java | 16 +++ .../deduction/DeductionController.java | 17 +++ .../dict/DataDictionaryController.java | 14 ++ .../controller/dict/DataDomainController.java | 13 ++ .../ContractEndorsementController.java | 14 ++ .../external/ExternalCallLogController.java | 9 ++ .../income/AgentAnnualIncomeController.java | 9 ++ .../income/YearEndStatementController.java | 10 ++ .../installment/GeneratePlanReq.java | 9 ++ .../installment/InstallmentController.java | 11 ++ .../InstallmentRatioController.java | 7 + .../controller/installment/PayPlanReq.java | 6 + .../internal/BankRetryController.java | 1 + .../internal/BankSettleController.java | 1 + .../kpi/ForecastKpiMonthlyController.java | 6 + .../ga/api/controller/kpi/KpiController.java | 12 ++ .../kpi/RetentionAnomalyAlertController.java | 9 ++ .../controller/ledger/LedgerController.java | 18 ++- .../license/AgentLicenseController.java | 13 ++ .../controller/mobile/MobileMeController.java | 13 ++ .../controller/notice/NoticeController.java | 20 +++ .../api/controller/org/AgentController.java | 16 +++ .../org/OrganizationController.java | 14 ++ .../controller/product/CompanyController.java | 12 ++ .../product/ContractController.java | 10 ++ .../controller/product/ProductController.java | 11 ++ .../controller/receive/ReceiveController.java | 14 ++ .../controller/receive/ResolveErrorReq.java | 5 + .../regulatory/ContractLimitCheckReq.java | 6 + .../regulatory/RegulatoryLimitController.java | 18 +++ .../controller/report/ReportController.java | 10 ++ .../api/controller/rule/RuleController.java | 20 ++- .../CarrierReconciliationOutController.java | 12 ++ .../settle/ChargebackController.java | 12 ++ .../settle/CommissionDisputeController.java | 21 +++ .../settle/IncentivePaymentController.java | 16 +++ .../settle/IncentiveProgramController.java | 15 ++ .../settle/PaymentBatchController.java | 12 ++ .../controller/settle/PaymentController.java | 15 ++ .../settle/PaymentStatusUpdateReq.java | 4 + .../settle/PeriodCloseController.java | 13 ++ .../controller/settle/SettleController.java | 19 +++ .../settle/SettleCorrectionController.java | 18 +++ .../settle/TaxInvoiceController.java | 13 ++ .../TerminationSettlementController.java | 17 +++ .../CommissionStatementController.java | 12 ++ .../ContractSuccessionController.java | 14 ++ .../api/controller/system/RoleController.java | 14 ++ .../api/controller/system/UserController.java | 13 ++ .../controller/tax/VatReportController.java | 9 ++ .../tax/WithholdingTaxReportController.java | 9 ++ .../training/AgentTrainingController.java | 20 +++ .../controller/upload/UploadController.java | 16 +++ .../upload/UploadTemplateController.java | 18 +++ .../withdraw/WithdrawRequestController.java | 15 ++ .../accounting/ContractAccountingService.java | 12 ++ .../api/service/approval/ApprovalService.java | 24 ++++ .../service/attachment/AttachmentService.java | 10 ++ .../chargeback/ChargebackCalculator.java | 15 ++ .../chargeback/ChargebackGradeService.java | 13 ++ .../service/chargeback/ChargebackResult.java | 7 + .../commission/AdvancePaymentService.java | 18 ++- .../api/service/commission/AwardService.java | 33 ++++- .../commission/BranchAllowanceService.java | 17 ++- .../CarrierReconciliationInService.java | 20 ++- .../commission/ChargebackWaiverService.java | 16 ++- .../ClawbackInstallmentService.java | 17 ++- .../ClawbackReceivableExcelRow.java | 5 + .../commission/ClawbackReceivableService.java | 14 +- .../CollectionCommissionService.java | 11 +- .../commission/CommissionMasterService.java | 12 ++ .../CommissionSimulationService.java | 13 ++ .../commission/GradeEvaluationService.java | 22 ++- .../commission/GuaranteeInsuranceService.java | 7 + .../service/commission/HqSettleService.java | 13 +- .../commission/IncomeCommissionService.java | 12 ++ .../commission/JointContractService.java | 9 ++ .../LapsedReinstatementService.java | 15 +- .../commission/LifeSupportService.java | 11 +- .../commission/OverrideLedgerService.java | 7 + .../commission/PersistencyBonusService.java | 11 +- .../RecognizedPerformanceService.java | 12 +- .../RecruiterCommissionService.java | 11 +- .../commission/ReferralCommissionService.java | 11 +- .../ReinstatementCommissionService.java | 11 +- .../commission/RenewalCommissionService.java | 10 +- .../commission/ReserveFundService.java | 10 ++ .../commission/SettlingSupportService.java | 11 +- .../service/commission/StatuteService.java | 13 +- .../service/commission/StepCloseService.java | 10 ++ .../service/complaint/ComplaintService.java | 12 ++ .../contract/AgentContractService.java | 12 ++ .../service/deduction/DeductionService.java | 14 ++ .../service/dict/DataDictionaryService.java | 13 ++ .../api/service/dict/DataDomainService.java | 13 ++ .../endorse/ContractEndorsementService.java | 11 ++ .../external/ExternalCallLogService.java | 2 + .../income/AgentAnnualIncomeService.java | 7 + .../income/YearEndStatementService.java | 9 ++ .../installment/InstallmentPlanGenerator.java | 24 +++- .../installment/InstallmentService.java | 29 ++++ .../kpi/ForecastKpiMonthlyService.java | 8 ++ .../com/ga/api/service/kpi/KpiService.java | 9 ++ .../kpi/RetentionAnomalyAlertService.java | 8 ++ .../ga/api/service/ledger/LedgerService.java | 21 +++ .../service/license/AgentLicenseService.java | 12 ++ .../api/service/mobile/MobileMeService.java | 5 + .../ga/api/service/notice/NoticeService.java | 17 +++ .../com/ga/api/service/org/AgentService.java | 20 +++ .../api/service/org/OrganizationService.java | 9 ++ .../api/service/product/ContractService.java | 11 ++ .../product/InsuranceCompanyService.java | 8 ++ .../api/service/product/ProductService.java | 10 ++ .../api/service/receive/ReceiveService.java | 10 +- .../regulatory/RegulatoryLimitChecker.java | 49 +++++-- .../regulatory/RegulatoryLimitService.java | 16 +++ .../service/regulatory/ViolationResult.java | 11 ++ .../ga/api/service/report/ReportService.java | 7 + .../com/ga/api/service/rule/RuleService.java | 15 +- .../CarrierReconciliationOutService.java | 7 + .../api/service/settle/ChargebackService.java | 6 + .../settle/CommissionDisputeService.java | 8 ++ .../settle/IncentivePaymentService.java | 8 ++ .../settle/IncentiveProgramService.java | 6 + .../service/settle/PaymentBatchService.java | 10 ++ .../ga/api/service/settle/PaymentService.java | 37 +++++ .../service/settle/PeriodCloseService.java | 6 + .../settle/SettleCorrectionService.java | 8 ++ .../ga/api/service/settle/SettleService.java | 17 +++ .../api/service/settle/TaxInvoiceService.java | 6 + .../settle/TerminationSettlementService.java | 9 ++ .../statement/CommissionStatementService.java | 13 ++ .../statement/ExcelStatementGenerator.java | 9 ++ .../statement/StatementAggregator.java | 8 ++ .../service/statement/StatementSummary.java | 9 ++ .../succession/ContractSuccessionService.java | 11 ++ .../ga/api/service/system/RoleService.java | 13 ++ .../ga/api/service/system/UserService.java | 15 ++ .../com/ga/api/service/tax/TaxBreakdown.java | 7 + .../com/ga/api/service/tax/TaxCalculator.java | 22 +++ .../ga/api/service/tax/VatReportService.java | 9 ++ .../tax/WithholdingTaxReportService.java | 7 + .../training/AgentTrainingService.java | 16 +++ .../service/transfer/TransferFileService.java | 12 ++ .../upload/TargetFieldMetaService.java | 5 + .../api/service/upload/TransformEngine.java | 19 +++ .../ga/api/service/upload/UploadService.java | 24 +++- .../service/upload/UploadTemplateService.java | 9 ++ .../withdraw/WithdrawRequestService.java | 8 ++ .../java/com/ga/batch/GaBatchApplication.java | 17 +++ .../java/com/ga/batch/config/BatchConfig.java | 34 +++++ .../controller/JobLauncherController.java | 13 ++ .../batch/settlement/SettlementContext.java | 30 ++-- .../settlement/calc/CommissionCalculator.java | 47 +++++-- .../domain/BatchChargebackCalculator.java | 25 +++- .../domain/BatchInstallmentPlanGenerator.java | 34 ++++- .../domain/BatchRegulatoryLimitChecker.java | 47 +++++-- .../settlement/domain/BatchTaxCalculator.java | 34 ++++- .../settlement/domain/ChargebackResult.java | 9 +- .../domain/ClawbackInterestCalculator.java | 20 ++- .../batch/settlement/domain/TaxBreakdown.java | 9 +- .../settlement/domain/ViolationResult.java | 13 +- .../settlement/receive/DataReceiver.java | 11 +- .../settlement/receive/ManualReceiver.java | 4 +- .../step/AccountingEntryGenerateStep.java | 15 +- .../batch/settlement/step/AggregateStep.java | 44 ++++-- .../settlement/step/CalcMaintainStep.java | 14 +- .../settlement/step/CalcOverrideStep.java | 22 ++- .../settlement/step/CalcRecruitStep.java | 58 +++++++- .../step/ChargebackExceptionStep.java | 9 +- .../settlement/step/PayInstallmentStep.java | 114 +++++++++++++++ .../settlement/step/ReceiveDataStep.java | 7 +- .../batch/settlement/step/ReconcileStep.java | 9 +- .../settlement/step/TransformDataStep.java | 7 +- .../step/VatReportQuarterlyStep.java | 10 +- .../step/WithholdingTaxQuarterlyStep.java | 10 +- .../step/YearEndIncomeAggregateStep.java | 10 +- .../settlement/transform/MappingEngine.java | 25 ++++ .../step/PayInstallmentStepTest.java | 133 ++++++++++++++++++ .../adapter/bank/BankTransferRequest.java | 13 ++ .../adapter/bank/BankTransferResponse.java | 11 ++ .../adapter/bank/BankTransferStatus.java | 12 +- .../adapter/message/MessageChannel.java | 9 +- .../adapter/message/MessageRequest.java | 12 ++ .../adapter/message/MessageResponse.java | 7 + .../ga/common/adapter/push/PushRequest.java | 11 ++ .../ga/common/adapter/push/PushResponse.java | 6 + .../ga/common/annotation/DataChangeLog.java | 18 ++- .../com/ga/common/annotation/ExcelColumn.java | 19 ++- .../java/com/ga/common/annotation/Mask.java | 10 +- .../common/annotation/RequirePermission.java | 17 ++- .../java/com/ga/common/aop/ApiLogAspect.java | 28 ++-- .../ga/common/aop/DataChangeLogAspect.java | 23 ++- .../com/ga/common/aop/PermissionAspect.java | 11 +- .../com/ga/common/auth/JwtAuthFilter.java | 36 +++-- .../com/ga/common/auth/JwtTokenProvider.java | 30 +++- .../java/com/ga/common/auth/LoginUser.java | 19 ++- .../ga/common/code/AccountCodeController.java | 6 + .../com/ga/common/code/AccountCodeMapper.java | 4 + .../ga/common/code/AccountCodeService.java | 13 ++ .../com/ga/common/code/AccountCodeVO.java | 15 +- .../ga/common/code/CommonCodeController.java | 7 + .../com/ga/common/code/CommonCodeGroupVO.java | 17 ++- .../com/ga/common/code/CommonCodeMapper.java | 6 + .../com/ga/common/code/CommonCodeService.java | 22 ++- .../java/com/ga/common/code/CommonCodeVO.java | 30 ++-- .../com/ga/common/config/AsyncConfig.java | 15 +- .../com/ga/common/config/MyBatisConfig.java | 8 ++ .../com/ga/common/config/RedisConfig.java | 6 +- .../ga/common/config/SimpleCacheConfig.java | 2 + .../com/ga/common/config/SwaggerConfig.java | 9 +- .../com/ga/common/config/WebMvcConfig.java | 10 +- .../com/ga/common/excel/ExcelColumnInfo.java | 8 ++ .../ga/common/excel/ExcelColumnParser.java | 5 + .../com/ga/common/excel/ExcelErrorRow.java | 13 +- .../ga/common/excel/ExcelImportResult.java | 8 ++ .../com/ga/common/excel/ExcelService.java | 44 ++++++ .../com/ga/common/exception/BizException.java | 17 ++- .../com/ga/common/exception/ErrorCode.java | 18 ++- .../exception/GlobalExceptionHandler.java | 20 ++- .../com/ga/common/file/FileController.java | 6 + .../java/com/ga/common/file/FileMapper.java | 3 + .../java/com/ga/common/file/FileService.java | 14 ++ .../main/java/com/ga/common/file/FileVO.java | 25 ++-- .../com/ga/common/menu/MenuController.java | 6 + .../java/com/ga/common/menu/MenuMapper.java | 4 + .../java/com/ga/common/menu/MenuService.java | 22 ++- .../com/ga/common/menu/MenuTreeBuilder.java | 11 ++ .../main/java/com/ga/common/menu/MenuVO.java | 29 ++-- .../com/ga/common/menu/PermissionChecker.java | 7 + .../java/com/ga/common/model/ApiResponse.java | 25 +++- .../com/ga/common/model/PageResponse.java | 22 ++- .../java/com/ga/common/model/SearchParam.java | 31 ++-- .../java/com/ga/common/model/TreeNode.java | 18 ++- .../ga/common/mybatis/AuditInterceptor.java | 24 +++- .../com/ga/common/mybatis/BaseMapper.java | 23 +-- .../ga/common/mybatis/EncryptTypeHandler.java | 3 + .../EncryptTypeHandlerInitializer.java | 8 +- .../ga/common/mybatis/JsonTypeHandler.java | 12 +- .../notification/NotificationController.java | 6 + .../notification/NotificationMapper.java | 3 + .../notification/NotificationService.java | 14 ++ .../common/notification/NotificationVO.java | 21 +-- .../security/AccessDeniedHandlerImpl.java | 11 +- .../ga/common/security/JwtAuthEntryPoint.java | 11 +- .../ga/common/security/SecurityConfig.java | 43 ++++-- .../ga/common/system/ApiAccessLogService.java | 8 ++ .../common/system/DataChangeLogService.java | 7 + .../common/system/SystemConfigController.java | 3 + .../ga/common/system/SystemConfigMapper.java | 3 + .../ga/common/system/SystemConfigService.java | 17 +++ .../com/ga/common/system/SystemConfigVO.java | 17 ++- .../ga/common/system/SystemLogController.java | 8 ++ .../com/ga/common/system/SystemLogMapper.java | 4 + .../java/com/ga/common/util/DateUtil.java | 18 ++- .../java/com/ga/common/util/MaskUtil.java | 12 ++ .../java/com/ga/common/util/MoneyUtil.java | 23 ++- .../java/com/ga/common/util/SecurityUtil.java | 10 ++ .../installment/InstallmentPlanMapper.java | 3 + .../installment/InstallmentPlanMapper.xml | 7 + .../mapper/ledger/RecruitLedgerMapper.xml | 4 +- ga-frontend/src/App.tsx | 8 ++ ga-frontend/src/api/agent.ts | 11 ++ ga-frontend/src/api/auth.ts | 13 ++ ga-frontend/src/api/commission.ts | 88 +++++++++--- ga-frontend/src/api/contract.ts | 12 ++ ga-frontend/src/api/request.ts | 20 +++ ga-frontend/src/api/settle.ts | 42 +++++- .../src/components/biz/ApprovalProgress.tsx | 5 + .../src/components/biz/AttachmentUpload.tsx | 7 + .../src/components/biz/NotificationBell.tsx | 3 + .../src/components/common/CodeSelect.tsx | 9 ++ .../src/components/common/ErrorBoundary.tsx | 6 + .../components/common/ExcelExportButton.tsx | 9 +- .../components/common/PermissionButton.tsx | 10 +- .../src/components/common/TemplateUpload.tsx | 8 ++ ga-frontend/src/layouts/MainLayout.tsx | 8 ++ ga-frontend/src/main.tsx | 11 ++ .../pages/accounting/AccountingCloseList.tsx | 9 +- ga-frontend/src/pages/commission/Award.tsx | 24 ++++ .../src/pages/commission/BranchAllowance.tsx | 22 +++ .../src/pages/commission/ChargebackWaiver.tsx | 20 +++ .../pages/commission/ClawbackInstallment.tsx | 15 ++ .../pages/commission/ClawbackReceivable.tsx | 13 ++ .../pages/commission/CollectionCommission.tsx | 11 ++ .../src/pages/commission/CommissionMaster.tsx | 14 ++ .../pages/commission/CommissionSimulator.tsx | 15 ++ ga-frontend/src/pages/kpi/ForecastKpiList.tsx | 17 +++ ga-frontend/src/pages/product/CompanyList.tsx | 12 ++ .../src/pages/product/ContractList.tsx | 8 +- ga-frontend/src/pages/product/ProductList.tsx | 9 ++ ga-frontend/src/pages/system/Approvals.tsx | 28 ++++ ga-frontend/src/pages/system/CodeList.tsx | 22 +++ .../src/pages/system/DataDictionary.tsx | 22 +++ .../src/pages/system/DataDomainList.tsx | 22 +++ .../src/pages/system/ExternalCallLogList.tsx | 15 ++ ga-frontend/src/pages/system/MenuManage.tsx | 15 ++ ga-frontend/src/pages/system/Notices.tsx | 17 +++ ga-frontend/src/pages/system/RoleList.tsx | 25 ++++ ga-frontend/src/pages/system/SystemConfig.tsx | 16 +++ ga-frontend/src/pages/system/SystemLog.tsx | 11 ++ .../pages/system/UploadTemplateManager.tsx | 25 ++++ ga-frontend/src/pages/system/UserList.tsx | 17 +++ ga-frontend/src/stores/auth.ts | 7 + progress.txt | 33 +++++ 345 files changed, 4759 insertions(+), 441 deletions(-) create mode 100644 ga-batch/src/main/java/com/ga/batch/settlement/step/PayInstallmentStep.java create mode 100644 ga-batch/src/test/java/com/ga/batch/settlement/step/PayInstallmentStepTest.java diff --git a/ga-api/src/main/java/com/ga/api/controller/accounting/AccountingCloseController.java b/ga-api/src/main/java/com/ga/api/controller/accounting/AccountingCloseController.java index 0b3403d..776d4a8 100644 --- a/ga-api/src/main/java/com/ga/api/controller/accounting/AccountingCloseController.java +++ b/ga-api/src/main/java/com/ga/api/controller/accounting/AccountingCloseController.java @@ -18,6 +18,15 @@ import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; +/** + * 회계 결산(accounting_close) 컨트롤러. + * + *

"결산"은 일정 기간(월/분기/연)의 회계 분개를 마감하고 계정과목별 잔액을 확정 저장하는 작업입니다. + * 흐름: DRAFT 생성(createDraft) → 마감 실행(close, 미전기 분개에 전표번호 부여 + 잔액 스냅샷 생성) → 필요 시 재오픈(reopen). + * + *

상태: DRAFT / REOPENED 에서만 마감 가능. 잔액 스냅샷(snapshots)은 계정과목별 차변·대변 합계입니다. + * 권한(menu=ACCOUNTING_CLOSE): 조회 READ, DRAFT생성 CREATE, 마감 EXECUTE, 재오픈 APPROVE. + */ @Tag(name = "회계 결산") @RestController @RequestMapping("/api/accounting-closes") diff --git a/ga-api/src/main/java/com/ga/api/controller/accounting/CloseJournalReq.java b/ga-api/src/main/java/com/ga/api/controller/accounting/CloseJournalReq.java index 5402d5b..67ac2d1 100644 --- a/ga-api/src/main/java/com/ga/api/controller/accounting/CloseJournalReq.java +++ b/ga-api/src/main/java/com/ga/api/controller/accounting/CloseJournalReq.java @@ -6,6 +6,10 @@ import lombok.Data; import java.util.List; +/** + * 분개 마감 요청 DTO. + * entryIds: 마감할 분개 ID 목록(필수, 1건 이상). journalNo: 부여할 전표번호(필수). + */ @Data public class CloseJournalReq { @NotEmpty diff --git a/ga-api/src/main/java/com/ga/api/controller/accounting/ContractAccountingController.java b/ga-api/src/main/java/com/ga/api/controller/accounting/ContractAccountingController.java index 2f678fe..472f2a1 100644 --- a/ga-api/src/main/java/com/ga/api/controller/accounting/ContractAccountingController.java +++ b/ga-api/src/main/java/com/ga/api/controller/accounting/ContractAccountingController.java @@ -13,6 +13,16 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 계약 회계 분개(contract_accounting_entry) 컨트롤러. + * + *

"분개"는 회계의 기본 기록 단위로, 거래 하나를 차변(빌린 곳)/대변(빌려준 곳) 두 계정으로 적는 것입니다. + * 여기서는 수수료 지급(payment) 건을 회계 분개로 자동 변환합니다. + * (차변: 8350 지급수수료 / 대변: 2530 미지급금) + * + *

generate: paymentIds 또는 settleMonth 기준으로 분개를 일괄 생성. close: 분개에 전표번호를 붙여 마감 처리. + * 권한(menu=ACCOUNTING_JOURNAL): 조회 READ, 분개생성 CREATE, 마감 UPDATE. + */ @Tag(name = "계약 회계 분개") @RestController @RequestMapping("/api/accounting-entries") diff --git a/ga-api/src/main/java/com/ga/api/controller/accounting/GenerateEntriesReq.java b/ga-api/src/main/java/com/ga/api/controller/accounting/GenerateEntriesReq.java index 272242a..294aca0 100644 --- a/ga-api/src/main/java/com/ga/api/controller/accounting/GenerateEntriesReq.java +++ b/ga-api/src/main/java/com/ga/api/controller/accounting/GenerateEntriesReq.java @@ -4,6 +4,10 @@ import lombok.Data; import java.util.List; +/** + * 자동 분개 생성 요청 DTO. + * paymentIds 가 있으면 해당 지급건들로, 없으면 settleMonth(정산월) 전체 지급건으로 분개를 생성합니다. + */ @Data public class GenerateEntriesReq { private List paymentIds; diff --git a/ga-api/src/main/java/com/ga/api/controller/approval/AdvanceReq.java b/ga-api/src/main/java/com/ga/api/controller/approval/AdvanceReq.java index 87d5b6c..70990d8 100644 --- a/ga-api/src/main/java/com/ga/api/controller/approval/AdvanceReq.java +++ b/ga-api/src/main/java/com/ga/api/controller/approval/AdvanceReq.java @@ -4,12 +4,20 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.Data; +/** + * 결재 단계 진행(승인/반려) 요청 DTO. + * + *

결재자(approverId)가 현재 단계에 대해 action(APPROVE=승인 / REJECT=반려)을 처리할 때 전달된다. + * comment는 선택 입력이다.

+ */ @Data public class AdvanceReq { + /** 결재 처리자(user_id). */ @NotNull private Long approverId; /** APPROVE / REJECT */ @NotBlank private String action; + /** 결재 코멘트(선택). */ private String comment; } diff --git a/ga-api/src/main/java/com/ga/api/controller/approval/ApprovalController.java b/ga-api/src/main/java/com/ga/api/controller/approval/ApprovalController.java index 03c532d..4ad49d2 100644 --- a/ga-api/src/main/java/com/ga/api/controller/approval/ApprovalController.java +++ b/ga-api/src/main/java/com/ga/api/controller/approval/ApprovalController.java @@ -13,6 +13,17 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +/** + * 전자결재 관리 API. + * + *

두 개념을 다룬다. + *

+ * 메뉴 APPROVAL 권한으로 보호되며, 단계 진행만 APPROVE 권한을 요구한다.

+ */ @Tag(name = "결재 관리") @RestController @RequestMapping("/api/approvals") @@ -23,6 +34,7 @@ public class ApprovalController { // ── 결재선 ───────────────────────────────────────────────────────────────── + /** 결재선 목록 조회(페이징). READ 권한 필요. */ @GetMapping("/lines") @RequirePermission(menu = "APPROVAL", perm = "READ") public ApiResponse> listLines( @@ -30,18 +42,21 @@ public class ApprovalController { return ApiResponse.ok(service.listLines(param)); } + /** 결재선 단건 상세 조회. READ 권한 필요. */ @GetMapping("/lines/{lineId}") @RequirePermission(menu = "APPROVAL", perm = "READ") public ApiResponse detailLine(@PathVariable Long lineId) { return ApiResponse.ok(service.detailLine(lineId)); } + /** 특정 결재선의 단계(step) 목록 조회. READ 권한 필요. */ @GetMapping("/lines/{lineId}/steps") @RequirePermission(menu = "APPROVAL", perm = "READ") public ApiResponse> stepsOfLine(@PathVariable Long lineId) { return ApiResponse.ok(service.stepsOfLine(lineId)); } + /** 결재선 신규 등록. CREATE 권한 필요. */ @PostMapping("/lines") @RequirePermission(menu = "APPROVAL", perm = "CREATE") @DataChangeLog(menu = "APPROVAL", table = "approval_line") @@ -49,6 +64,7 @@ public class ApprovalController { return ApiResponse.ok(service.createLine(req)); } + /** 결재선 수정. UPDATE 권한 필요. */ @PutMapping("/lines/{lineId}") @RequirePermission(menu = "APPROVAL", perm = "UPDATE") @DataChangeLog(menu = "APPROVAL", table = "approval_line") @@ -58,6 +74,7 @@ public class ApprovalController { return ApiResponse.ok(); } + /** 결재선에 단계(step) 추가. 경로의 lineId를 요청에 주입한다. UPDATE 권한 필요. */ @PostMapping("/lines/{lineId}/steps") @RequirePermission(menu = "APPROVAL", perm = "UPDATE") @DataChangeLog(menu = "APPROVAL", table = "approval_line_step") @@ -70,6 +87,7 @@ public class ApprovalController { // ── 결재 요청 ────────────────────────────────────────────────────────────── + /** 결재 요청 전체 목록 조회(페이징). READ 권한 필요. */ @GetMapping("/requests") @RequirePermission(menu = "APPROVAL", perm = "READ") public ApiResponse> listRequests( @@ -77,6 +95,7 @@ public class ApprovalController { return ApiResponse.ok(service.listRequests(param)); } + /** 내가 상신한 결재 요청 목록. 현재 로그인 사용자로 필터된다. READ 권한 필요. */ @GetMapping("/requests/mine") @RequirePermission(menu = "APPROVAL", perm = "READ") public ApiResponse> myRequests( @@ -84,6 +103,7 @@ public class ApprovalController { return ApiResponse.ok(service.myRequests(param)); } + /** 진행 중(PENDING) 결재 요청 목록. READ 권한 필요. */ @GetMapping("/requests/pending") @RequirePermission(menu = "APPROVAL", perm = "READ") public ApiResponse> pendingRequests( @@ -91,18 +111,21 @@ public class ApprovalController { return ApiResponse.ok(service.pendingRequests(param)); } + /** 결재 요청 단건 상세 조회. READ 권한 필요. */ @GetMapping("/requests/{requestId}") @RequirePermission(menu = "APPROVAL", perm = "READ") public ApiResponse detailRequest(@PathVariable Long requestId) { return ApiResponse.ok(service.detailRequest(requestId)); } + /** 결재 요청의 단계별 처리 이력 조회. READ 권한 필요. */ @GetMapping("/requests/{requestId}/history") @RequirePermission(menu = "APPROVAL", perm = "READ") public ApiResponse> historyOfRequest(@PathVariable Long requestId) { return ApiResponse.ok(service.historyOfRequest(requestId)); } + /** 결재 상신(요청 생성). PENDING 상태로 시작하며 1단계 결재자에게 알림이 발송된다. CREATE 권한 필요. */ @PostMapping("/requests") @RequirePermission(menu = "APPROVAL", perm = "CREATE") @DataChangeLog(menu = "APPROVAL", table = "approval_request") @@ -110,6 +133,7 @@ public class ApprovalController { return ApiResponse.ok(service.createRequest(req)); } + /** 결재 단계 진행(승인/반려). 마지막 단계 승인 시 대상 도메인에 후처리 콜백이 일어난다. APPROVE 권한 필요. */ @PostMapping("/requests/{requestId}/advance") @RequirePermission(menu = "APPROVAL", perm = "APPROVE") @DataChangeLog(menu = "APPROVAL", table = "approval_request") @@ -119,6 +143,7 @@ public class ApprovalController { return ApiResponse.ok(); } + /** 결재 요청 취소. 진행 중(PENDING)인 건만 취소 가능. UPDATE 권한 필요. */ @PostMapping("/requests/{requestId}/cancel") @RequirePermission(menu = "APPROVAL", perm = "UPDATE") @DataChangeLog(menu = "APPROVAL", table = "approval_request") diff --git a/ga-api/src/main/java/com/ga/api/controller/attachment/AttachmentController.java b/ga-api/src/main/java/com/ga/api/controller/attachment/AttachmentController.java index 3afc5ac..bb58b95 100644 --- a/ga-api/src/main/java/com/ga/api/controller/attachment/AttachmentController.java +++ b/ga-api/src/main/java/com/ga/api/controller/attachment/AttachmentController.java @@ -23,6 +23,15 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +/** + * 첨부파일 관리 API. + * + *

각종 도메인(refType + refId 조합)에 파일을 첨부·다운로드·삭제한다. + * refType은 "어떤 종류의 대상인지"(예: 계약/민원 등), refId는 그 대상의 PK다. + * 파일은 서버 디스크에 저장되고, 메타 정보(파일명/경로/크기/MIME)는 DB로 관리된다.

+ * + *

메뉴 ATTACHMENT 권한으로 보호된다.

+ */ @Tag(name = "첨부파일") @RestController @RequestMapping("/api/attachments") @@ -31,12 +40,14 @@ public class AttachmentController { private final AttachmentService service; + /** 첨부파일 메타 목록 조회(페이징). READ 권한 필요. */ @GetMapping @RequirePermission(menu = "ATTACHMENT", perm = "READ") public ApiResponse> list(@ModelAttribute AttachmentSearchParam param) { return ApiResponse.ok(service.list(param)); } + /** 특정 대상(refType+refId)에 달린 첨부파일 목록 조회. READ 권한 필요. */ @GetMapping("/by-ref") @RequirePermission(menu = "ATTACHMENT", perm = "READ") public ApiResponse> listByRef(@RequestParam String refType, @@ -44,12 +55,14 @@ public class AttachmentController { return ApiResponse.ok(service.listByRef(refType, refId)); } + /** 첨부파일 메타 단건 조회. READ 권한 필요. */ @GetMapping("/{attachmentId}/meta") @RequirePermission(menu = "ATTACHMENT", perm = "READ") public ApiResponse detail(@PathVariable Long attachmentId) { return ApiResponse.ok(service.detail(attachmentId)); } + /** 파일 업로드(multipart). 대상(refType+refId)에 연결해 저장한다. CREATE 권한 필요. */ @PostMapping(consumes = "multipart/form-data") @RequirePermission(menu = "ATTACHMENT", perm = "CREATE") @DataChangeLog(menu = "ATTACHMENT", table = "attachment") @@ -59,6 +72,7 @@ public class AttachmentController { return ApiResponse.ok(service.upload(file, refType, refId)); } + /** 파일 다운로드. 메타의 MIME/파일명으로 응답 헤더를 구성하고 파일 스트림을 내려준다. READ 권한 필요. */ @GetMapping("/{attachmentId}/download") @RequirePermission(menu = "ATTACHMENT", perm = "READ") public void download(@PathVariable Long attachmentId, HttpServletResponse response) throws IOException { @@ -75,6 +89,7 @@ public class AttachmentController { } } + /** 첨부파일 삭제(메타 + 실제 파일). DELETE 권한 필요. */ @DeleteMapping("/{attachmentId}") @RequirePermission(menu = "ATTACHMENT", perm = "DELETE") @DataChangeLog(menu = "ATTACHMENT", table = "attachment") diff --git a/ga-api/src/main/java/com/ga/api/controller/batch/BatchTriggerController.java b/ga-api/src/main/java/com/ga/api/controller/batch/BatchTriggerController.java index 36c5b3e..b702a74 100644 --- a/ga-api/src/main/java/com/ga/api/controller/batch/BatchTriggerController.java +++ b/ga-api/src/main/java/com/ga/api/controller/batch/BatchTriggerController.java @@ -50,12 +50,14 @@ public class BatchTriggerController { return ApiResponse.ok(log.getJobId()); } + /** 특정 배치 잡의 최신 실행 상태 조회(기본 월정산). READ 권한 필요. */ @GetMapping("/status") @RequirePermission(menu = "BATCH_RUN", perm = "READ") public ApiResponse latest(@RequestParam(defaultValue = "MONTHLY_SETTLEMENT") String jobName) { return ApiResponse.ok(jobLogMapper.selectLatest(jobName)); } + /** 배치 실행 이력 목록 조회(잡명/정산월 필터). READ 권한 필요. */ @GetMapping("/jobs") @RequirePermission(menu = "BATCH_RUN", perm = "READ") public ApiResponse> list(@RequestParam(required = false) String jobName, @@ -63,6 +65,7 @@ public class BatchTriggerController { return ApiResponse.ok(jobLogMapper.selectList(jobName, settleMonth)); } + /** 배치 실행 이력 단건 상세 조회. READ 권한 필요. */ @GetMapping("/jobs/{jobId}") @RequirePermission(menu = "BATCH_RUN", perm = "READ") public ApiResponse detail(@PathVariable Long jobId) { diff --git a/ga-api/src/main/java/com/ga/api/controller/chargeback/ChargebackCalculateReq.java b/ga-api/src/main/java/com/ga/api/controller/chargeback/ChargebackCalculateReq.java index 3a4c92d..cd251c1 100644 --- a/ga-api/src/main/java/com/ga/api/controller/chargeback/ChargebackCalculateReq.java +++ b/ga-api/src/main/java/com/ga/api/controller/chargeback/ChargebackCalculateReq.java @@ -7,18 +7,27 @@ import lombok.Data; import java.math.BigDecimal; import java.time.LocalDate; +/** + * 차등환수 금액 계산(시뮬레이션) 요청 DTO. + * 차등환수란 고객이 계약을 조기 해지하면 이미 지급한 수수료의 일부를 경과기간에 따라 차등 회수하는 것이다. + * (계약일~해지일 경과개월수가 짧을수록 환수율이 높다) + */ @Data public class ChargebackCalculateReq { + /** 계약 체결일. 경과개월수 계산의 시작점. */ @NotNull private LocalDate contractDate; + /** 해지일. 계약일과의 차이로 경과개월수를 구한다. */ @NotNull private LocalDate terminationDate; + /** 당초 지급했던 수수료 원금(양수). 여기에 환수율을 곱해 환수액을 산출한다. */ @NotNull @Positive private BigDecimal originalCommission; + /** 상품 분류. 분류별로 적용 환수율 구간(chargeback_grade)이 다르다. */ private String productCategory; } diff --git a/ga-api/src/main/java/com/ga/api/controller/chargeback/ChargebackGradeController.java b/ga-api/src/main/java/com/ga/api/controller/chargeback/ChargebackGradeController.java index 66161f3..66d63a6 100644 --- a/ga-api/src/main/java/com/ga/api/controller/chargeback/ChargebackGradeController.java +++ b/ga-api/src/main/java/com/ga/api/controller/chargeback/ChargebackGradeController.java @@ -15,6 +15,12 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 차등환수 등급(chargeback_grade) 관리 REST 컨트롤러. + * 환수 등급표는 "경과개월 구간별 환수율(%)"을 정의하는 기준표이며, 조기해지 시 환수액 계산에 쓰인다. + * - gradeService : 환수 등급표 CRUD + 비활성화 + * - calculator : 실제 계약 정보로 환수액을 계산(시뮬레이션) + */ @Tag(name = "환수 등급 관리") @RestController @RequestMapping("/api/chargeback-grades") @@ -24,18 +30,21 @@ public class ChargebackGradeController { private final ChargebackGradeService gradeService; private final ChargebackCalculator calculator; + /** 환수 등급표 목록 페이징 조회. */ @GetMapping @RequirePermission(menu = "CHARGEBACK_GRADE", perm = "READ") public ApiResponse> list(ChargebackGradeSearchParam param) { return ApiResponse.ok(gradeService.list(param)); } + /** 환수 등급 단건 상세 조회. */ @GetMapping("/{gradeId}") @RequirePermission(menu = "CHARGEBACK_GRADE", perm = "READ") public ApiResponse detail(@PathVariable Long gradeId) { return ApiResponse.ok(gradeService.detail(gradeId)); } + /** 환수 등급 신규 등록. */ @PostMapping @RequirePermission(menu = "CHARGEBACK_GRADE", perm = "CREATE") @DataChangeLog(menu = "CHARGEBACK_GRADE", table = "chargeback_grade") @@ -44,6 +53,7 @@ public class ChargebackGradeController { return ApiResponse.ok(); } + /** 환수 등급 수정. */ @PutMapping("/{gradeId}") @RequirePermission(menu = "CHARGEBACK_GRADE", perm = "UPDATE") @DataChangeLog(menu = "CHARGEBACK_GRADE", table = "chargeback_grade") @@ -53,6 +63,7 @@ public class ChargebackGradeController { return ApiResponse.ok(); } + /** 환수 등급 비활성화(사용 중지). 비활성 등급은 환수액 계산에서 제외된다. */ @PutMapping("/{gradeId}/deactivate") @RequirePermission(menu = "CHARGEBACK_GRADE", perm = "UPDATE") @DataChangeLog(menu = "CHARGEBACK_GRADE", table = "chargeback_grade") @@ -61,6 +72,7 @@ public class ChargebackGradeController { return ApiResponse.ok(); } + /** 계약일·해지일·원수수료를 입력받아 환수액을 계산(시뮬레이션)한다. */ @PostMapping("/calculate") @RequirePermission(menu = "CHARGEBACK_GRADE", perm = "READ") public ApiResponse calculate(@Valid @RequestBody ChargebackCalculateReq req) { diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/AdvancePaymentController.java b/ga-api/src/main/java/com/ga/api/controller/commission/AdvancePaymentController.java index 0724cd6..cea565e 100644 --- a/ga-api/src/main/java/com/ga/api/controller/commission/AdvancePaymentController.java +++ b/ga-api/src/main/java/com/ga/api/controller/commission/AdvancePaymentController.java @@ -13,6 +13,16 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 선지급정산 API. + * + * 선지급(advance payment): 아직 확정되지 않은 미래 수수료를 설계사에게 미리 지급(가불)하는 것. + * 이후 실제 수수료가 발생하면 그 금액에서 차감(상계)하여 회수한다. + * - principalAmount(선지급 원금) / recoveredAmount(회수 누계) / balance(미상환 잔액) + * - status: OUTSTANDING(미상환) → PARTIAL(일부회수) → CLEARED(완전회수) 또는 WAIVED(면제) + * + * 권한: @RequirePermission(menu="ADVANCE_PAYMENT"), perm=READ/CREATE/UPDATE. 변경은 @DataChangeLog 로 감사 기록. + */ @Tag(name = "선지급정산") @RestController @RequestMapping("/api/advance-payments") @@ -21,6 +31,7 @@ public class AdvancePaymentController { private final AdvancePaymentService service; + /** 선지급 목록 조회(검색 + 페이징). 권한: ADVANCE_PAYMENT READ. */ @GetMapping @RequirePermission(menu = "ADVANCE_PAYMENT", perm = "READ") public ApiResponse> list( @@ -28,12 +39,14 @@ public class AdvancePaymentController { return ApiResponse.ok(service.list(param)); } + /** 선지급 단건 상세 조회. 권한: ADVANCE_PAYMENT READ. */ @GetMapping("/{advanceId}") @RequirePermission(menu = "ADVANCE_PAYMENT", perm = "READ") public ApiResponse detail(@PathVariable Long advanceId) { return ApiResponse.ok(service.detail(advanceId)); } + /** 선지급 신규 등록. 등록 시 잔액=원금, 상태=OUTSTANDING. 응답: 생성된 advanceId. 권한: ADVANCE_PAYMENT CREATE. */ @PostMapping @RequirePermission(menu = "ADVANCE_PAYMENT", perm = "CREATE") @DataChangeLog(menu = "ADVANCE_PAYMENT", table = "advance_payment") @@ -41,6 +54,7 @@ public class AdvancePaymentController { return ApiResponse.ok(service.create(req)); } + /** 선지급 면제 처리. 남은 잔액 회수를 포기(상태=WAIVED, 잔액=0). 권한: ADVANCE_PAYMENT UPDATE. */ @PostMapping("/{advanceId}/waive") @RequirePermission(menu = "ADVANCE_PAYMENT", perm = "UPDATE") @DataChangeLog(menu = "ADVANCE_PAYMENT", table = "advance_payment") diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/AwardClawbackReq.java b/ga-api/src/main/java/com/ga/api/controller/commission/AwardClawbackReq.java index faf9382..204efa7 100644 --- a/ga-api/src/main/java/com/ga/api/controller/commission/AwardClawbackReq.java +++ b/ga-api/src/main/java/com/ga/api/controller/commission/AwardClawbackReq.java @@ -7,14 +7,19 @@ import lombok.Data; import java.math.BigDecimal; /** - * 시상 원장 환수 처리 요청 (PUT /api/awards/ledgers/{id}/clawback). + * 시상(award) 원장 환수 처리 요청 바디 (PUT /api/awards/ledgers/{id}/clawback). + * + * 환수(clawback): 이미 지급한 시상금을 일정 사유로 되돌려 받는 것. + * clawbackAmount(환수 금액, 0 이상 필수) + reason(환수 사유). */ @Data public class AwardClawbackReq { + /** 환수 금액. 0 이상이어야 하며(@DecimalMin("0")) 필수값(@NotNull). */ @NotNull @DecimalMin("0") private BigDecimal clawbackAmount; + /** 환수 사유(선택). */ private String reason; } diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/AwardController.java b/ga-api/src/main/java/com/ga/api/controller/commission/AwardController.java index c58a285..6f8ff40 100644 --- a/ga-api/src/main/java/com/ga/api/controller/commission/AwardController.java +++ b/ga-api/src/main/java/com/ga/api/controller/commission/AwardController.java @@ -11,6 +11,15 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 시상(award) API. + * + * 시상: 일정 실적 조건을 달성한 설계사에게 지급하는 포상금/인센티브. + * - Rule(시상 규칙): 어떤 조건일 때 얼마를 지급하는지 정의(생성/수정/삭제 가능). + * - Ledger(시상 원장): 규칙에 따라 실제 산출/지급된 시상금 내역. clawback(환수)로 회수 가능. + * + * 권한: @RequirePermission(menu="AWARD"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 로 감사 기록. + */ @Tag(name = "시상") @RestController @RequestMapping("/api/awards") @@ -19,8 +28,9 @@ public class AwardController { private final AwardService service; - // ── Rule ────────────────────────────────────────────────────────────────── + // ── Rule (시상 규칙) ──────────────────────────────────────────────────── + /** 시상 규칙 목록 조회. 권한: AWARD READ. */ @GetMapping("/rules") @RequirePermission(menu = "AWARD", perm = "READ") public ApiResponse> listRules( @@ -28,12 +38,14 @@ public class AwardController { return ApiResponse.ok(service.listRules(param)); } + /** 시상 규칙 단건 상세. 권한: AWARD READ. */ @GetMapping("/rules/{ruleId}") @RequirePermission(menu = "AWARD", perm = "READ") public ApiResponse detailRule(@PathVariable Long ruleId) { return ApiResponse.ok(service.detailRule(ruleId)); } + /** 시상 규칙 신규 등록. 응답: 생성된 ruleId. 권한: AWARD CREATE. */ @PostMapping("/rules") @RequirePermission(menu = "AWARD", perm = "CREATE") @DataChangeLog(menu = "AWARD", table = "award_rule") @@ -41,6 +53,7 @@ public class AwardController { return ApiResponse.ok(service.createRule(req)); } + /** 시상 규칙 수정. 권한: AWARD UPDATE. */ @PutMapping("/rules/{ruleId}") @RequirePermission(menu = "AWARD", perm = "UPDATE") @DataChangeLog(menu = "AWARD", table = "award_rule") @@ -50,6 +63,7 @@ public class AwardController { return ApiResponse.ok(); } + /** 시상 규칙 삭제. 권한: AWARD DELETE. */ @DeleteMapping("/rules/{ruleId}") @RequirePermission(menu = "AWARD", perm = "DELETE") @DataChangeLog(menu = "AWARD", table = "award_rule") @@ -58,8 +72,9 @@ public class AwardController { return ApiResponse.ok(); } - // ── Ledger ──────────────────────────────────────────────────────────────── + // ── Ledger (시상 원장) ────────────────────────────────────────────────── + /** 시상 원장 목록 조회. 권한: AWARD READ. */ @GetMapping("/ledgers") @RequirePermission(menu = "AWARD", perm = "READ") public ApiResponse> listLedgers( @@ -67,12 +82,14 @@ public class AwardController { return ApiResponse.ok(service.listLedgers(param)); } + /** 시상 원장 단건 상세. 권한: AWARD READ. */ @GetMapping("/ledgers/{ledgerId}") @RequirePermission(menu = "AWARD", perm = "READ") public ApiResponse detailLedger(@PathVariable Long ledgerId) { return ApiResponse.ok(service.detailLedger(ledgerId)); } + /** 시상 원장 신규 등록(시상금 산출/지급 기록). 응답: 생성된 ledgerId. 권한: AWARD CREATE. */ @PostMapping("/ledgers") @RequirePermission(menu = "AWARD", perm = "CREATE") @DataChangeLog(menu = "AWARD", table = "award_ledger") @@ -80,6 +97,7 @@ public class AwardController { return ApiResponse.ok(service.createLedger(req)); } + /** 시상금 환수(clawback) 적용. 이미 지급한 시상금을 일부/전부 회수한다. 권한: AWARD UPDATE. */ @PutMapping("/ledgers/{ledgerId}/clawback") @RequirePermission(menu = "AWARD", perm = "UPDATE") @DataChangeLog(menu = "AWARD", table = "award_ledger") diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/BranchAllowanceController.java b/ga-api/src/main/java/com/ga/api/controller/commission/BranchAllowanceController.java index 10ddf8f..9582218 100644 --- a/ga-api/src/main/java/com/ga/api/controller/commission/BranchAllowanceController.java +++ b/ga-api/src/main/java/com/ga/api/controller/commission/BranchAllowanceController.java @@ -11,6 +11,15 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 지점장수당 API. + * + * 지점장수당(branch manager allowance): 지점에 소속된 설계사들의 실적 합계에 따라 + * 지점장에게 추가로 지급하는 수당. 상한(cap)이 적용될 수 있다. + * - Rule(수당 규칙): 지점 실적 대비 지급률/상한 정의. - Ledger(수당 원장): 실제 산출/지급 내역. + * + * 권한: @RequirePermission(menu="BRANCH_MGR_ALLOWANCE"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 기록. + */ @Tag(name = "지점장수당") @RestController @RequestMapping("/api/branch-allowances") @@ -19,8 +28,9 @@ public class BranchAllowanceController { private final BranchAllowanceService service; - // ── Rule ────────────────────────────────────────────────────────────────── + // ── Rule (수당 규칙) ──────────────────────────────────────────────────── + /** 지점장수당 규칙 목록 조회. 권한: BRANCH_MGR_ALLOWANCE READ. */ @GetMapping("/rules") @RequirePermission(menu = "BRANCH_MGR_ALLOWANCE", perm = "READ") public ApiResponse> listRules( @@ -34,6 +44,7 @@ public class BranchAllowanceController { return ApiResponse.ok(service.detailRule(ruleId)); } + /** 지점장수당 규칙 신규 등록. 응답: ruleId. 권한: BRANCH_MGR_ALLOWANCE CREATE. */ @PostMapping("/rules") @RequirePermission(menu = "BRANCH_MGR_ALLOWANCE", perm = "CREATE") @DataChangeLog(menu = "BRANCH_MGR_ALLOWANCE", table = "branch_mgr_allowance_rule") @@ -41,6 +52,7 @@ public class BranchAllowanceController { return ApiResponse.ok(service.createRule(req)); } + /** 지점장수당 규칙 수정. 권한: BRANCH_MGR_ALLOWANCE UPDATE. */ @PutMapping("/rules/{ruleId}") @RequirePermission(menu = "BRANCH_MGR_ALLOWANCE", perm = "UPDATE") @DataChangeLog(menu = "BRANCH_MGR_ALLOWANCE", table = "branch_mgr_allowance_rule") @@ -50,6 +62,7 @@ public class BranchAllowanceController { return ApiResponse.ok(); } + /** 지점장수당 규칙 삭제. 권한: BRANCH_MGR_ALLOWANCE DELETE. */ @DeleteMapping("/rules/{ruleId}") @RequirePermission(menu = "BRANCH_MGR_ALLOWANCE", perm = "DELETE") @DataChangeLog(menu = "BRANCH_MGR_ALLOWANCE", table = "branch_mgr_allowance_rule") @@ -58,8 +71,9 @@ public class BranchAllowanceController { return ApiResponse.ok(); } - // ── Ledger ──────────────────────────────────────────────────────────────── + // ── Ledger (수당 원장) ────────────────────────────────────────────────── + /** 지점장수당 원장 목록 조회. 권한: BRANCH_MGR_ALLOWANCE READ. */ @GetMapping("/ledgers") @RequirePermission(menu = "BRANCH_MGR_ALLOWANCE", perm = "READ") public ApiResponse> listLedgers( @@ -67,12 +81,14 @@ public class BranchAllowanceController { return ApiResponse.ok(service.listLedgers(param)); } + /** 지점장수당 원장 단건 상세. 권한: BRANCH_MGR_ALLOWANCE READ. */ @GetMapping("/ledgers/{ledgerId}") @RequirePermission(menu = "BRANCH_MGR_ALLOWANCE", perm = "READ") public ApiResponse detailLedger(@PathVariable Long ledgerId) { return ApiResponse.ok(service.detailLedger(ledgerId)); } + /** 지점장수당 원장 신규 등록(수당 산출/지급 기록). 응답: ledgerId. 권한: BRANCH_MGR_ALLOWANCE CREATE. */ @PostMapping("/ledgers") @RequirePermission(menu = "BRANCH_MGR_ALLOWANCE", perm = "CREATE") @DataChangeLog(menu = "BRANCH_MGR_ALLOWANCE", table = "branch_mgr_allowance_ledger") diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/CarrierReconciliationInController.java b/ga-api/src/main/java/com/ga/api/controller/commission/CarrierReconciliationInController.java index f52eaef..558ed5e 100644 --- a/ga-api/src/main/java/com/ga/api/controller/commission/CarrierReconciliationInController.java +++ b/ga-api/src/main/java/com/ga/api/controller/commission/CarrierReconciliationInController.java @@ -13,6 +13,15 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 정산대사(입수) API. + * + * 정산대사(reconciliation): 보험사로부터 받은 정산 명세(입수 자료)와 우리 시스템이 계산한 금액을 + * 서로 맞춰 보아(대사) 차이를 확인/확정하는 작업. + * - reconcile: 대사 실행(차이 비교) - approve: 대사 결과 승인(확정) + * + * 권한: @RequirePermission(menu="CARRIER_RECON_IN"), perm=READ/CREATE/UPDATE. 변경은 @DataChangeLog 기록. + */ @Tag(name = "정산대사입수") @RestController @RequestMapping("/api/carrier-recon-in") @@ -21,6 +30,7 @@ public class CarrierReconciliationInController { private final CarrierReconciliationInService service; + /** 대사 입수 자료 목록 조회. 권한: CARRIER_RECON_IN READ. */ @GetMapping @RequirePermission(menu = "CARRIER_RECON_IN", perm = "READ") public ApiResponse> list( @@ -28,12 +38,14 @@ public class CarrierReconciliationInController { return ApiResponse.ok(service.list(param)); } + /** 대사 입수 단건 상세. 권한: CARRIER_RECON_IN READ. */ @GetMapping("/{reconId}") @RequirePermission(menu = "CARRIER_RECON_IN", perm = "READ") public ApiResponse detail(@PathVariable Long reconId) { return ApiResponse.ok(service.detail(reconId)); } + /** 대사 입수 자료 신규 등록. 응답: reconId. 권한: CARRIER_RECON_IN CREATE. */ @PostMapping @RequirePermission(menu = "CARRIER_RECON_IN", perm = "CREATE") @DataChangeLog(menu = "CARRIER_RECON_IN", table = "carrier_reconciliation_in") @@ -41,6 +53,7 @@ public class CarrierReconciliationInController { return ApiResponse.ok(service.create(req)); } + /** 대사 실행. 보험사 명세와 시스템 계산액을 비교해 차이를 계산한다. 권한: CARRIER_RECON_IN UPDATE. */ @PostMapping("/{reconId}/reconcile") @RequirePermission(menu = "CARRIER_RECON_IN", perm = "UPDATE") @DataChangeLog(menu = "CARRIER_RECON_IN", table = "carrier_reconciliation_in") @@ -49,6 +62,7 @@ public class CarrierReconciliationInController { return ApiResponse.ok(); } + /** 대사 결과 승인(확정). 권한: CARRIER_RECON_IN UPDATE. */ @PostMapping("/{reconId}/approve") @RequirePermission(menu = "CARRIER_RECON_IN", perm = "UPDATE") @DataChangeLog(menu = "CARRIER_RECON_IN", table = "carrier_reconciliation_in") diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/ChargebackWaiverController.java b/ga-api/src/main/java/com/ga/api/controller/commission/ChargebackWaiverController.java index 058474a..1cb21e1 100644 --- a/ga-api/src/main/java/com/ga/api/controller/commission/ChargebackWaiverController.java +++ b/ga-api/src/main/java/com/ga/api/controller/commission/ChargebackWaiverController.java @@ -13,6 +13,15 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 환수감면신청 API. + * + * 환수(chargeback): 계약 해지/실효 등으로 이미 지급한 수수료를 설계사로부터 다시 회수하는 것. + * 환수감면(waiver): 특별한 사유가 인정되면 그 환수 금액을 줄이거나 면제해 달라고 신청하는 것. + * - approve: 감면 신청 승인 - reject: 감면 신청 반려 + * + * 권한: @RequirePermission(menu="CHARGEBACK_WAIVER"), perm=READ/CREATE/UPDATE. 변경은 @DataChangeLog 기록. + */ @Tag(name = "환수감면신청") @RestController @RequestMapping("/api/chargeback-waivers") @@ -21,6 +30,7 @@ public class ChargebackWaiverController { private final ChargebackWaiverService service; + /** 환수감면 신청 목록 조회. 권한: CHARGEBACK_WAIVER READ. */ @GetMapping @RequirePermission(menu = "CHARGEBACK_WAIVER", perm = "READ") public ApiResponse> list( @@ -28,12 +38,14 @@ public class ChargebackWaiverController { return ApiResponse.ok(service.list(param)); } + /** 환수감면 신청 단건 상세. 권한: CHARGEBACK_WAIVER READ. */ @GetMapping("/{requestId}") @RequirePermission(menu = "CHARGEBACK_WAIVER", perm = "READ") public ApiResponse detail(@PathVariable Long requestId) { return ApiResponse.ok(service.detail(requestId)); } + /** 환수감면 신청 등록. 응답: requestId. 권한: CHARGEBACK_WAIVER CREATE. */ @PostMapping @RequirePermission(menu = "CHARGEBACK_WAIVER", perm = "CREATE") @DataChangeLog(menu = "CHARGEBACK_WAIVER", table = "chargeback_waiver_request") @@ -41,6 +53,7 @@ public class ChargebackWaiverController { return ApiResponse.ok(service.create(req)); } + /** 환수감면 신청 승인. 권한: CHARGEBACK_WAIVER UPDATE. */ @PostMapping("/{requestId}/approve") @RequirePermission(menu = "CHARGEBACK_WAIVER", perm = "UPDATE") @DataChangeLog(menu = "CHARGEBACK_WAIVER", table = "chargeback_waiver_request") @@ -49,6 +62,7 @@ public class ChargebackWaiverController { return ApiResponse.ok(); } + /** 환수감면 신청 반려. 권한: CHARGEBACK_WAIVER UPDATE. */ @PostMapping("/{requestId}/reject") @RequirePermission(menu = "CHARGEBACK_WAIVER", perm = "UPDATE") @DataChangeLog(menu = "CHARGEBACK_WAIVER", table = "chargeback_waiver_request") diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/ClawbackInstallmentController.java b/ga-api/src/main/java/com/ga/api/controller/commission/ClawbackInstallmentController.java index 15bbdcc..518c31f 100644 --- a/ga-api/src/main/java/com/ga/api/controller/commission/ClawbackInstallmentController.java +++ b/ga-api/src/main/java/com/ga/api/controller/commission/ClawbackInstallmentController.java @@ -15,6 +15,15 @@ import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; +/** + * 환수분할상환 API. + * + * 환수채권(받아야 할 환수 금액)을 한 번에 못 갚을 때, 여러 달에 나눠 갚도록(분할상환) 계획을 만든다. + * - generate: 환수채권(receivableId) 하나를 N개월(count)로 나눠 baseMonth 부터 매월 상환 계획을 생성 + * - cancel: 특정 회차 계획 취소 + * + * 권한: @RequirePermission(menu="CLAWBACK_INSTALLMENT"), perm=READ/CREATE/UPDATE. 변경은 @DataChangeLog 기록. + */ @Tag(name = "환수분할상환") @RestController @RequestMapping("/api/clawback-installments") @@ -23,6 +32,7 @@ public class ClawbackInstallmentController { private final ClawbackInstallmentService service; + /** 분할상환 계획 목록 조회. 권한: CLAWBACK_INSTALLMENT READ. */ @GetMapping @RequirePermission(menu = "CLAWBACK_INSTALLMENT", perm = "READ") public ApiResponse> list( @@ -30,12 +40,14 @@ public class ClawbackInstallmentController { return ApiResponse.ok(service.list(param)); } + /** 분할상환 계획 단건 상세. 권한: CLAWBACK_INSTALLMENT READ. */ @GetMapping("/{planId}") @RequirePermission(menu = "CLAWBACK_INSTALLMENT", perm = "READ") public ApiResponse detail(@PathVariable Long planId) { return ApiResponse.ok(service.detail(planId)); } + /** 특정 환수채권에 묶인 분할상환 회차 전체 조회. 권한: CLAWBACK_INSTALLMENT READ. */ @GetMapping("/by-receivable/{receivableId}") @RequirePermission(menu = "CLAWBACK_INSTALLMENT", perm = "READ") public ApiResponse> listByReceivable( @@ -44,20 +56,23 @@ public class ClawbackInstallmentController { } /** - * 분할상환 계획 생성. - * Request body: { "count": N, "baseMonth": "YYYYMM" } + * 분할상환 계획 생성. 환수채권을 N개월로 균등 분할해 baseMonth 부터 회차를 만든다. + * Request body: { "count": N(분할 개월 수), "baseMonth": "YYYYMM"(첫 회차 월) } + * 권한: CLAWBACK_INSTALLMENT CREATE. */ @PostMapping("/generate/{receivableId}") @RequirePermission(menu = "CLAWBACK_INSTALLMENT", perm = "CREATE") @DataChangeLog(menu = "CLAWBACK_INSTALLMENT", table = "clawback_installment_plan") public ApiResponse generate(@PathVariable Long receivableId, @RequestBody Map body) { + // body 의 count/baseMonth 를 꺼내 서비스로 전달 (JSON 값은 문자열로 변환 후 파싱) int count = Integer.parseInt(body.get("count").toString()); String baseMonth = body.get("baseMonth").toString(); service.generate(receivableId, count, baseMonth); return ApiResponse.ok(); } + /** 분할상환 회차 취소. 권한: CLAWBACK_INSTALLMENT UPDATE. */ @PostMapping("/{planId}/cancel") @RequirePermission(menu = "CLAWBACK_INSTALLMENT", perm = "UPDATE") @DataChangeLog(menu = "CLAWBACK_INSTALLMENT", table = "clawback_installment_plan") diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/ClawbackReceivableController.java b/ga-api/src/main/java/com/ga/api/controller/commission/ClawbackReceivableController.java index f0abded..68d718c 100644 --- a/ga-api/src/main/java/com/ga/api/controller/commission/ClawbackReceivableController.java +++ b/ga-api/src/main/java/com/ga/api/controller/commission/ClawbackReceivableController.java @@ -18,6 +18,16 @@ import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; +/** + * 환수채권 관리 API. + * + * 환수채권(clawback receivable): 환수가 확정되어 설계사로부터 "받아야 할 돈"(미수금). + * 발생액(original) 중 회수액(recovered)을 빼면 잔액(balance)이 남는다. + * - write-off(대손상각): 회수가 불가능하다고 판단된 잔액을 손실로 털어내는 처리. + * - summary: 상태/설계사별 집계, export: 엑셀 다운로드. + * + * 권한: @RequirePermission(menu="CLAWBACK_RECEIVABLE"), perm=READ/UPDATE/EXPORT. 변경은 @DataChangeLog 기록. + */ @Tag(name = "환수채권 관리") @RestController @RequestMapping("/api/clawback-receivables") @@ -26,6 +36,7 @@ public class ClawbackReceivableController { private final ClawbackReceivableService service; + /** 환수채권 목록 조회(검색 + 페이징). 권한: CLAWBACK_RECEIVABLE READ. */ @GetMapping @RequirePermission(menu = "CLAWBACK_RECEIVABLE", perm = "READ") public ApiResponse> list( @@ -33,6 +44,7 @@ public class ClawbackReceivableController { return ApiResponse.ok(service.list(param)); } + /** 환수채권 집계 요약(상태/그룹별 합계). 권한: CLAWBACK_RECEIVABLE READ. */ @GetMapping("/summary") @RequirePermission(menu = "CLAWBACK_RECEIVABLE", perm = "READ") public ApiResponse>> summary( @@ -40,12 +52,14 @@ public class ClawbackReceivableController { return ApiResponse.ok(service.summary(param)); } + /** 환수채권 단건 상세. 권한: CLAWBACK_RECEIVABLE READ. */ @GetMapping("/{id}") @RequirePermission(menu = "CLAWBACK_RECEIVABLE", perm = "READ") public ApiResponse detail(@PathVariable Long id) { return ApiResponse.ok(service.detail(id)); } + /** 대손상각(write-off) 처리. 잔액을 손실 처리한다. 처리자(현재 로그인 사용자 ID)를 함께 기록. 권한: CLAWBACK_RECEIVABLE UPDATE. */ @PutMapping("/{id}/write-off") @RequirePermission(menu = "CLAWBACK_RECEIVABLE", perm = "UPDATE") @DataChangeLog(menu = "CLAWBACK_RECEIVABLE", table = "clawback_receivable") @@ -55,6 +69,7 @@ public class ClawbackReceivableController { return ApiResponse.ok(); } + /** 환수채권 목록 엑셀 다운로드. 응답 스트림(response)에 직접 파일을 쓴다. 권한: CLAWBACK_RECEIVABLE EXPORT. */ @GetMapping("/export") @RequirePermission(menu = "CLAWBACK_RECEIVABLE", perm = "EXPORT") public void export(@ModelAttribute ClawbackReceivableSearchParam param, diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/CollectionCommissionController.java b/ga-api/src/main/java/com/ga/api/controller/commission/CollectionCommissionController.java index d8578b9..eb75f53 100644 --- a/ga-api/src/main/java/com/ga/api/controller/commission/CollectionCommissionController.java +++ b/ga-api/src/main/java/com/ga/api/controller/commission/CollectionCommissionController.java @@ -11,6 +11,14 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 수금수수료 API. + * + * 수금수수료(collection commission): 설계사가 보험료를 정상적으로 수금(납입 유도/관리)한 것에 대해 + * 지급하는 수수료. 규칙(Rule)에 따라 산출되어 원장(Ledger)에 기록된다. + * + * 권한: @RequirePermission(menu="COLLECT_COMM"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 기록. + */ @Tag(name = "수금수수료") @RestController @RequestMapping("/api/collection-commissions") @@ -19,8 +27,9 @@ public class CollectionCommissionController { private final CollectionCommissionService service; - // ── Rule ────────────────────────────────────────────────────────────────── + // ── Rule (수금수수료 규칙) ────────────────────────────────────────────── + /** 수금수수료 규칙 목록 조회. 권한: COLLECT_COMM READ. */ @GetMapping("/rules") @RequirePermission(menu = "COLLECT_COMM", perm = "READ") public ApiResponse> listRules( @@ -28,12 +37,14 @@ public class CollectionCommissionController { return ApiResponse.ok(service.listRules(param)); } + /** 수금수수료 규칙 단건 상세. 권한: COLLECT_COMM READ. */ @GetMapping("/rules/{ruleId}") @RequirePermission(menu = "COLLECT_COMM", perm = "READ") public ApiResponse detailRule(@PathVariable Long ruleId) { return ApiResponse.ok(service.detailRule(ruleId)); } + /** 수금수수료 규칙 신규 등록. 응답: ruleId. 권한: COLLECT_COMM CREATE. */ @PostMapping("/rules") @RequirePermission(menu = "COLLECT_COMM", perm = "CREATE") @DataChangeLog(menu = "COLLECT_COMM", table = "collection_commission_rule") @@ -41,6 +52,7 @@ public class CollectionCommissionController { return ApiResponse.ok(service.createRule(req)); } + /** 수금수수료 규칙 수정. 권한: COLLECT_COMM UPDATE. */ @PutMapping("/rules/{ruleId}") @RequirePermission(menu = "COLLECT_COMM", perm = "UPDATE") @DataChangeLog(menu = "COLLECT_COMM", table = "collection_commission_rule") @@ -50,6 +62,7 @@ public class CollectionCommissionController { return ApiResponse.ok(); } + /** 수금수수료 규칙 삭제. 권한: COLLECT_COMM DELETE. */ @DeleteMapping("/rules/{ruleId}") @RequirePermission(menu = "COLLECT_COMM", perm = "DELETE") @DataChangeLog(menu = "COLLECT_COMM", table = "collection_commission_rule") @@ -58,8 +71,9 @@ public class CollectionCommissionController { return ApiResponse.ok(); } - // ── Ledger ──────────────────────────────────────────────────────────────── + // ── Ledger (수금수수료 원장) ──────────────────────────────────────────── + /** 수금수수료 원장 목록 조회. 권한: COLLECT_COMM READ. */ @GetMapping("/ledgers") @RequirePermission(menu = "COLLECT_COMM", perm = "READ") public ApiResponse> listLedgers( @@ -67,12 +81,14 @@ public class CollectionCommissionController { return ApiResponse.ok(service.listLedgers(param)); } + /** 수금수수료 원장 단건 상세. 권한: COLLECT_COMM READ. */ @GetMapping("/ledgers/{ledgerId}") @RequirePermission(menu = "COLLECT_COMM", perm = "READ") public ApiResponse detailLedger(@PathVariable Long ledgerId) { return ApiResponse.ok(service.detailLedger(ledgerId)); } + /** 수금수수료 원장 신규 등록. 응답: ledgerId. 권한: COLLECT_COMM CREATE. */ @PostMapping("/ledgers") @RequirePermission(menu = "COLLECT_COMM", perm = "CREATE") @DataChangeLog(menu = "COLLECT_COMM", table = "collection_commission_ledger") diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/CommissionMasterController.java b/ga-api/src/main/java/com/ga/api/controller/commission/CommissionMasterController.java index 3e50482..0f1ea77 100644 --- a/ga-api/src/main/java/com/ga/api/controller/commission/CommissionMasterController.java +++ b/ga-api/src/main/java/com/ga/api/controller/commission/CommissionMasterController.java @@ -14,6 +14,15 @@ import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; +/** + * 수수료 마스터 API. + * + * 수수료 마스터(commission master): 설계사·정산월 단위로 각종 수수료(도입/소개/유지/시상 등)를 + * 한데 모아 최종 지급액을 관리하는 핵심 원장. 여러 세부 원장이 이 마스터로 집계된다. + * - by-agent: 설계사+정산월의 수수료들, aggregated: 정산월 전체 집계, status: 정산 상태 변경(확정/지급 등). + * + * 권한: @RequirePermission(menu="COMM_MASTER"), perm=READ/UPDATE/DELETE. 변경은 @DataChangeLog 기록. + */ @Tag(name = "수수료 마스터") @RestController @RequestMapping("/api/commissions") @@ -22,18 +31,21 @@ public class CommissionMasterController { private final CommissionMasterService service; + /** 수수료 마스터 목록 조회(검색 + 페이징). 권한: COMM_MASTER READ. */ @GetMapping @RequirePermission(menu = "COMM_MASTER", perm = "READ") public ApiResponse> list(@ModelAttribute CommissionMasterSearchParam param) { return ApiResponse.ok(service.list(param)); } + /** 수수료 마스터 단건 상세. 권한: COMM_MASTER READ. */ @GetMapping("/{masterId}") @RequirePermission(menu = "COMM_MASTER", perm = "READ") public ApiResponse detail(@PathVariable Long masterId) { return ApiResponse.ok(service.detail(masterId)); } + /** 특정 설계사(agentId)의 특정 정산월(settleMonth) 수수료 내역 조회. 권한: COMM_MASTER READ. */ @GetMapping("/by-agent") @RequirePermission(menu = "COMM_MASTER", perm = "READ") public ApiResponse> byAgent(@RequestParam Long agentId, @@ -41,12 +53,14 @@ public class CommissionMasterController { return ApiResponse.ok(service.listByAgentAndMonth(agentId, settleMonth)); } + /** 정산월 단위 전체 집계 조회. 권한: COMM_MASTER READ. */ @GetMapping("/aggregated") @RequirePermission(menu = "COMM_MASTER", perm = "READ") public ApiResponse> aggregated(@RequestParam String settleMonth) { return ApiResponse.ok(service.aggregatedByMonth(settleMonth)); } + /** 정산 상태 변경(예: 확정/지급 등). body의 "status" 값으로 갱신. 권한: COMM_MASTER UPDATE. */ @PutMapping("/{masterId}/status") @RequirePermission(menu = "COMM_MASTER", perm = "UPDATE") @DataChangeLog(menu = "COMM_MASTER", table = "commission_master") @@ -56,6 +70,7 @@ public class CommissionMasterController { return ApiResponse.ok(); } + /** 수수료 마스터 삭제. 권한: COMM_MASTER DELETE. */ @DeleteMapping("/{masterId}") @RequirePermission(menu = "COMM_MASTER", perm = "DELETE") @DataChangeLog(menu = "COMM_MASTER", table = "commission_master") diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/CommissionSimulationController.java b/ga-api/src/main/java/com/ga/api/controller/commission/CommissionSimulationController.java index dd65ace..ac9c5cd 100644 --- a/ga-api/src/main/java/com/ga/api/controller/commission/CommissionSimulationController.java +++ b/ga-api/src/main/java/com/ga/api/controller/commission/CommissionSimulationController.java @@ -13,6 +13,14 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 수수료 시뮬레이터 API. + * + * 시뮬레이션(simulation): 실제 정산을 확정하기 전에 "이 조건이면 수수료가 얼마 나올까"를 미리 계산해 보는 기능. + * 결과는 저장되어 이력으로 조회할 수 있다(실제 지급에는 영향 없음). + * + * 권한: @RequirePermission(menu="COMM_SIMULATOR"), perm=READ/CREATE. + */ @Tag(name = "수수료 시뮬레이터") @RestController @RequestMapping("/api/commission-simulations") @@ -21,6 +29,7 @@ public class CommissionSimulationController { private final CommissionSimulationService service; + /** 시뮬레이션 이력 목록 조회. 권한: COMM_SIMULATOR READ. */ @GetMapping @RequirePermission(menu = "COMM_SIMULATOR", perm = "READ") public ApiResponse> list( @@ -28,12 +37,14 @@ public class CommissionSimulationController { return ApiResponse.ok(service.list(param)); } + /** 시뮬레이션 단건 상세. 권한: COMM_SIMULATOR READ. */ @GetMapping("/{simId}") @RequirePermission(menu = "COMM_SIMULATOR", perm = "READ") public ApiResponse detail(@PathVariable Long simId) { return ApiResponse.ok(service.detail(simId)); } + /** 시뮬레이션 실행. 요청 조건으로 예상 수수료를 계산해 결과를 반환(및 이력 저장). 권한: COMM_SIMULATOR CREATE. */ @PostMapping("/simulate") @RequirePermission(menu = "COMM_SIMULATOR", perm = "CREATE") @DataChangeLog(menu = "COMM_SIMULATOR", table = "commission_simulation") diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/GradeEvaluationController.java b/ga-api/src/main/java/com/ga/api/controller/commission/GradeEvaluationController.java index faa9558..ec4d4ef 100644 --- a/ga-api/src/main/java/com/ga/api/controller/commission/GradeEvaluationController.java +++ b/ga-api/src/main/java/com/ga/api/controller/commission/GradeEvaluationController.java @@ -16,6 +16,15 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 등급평가 API. + * + * 등급평가(grade evaluation): 설계사의 실적 등을 기준 규칙(Rule)에 따라 평가해 등급을 매기는 기능. + * 등급은 수수료율·시상 등에 영향을 준다. + * - Rule(평가 규칙) CRUD - Evaluation(평가 결과): 생성 후 apply(적용)하면 실제 설계사 등급에 반영된다. + * + * 권한: @RequirePermission(menu="GRADE_EVAL"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 기록. + */ @Tag(name = "등급평가") @RestController @RequestMapping("/api/grade-evaluations") @@ -24,20 +33,23 @@ public class GradeEvaluationController { private final GradeEvaluationService service; - // ── Rule ────────────────────────────────────────────────────────────────── + // ── Rule (평가 규칙) ──────────────────────────────────────────────────── + /** 등급평가 규칙 목록 조회. 권한: GRADE_EVAL READ. */ @GetMapping("/rules") @RequirePermission(menu = "GRADE_EVAL", perm = "READ") public ApiResponse> listRules(@ModelAttribute GradeEvalRuleSearchParam param) { return ApiResponse.ok(service.listRules(param)); } + /** 등급평가 규칙 단건 상세. 권한: GRADE_EVAL READ. */ @GetMapping("/rules/{ruleId}") @RequirePermission(menu = "GRADE_EVAL", perm = "READ") public ApiResponse detailRule(@PathVariable Long ruleId) { return ApiResponse.ok(service.detailRule(ruleId)); } + /** 등급평가 규칙 신규 등록. 응답: ruleId. 권한: GRADE_EVAL CREATE. */ @PostMapping("/rules") @RequirePermission(menu = "GRADE_EVAL", perm = "CREATE") @DataChangeLog(menu = "GRADE_EVAL", table = "grade_eval_rule") @@ -45,6 +57,7 @@ public class GradeEvaluationController { return ApiResponse.ok(service.createRule(req)); } + /** 등급평가 규칙 수정. 권한: GRADE_EVAL UPDATE. */ @PutMapping("/rules/{ruleId}") @RequirePermission(menu = "GRADE_EVAL", perm = "UPDATE") @DataChangeLog(menu = "GRADE_EVAL", table = "grade_eval_rule") @@ -54,6 +67,7 @@ public class GradeEvaluationController { return ApiResponse.ok(); } + /** 등급평가 규칙 삭제. 권한: GRADE_EVAL DELETE. */ @DeleteMapping("/rules/{ruleId}") @RequirePermission(menu = "GRADE_EVAL", perm = "DELETE") @DataChangeLog(menu = "GRADE_EVAL", table = "grade_eval_rule") @@ -62,8 +76,9 @@ public class GradeEvaluationController { return ApiResponse.ok(); } - // ── Evaluation ──────────────────────────────────────────────────────────── + // ── Evaluation (평가 결과) ────────────────────────────────────────────── + /** 등급평가 결과 목록 조회. 권한: GRADE_EVAL READ. */ @GetMapping @RequirePermission(menu = "GRADE_EVAL", perm = "READ") public ApiResponse> listEvals( @@ -71,12 +86,14 @@ public class GradeEvaluationController { return ApiResponse.ok(service.listEvals(param)); } + /** 등급평가 결과 단건 상세. 권한: GRADE_EVAL READ. */ @GetMapping("/{evalId}") @RequirePermission(menu = "GRADE_EVAL", perm = "READ") public ApiResponse detailEval(@PathVariable Long evalId) { return ApiResponse.ok(service.detailEval(evalId)); } + /** 등급평가 결과 신규 생성(아직 미적용 상태). 응답: evalId. 권한: GRADE_EVAL CREATE. */ @PostMapping @RequirePermission(menu = "GRADE_EVAL", perm = "CREATE") @DataChangeLog(menu = "GRADE_EVAL", table = "agent_grade_evaluation") @@ -84,6 +101,7 @@ public class GradeEvaluationController { return ApiResponse.ok(service.createEval(req)); } + /** 등급평가 결과 적용. 평가된 등급을 실제 설계사 등급에 반영한다. 권한: GRADE_EVAL UPDATE. */ @PostMapping("/{evalId}/apply") @RequirePermission(menu = "GRADE_EVAL", perm = "UPDATE") @DataChangeLog(menu = "GRADE_EVAL", table = "agent_grade_evaluation") @@ -92,6 +110,7 @@ public class GradeEvaluationController { return ApiResponse.ok(); } + /** 등급평가 결과 삭제. 권한: GRADE_EVAL DELETE. */ @DeleteMapping("/{evalId}") @RequirePermission(menu = "GRADE_EVAL", perm = "DELETE") @DataChangeLog(menu = "GRADE_EVAL", table = "agent_grade_evaluation") diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/GuaranteeInsuranceController.java b/ga-api/src/main/java/com/ga/api/controller/commission/GuaranteeInsuranceController.java index 9a7bd12..567022b 100644 --- a/ga-api/src/main/java/com/ga/api/controller/commission/GuaranteeInsuranceController.java +++ b/ga-api/src/main/java/com/ga/api/controller/commission/GuaranteeInsuranceController.java @@ -13,6 +13,14 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 보증보험 API. + * + * 보증보험(guarantee insurance): 설계사가 선지급금 등을 갚지 못할 경우에 대비해 가입하는 보험. + * 설계사별 가입 정보(보험사/증권번호/보장한도/기간 등)를 관리한다. + * + * 권한: @RequirePermission(menu="GUARANTEE_INS"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 기록. + */ @Tag(name = "보증보험") @RestController @RequestMapping("/api/guarantee-insurances") @@ -21,6 +29,7 @@ public class GuaranteeInsuranceController { private final GuaranteeInsuranceService service; + /** 보증보험 가입 목록 조회. 권한: GUARANTEE_INS READ. */ @GetMapping @RequirePermission(menu = "GUARANTEE_INS", perm = "READ") public ApiResponse> list( @@ -28,12 +37,14 @@ public class GuaranteeInsuranceController { return ApiResponse.ok(service.list(param)); } + /** 보증보험 단건 상세. 권한: GUARANTEE_INS READ. */ @GetMapping("/{guaranteeId}") @RequirePermission(menu = "GUARANTEE_INS", perm = "READ") public ApiResponse detail(@PathVariable Long guaranteeId) { return ApiResponse.ok(service.detail(guaranteeId)); } + /** 보증보험 가입 등록. 응답: guaranteeId. 권한: GUARANTEE_INS CREATE. */ @PostMapping @RequirePermission(menu = "GUARANTEE_INS", perm = "CREATE") @DataChangeLog(menu = "GUARANTEE_INS", table = "agent_guarantee_insurance") @@ -41,6 +52,7 @@ public class GuaranteeInsuranceController { return ApiResponse.ok(service.create(req)); } + /** 보증보험 정보 수정. 권한: GUARANTEE_INS UPDATE. */ @PutMapping("/{guaranteeId}") @RequirePermission(menu = "GUARANTEE_INS", perm = "UPDATE") @DataChangeLog(menu = "GUARANTEE_INS", table = "agent_guarantee_insurance") @@ -50,6 +62,7 @@ public class GuaranteeInsuranceController { return ApiResponse.ok(); } + /** 보증보험 삭제. 권한: GUARANTEE_INS DELETE. */ @DeleteMapping("/{guaranteeId}") @RequirePermission(menu = "GUARANTEE_INS", perm = "DELETE") @DataChangeLog(menu = "GUARANTEE_INS", table = "agent_guarantee_insurance") diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/HqSettleController.java b/ga-api/src/main/java/com/ga/api/controller/commission/HqSettleController.java index 90eaba6..d2af99d 100644 --- a/ga-api/src/main/java/com/ga/api/controller/commission/HqSettleController.java +++ b/ga-api/src/main/java/com/ga/api/controller/commission/HqSettleController.java @@ -14,6 +14,14 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 본사정산 API. + * + * 본사정산(HQ settle): 본사 차원에서 발생하는 정산 항목(item)을 정산월 단위로 관리하고 합계(summary)를 낸다. + * 설계사 지급과 별개로 본사가 직접 가감하는 비용/수입 항목 등을 다룬다. + * + * 권한: @RequirePermission(menu="HQ_SETTLE"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 기록. + */ @Tag(name = "본사정산") @RestController @RequestMapping("/api/hq-settles") @@ -22,24 +30,28 @@ public class HqSettleController { private final HqSettleService service; + /** 본사정산 항목 목록 조회. 권한: HQ_SETTLE READ. */ @GetMapping @RequirePermission(menu = "HQ_SETTLE", perm = "READ") public ApiResponse> list(@ModelAttribute HqSettleItemSearchParam param) { return ApiResponse.ok(service.list(param)); } + /** 특정 정산월의 본사정산 합계 요약. 권한: HQ_SETTLE READ. */ @GetMapping("/summary") @RequirePermission(menu = "HQ_SETTLE", perm = "READ") public ApiResponse summary(@RequestParam String settleMonth) { return ApiResponse.ok(service.summary(settleMonth)); } + /** 본사정산 항목 단건 상세. 권한: HQ_SETTLE READ. */ @GetMapping("/{itemId}") @RequirePermission(menu = "HQ_SETTLE", perm = "READ") public ApiResponse detail(@PathVariable Long itemId) { return ApiResponse.ok(service.detail(itemId)); } + /** 본사정산 항목 신규 등록. 응답: itemId. 권한: HQ_SETTLE CREATE. */ @PostMapping @RequirePermission(menu = "HQ_SETTLE", perm = "CREATE") @DataChangeLog(menu = "HQ_SETTLE", table = "hq_settle_item") @@ -47,6 +59,7 @@ public class HqSettleController { return ApiResponse.ok(service.create(req)); } + /** 본사정산 항목 수정. 권한: HQ_SETTLE UPDATE. */ @PutMapping("/{itemId}") @RequirePermission(menu = "HQ_SETTLE", perm = "UPDATE") @DataChangeLog(menu = "HQ_SETTLE", table = "hq_settle_item") @@ -56,6 +69,7 @@ public class HqSettleController { return ApiResponse.ok(); } + /** 본사정산 항목 삭제. 권한: HQ_SETTLE DELETE. */ @DeleteMapping("/{itemId}") @RequirePermission(menu = "HQ_SETTLE", perm = "DELETE") @DataChangeLog(menu = "HQ_SETTLE", table = "hq_settle_item") diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/IncomeCommissionController.java b/ga-api/src/main/java/com/ga/api/controller/commission/IncomeCommissionController.java index 9cbccad..ac8d624 100644 --- a/ga-api/src/main/java/com/ga/api/controller/commission/IncomeCommissionController.java +++ b/ga-api/src/main/java/com/ga/api/controller/commission/IncomeCommissionController.java @@ -11,6 +11,17 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 수입수수료/수지차 API. + * + * - 수입수수료(income commission): GA가 보험사로부터 받는 수수료. "보험료 x 보험사 요율"로 계산되며, + * 설계사에게 지급하는 수수료(지출)의 재원이 된다. + * - 수지차(margin): 같은 정산월의 수입수수료 합계에서 설계사 지급액 합계를 뺀 GA의 마진. + * 양수면 흑자, 음수면 적자를 뜻한다. + * + * 모든 엔드포인트는 @RequirePermission(menu="INCOME_COMM")로 보호되며, perm 은 READ(조회)/CREATE(생성) 권한을 의미한다. + * 데이터 변경(POST)에는 @DataChangeLog 로 변경 이력(감사 로그)이 자동 기록된다. + */ @Tag(name = "수입수수료/수지차") @RestController @RequestMapping("/api/income-commissions") @@ -19,8 +30,9 @@ public class IncomeCommissionController { private final IncomeCommissionService service; - // ── Income Ledger ──────────────────────────────────────────────────────── + // ── Income Ledger (수입수수료 원장) ─────────────────────────────────────── + /** 수입수수료 원장 목록 조회(검색조건 + 페이징). 권한: INCOME_COMM READ. */ @GetMapping("/ledgers") @RequirePermission(menu = "INCOME_COMM", perm = "READ") public ApiResponse> listLedgers( @@ -28,12 +40,14 @@ public class IncomeCommissionController { return ApiResponse.ok(service.listLedgers(param)); } + /** 수입수수료 원장 단건 상세 조회. 권한: INCOME_COMM READ. */ @GetMapping("/ledgers/{incomeId}") @RequirePermission(menu = "INCOME_COMM", perm = "READ") public ApiResponse detailLedger(@PathVariable Long incomeId) { return ApiResponse.ok(service.detailLedger(incomeId)); } + /** 수입수수료 원장 신규 등록. 등록 시 "보험료 x 보험사요율"로 수입액이 계산된다. 응답: 생성된 incomeId. 권한: INCOME_COMM CREATE. */ @PostMapping("/ledgers") @RequirePermission(menu = "INCOME_COMM", perm = "CREATE") @DataChangeLog(menu = "INCOME_COMM", table = "income_commission_ledger") @@ -41,8 +55,9 @@ public class IncomeCommissionController { return ApiResponse.ok(service.createLedger(req)); } - // ── Margin Summary ─────────────────────────────────────────────────────── + // ── Margin Summary (수지차 집계) ────────────────────────────────────────── + /** 수지차 집계 목록 조회. 권한: INCOME_COMM READ. */ @GetMapping("/margins") @RequirePermission(menu = "INCOME_COMM", perm = "READ") public ApiResponse> listMargins( @@ -50,12 +65,17 @@ public class IncomeCommissionController { return ApiResponse.ok(service.listMargins(param)); } + /** 수지차 집계 단건 상세 조회. 권한: INCOME_COMM READ. */ @GetMapping("/margins/{marginId}") @RequirePermission(menu = "INCOME_COMM", perm = "READ") public ApiResponse detailMargin(@PathVariable Long marginId) { return ApiResponse.ok(service.detailMargin(marginId)); } + /** + * 수지차 재계산. 정산월(settleMonth) 기준으로 수입수수료 합 - 설계사 지급액 합을 다시 산출해 저장한다. + * agentId 가 주어지면 해당 설계사 지급분만 좁혀 집계한다(미지정 시 GA 월 전체). 응답: marginId. 권한: INCOME_COMM CREATE. + */ @PostMapping("/margin/recalc") @RequirePermission(menu = "INCOME_COMM", perm = "CREATE") @DataChangeLog(menu = "INCOME_COMM", table = "margin_summary") diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/JointContractController.java b/ga-api/src/main/java/com/ga/api/controller/commission/JointContractController.java index 503bea1..9ec730f 100644 --- a/ga-api/src/main/java/com/ga/api/controller/commission/JointContractController.java +++ b/ga-api/src/main/java/com/ga/api/controller/commission/JointContractController.java @@ -13,6 +13,14 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 공동계약 API. + * + * 공동계약(joint contract): 한 계약을 두 명 이상의 설계사가 함께 모집한 경우, 그 수수료를 + * 약정 비율(지분)대로 나눠 갖도록 관리하는 기능. 비율 합이 100%가 되도록 등록한다. + * + * 권한: @RequirePermission(menu="JOINT_CONTRACT"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 기록. + */ @Tag(name = "공동계약") @RestController @RequestMapping("/api/joint-contracts") @@ -21,18 +29,21 @@ public class JointContractController { private final JointContractService service; + /** 공동계약 목록 조회. 권한: JOINT_CONTRACT READ. */ @GetMapping @RequirePermission(menu = "JOINT_CONTRACT", perm = "READ") public ApiResponse> list(@ModelAttribute JointContractSearchParam param) { return ApiResponse.ok(service.list(param)); } + /** 공동계약 단건 상세. 권한: JOINT_CONTRACT READ. */ @GetMapping("/{jointId}") @RequirePermission(menu = "JOINT_CONTRACT", perm = "READ") public ApiResponse detail(@PathVariable Long jointId) { return ApiResponse.ok(service.detail(jointId)); } + /** 공동계약 신규 등록(설계사별 지분 비율 포함). 응답: jointId. 권한: JOINT_CONTRACT CREATE. */ @PostMapping @RequirePermission(menu = "JOINT_CONTRACT", perm = "CREATE") @DataChangeLog(menu = "JOINT_CONTRACT", table = "joint_contract") @@ -40,6 +51,7 @@ public class JointContractController { return ApiResponse.ok(service.create(req)); } + /** 공동계약 수정(지분 비율 변경 등). 권한: JOINT_CONTRACT UPDATE. */ @PutMapping("/{jointId}") @RequirePermission(menu = "JOINT_CONTRACT", perm = "UPDATE") @DataChangeLog(menu = "JOINT_CONTRACT", table = "joint_contract") @@ -49,6 +61,7 @@ public class JointContractController { return ApiResponse.ok(); } + /** 공동계약 삭제. 권한: JOINT_CONTRACT DELETE. */ @DeleteMapping("/{jointId}") @RequirePermission(menu = "JOINT_CONTRACT", perm = "DELETE") @DataChangeLog(menu = "JOINT_CONTRACT", table = "joint_contract") diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/LapsedReinstatementController.java b/ga-api/src/main/java/com/ga/api/controller/commission/LapsedReinstatementController.java index d9a18b2..7e499d7 100644 --- a/ga-api/src/main/java/com/ga/api/controller/commission/LapsedReinstatementController.java +++ b/ga-api/src/main/java/com/ga/api/controller/commission/LapsedReinstatementController.java @@ -13,6 +13,16 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 익월부활재지급 API. + * + * 실효(lapsed): 보험료 미납 등으로 계약 효력이 일시 정지된 상태. 실효가 나면 그 달에 지급했던 수수료를 환수한다. + * 부활(reinstatement): 실효된 계약이 다시 살아나는 것. 다음 달(익월)에 부활하면 앞서 환수했던 수수료를 + * 다시 지급(재지급)해 준다. 이 도메인은 그 "환수 → 익월 부활 시 재지급(회수)" 흐름을 관리한다. + * - repay: 부활에 따른 재지급(회수) 확정 - cancel: 재지급 취소 + * + * 권한: @RequirePermission(menu="LAPSED_REINSTATEMENT"), perm=READ/CREATE/UPDATE. 변경은 @DataChangeLog 기록. + */ @Tag(name = "익월부활재지급") @RestController @RequestMapping("/api/lapsed-reinstatements") @@ -21,6 +31,7 @@ public class LapsedReinstatementController { private final LapsedReinstatementService service; + /** 익월부활 재지급 대상 목록 조회. 권한: LAPSED_REINSTATEMENT READ. */ @GetMapping @RequirePermission(menu = "LAPSED_REINSTATEMENT", perm = "READ") public ApiResponse> list( @@ -28,12 +39,14 @@ public class LapsedReinstatementController { return ApiResponse.ok(service.list(param)); } + /** 재지급 건 단건 상세. 권한: LAPSED_REINSTATEMENT READ. */ @GetMapping("/{recoveryId}") @RequirePermission(menu = "LAPSED_REINSTATEMENT", perm = "READ") public ApiResponse detail(@PathVariable Long recoveryId) { return ApiResponse.ok(service.detail(recoveryId)); } + /** 재지급 건 신규 등록. 응답: recoveryId. 권한: LAPSED_REINSTATEMENT CREATE. */ @PostMapping @RequirePermission(menu = "LAPSED_REINSTATEMENT", perm = "CREATE") @DataChangeLog(menu = "LAPSED_REINSTATEMENT", table = "lapsed_reinstatement_recovery") @@ -41,6 +54,7 @@ public class LapsedReinstatementController { return ApiResponse.ok(service.create(req)); } + /** 부활에 따른 재지급(회수) 확정 처리. 권한: LAPSED_REINSTATEMENT UPDATE. */ @PostMapping("/{recoveryId}/repay") @RequirePermission(menu = "LAPSED_REINSTATEMENT", perm = "UPDATE") @DataChangeLog(menu = "LAPSED_REINSTATEMENT", table = "lapsed_reinstatement_recovery") @@ -49,6 +63,7 @@ public class LapsedReinstatementController { return ApiResponse.ok(); } + /** 재지급 취소 처리. 권한: LAPSED_REINSTATEMENT UPDATE. */ @PostMapping("/{recoveryId}/cancel") @RequirePermission(menu = "LAPSED_REINSTATEMENT", perm = "UPDATE") @DataChangeLog(menu = "LAPSED_REINSTATEMENT", table = "lapsed_reinstatement_recovery") diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/LifeSupportController.java b/ga-api/src/main/java/com/ga/api/controller/commission/LifeSupportController.java index 61a2da7..662ba85 100644 --- a/ga-api/src/main/java/com/ga/api/controller/commission/LifeSupportController.java +++ b/ga-api/src/main/java/com/ga/api/controller/commission/LifeSupportController.java @@ -11,6 +11,15 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 생보운영지원수수료 API. + * + * 생명보험사 운영지원수수료(life operation support): 생보 상품 운영/관리 활동에 대해 지급하는 지원성 수수료. + * 규칙(Rule)에 정의된 기준에 따라 산출되어 원장(Ledger)에 기록된다. + * + * 구조는 "규칙(Rule) CRUD + 원장(Ledger) 조회/등록"으로, 다른 수수료 도메인과 동일한 패턴이다. + * 권한: @RequirePermission(menu="LIFE_OP_SUPPORT"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 기록. + */ @Tag(name = "생보운영지원수수료") @RestController @RequestMapping("/api/life-supports") @@ -19,8 +28,9 @@ public class LifeSupportController { private final LifeSupportService service; - // ── Rule ────────────────────────────────────────────────────────────────── + // ── Rule (지원수수료 규칙) ────────────────────────────────────────────── + /** 규칙 목록 조회. 권한: LIFE_OP_SUPPORT READ. */ @GetMapping("/rules") @RequirePermission(menu = "LIFE_OP_SUPPORT", perm = "READ") public ApiResponse> listRules( @@ -28,12 +38,14 @@ public class LifeSupportController { return ApiResponse.ok(service.listRules(param)); } + /** 규칙 단건 상세. 권한: LIFE_OP_SUPPORT READ. */ @GetMapping("/rules/{ruleId}") @RequirePermission(menu = "LIFE_OP_SUPPORT", perm = "READ") public ApiResponse detailRule(@PathVariable Long ruleId) { return ApiResponse.ok(service.detailRule(ruleId)); } + /** 규칙 신규 등록. 응답: ruleId. 권한: LIFE_OP_SUPPORT CREATE. */ @PostMapping("/rules") @RequirePermission(menu = "LIFE_OP_SUPPORT", perm = "CREATE") @DataChangeLog(menu = "LIFE_OP_SUPPORT", table = "life_support_rule") @@ -41,6 +53,7 @@ public class LifeSupportController { return ApiResponse.ok(service.createRule(req)); } + /** 규칙 수정. 권한: LIFE_OP_SUPPORT UPDATE. */ @PutMapping("/rules/{ruleId}") @RequirePermission(menu = "LIFE_OP_SUPPORT", perm = "UPDATE") @DataChangeLog(menu = "LIFE_OP_SUPPORT", table = "life_support_rule") @@ -50,6 +63,7 @@ public class LifeSupportController { return ApiResponse.ok(); } + /** 규칙 삭제. 권한: LIFE_OP_SUPPORT DELETE. */ @DeleteMapping("/rules/{ruleId}") @RequirePermission(menu = "LIFE_OP_SUPPORT", perm = "DELETE") @DataChangeLog(menu = "LIFE_OP_SUPPORT", table = "life_support_rule") @@ -58,8 +72,9 @@ public class LifeSupportController { return ApiResponse.ok(); } - // ── Ledger ──────────────────────────────────────────────────────────────── + // ── Ledger (지원수수료 원장) ──────────────────────────────────────────── + /** 원장 목록 조회. 권한: LIFE_OP_SUPPORT READ. */ @GetMapping("/ledgers") @RequirePermission(menu = "LIFE_OP_SUPPORT", perm = "READ") public ApiResponse> listLedgers( @@ -67,12 +82,14 @@ public class LifeSupportController { return ApiResponse.ok(service.listLedgers(param)); } + /** 원장 단건 상세. 권한: LIFE_OP_SUPPORT READ. */ @GetMapping("/ledgers/{ledgerId}") @RequirePermission(menu = "LIFE_OP_SUPPORT", perm = "READ") public ApiResponse detailLedger(@PathVariable Long ledgerId) { return ApiResponse.ok(service.detailLedger(ledgerId)); } + /** 원장 신규 등록(지원수수료 산출/지급 기록). 응답: ledgerId. 권한: LIFE_OP_SUPPORT CREATE. */ @PostMapping("/ledgers") @RequirePermission(menu = "LIFE_OP_SUPPORT", perm = "CREATE") @DataChangeLog(menu = "LIFE_OP_SUPPORT", table = "life_support_ledger") diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/OverrideLedgerController.java b/ga-api/src/main/java/com/ga/api/controller/commission/OverrideLedgerController.java index 76b3649..6818c89 100644 --- a/ga-api/src/main/java/com/ga/api/controller/commission/OverrideLedgerController.java +++ b/ga-api/src/main/java/com/ga/api/controller/commission/OverrideLedgerController.java @@ -11,6 +11,16 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +/** + * 오버라이드 원장 API. + * + * 오버라이드(override): 상위 직급자(예: 팀장/지점장)가 자기 산하 하위 설계사의 실적에 대해 + * 일정 비율로 추가로 받는 수수료. "하위가 판 만큼 상위에게도 얹어주는" 구조라 override 라 부른다. + * 이 원장은 누가(fromAgent) 누구에게(toAgentId) 얼마를 넘겨주는지를 기록한다. + * - summary: 수령자(상위자) 기준 집계. + * + * 권한: @RequirePermission(menu="OVERRIDE_LEDGER"), perm=READ. (조회 전용 - 생성/배치는 별도 계산 로직에서 처리) + */ @Tag(name = "오버라이드 원장") @RestController @RequestMapping("/api/override-ledgers") @@ -19,24 +29,28 @@ public class OverrideLedgerController { private final OverrideLedgerService service; + /** 오버라이드 원장 목록 조회. 권한: OVERRIDE_LEDGER READ. */ @GetMapping @RequirePermission(menu = "OVERRIDE_LEDGER", perm = "READ") public ApiResponse> list(@ModelAttribute OverrideLedgerSearchParam param) { return ApiResponse.ok(service.list(param)); } + /** 오버라이드 원장 단건 상세. 권한: OVERRIDE_LEDGER READ. */ @GetMapping("/{ledgerId}") @RequirePermission(menu = "OVERRIDE_LEDGER", perm = "READ") public ApiResponse detail(@PathVariable Long ledgerId) { return ApiResponse.ok(service.detail(ledgerId)); } + /** 오버라이드 집계 목록(수령자별 합계 등). 권한: OVERRIDE_LEDGER READ. */ @GetMapping("/summary") @RequirePermission(menu = "OVERRIDE_LEDGER", perm = "READ") public ApiResponse> summaryList(@ModelAttribute OverrideSummarySearchParam param) { return ApiResponse.ok(service.summaryList(param)); } + /** 특정 수령자(toAgentId)의 특정 정산월(settleMonth) 오버라이드 합계. 권한: OVERRIDE_LEDGER READ. */ @GetMapping("/summary/by-agent") @RequirePermission(menu = "OVERRIDE_LEDGER", perm = "READ") public ApiResponse summaryByAgent(@RequestParam Long toAgentId, diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/PersistencyBonusController.java b/ga-api/src/main/java/com/ga/api/controller/commission/PersistencyBonusController.java index c176c12..4d3b210 100644 --- a/ga-api/src/main/java/com/ga/api/controller/commission/PersistencyBonusController.java +++ b/ga-api/src/main/java/com/ga/api/controller/commission/PersistencyBonusController.java @@ -11,6 +11,15 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 유지보너스(유지보수수당) API. + * + * 유지율 보너스(persistency bonus): 계약이 일정 기간(예: 13회차/25회차)까지 해지 없이 잘 "유지"되면 + * 그 유지 성과에 대해 설계사에게 지급하는 보너스. 유지율이 높을수록 보너스가 커진다. + * - Rule(보너스 규칙) CRUD - Bonus(보너스 산출 내역): upsert(있으면 갱신, 없으면 생성)로 등록. + * + * 권한: @RequirePermission(menu="PERSIST_BONUS"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 기록. + */ @Tag(name = "유지보수수당") @RestController @RequestMapping("/api/persistency-bonus") @@ -19,6 +28,7 @@ public class PersistencyBonusController { private final PersistencyBonusService service; + /** 유지보너스 규칙 목록 조회. 권한: PERSIST_BONUS READ. */ @GetMapping("/rules") @RequirePermission(menu = "PERSIST_BONUS", perm = "READ") public ApiResponse> listRules( @@ -26,12 +36,14 @@ public class PersistencyBonusController { return ApiResponse.ok(service.listRules(param)); } + /** 유지보너스 규칙 단건 상세. 권한: PERSIST_BONUS READ. */ @GetMapping("/rules/{ruleId}") @RequirePermission(menu = "PERSIST_BONUS", perm = "READ") public ApiResponse detailRule(@PathVariable Long ruleId) { return ApiResponse.ok(service.detailRule(ruleId)); } + /** 유지보너스 규칙 신규 등록. 응답: ruleId. 권한: PERSIST_BONUS CREATE. */ @PostMapping("/rules") @RequirePermission(menu = "PERSIST_BONUS", perm = "CREATE") @DataChangeLog(menu = "PERSIST_BONUS", table = "persistency_bonus_rule") @@ -39,6 +51,7 @@ public class PersistencyBonusController { return ApiResponse.ok(service.createRule(req)); } + /** 유지보너스 규칙 수정. 권한: PERSIST_BONUS UPDATE. */ @PutMapping("/rules/{ruleId}") @RequirePermission(menu = "PERSIST_BONUS", perm = "UPDATE") @DataChangeLog(menu = "PERSIST_BONUS", table = "persistency_bonus_rule") @@ -48,6 +61,7 @@ public class PersistencyBonusController { return ApiResponse.ok(); } + /** 유지보너스 규칙 삭제. 권한: PERSIST_BONUS DELETE. */ @DeleteMapping("/rules/{ruleId}") @RequirePermission(menu = "PERSIST_BONUS", perm = "DELETE") @DataChangeLog(menu = "PERSIST_BONUS", table = "persistency_bonus_rule") @@ -56,6 +70,7 @@ public class PersistencyBonusController { return ApiResponse.ok(); } + /** 유지보너스 산출 내역 목록 조회. 권한: PERSIST_BONUS READ. */ @GetMapping("/bonuses") @RequirePermission(menu = "PERSIST_BONUS", perm = "READ") public ApiResponse> listBonuses( @@ -63,12 +78,14 @@ public class PersistencyBonusController { return ApiResponse.ok(service.listBonuses(param)); } + /** 유지보너스 산출 내역 단건 상세. 권한: PERSIST_BONUS READ. */ @GetMapping("/bonuses/{bonusId}") @RequirePermission(menu = "PERSIST_BONUS", perm = "READ") public ApiResponse detailBonus(@PathVariable Long bonusId) { return ApiResponse.ok(service.detailBonus(bonusId)); } + /** 유지보너스 산출 내역 upsert(동일 키가 있으면 갱신, 없으면 신규 생성). 권한: PERSIST_BONUS CREATE. */ @PostMapping("/bonuses") @RequirePermission(menu = "PERSIST_BONUS", perm = "CREATE") @DataChangeLog(menu = "PERSIST_BONUS", table = "persistency_bonus") diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/RecognizedPerformanceController.java b/ga-api/src/main/java/com/ga/api/controller/commission/RecognizedPerformanceController.java index d9cebe7..2307eb4 100644 --- a/ga-api/src/main/java/com/ga/api/controller/commission/RecognizedPerformanceController.java +++ b/ga-api/src/main/java/com/ga/api/controller/commission/RecognizedPerformanceController.java @@ -11,6 +11,16 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 인정실적 API. + * + * 인정실적(recognized performance): 실제 보험료를 그대로 실적으로 보지 않고, 상품 종류에 따른 + * 환산율(product conversion rate)을 곱해 "회사가 인정해 주는 실적"으로 환산한 값. + * (예: 보장성 100%, 저축성 30% 등. 같은 보험료라도 상품에 따라 인정실적이 달라진다.) + * - rates(환산율 규칙) CRUD - ledgers(인정실적 원장) 조회/등록. + * + * 권한: @RequirePermission(menu="RECOGNIZED_PERF"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 기록. + */ @Tag(name = "인정실적") @RestController @RequestMapping("/api/recognized-performances") @@ -19,8 +29,9 @@ public class RecognizedPerformanceController { private final RecognizedPerformanceService service; - // ── Product Conversion Rate (rule) ──────────────────────────────────────── + // ── Product Conversion Rate (상품 환산율 규칙) ────────────────────────── + /** 상품 환산율 목록 조회. 권한: RECOGNIZED_PERF READ. */ @GetMapping("/rates") @RequirePermission(menu = "RECOGNIZED_PERF", perm = "READ") public ApiResponse> listRates( @@ -28,12 +39,14 @@ public class RecognizedPerformanceController { return ApiResponse.ok(service.listRates(param)); } + /** 상품 환산율 단건 상세. 권한: RECOGNIZED_PERF READ. */ @GetMapping("/rates/{rateId}") @RequirePermission(menu = "RECOGNIZED_PERF", perm = "READ") public ApiResponse detailRate(@PathVariable Long rateId) { return ApiResponse.ok(service.detailRate(rateId)); } + /** 상품 환산율 신규 등록. 응답: rateId. 권한: RECOGNIZED_PERF CREATE. */ @PostMapping("/rates") @RequirePermission(menu = "RECOGNIZED_PERF", perm = "CREATE") @DataChangeLog(menu = "RECOGNIZED_PERF", table = "product_conversion_rate") @@ -41,6 +54,7 @@ public class RecognizedPerformanceController { return ApiResponse.ok(service.createRate(req)); } + /** 상품 환산율 수정. 권한: RECOGNIZED_PERF UPDATE. */ @PutMapping("/rates/{rateId}") @RequirePermission(menu = "RECOGNIZED_PERF", perm = "UPDATE") @DataChangeLog(menu = "RECOGNIZED_PERF", table = "product_conversion_rate") @@ -50,6 +64,7 @@ public class RecognizedPerformanceController { return ApiResponse.ok(); } + /** 상품 환산율 삭제. 권한: RECOGNIZED_PERF DELETE. */ @DeleteMapping("/rates/{rateId}") @RequirePermission(menu = "RECOGNIZED_PERF", perm = "DELETE") @DataChangeLog(menu = "RECOGNIZED_PERF", table = "product_conversion_rate") @@ -58,8 +73,9 @@ public class RecognizedPerformanceController { return ApiResponse.ok(); } - // ── Ledger ──────────────────────────────────────────────────────────────── + // ── Ledger (인정실적 원장) ────────────────────────────────────────────── + /** 인정실적 원장 목록 조회. 권한: RECOGNIZED_PERF READ. */ @GetMapping("/ledgers") @RequirePermission(menu = "RECOGNIZED_PERF", perm = "READ") public ApiResponse> listLedgers( @@ -67,12 +83,14 @@ public class RecognizedPerformanceController { return ApiResponse.ok(service.listLedgers(param)); } + /** 인정실적 원장 단건 상세. 권한: RECOGNIZED_PERF READ. */ @GetMapping("/ledgers/{ledgerId}") @RequirePermission(menu = "RECOGNIZED_PERF", perm = "READ") public ApiResponse detailLedger(@PathVariable Long ledgerId) { return ApiResponse.ok(service.detailLedger(ledgerId)); } + /** 인정실적 원장 신규 등록(환산율 적용해 인정실적 산출). 응답: ledgerId. 권한: RECOGNIZED_PERF CREATE. */ @PostMapping("/ledgers") @RequirePermission(menu = "RECOGNIZED_PERF", perm = "CREATE") @DataChangeLog(menu = "RECOGNIZED_PERF", table = "recognized_performance_ledger") diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/RecruiterCommissionController.java b/ga-api/src/main/java/com/ga/api/controller/commission/RecruiterCommissionController.java index aba7fb7..df0b734 100644 --- a/ga-api/src/main/java/com/ga/api/controller/commission/RecruiterCommissionController.java +++ b/ga-api/src/main/java/com/ga/api/controller/commission/RecruiterCommissionController.java @@ -11,6 +11,15 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 도입수수료 API. + * + * 도입수수료(recruiter commission): 새 설계사를 회사에 데려온(도입/리크루팅) 사람에게 지급하는 수수료. + * 도입한 설계사가 실적을 내면, 그 실적에 연동해 도입자에게도 일정 수수료가 발생한다. + * - Rule(도입수수료 규칙) CRUD - Ledger(도입수수료 원장) 조회/등록. + * + * 권한: @RequirePermission(menu="RECRUITER_COMM"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 기록. + */ @Tag(name = "도입수수료") @RestController @RequestMapping("/api/recruiter-commissions") @@ -19,8 +28,9 @@ public class RecruiterCommissionController { private final RecruiterCommissionService service; - // ── Rule ────────────────────────────────────────────────────────────────── + // ── Rule (도입수수료 규칙) ────────────────────────────────────────────── + /** 도입수수료 규칙 목록 조회. 권한: RECRUITER_COMM READ. */ @GetMapping("/rules") @RequirePermission(menu = "RECRUITER_COMM", perm = "READ") public ApiResponse> listRules( @@ -28,12 +38,14 @@ public class RecruiterCommissionController { return ApiResponse.ok(service.listRules(param)); } + /** 도입수수료 규칙 단건 상세. 권한: RECRUITER_COMM READ. */ @GetMapping("/rules/{ruleId}") @RequirePermission(menu = "RECRUITER_COMM", perm = "READ") public ApiResponse detailRule(@PathVariable Long ruleId) { return ApiResponse.ok(service.detailRule(ruleId)); } + /** 도입수수료 규칙 신규 등록. 응답: ruleId. 권한: RECRUITER_COMM CREATE. */ @PostMapping("/rules") @RequirePermission(menu = "RECRUITER_COMM", perm = "CREATE") @DataChangeLog(menu = "RECRUITER_COMM", table = "recruiter_commission_rule") @@ -41,6 +53,7 @@ public class RecruiterCommissionController { return ApiResponse.ok(service.createRule(req)); } + /** 도입수수료 규칙 수정. 권한: RECRUITER_COMM UPDATE. */ @PutMapping("/rules/{ruleId}") @RequirePermission(menu = "RECRUITER_COMM", perm = "UPDATE") @DataChangeLog(menu = "RECRUITER_COMM", table = "recruiter_commission_rule") @@ -50,6 +63,7 @@ public class RecruiterCommissionController { return ApiResponse.ok(); } + /** 도입수수료 규칙 삭제. 권한: RECRUITER_COMM DELETE. */ @DeleteMapping("/rules/{ruleId}") @RequirePermission(menu = "RECRUITER_COMM", perm = "DELETE") @DataChangeLog(menu = "RECRUITER_COMM", table = "recruiter_commission_rule") @@ -58,8 +72,9 @@ public class RecruiterCommissionController { return ApiResponse.ok(); } - // ── Ledger ──────────────────────────────────────────────────────────────── + // ── Ledger (도입수수료 원장) ──────────────────────────────────────────── + /** 도입수수료 원장 목록 조회. 권한: RECRUITER_COMM READ. */ @GetMapping("/ledgers") @RequirePermission(menu = "RECRUITER_COMM", perm = "READ") public ApiResponse> listLedgers( @@ -67,12 +82,14 @@ public class RecruiterCommissionController { return ApiResponse.ok(service.listLedgers(param)); } + /** 도입수수료 원장 단건 상세. 권한: RECRUITER_COMM READ. */ @GetMapping("/ledgers/{ledgerId}") @RequirePermission(menu = "RECRUITER_COMM", perm = "READ") public ApiResponse detailLedger(@PathVariable Long ledgerId) { return ApiResponse.ok(service.detailLedger(ledgerId)); } + /** 도입수수료 원장 신규 등록. 응답: ledgerId. 권한: RECRUITER_COMM CREATE. */ @PostMapping("/ledgers") @RequirePermission(menu = "RECRUITER_COMM", perm = "CREATE") @DataChangeLog(menu = "RECRUITER_COMM", table = "recruiter_commission_ledger") diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/ReferralCommissionController.java b/ga-api/src/main/java/com/ga/api/controller/commission/ReferralCommissionController.java index 3c71fce..68f6b69 100644 --- a/ga-api/src/main/java/com/ga/api/controller/commission/ReferralCommissionController.java +++ b/ga-api/src/main/java/com/ga/api/controller/commission/ReferralCommissionController.java @@ -11,6 +11,15 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 소개·이관수수료 API. + * + * 소개수수료(referral commission): 고객이나 계약을 다른 설계사에게 "소개·이관"해 준 사람에게 지급하는 수수료. + * 소개 받은 설계사가 계약을 성사시키면, 소개해 준 사람에게 규칙(Rule)에 따라 정액(FIXED) 또는 정률(RATE)로 지급한다. + * - Rule(소개수수료 규칙) CRUD - Ledger(소개수수료 원장) 조회/등록. + * + * 권한: @RequirePermission(menu="REFERRAL_COMM"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 기록. + */ @Tag(name = "소개·이관수수료") @RestController @RequestMapping("/api/referral-commissions") @@ -19,8 +28,9 @@ public class ReferralCommissionController { private final ReferralCommissionService service; - // ── Rule ────────────────────────────────────────────────────────────────── + // ── Rule (소개수수료 규칙) ────────────────────────────────────────────── + /** 소개수수료 규칙 목록 조회. 권한: REFERRAL_COMM READ. */ @GetMapping("/rules") @RequirePermission(menu = "REFERRAL_COMM", perm = "READ") public ApiResponse> listRules( @@ -28,12 +38,14 @@ public class ReferralCommissionController { return ApiResponse.ok(service.listRules(param)); } + /** 소개수수료 규칙 단건 상세. 권한: REFERRAL_COMM READ. */ @GetMapping("/rules/{ruleId}") @RequirePermission(menu = "REFERRAL_COMM", perm = "READ") public ApiResponse detailRule(@PathVariable Long ruleId) { return ApiResponse.ok(service.detailRule(ruleId)); } + /** 소개수수료 규칙 신규 등록. 응답: ruleId. 권한: REFERRAL_COMM CREATE. */ @PostMapping("/rules") @RequirePermission(menu = "REFERRAL_COMM", perm = "CREATE") @DataChangeLog(menu = "REFERRAL_COMM", table = "referral_commission_rule") @@ -41,6 +53,7 @@ public class ReferralCommissionController { return ApiResponse.ok(service.createRule(req)); } + /** 소개수수료 규칙 수정. 권한: REFERRAL_COMM UPDATE. */ @PutMapping("/rules/{ruleId}") @RequirePermission(menu = "REFERRAL_COMM", perm = "UPDATE") @DataChangeLog(menu = "REFERRAL_COMM", table = "referral_commission_rule") @@ -50,6 +63,7 @@ public class ReferralCommissionController { return ApiResponse.ok(); } + /** 소개수수료 규칙 삭제. 권한: REFERRAL_COMM DELETE. */ @DeleteMapping("/rules/{ruleId}") @RequirePermission(menu = "REFERRAL_COMM", perm = "DELETE") @DataChangeLog(menu = "REFERRAL_COMM", table = "referral_commission_rule") @@ -58,8 +72,9 @@ public class ReferralCommissionController { return ApiResponse.ok(); } - // ── Ledger ──────────────────────────────────────────────────────────────── + // ── Ledger (소개수수료 원장) ──────────────────────────────────────────── + /** 소개수수료 원장 목록 조회. 권한: REFERRAL_COMM READ. */ @GetMapping("/ledgers") @RequirePermission(menu = "REFERRAL_COMM", perm = "READ") public ApiResponse> listLedgers( @@ -67,12 +82,14 @@ public class ReferralCommissionController { return ApiResponse.ok(service.listLedgers(param)); } + /** 소개수수료 원장 단건 상세. 권한: REFERRAL_COMM READ. */ @GetMapping("/ledgers/{ledgerId}") @RequirePermission(menu = "REFERRAL_COMM", perm = "READ") public ApiResponse detailLedger(@PathVariable Long ledgerId) { return ApiResponse.ok(service.detailLedger(ledgerId)); } + /** 소개수수료 원장 신규 등록. 응답: ledgerId. 권한: REFERRAL_COMM CREATE. */ @PostMapping("/ledgers") @RequirePermission(menu = "REFERRAL_COMM", perm = "CREATE") @DataChangeLog(menu = "REFERRAL_COMM", table = "referral_commission_ledger") diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/ReinstatementCommissionController.java b/ga-api/src/main/java/com/ga/api/controller/commission/ReinstatementCommissionController.java index fb8bba8..9be88d5 100644 --- a/ga-api/src/main/java/com/ga/api/controller/commission/ReinstatementCommissionController.java +++ b/ga-api/src/main/java/com/ga/api/controller/commission/ReinstatementCommissionController.java @@ -11,6 +11,15 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 부활수수료 API. + * + * 부활(reinstatement): 실효(보험료 미납 등으로 효력 정지)됐던 계약이 밀린 보험료 납입 등으로 다시 살아나는 것. + * 부활수수료: 계약을 부활시킨 설계사에게 규칙(Rule)에 따라 지급하는 수수료. + * - Rule(부활수수료 규칙) CRUD - Ledger(부활수수료 원장) 조회/등록. + * + * 권한: @RequirePermission(menu="REINSTATE_COMM"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 기록. + */ @Tag(name = "부활수수료") @RestController @RequestMapping("/api/reinstatement-commissions") @@ -19,6 +28,7 @@ public class ReinstatementCommissionController { private final ReinstatementCommissionService service; + /** 부활수수료 규칙 목록 조회. 권한: REINSTATE_COMM READ. */ @GetMapping("/rules") @RequirePermission(menu = "REINSTATE_COMM", perm = "READ") public ApiResponse> listRules( @@ -26,12 +36,14 @@ public class ReinstatementCommissionController { return ApiResponse.ok(service.listRules(param)); } + /** 부활수수료 규칙 단건 상세. 권한: REINSTATE_COMM READ. */ @GetMapping("/rules/{ruleId}") @RequirePermission(menu = "REINSTATE_COMM", perm = "READ") public ApiResponse detailRule(@PathVariable Long ruleId) { return ApiResponse.ok(service.detailRule(ruleId)); } + /** 부활수수료 규칙 신규 등록. 응답: ruleId. 권한: REINSTATE_COMM CREATE. */ @PostMapping("/rules") @RequirePermission(menu = "REINSTATE_COMM", perm = "CREATE") @DataChangeLog(menu = "REINSTATE_COMM", table = "reinstatement_commission_rule") @@ -39,6 +51,7 @@ public class ReinstatementCommissionController { return ApiResponse.ok(service.createRule(req)); } + /** 부활수수료 규칙 수정. 권한: REINSTATE_COMM UPDATE. */ @PutMapping("/rules/{ruleId}") @RequirePermission(menu = "REINSTATE_COMM", perm = "UPDATE") @DataChangeLog(menu = "REINSTATE_COMM", table = "reinstatement_commission_rule") @@ -48,6 +61,7 @@ public class ReinstatementCommissionController { return ApiResponse.ok(); } + /** 부활수수료 규칙 삭제. 권한: REINSTATE_COMM DELETE. */ @DeleteMapping("/rules/{ruleId}") @RequirePermission(menu = "REINSTATE_COMM", perm = "DELETE") @DataChangeLog(menu = "REINSTATE_COMM", table = "reinstatement_commission_rule") @@ -56,6 +70,7 @@ public class ReinstatementCommissionController { return ApiResponse.ok(); } + /** 부활수수료 원장 목록 조회. 권한: REINSTATE_COMM READ. */ @GetMapping("/ledgers") @RequirePermission(menu = "REINSTATE_COMM", perm = "READ") public ApiResponse> listLedgers( @@ -63,12 +78,14 @@ public class ReinstatementCommissionController { return ApiResponse.ok(service.listLedgers(param)); } + /** 부활수수료 원장 단건 상세. 권한: REINSTATE_COMM READ. */ @GetMapping("/ledgers/{ledgerId}") @RequirePermission(menu = "REINSTATE_COMM", perm = "READ") public ApiResponse detailLedger(@PathVariable Long ledgerId) { return ApiResponse.ok(service.detailLedger(ledgerId)); } + /** 부활수수료 원장 신규 등록. 응답: ledgerId. 권한: REINSTATE_COMM CREATE. */ @PostMapping("/ledgers") @RequirePermission(menu = "REINSTATE_COMM", perm = "CREATE") @DataChangeLog(menu = "REINSTATE_COMM", table = "reinstatement_commission_ledger") diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/RenewalCommissionController.java b/ga-api/src/main/java/com/ga/api/controller/commission/RenewalCommissionController.java index 781c970..78a025a 100644 --- a/ga-api/src/main/java/com/ga/api/controller/commission/RenewalCommissionController.java +++ b/ga-api/src/main/java/com/ga/api/controller/commission/RenewalCommissionController.java @@ -11,6 +11,15 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 갱신수수료 API. + * + * 갱신(renewal): 보험 기간이 끝난 계약을 같은 조건으로 다시 이어가는 것(예: 1년 갱신형 보험). + * 갱신수수료: 계약이 갱신될 때 그 유지·관리 대가로 설계사에게 지급하는 수수료. + * - Rule(갱신수수료 규칙) CRUD - Ledger(갱신수수료 원장) 조회/등록. + * + * 권한: @RequirePermission(menu="RENEWAL_COMM"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 기록. + */ @Tag(name = "갱신수수료") @RestController @RequestMapping("/api/renewal-commissions") @@ -19,6 +28,7 @@ public class RenewalCommissionController { private final RenewalCommissionService service; + /** 갱신수수료 규칙 목록 조회. 권한: RENEWAL_COMM READ. */ @GetMapping("/rules") @RequirePermission(menu = "RENEWAL_COMM", perm = "READ") public ApiResponse> listRules( @@ -26,12 +36,14 @@ public class RenewalCommissionController { return ApiResponse.ok(service.listRules(param)); } + /** 갱신수수료 규칙 단건 상세. 권한: RENEWAL_COMM READ. */ @GetMapping("/rules/{ruleId}") @RequirePermission(menu = "RENEWAL_COMM", perm = "READ") public ApiResponse detailRule(@PathVariable Long ruleId) { return ApiResponse.ok(service.detailRule(ruleId)); } + /** 갱신수수료 규칙 신규 등록. 응답: ruleId. 권한: RENEWAL_COMM CREATE. */ @PostMapping("/rules") @RequirePermission(menu = "RENEWAL_COMM", perm = "CREATE") @DataChangeLog(menu = "RENEWAL_COMM", table = "renewal_commission_rule") @@ -39,6 +51,7 @@ public class RenewalCommissionController { return ApiResponse.ok(service.createRule(req)); } + /** 갱신수수료 규칙 수정. 권한: RENEWAL_COMM UPDATE. */ @PutMapping("/rules/{ruleId}") @RequirePermission(menu = "RENEWAL_COMM", perm = "UPDATE") @DataChangeLog(menu = "RENEWAL_COMM", table = "renewal_commission_rule") @@ -48,6 +61,7 @@ public class RenewalCommissionController { return ApiResponse.ok(); } + /** 갱신수수료 규칙 삭제. 권한: RENEWAL_COMM DELETE. */ @DeleteMapping("/rules/{ruleId}") @RequirePermission(menu = "RENEWAL_COMM", perm = "DELETE") @DataChangeLog(menu = "RENEWAL_COMM", table = "renewal_commission_rule") @@ -56,6 +70,7 @@ public class RenewalCommissionController { return ApiResponse.ok(); } + /** 갱신수수료 원장 목록 조회. 권한: RENEWAL_COMM READ. */ @GetMapping("/ledgers") @RequirePermission(menu = "RENEWAL_COMM", perm = "READ") public ApiResponse> listLedgers( @@ -63,12 +78,14 @@ public class RenewalCommissionController { return ApiResponse.ok(service.listLedgers(param)); } + /** 갱신수수료 원장 단건 상세. 권한: RENEWAL_COMM READ. */ @GetMapping("/ledgers/{ledgerId}") @RequirePermission(menu = "RENEWAL_COMM", perm = "READ") public ApiResponse detailLedger(@PathVariable Long ledgerId) { return ApiResponse.ok(service.detailLedger(ledgerId)); } + /** 갱신수수료 원장 신규 등록. 응답: ledgerId. 권한: RENEWAL_COMM CREATE. */ @PostMapping("/ledgers") @RequirePermission(menu = "RENEWAL_COMM", perm = "CREATE") @DataChangeLog(menu = "RENEWAL_COMM", table = "renewal_commission_ledger") diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/ReserveFundController.java b/ga-api/src/main/java/com/ga/api/controller/commission/ReserveFundController.java index ceb67b6..e428542 100644 --- a/ga-api/src/main/java/com/ga/api/controller/commission/ReserveFundController.java +++ b/ga-api/src/main/java/com/ga/api/controller/commission/ReserveFundController.java @@ -13,6 +13,14 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 적립금 API. + * + * 적립금(reserve fund): 향후 발생할 수 있는 환수 등에 대비해, 설계사 수수료 중 일부를 떼어 모아 두는 돈. + * 환수가 생기면 이 적립금에서 우선 충당할 수 있다. 설계사별 적립 내역을 관리한다. + * + * 권한: @RequirePermission(menu="RESERVE_FUND"), perm=READ/CREATE. 변경은 @DataChangeLog 기록. + */ @Tag(name = "적립금") @RestController @RequestMapping("/api/reserve-funds") @@ -21,18 +29,21 @@ public class ReserveFundController { private final ReserveFundService service; + /** 적립금 내역 목록 조회. 권한: RESERVE_FUND READ. */ @GetMapping @RequirePermission(menu = "RESERVE_FUND", perm = "READ") public ApiResponse> list(@ModelAttribute AgentReserveFundSearchParam param) { return ApiResponse.ok(service.list(param)); } + /** 적립금 단건 상세. 권한: RESERVE_FUND READ. */ @GetMapping("/{reserveId}") @RequirePermission(menu = "RESERVE_FUND", perm = "READ") public ApiResponse detail(@PathVariable Long reserveId) { return ApiResponse.ok(service.detail(reserveId)); } + /** 적립금 내역 신규 등록(적립/사용 기록). 응답: reserveId. 권한: RESERVE_FUND CREATE. */ @PostMapping @RequirePermission(menu = "RESERVE_FUND", perm = "CREATE") @DataChangeLog(menu = "RESERVE_FUND", table = "agent_reserve_fund") diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/SettlingSupportController.java b/ga-api/src/main/java/com/ga/api/controller/commission/SettlingSupportController.java index dea0a89..00fcf00 100644 --- a/ga-api/src/main/java/com/ga/api/controller/commission/SettlingSupportController.java +++ b/ga-api/src/main/java/com/ga/api/controller/commission/SettlingSupportController.java @@ -11,6 +11,15 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 정착지원금 API. + * + * 정착지원금(settling support): 새로 들어온 설계사가 안정적으로 자리 잡을 때까지 일정 기간 지급하는 지원금. + * 보통 실적이 충분치 않은 초기 기간 동안 최소 수입을 보전해 준다(조건 미달 시 환수될 수도 있음). + * - Rule(지원금 규칙) CRUD - Ledger(지원금 원장) 조회/등록. + * + * 권한: @RequirePermission(menu="SETTLING_SUPPORT"), perm=READ/CREATE/UPDATE/DELETE. 변경은 @DataChangeLog 기록. + */ @Tag(name = "정착지원금") @RestController @RequestMapping("/api/settling-supports") @@ -19,8 +28,9 @@ public class SettlingSupportController { private final SettlingSupportService service; - // ── Rule ────────────────────────────────────────────────────────────────── + // ── Rule (정착지원금 규칙) ────────────────────────────────────────────── + /** 정착지원금 규칙 목록 조회. 권한: SETTLING_SUPPORT READ. */ @GetMapping("/rules") @RequirePermission(menu = "SETTLING_SUPPORT", perm = "READ") public ApiResponse> listRules( @@ -28,12 +38,14 @@ public class SettlingSupportController { return ApiResponse.ok(service.listRules(param)); } + /** 정착지원금 규칙 단건 상세. 권한: SETTLING_SUPPORT READ. */ @GetMapping("/rules/{ruleId}") @RequirePermission(menu = "SETTLING_SUPPORT", perm = "READ") public ApiResponse detailRule(@PathVariable Long ruleId) { return ApiResponse.ok(service.detailRule(ruleId)); } + /** 정착지원금 규칙 신규 등록. 응답: ruleId. 권한: SETTLING_SUPPORT CREATE. */ @PostMapping("/rules") @RequirePermission(menu = "SETTLING_SUPPORT", perm = "CREATE") @DataChangeLog(menu = "SETTLING_SUPPORT", table = "settling_support_rule") @@ -41,6 +53,7 @@ public class SettlingSupportController { return ApiResponse.ok(service.createRule(req)); } + /** 정착지원금 규칙 수정. 권한: SETTLING_SUPPORT UPDATE. */ @PutMapping("/rules/{ruleId}") @RequirePermission(menu = "SETTLING_SUPPORT", perm = "UPDATE") @DataChangeLog(menu = "SETTLING_SUPPORT", table = "settling_support_rule") @@ -50,6 +63,7 @@ public class SettlingSupportController { return ApiResponse.ok(); } + /** 정착지원금 규칙 삭제. 권한: SETTLING_SUPPORT DELETE. */ @DeleteMapping("/rules/{ruleId}") @RequirePermission(menu = "SETTLING_SUPPORT", perm = "DELETE") @DataChangeLog(menu = "SETTLING_SUPPORT", table = "settling_support_rule") @@ -58,8 +72,9 @@ public class SettlingSupportController { return ApiResponse.ok(); } - // ── Ledger ──────────────────────────────────────────────────────────────── + // ── Ledger (정착지원금 원장) ──────────────────────────────────────────── + /** 정착지원금 원장 목록 조회. 권한: SETTLING_SUPPORT READ. */ @GetMapping("/ledgers") @RequirePermission(menu = "SETTLING_SUPPORT", perm = "READ") public ApiResponse> listLedgers( @@ -67,12 +82,14 @@ public class SettlingSupportController { return ApiResponse.ok(service.listLedgers(param)); } + /** 정착지원금 원장 단건 상세. 권한: SETTLING_SUPPORT READ. */ @GetMapping("/ledgers/{ledgerId}") @RequirePermission(menu = "SETTLING_SUPPORT", perm = "READ") public ApiResponse detailLedger(@PathVariable Long ledgerId) { return ApiResponse.ok(service.detailLedger(ledgerId)); } + /** 정착지원금 원장 신규 등록. 응답: ledgerId. 권한: SETTLING_SUPPORT CREATE. */ @PostMapping("/ledgers") @RequirePermission(menu = "SETTLING_SUPPORT", perm = "CREATE") @DataChangeLog(menu = "SETTLING_SUPPORT", table = "settling_support_ledger") diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/StatuteController.java b/ga-api/src/main/java/com/ga/api/controller/commission/StatuteController.java index 0d319d9..29e5e07 100644 --- a/ga-api/src/main/java/com/ga/api/controller/commission/StatuteController.java +++ b/ga-api/src/main/java/com/ga/api/controller/commission/StatuteController.java @@ -15,6 +15,14 @@ import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; +/** + * 환수시효관리 API. + * + * 시효(statute of limitations): 환수채권을 받아낼 수 있는 법적 기한. 기한이 지나면(시효 만료) 더 이상 회수할 수 없어 + * 손실(대손)로 처리해야 한다. 이 도메인은 시효 기준 설정(config)과 만료 건 일괄 정리를 담당한다. + * + * 권한: @RequirePermission(menu="STATUTE_CONFIG"), perm=READ/CREATE/UPDATE. 변경은 @DataChangeLog 기록. + */ @Tag(name = "환수시효관리") @RestController @RequestMapping("/api/statute") @@ -23,18 +31,21 @@ public class StatuteController { private final StatuteService service; + /** 시효 설정 전체 목록 조회. 권한: STATUTE_CONFIG READ. */ @GetMapping @RequirePermission(menu = "STATUTE_CONFIG", perm = "READ") public ApiResponse> list() { return ApiResponse.ok(service.listConfigs()); } + /** 시효 설정 단건 상세. 권한: STATUTE_CONFIG READ. */ @GetMapping("/{configId}") @RequirePermission(menu = "STATUTE_CONFIG", perm = "READ") public ApiResponse detail(@PathVariable Long configId) { return ApiResponse.ok(service.detailConfig(configId)); } + /** 시효 설정 신규 등록. 응답: configId. 권한: STATUTE_CONFIG CREATE. */ @PostMapping @RequirePermission(menu = "STATUTE_CONFIG", perm = "CREATE") @DataChangeLog(menu = "STATUTE_CONFIG", table = "statute_config") @@ -42,6 +53,7 @@ public class StatuteController { return ApiResponse.ok(service.createConfig(req)); } + /** 시효 설정 수정. 권한: STATUTE_CONFIG UPDATE. */ @PutMapping("/{configId}") @RequirePermission(menu = "STATUTE_CONFIG", perm = "UPDATE") @DataChangeLog(menu = "STATUTE_CONFIG", table = "statute_config") @@ -52,13 +64,16 @@ public class StatuteController { } /** - * 만료 대상 환수채권 일괄 대손 처리. + * 만료 대상 환수채권 일괄 대손 처리. 기준월(baseMonth)까지 시효가 지난 환수채권을 한꺼번에 손실 처리하고 + * 처리 건수를 반환한다. baseMonth 미지정 시 현재 정산월을 사용한다. * Request body: { "baseMonth": "YYYYMM" } + * 권한: STATUTE_CONFIG UPDATE. */ @PostMapping("/expire") @RequirePermission(menu = "STATUTE_CONFIG", perm = "UPDATE") @DataChangeLog(menu = "STATUTE_CONFIG", table = "clawback_receivable") public ApiResponse expire(@RequestBody Map body) { + // baseMonth 가 비어 있으면 현재 정산월로 대체 String baseMonth = body.get("baseMonth"); if (baseMonth == null || baseMonth.isBlank()) { baseMonth = com.ga.common.util.DateUtil.getSettleMonth(); diff --git a/ga-api/src/main/java/com/ga/api/controller/commission/StepCloseController.java b/ga-api/src/main/java/com/ga/api/controller/commission/StepCloseController.java index 3b55822..e933fbd 100644 --- a/ga-api/src/main/java/com/ga/api/controller/commission/StepCloseController.java +++ b/ga-api/src/main/java/com/ga/api/controller/commission/StepCloseController.java @@ -15,6 +15,15 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +/** + * 단계마감 API. + * + * 단계마감(step close): 월 정산을 여러 단계로 나눠, 각 단계가 끝나면 "마감"해서 더 이상 수정 못 하도록 잠그는 것. + * 마감(close) 후 수정이 필요하면 다시 reopen(마감 해제)할 수 있다. 마감/해제는 승인(APPROVE) 권한이 필요하다. + * - close: 단계 마감(잠금) - reopen: 마감 해제(다시 열기) + * + * 권한: @RequirePermission(menu="STEP_CLOSE"), perm=READ/UPDATE/APPROVE. 변경은 @DataChangeLog 기록. + */ @Tag(name = "단계마감") @RestController @RequestMapping("/api/step-closes") @@ -23,24 +32,28 @@ public class StepCloseController { private final StepCloseService service; + /** 단계마감 목록 조회. 권한: STEP_CLOSE READ. */ @GetMapping @RequirePermission(menu = "STEP_CLOSE", perm = "READ") public ApiResponse> list(@ModelAttribute StepCloseSearchParam param) { return ApiResponse.ok(service.list(param)); } + /** 특정 정산월(settleMonth)의 단계마감 현황 조회. 권한: STEP_CLOSE READ. */ @GetMapping("/by-month") @RequirePermission(menu = "STEP_CLOSE", perm = "READ") public ApiResponse> listByMonth(@RequestParam String settleMonth) { return ApiResponse.ok(service.listByMonth(settleMonth)); } + /** 단계마감 단건 상세. 권한: STEP_CLOSE READ. */ @GetMapping("/{stepCloseId}") @RequirePermission(menu = "STEP_CLOSE", perm = "READ") public ApiResponse detail(@PathVariable Long stepCloseId) { return ApiResponse.ok(service.detail(stepCloseId)); } + /** 단계마감 항목 신규 생성(아직 미마감 상태). 응답: stepCloseId. 권한: STEP_CLOSE UPDATE. */ @PostMapping @RequirePermission(menu = "STEP_CLOSE", perm = "UPDATE") @DataChangeLog(menu = "STEP_CLOSE", table = "settlement_step_close") @@ -48,6 +61,7 @@ public class StepCloseController { return ApiResponse.ok(service.create(req)); } + /** 단계 마감 확정(해당 단계 잠금). 승인 권한 필요. 권한: STEP_CLOSE APPROVE. */ @PostMapping("/{stepCloseId}/close") @RequirePermission(menu = "STEP_CLOSE", perm = "APPROVE") @DataChangeLog(menu = "STEP_CLOSE", table = "settlement_step_close") @@ -56,6 +70,7 @@ public class StepCloseController { return ApiResponse.ok(); } + /** 단계 마감 해제(다시 수정 가능 상태로). 승인 권한 필요. 권한: STEP_CLOSE APPROVE. */ @PostMapping("/{stepCloseId}/reopen") @RequirePermission(menu = "STEP_CLOSE", perm = "APPROVE") @DataChangeLog(menu = "STEP_CLOSE", table = "settlement_step_close") diff --git a/ga-api/src/main/java/com/ga/api/controller/complaint/ComplaintController.java b/ga-api/src/main/java/com/ga/api/controller/complaint/ComplaintController.java index ffff2f4..8f8201e 100644 --- a/ga-api/src/main/java/com/ga/api/controller/complaint/ComplaintController.java +++ b/ga-api/src/main/java/com/ga/api/controller/complaint/ComplaintController.java @@ -15,6 +15,15 @@ import org.springframework.web.bind.annotation.*; import java.util.Map; +/** + * 민원 관리 API. + * + *

고객 민원을 접수·처리하는 도메인이다. 민원은 특정 보험계약(contractId)·담당 설계사(agentId)와 연결되며, + * 상태는 OPEN(접수) → CLOSED(처리완료)로 전이된다. 환수 유발(chargebackTriggered=true) 민원은 + * 처리 후 정산 배치(P5)가 실제 수수료 환수(chargeback)를 생성한다(여기서는 플래그만 관리).

+ * + *

메뉴 COMPLAINT 권한으로 보호된다.

+ */ @Tag(name = "민원 관리") @RestController @RequestMapping("/api/complaints") @@ -23,18 +32,21 @@ public class ComplaintController { private final ComplaintService service; + /** 민원 목록 조회(페이징). READ 권한 필요. */ @GetMapping @RequirePermission(menu = "COMPLAINT", perm = "READ") public ApiResponse> list(@ModelAttribute ComplaintSearchParam param) { return ApiResponse.ok(service.list(param)); } + /** 민원 단건 상세 조회. READ 권한 필요. */ @GetMapping("/{complaintId}") @RequirePermission(menu = "COMPLAINT", perm = "READ") public ApiResponse detail(@PathVariable Long complaintId) { return ApiResponse.ok(service.detail(complaintId)); } + /** 민원 신규 접수. 등록 시 OPEN 상태로 시작한다. CREATE 권한 필요. */ @PostMapping @RequirePermission(menu = "COMPLAINT", perm = "CREATE") @DataChangeLog(menu = "COMPLAINT", table = "complaint") @@ -42,6 +54,7 @@ public class ComplaintController { return ApiResponse.ok(service.create(req)); } + /** 민원 내용 수정. 상태는 변경하지 않는다(처리완료는 별도 엔드포인트). UPDATE 권한 필요. */ @PutMapping("/{complaintId}") @RequirePermission(menu = "COMPLAINT", perm = "UPDATE") @DataChangeLog(menu = "COMPLAINT", table = "complaint") @@ -51,6 +64,7 @@ public class ComplaintController { return ApiResponse.ok(); } + /** 민원 처리완료(status=CLOSED). 처리결과(resolution)를 기록하며 이미 완료된 건은 거부된다. UPDATE 권한 필요. */ @PostMapping("/{complaintId}/close") @RequirePermission(menu = "COMPLAINT", perm = "UPDATE") @DataChangeLog(menu = "COMPLAINT", table = "complaint") @@ -60,6 +74,7 @@ public class ComplaintController { return ApiResponse.ok(); } + /** 민원 레코드 삭제. DELETE 권한 필요. */ @DeleteMapping("/{complaintId}") @RequirePermission(menu = "COMPLAINT", perm = "DELETE") @DataChangeLog(menu = "COMPLAINT", table = "complaint") diff --git a/ga-api/src/main/java/com/ga/api/controller/contract/AgentContractController.java b/ga-api/src/main/java/com/ga/api/controller/contract/AgentContractController.java index f42ffe2..9f81d5c 100644 --- a/ga-api/src/main/java/com/ga/api/controller/contract/AgentContractController.java +++ b/ga-api/src/main/java/com/ga/api/controller/contract/AgentContractController.java @@ -15,6 +15,16 @@ import org.springframework.web.bind.annotation.*; import java.util.Map; +/** + * 위촉계약 관리 API. + * + *

위촉계약(agent_contract)은 설계사와 보험사(carrier) 사이의 모집 위탁 계약이다. + * 어떤 설계사가 어떤 보험사의 상품을 팔 수 있는지를 정의하며, 유효기간(effectiveFrom~To)과 + * 정착지원금(onboardingBonusAmount) 등을 포함한다. 상태는 ACTIVE(유효)에서 TERMINATED(해촉)로 전이된다.

+ * + *

주의: 여기서 말하는 계약은 "설계사 위촉계약"이며, 고객이 가입한 "보험계약"과는 다르다. + * 메뉴 AGENT_CONTRACT 권한으로 보호된다.

+ */ @Tag(name = "위촉계약 관리") @RestController @RequestMapping("/api/agent-contracts") @@ -23,18 +33,21 @@ public class AgentContractController { private final AgentContractService service; + /** 위촉계약 목록 조회(페이징). READ 권한 필요. */ @GetMapping @RequirePermission(menu = "AGENT_CONTRACT", perm = "READ") public ApiResponse> list(@ModelAttribute AgentContractSearchParam param) { return ApiResponse.ok(service.list(param)); } + /** 위촉계약 단건 상세 조회. READ 권한 필요. */ @GetMapping("/{contractId}") @RequirePermission(menu = "AGENT_CONTRACT", perm = "READ") public ApiResponse detail(@PathVariable Long contractId) { return ApiResponse.ok(service.detail(contractId)); } + /** 위촉계약 신규 등록. 등록 시 ACTIVE 상태로 시작한다. CREATE 권한 필요. */ @PostMapping @RequirePermission(menu = "AGENT_CONTRACT", perm = "CREATE") @DataChangeLog(menu = "AGENT_CONTRACT", table = "agent_contract") @@ -42,6 +55,7 @@ public class AgentContractController { return ApiResponse.ok(service.create(req)); } + /** 위촉계약 수정. 상태는 변경하지 않는다(해촉은 별도 엔드포인트). UPDATE 권한 필요. */ @PutMapping("/{contractId}") @RequirePermission(menu = "AGENT_CONTRACT", perm = "UPDATE") @DataChangeLog(menu = "AGENT_CONTRACT", table = "agent_contract") @@ -51,6 +65,7 @@ public class AgentContractController { return ApiResponse.ok(); } + /** 위촉계약 해촉(status=TERMINATED). 사유(reason)를 함께 기록하며 이미 해촉된 건은 거부된다. UPDATE 권한 필요. */ @PostMapping("/{contractId}/terminate") @RequirePermission(menu = "AGENT_CONTRACT", perm = "UPDATE") @DataChangeLog(menu = "AGENT_CONTRACT", table = "agent_contract") @@ -60,6 +75,7 @@ public class AgentContractController { return ApiResponse.ok(); } + /** 위촉계약 레코드 삭제. DELETE 권한 필요. */ @DeleteMapping("/{contractId}") @RequirePermission(menu = "AGENT_CONTRACT", perm = "DELETE") @DataChangeLog(menu = "AGENT_CONTRACT", table = "agent_contract") diff --git a/ga-api/src/main/java/com/ga/api/controller/deduction/DeductionController.java b/ga-api/src/main/java/com/ga/api/controller/deduction/DeductionController.java index 5e22d37..8aa38b1 100644 --- a/ga-api/src/main/java/com/ga/api/controller/deduction/DeductionController.java +++ b/ga-api/src/main/java/com/ga/api/controller/deduction/DeductionController.java @@ -13,6 +13,18 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 공제/차감(agent_deduction) 관리 컨트롤러. + * + *

"차감"은 설계사에게 줄 수수료에서 미리 빼야 할 금액(예: 대출 상환, 가지급금 회수 등)을 등록해 두는 것입니다. + * 여기서 PENDING(대기) 상태로 등록해 두면, 지급 처리 단계에서 실지급액(netAmount)에서 자동으로 차감됩니다. + * + *

상태 흐름(DeductionStatus): PENDING(대기) → APPLIED(적용완료), 또는 CANCELLED(취소). + * 수정·취소는 아직 적용(APPLIED)되지 않은 PENDING 건만 가능합니다. + * 같은 설계사+정산월+차감유형 조합은 중복 등록이 거부됩니다. + * + *

권한(menu=DEDUCTION): 조회 READ, 등록 CREATE, 수정/취소 UPDATE. + */ @Tag(name = "공제 관리") @RestController @RequestMapping("/api/deductions") @@ -21,18 +33,21 @@ public class DeductionController { private final DeductionService deductionService; + /** 차감 목록 조회(검색 + 페이징). */ @GetMapping @RequirePermission(menu = "DEDUCTION", perm = "READ") public ApiResponse> list(@ModelAttribute AgentDeductionSearchParam param) { return ApiResponse.ok(deductionService.list(param)); } + /** 차감 단건 상세 조회. */ @GetMapping("/{deductionId}") @RequirePermission(menu = "DEDUCTION", perm = "READ") public ApiResponse detail(@PathVariable Long deductionId) { return ApiResponse.ok(deductionService.detail(deductionId)); } + /** 차감 등록(PENDING 상태). 동일 설계사+정산월+차감유형 중복은 거부됩니다. */ @PostMapping @RequirePermission(menu = "DEDUCTION", perm = "CREATE") @DataChangeLog(menu = "DEDUCTION", table = "agent_deduction") @@ -40,6 +55,7 @@ public class DeductionController { return ApiResponse.ok(deductionService.create(req)); } + /** 차감 금액·설명 수정. PENDING 상태에서만 가능합니다. */ @PutMapping("/{deductionId}") @RequirePermission(menu = "DEDUCTION", perm = "UPDATE") @DataChangeLog(menu = "DEDUCTION", table = "agent_deduction") @@ -49,6 +65,7 @@ public class DeductionController { return ApiResponse.ok(); } + /** 차감 취소(→ CANCELLED). 이미 적용(APPLIED)된 차감은 취소할 수 없습니다. */ @PutMapping("/{deductionId}/cancel") @RequirePermission(menu = "DEDUCTION", perm = "UPDATE") @DataChangeLog(menu = "DEDUCTION", table = "agent_deduction") diff --git a/ga-api/src/main/java/com/ga/api/controller/dict/DataDictionaryController.java b/ga-api/src/main/java/com/ga/api/controller/dict/DataDictionaryController.java index e236aef..06ecd64 100644 --- a/ga-api/src/main/java/com/ga/api/controller/dict/DataDictionaryController.java +++ b/ga-api/src/main/java/com/ga/api/controller/dict/DataDictionaryController.java @@ -16,6 +16,13 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +/** + * 데이터 사전(Data Dictionary) 관리 API. + * + *

DB 테이블/컬럼의 의미·도메인 코드 매핑 등을 문서화해 관리하는 메타데이터 도구다. + * 각 항목(dictId)은 특정 테이블의 컬럼을 설명하며, 도메인 코드(domainCode)와 연결될 수 있다. + * 메뉴 SYSTEM_DATA_DICT 권한으로 보호된다.

+ */ @Tag(name = "데이터 사전") @RestController @RequestMapping("/api/data-dictionary") @@ -24,18 +31,21 @@ public class DataDictionaryController { private final DataDictionaryService dataDictionaryService; + /** 데이터 사전 항목 목록 조회(페이징). READ 권한 필요. */ @GetMapping @RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "READ") public ApiResponse> list(@ModelAttribute DataDictionarySearchParam param) { return ApiResponse.ok(dataDictionaryService.list(param)); } + /** 사전에 등록된 테이블명 목록 조회. READ 권한 필요. */ @GetMapping("/tables") @RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "READ") public ApiResponse> tables() { return ApiResponse.ok(dataDictionaryService.tables()); } + /** 사전 뷰 기반 통합 조회(테이블명 선택 필터). 컬럼+도메인 결합 정보를 반환한다. READ 권한 필요. */ @GetMapping("/full") @RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "READ") public ApiResponse> full( @@ -43,12 +53,14 @@ public class DataDictionaryController { return ApiResponse.ok(dataDictionaryService.full(tableName)); } + /** 데이터 사전 항목 단건 조회. READ 권한 필요. */ @GetMapping("/{dictId}") @RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "READ") public ApiResponse detail(@PathVariable Long dictId) { return ApiResponse.ok(dataDictionaryService.detail(dictId)); } + /** 데이터 사전 항목 등록. 도메인 코드를 지정하면 실존 여부를 검증한다. CREATE 권한 필요. */ @PostMapping @RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "CREATE") @DataChangeLog(menu = "SYSTEM_DATA_DICT", table = "data_dictionary") @@ -57,6 +69,7 @@ public class DataDictionaryController { return ApiResponse.ok(); } + /** 데이터 사전 항목 수정. UPDATE 권한 필요. */ @PutMapping("/{dictId}") @RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "UPDATE") @DataChangeLog(menu = "SYSTEM_DATA_DICT", table = "data_dictionary") @@ -66,6 +79,7 @@ public class DataDictionaryController { return ApiResponse.ok(); } + /** 데이터 사전 항목 삭제. DELETE 권한 필요. */ @DeleteMapping("/{dictId}") @RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "DELETE") @DataChangeLog(menu = "SYSTEM_DATA_DICT", table = "data_dictionary") diff --git a/ga-api/src/main/java/com/ga/api/controller/dict/DataDomainController.java b/ga-api/src/main/java/com/ga/api/controller/dict/DataDomainController.java index ef504c4..db6f304 100644 --- a/ga-api/src/main/java/com/ga/api/controller/dict/DataDomainController.java +++ b/ga-api/src/main/java/com/ga/api/controller/dict/DataDomainController.java @@ -16,6 +16,13 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +/** + * 데이터 도메인 사전 관리 API. + * + *

도메인(domain)은 컬럼들이 공통으로 따르는 데이터 타입/형식 규칙의 정의다(예: 금액형, 날짜형 등). + * 데이터 사전(data_dictionary)의 컬럼들이 이 도메인 코드를 참조한다. 식별자는 문자열 도메인 코드(domainCode)다. + * 메뉴 SYSTEM_DATA_DOMAIN 권한으로 보호된다.

+ */ @Tag(name = "데이터 도메인 사전") @RestController @RequestMapping("/api/data-domains") @@ -24,24 +31,28 @@ public class DataDomainController { private final DataDomainService dataDomainService; + /** 도메인 목록 조회(페이징). READ 권한 필요. */ @GetMapping @RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "READ") public ApiResponse> list(@ModelAttribute DataDomainSearchParam param) { return ApiResponse.ok(dataDomainService.list(param)); } + /** 선택 옵션용 도메인 전체 목록(코드/명칭 등 경량 정보). READ 권한 필요. */ @GetMapping("/options") @RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "READ") public ApiResponse> options() { return ApiResponse.ok(dataDomainService.options()); } + /** 도메인 단건 상세 조회(도메인 코드 기준). READ 권한 필요. */ @GetMapping("/{domainCode}") @RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "READ") public ApiResponse detail(@PathVariable String domainCode) { return ApiResponse.ok(dataDomainService.detail(domainCode)); } + /** 도메인 등록. 코드 중복이면 거부된다. CREATE 권한 필요. */ @PostMapping @RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "CREATE") @DataChangeLog(menu = "SYSTEM_DATA_DOMAIN", table = "data_domain") @@ -50,6 +61,7 @@ public class DataDomainController { return ApiResponse.ok(); } + /** 도메인 수정. UPDATE 권한 필요. */ @PutMapping("/{domainCode}") @RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "UPDATE") @DataChangeLog(menu = "SYSTEM_DATA_DOMAIN", table = "data_domain") @@ -59,6 +71,7 @@ public class DataDomainController { return ApiResponse.ok(); } + /** 도메인 삭제. 이 도메인을 참조하는 컬럼이 있으면 삭제 불가(서비스에서 검증). DELETE 권한 필요. */ @DeleteMapping("/{domainCode}") @RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "DELETE") @DataChangeLog(menu = "SYSTEM_DATA_DOMAIN", table = "data_domain") diff --git a/ga-api/src/main/java/com/ga/api/controller/endorse/ContractEndorsementController.java b/ga-api/src/main/java/com/ga/api/controller/endorse/ContractEndorsementController.java index ec9b783..3ee85b9 100644 --- a/ga-api/src/main/java/com/ga/api/controller/endorse/ContractEndorsementController.java +++ b/ga-api/src/main/java/com/ga/api/controller/endorse/ContractEndorsementController.java @@ -13,6 +13,15 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 계약 변경(배서, Endorsement) 관리 API. + * + *

배서란 이미 체결된 계약의 내용을 바꾸는 것을 말한다(예: 보험료 증감, 피보험자/수익자 변경, 담보 추가 등). + * 변경 유형(endorsementType)과 변경 전/후 값(oldValue/newValue)을 기록하며, + * 보험료가 바뀌어 수수료 재계산이 필요한 경우(requiresCommissionRecalc) 정산 배치가 이를 반영한다.

+ * + *

메뉴 ENDORSE 권한으로 보호된다.

+ */ @Tag(name = "계약 변경") @RestController @RequestMapping("/api/contract-endorsements") @@ -21,6 +30,7 @@ public class ContractEndorsementController { private final ContractEndorsementService service; + /** 배서 목록 조회(페이징). READ 권한 필요. */ @GetMapping @RequirePermission(menu = "ENDORSE", perm = "READ") public ApiResponse> list( @@ -28,12 +38,14 @@ public class ContractEndorsementController { return ApiResponse.ok(service.list(param)); } + /** 배서 단건 상세 조회. READ 권한 필요. */ @GetMapping("/{endorsementId}") @RequirePermission(menu = "ENDORSE", perm = "READ") public ApiResponse detail(@PathVariable Long endorsementId) { return ApiResponse.ok(service.detail(endorsementId)); } + /** 배서 등록. 등록 직후엔 미처리(processed=false) 상태다. CREATE 권한 필요. */ @PostMapping @RequirePermission(menu = "ENDORSE", perm = "CREATE") @DataChangeLog(menu = "ENDORSE", table = "contract_endorsement") @@ -41,6 +53,7 @@ public class ContractEndorsementController { return ApiResponse.ok(service.create(req)); } + /** 배서 처리 완료 표시(processed=true). 실제 수수료 재계산은 배치(P5)가 담당한다. UPDATE 권한 필요. */ @PostMapping("/{endorsementId}/process") @RequirePermission(menu = "ENDORSE", perm = "UPDATE") @DataChangeLog(menu = "ENDORSE", table = "contract_endorsement") @@ -49,6 +62,7 @@ public class ContractEndorsementController { return ApiResponse.ok(); } + /** 배서 레코드 삭제. DELETE 권한 필요. */ @DeleteMapping("/{endorsementId}") @RequirePermission(menu = "ENDORSE", perm = "DELETE") @DataChangeLog(menu = "ENDORSE", table = "contract_endorsement") diff --git a/ga-api/src/main/java/com/ga/api/controller/external/ExternalCallLogController.java b/ga-api/src/main/java/com/ga/api/controller/external/ExternalCallLogController.java index ec1c7db..cf0693d 100644 --- a/ga-api/src/main/java/com/ga/api/controller/external/ExternalCallLogController.java +++ b/ga-api/src/main/java/com/ga/api/controller/external/ExternalCallLogController.java @@ -10,6 +10,13 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 외부연동 호출 이력 조회 API. + * + *

SMS/알림톡, 펌뱅킹, 푸시 등 외부 시스템 호출 결과를 감사용으로 기록한 이력을 조회한다(읽기 전용). + * 이력 적재 자체는 AOP가 자동 수행하며, 여기서는 운영/장애 추적용 조회만 제공한다. + * 메뉴 SYSTEM_EXTERNAL_CALL 권한으로 보호된다.

+ */ @Tag(name = "외부연동 호출이력") @RestController @RequiredArgsConstructor @@ -17,12 +24,14 @@ public class ExternalCallLogController { private final ExternalCallLogService service; + /** 외부 호출 이력 목록 조회(페이징). READ 권한 필요. */ @GetMapping("/api/external-call-logs") @RequirePermission(menu = "SYSTEM_EXTERNAL_CALL", perm = "READ") public ApiResponse> list(@ModelAttribute ExternalCallLogSearchParam param) { return ApiResponse.ok(service.list(param)); } + /** 외부 호출 이력 단건 상세 조회. READ 권한 필요. */ @GetMapping("/api/external-call-logs/{logId}") @RequirePermission(menu = "SYSTEM_EXTERNAL_CALL", perm = "READ") public ApiResponse detail(@PathVariable Long logId) { diff --git a/ga-api/src/main/java/com/ga/api/controller/income/AgentAnnualIncomeController.java b/ga-api/src/main/java/com/ga/api/controller/income/AgentAnnualIncomeController.java index e86402a..3e0a996 100644 --- a/ga-api/src/main/java/com/ga/api/controller/income/AgentAnnualIncomeController.java +++ b/ga-api/src/main/java/com/ga/api/controller/income/AgentAnnualIncomeController.java @@ -12,6 +12,14 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * [연간 사업소득 API] + * + * 신입 개발자 안내: + * - 설계사별로 한 해(연도) 동안의 총 사업소득과 떼인 원천세 등을 합산해 보관하는 자료다(연말정산/지급명세서의 기초). + * - 월별 지급/정산 데이터를 연 단위로 집계(regenerate)하여 agent_annual_income 에 UPSERT 한다. + * - 상세는 (설계사ID, 정산연도) 조합으로 1건을 조회한다. + */ @Tag(name = "연간 사업소득") @RestController @RequestMapping("/api/agent-annual-incomes") @@ -37,6 +45,7 @@ public class AgentAnnualIncomeController { return ApiResponse.ok(service.detail(agentId, settleYear)); } + // 지정 연도의 모든 설계사 연간소득을 다시 집계(UPSERT). 월별 데이터가 바뀌었을 때 다시 돌린다. @PostMapping("/regenerate") @Operation(summary = "연도 전체 재집계") @RequirePermission(menu = "ANNUAL_INCOME", perm = "EXECUTE") diff --git a/ga-api/src/main/java/com/ga/api/controller/income/YearEndStatementController.java b/ga-api/src/main/java/com/ga/api/controller/income/YearEndStatementController.java index b4cc8b2..190049b 100644 --- a/ga-api/src/main/java/com/ga/api/controller/income/YearEndStatementController.java +++ b/ga-api/src/main/java/com/ga/api/controller/income/YearEndStatementController.java @@ -18,6 +18,15 @@ import org.springframework.web.bind.annotation.*; import java.util.Map; +/** + * [연말 지급명세서 API] + * + * 신입 개발자 안내: + * - 연말정산을 위해 "한 해 동안 이 설계사에게 얼마를 지급하고 세금을 얼마 뗐는지"를 국세청 제출용으로 정리한 명세서다. + * - 데이터 출처: 연간 사업소득(agent_annual_income) 집계 결과를 기반으로 한다. + * - 흐름: regenerate(연도별 UPSERT 생성) → generate(단건 파일 발급) → send(설계사에게 발송 처리) → export(엑셀 다운로드). + * - export 는 데이터가 많을 수 있어 Cursor 스트리밍(ExcelService.exportLargeExcel)으로 내려보낸다. + */ @Tag(name = "연말 지급명세서") @RestController @RequestMapping("/api/year-end-statements") @@ -68,6 +77,7 @@ public class YearEndStatementController { return ApiResponse.ok(); } + // 대용량 엑셀 다운로드: 조회 결과를 Cursor 로 스트리밍하며 한 번에 메모리에 올리지 않고 파일로 내려쓴다. @GetMapping("/export") @Operation(summary = "엑셀 다운로드") @RequirePermission(menu = "YEAR_END_STMT", perm = "EXPORT") diff --git a/ga-api/src/main/java/com/ga/api/controller/installment/GeneratePlanReq.java b/ga-api/src/main/java/com/ga/api/controller/installment/GeneratePlanReq.java index d4374eb..eaec3d8 100644 --- a/ga-api/src/main/java/com/ga/api/controller/installment/GeneratePlanReq.java +++ b/ga-api/src/main/java/com/ga/api/controller/installment/GeneratePlanReq.java @@ -7,18 +7,27 @@ import lombok.Data; import java.math.BigDecimal; import java.time.LocalDate; +/** + * 분급(分給) 계획 생성 요청 DTO. + * 분급이란 총수수료를 한 번에 주지 않고 여러 회차로 나눠 지급하는 것으로, + * 이 요청을 받아 회차별 지급 스케줄(installment_plan)을 자동 생성한다. + */ @Data public class GeneratePlanReq { + /** 분급 대상 계약 ID. */ @NotNull private Long contractId; + /** 나눠서 지급할 총수수료 금액(양수). 분급 비율에 따라 회차별로 쪼개진다. */ @NotNull @Positive private BigDecimal totalCommission; + /** 계약일. 회차별 지급 예정월 계산의 기준이 된다. */ @NotNull private LocalDate contractDate; + /** 상품 분류. 분류에 따라 적용할 분급 비율표(installment_ratio)가 달라진다. */ private String productCategory; } diff --git a/ga-api/src/main/java/com/ga/api/controller/installment/InstallmentController.java b/ga-api/src/main/java/com/ga/api/controller/installment/InstallmentController.java index 759b16a..50af696 100644 --- a/ga-api/src/main/java/com/ga/api/controller/installment/InstallmentController.java +++ b/ga-api/src/main/java/com/ga/api/controller/installment/InstallmentController.java @@ -15,6 +15,10 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +/** + * 분급(分給) 계획 관리 REST 컨트롤러. + * 총수수료를 여러 회차로 나눠 지급하는 분급 계획(installment_plan)의 조회·생성·지급/이연/취소를 처리한다. + */ @Tag(name = "분급 계획 관리") @RestController @RequestMapping("/api/installment-plans") @@ -23,18 +27,21 @@ public class InstallmentController { private final InstallmentService installmentService; + /** 분급 계획(회차) 목록 페이징 조회. */ @GetMapping @RequirePermission(menu = "INSTALLMENT_PLAN", perm = "READ") public ApiResponse> list(InstallmentPlanSearchParam param) { return ApiResponse.ok(installmentService.list(param)); } + /** 분급 계획 단건 상세 조회. */ @GetMapping("/{planId}") @RequirePermission(menu = "INSTALLMENT_PLAN", perm = "READ") public ApiResponse detail(@PathVariable Long planId) { return ApiResponse.ok(installmentService.detail(planId)); } + /** 분급 계획 생성. 총수수료를 분급 비율표에 따라 회차별 지급 스케줄로 자동 분해한다. */ @PostMapping("/generate") @RequirePermission(menu = "INSTALLMENT_PLAN", perm = "CREATE") @DataChangeLog(menu = "INSTALLMENT_PLAN", table = "installment_plan") @@ -48,6 +55,7 @@ public class InstallmentController { return ApiResponse.ok(); } + /** 특정 회차 지급 완료 처리(지급건 ID와 지급액 기록). */ @PutMapping("/{planId}/pay") @RequirePermission(menu = "INSTALLMENT_PLAN", perm = "UPDATE") @DataChangeLog(menu = "INSTALLMENT_PLAN", table = "installment_plan") @@ -57,6 +65,7 @@ public class InstallmentController { return ApiResponse.ok(); } + /** 특정 회차 지급 이연(다음 기회로 미룸) 처리. */ @PutMapping("/{planId}/defer") @RequirePermission(menu = "INSTALLMENT_PLAN", perm = "UPDATE") @DataChangeLog(menu = "INSTALLMENT_PLAN", table = "installment_plan") @@ -65,6 +74,7 @@ public class InstallmentController { return ApiResponse.ok(); } + /** 특정 회차 지급 취소(예: 계약 해지로 향후 분급 중단). */ @PutMapping("/{planId}/cancel") @RequirePermission(menu = "INSTALLMENT_PLAN", perm = "UPDATE") @DataChangeLog(menu = "INSTALLMENT_PLAN", table = "installment_plan") @@ -73,6 +83,7 @@ public class InstallmentController { return ApiResponse.ok(); } + /** 특정 정산월(settleMonth)에 지급 예정인 분급 회차 목록 조회. 월 정산 배치가 이 목록을 사용한다. */ @GetMapping("/scheduled") @RequirePermission(menu = "INSTALLMENT_PLAN", perm = "READ") public ApiResponse> scheduled(@RequestParam String settleMonth) { diff --git a/ga-api/src/main/java/com/ga/api/controller/installment/InstallmentRatioController.java b/ga-api/src/main/java/com/ga/api/controller/installment/InstallmentRatioController.java index 4bbd0dd..d93e3ae 100644 --- a/ga-api/src/main/java/com/ga/api/controller/installment/InstallmentRatioController.java +++ b/ga-api/src/main/java/com/ga/api/controller/installment/InstallmentRatioController.java @@ -13,6 +13,11 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 분급 비율(installment_ratio) 관리 REST 컨트롤러. + * 분급 비율표는 "총수수료를 몇 회차에 걸쳐, 각 회차 몇 %씩 지급할지"를 정의하는 기준표이며, + * 분급 계획(installment_plan) 생성 시 이 비율이 적용된다. + */ @Tag(name = "분급 비율 관리") @RestController @RequestMapping("/api/installment-ratios") @@ -21,12 +26,14 @@ public class InstallmentRatioController { private final InstallmentService installmentService; + /** 분급 비율표 목록 페이징 조회. */ @GetMapping @RequirePermission(menu = "INSTALLMENT_RATIO", perm = "READ") public ApiResponse> list(InstallmentRatioSearchParam param) { return ApiResponse.ok(installmentService.listRatios(param)); } + /** 분급 비율표 신규 등록. */ @PostMapping @RequirePermission(menu = "INSTALLMENT_RATIO", perm = "CREATE") @DataChangeLog(menu = "INSTALLMENT_RATIO", table = "installment_ratio") diff --git a/ga-api/src/main/java/com/ga/api/controller/installment/PayPlanReq.java b/ga-api/src/main/java/com/ga/api/controller/installment/PayPlanReq.java index c171980..9c58791 100644 --- a/ga-api/src/main/java/com/ga/api/controller/installment/PayPlanReq.java +++ b/ga-api/src/main/java/com/ga/api/controller/installment/PayPlanReq.java @@ -6,12 +6,18 @@ import lombok.Data; import java.math.BigDecimal; +/** + * 분급 회차 지급 처리 요청 DTO. + * 특정 회차(plan)를 실제로 지급 완료 처리할 때, 어떤 지급건(paymentId)으로 얼마(paidAmount)를 지급했는지 전달한다. + */ @Data public class PayPlanReq { + /** 실제 지급이 일어난 지급(payment) 레코드 ID. */ @NotNull private Long paymentId; + /** 이번 회차에 지급한 금액(양수). */ @NotNull @Positive private BigDecimal paidAmount; diff --git a/ga-api/src/main/java/com/ga/api/controller/internal/BankRetryController.java b/ga-api/src/main/java/com/ga/api/controller/internal/BankRetryController.java index 98284ee..7dc0f0a 100644 --- a/ga-api/src/main/java/com/ga/api/controller/internal/BankRetryController.java +++ b/ga-api/src/main/java/com/ga/api/controller/internal/BankRetryController.java @@ -21,6 +21,7 @@ public class BankRetryController { private final WithdrawBankRetryService bankRetryService; + /** 펌뱅킹 이체 실패(FAILED) 건을 즉시 1회 재시도하고 처리 건수를 반환한다. WITHDRAW EXECUTE 권한 필요. */ @PostMapping @RequirePermission(menu = "WITHDRAW", perm = "EXECUTE") public ApiResponse retry() { diff --git a/ga-api/src/main/java/com/ga/api/controller/internal/BankSettleController.java b/ga-api/src/main/java/com/ga/api/controller/internal/BankSettleController.java index 390717d..e66da18 100644 --- a/ga-api/src/main/java/com/ga/api/controller/internal/BankSettleController.java +++ b/ga-api/src/main/java/com/ga/api/controller/internal/BankSettleController.java @@ -21,6 +21,7 @@ public class BankSettleController { private final WithdrawBankSettleService bankSettleService; + /** 펌뱅킹 입금 확정(SETTLED) 상태를 즉시 1회 동기화하고 처리 건수를 반환한다. WITHDRAW EXECUTE 권한 필요. */ @PostMapping @RequirePermission(menu = "WITHDRAW", perm = "EXECUTE") public ApiResponse sync() { diff --git a/ga-api/src/main/java/com/ga/api/controller/kpi/ForecastKpiMonthlyController.java b/ga-api/src/main/java/com/ga/api/controller/kpi/ForecastKpiMonthlyController.java index 5c74445..4084bd8 100644 --- a/ga-api/src/main/java/com/ga/api/controller/kpi/ForecastKpiMonthlyController.java +++ b/ga-api/src/main/java/com/ga/api/controller/kpi/ForecastKpiMonthlyController.java @@ -14,6 +14,10 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 차월(다음 달) KPI 예측 REST 컨트롤러. + * 과거 실적 데이터를 이동평균(SMA) 등으로 분석해 다음 달 실적 KPI를 미리 예측·관리한다. + */ @Tag(name = "차월 KPI 예측") @RestController @RequestMapping("/api/forecast-kpi") @@ -22,6 +26,7 @@ public class ForecastKpiMonthlyController { private final ForecastKpiMonthlyService service; + /** 예측값 목록 페이징 조회. */ @GetMapping @Operation(summary = "예측 목록") @RequirePermission(menu = "FORECAST_KPI", perm = "READ") @@ -29,6 +34,7 @@ public class ForecastKpiMonthlyController { return ApiResponse.ok(service.list(p)); } + /** 차월 예측 재산출(실적 변동 시 다시 계산). 갱신된 행 수를 반환한다. */ @PostMapping("/regenerate") @Operation(summary = "차월 예측 재산출") @RequirePermission(menu = "FORECAST_KPI", perm = "EXECUTE") diff --git a/ga-api/src/main/java/com/ga/api/controller/kpi/KpiController.java b/ga-api/src/main/java/com/ga/api/controller/kpi/KpiController.java index 41c136b..f052c79 100644 --- a/ga-api/src/main/java/com/ga/api/controller/kpi/KpiController.java +++ b/ga-api/src/main/java/com/ga/api/controller/kpi/KpiController.java @@ -16,6 +16,14 @@ import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +/** + * KPI 대시보드 REST 컨트롤러. + * 실적 집계(KPI)를 여러 관점으로 조회하는 읽기 전용 화면용 API다. + * - monthly-summary : 월별 실적 요약 + * - org-summary : 조직(지점/본부)별 실적 요약 + * - retention : 계약 유지율 지표 + * - cumulative : 누적 실적 추이 + */ @Tag(name = "KPI 대시보드") @RestController @RequestMapping("/api/kpi") @@ -24,24 +32,28 @@ public class KpiController { private final KpiService service; + /** 월별 실적 요약 조회. */ @GetMapping("/monthly-summary") @RequirePermission(menu = "KPI_MONTHLY", perm = "READ") public ApiResponse> monthlySummary(@ModelAttribute KpiSearchParam param) { return ApiResponse.ok(service.monthlySummary(param)); } + /** 조직별 실적 요약 조회. */ @GetMapping("/org-summary") @RequirePermission(menu = "KPI_ORG", perm = "READ") public ApiResponse> orgSummary(@ModelAttribute KpiSearchParam param) { return ApiResponse.ok(service.orgSummary(param)); } + /** 계약 유지율 지표 조회. */ @GetMapping("/retention") @RequirePermission(menu = "KPI_RETENTION", perm = "READ") public ApiResponse> retention(@ModelAttribute KpiSearchParam param) { return ApiResponse.ok(service.retention(param)); } + /** 누적 실적 추이 조회. */ @GetMapping("/cumulative") @RequirePermission(menu = "KPI_CUMULATIVE", perm = "READ") public ApiResponse> cumulative(@ModelAttribute KpiSearchParam param) { diff --git a/ga-api/src/main/java/com/ga/api/controller/kpi/RetentionAnomalyAlertController.java b/ga-api/src/main/java/com/ga/api/controller/kpi/RetentionAnomalyAlertController.java index 6243d51..ecf03be 100644 --- a/ga-api/src/main/java/com/ga/api/controller/kpi/RetentionAnomalyAlertController.java +++ b/ga-api/src/main/java/com/ga/api/controller/kpi/RetentionAnomalyAlertController.java @@ -15,6 +15,11 @@ import org.springframework.web.bind.annotation.*; import java.util.Map; +/** + * 유지율 이상치(retention anomaly) 알림 REST 컨트롤러. + * 특정 조직/설계사의 계약 유지율이 평소 대비 비정상적으로 떨어진 경우를 탐지해 알림으로 관리한다. + * (유지율 급락은 부실모집/불완전판매 신호일 수 있어 모니터링 대상이다) + */ @Tag(name = "유지율 이상치") @RestController @RequestMapping("/api/retention-anomalies") @@ -23,6 +28,7 @@ public class RetentionAnomalyAlertController { private final RetentionAnomalyAlertService service; + /** 이상치 알림 목록 페이징 조회. */ @GetMapping @Operation(summary = "이상치 목록") @RequirePermission(menu = "RETENTION_ANOMALY", perm = "READ") @@ -30,6 +36,7 @@ public class RetentionAnomalyAlertController { return ApiResponse.ok(service.list(p)); } + /** 이상치 알림 단건 상세 조회. */ @GetMapping("/{id}") @Operation(summary = "이상치 단건 상세") @RequirePermission(menu = "RETENTION_ANOMALY", perm = "READ") @@ -37,6 +44,7 @@ public class RetentionAnomalyAlertController { return ApiResponse.ok(service.detail(id)); } + /** 지정한 월(alertMonth)에 대해 이상치를 다시 탐지·저장한다. 새로 탐지된 건수를 반환. */ @PostMapping("/detect") @Operation(summary = "이상치 재탐지 (월 단위)") @RequirePermission(menu = "RETENTION_ANOMALY", perm = "UPDATE") @@ -45,6 +53,7 @@ public class RetentionAnomalyAlertController { return ApiResponse.ok(service.detect(body.get("alertMonth"))); } + /** 탐지된 이상치를 검토/완료 상태로 처리(담당자 확인 결과 기록). */ @PutMapping("/review") @Operation(summary = "이상치 검토/완료 처리") @RequirePermission(menu = "RETENTION_ANOMALY", perm = "UPDATE") diff --git a/ga-api/src/main/java/com/ga/api/controller/ledger/LedgerController.java b/ga-api/src/main/java/com/ga/api/controller/ledger/LedgerController.java index d272ee4..de044ff 100644 --- a/ga-api/src/main/java/com/ga/api/controller/ledger/LedgerController.java +++ b/ga-api/src/main/java/com/ga/api/controller/ledger/LedgerController.java @@ -15,6 +15,14 @@ import org.springframework.web.bind.annotation.*; import java.util.Map; +/** + * 수수료 원장(ledger) 관리 REST 컨트롤러. + * 원장은 설계사에게 지급된/지급될 수수료 내역을 종류별로 기록하는 장부다. + * - 모집(recruit) : 신계약 체결로 발생한 모집수수료 원장 + * - 유지(maintain) : 계약 유지에 따라 매월 발생하는 유지수수료 원장 + * - 예외(exception) : 정상 계산에 안 잡히는 수기 조정 수수료(승인 절차 필요) + * 조회/상세 외에 대용량 엑셀 다운로드(exportLargeExcel: 커서 스트리밍)를 제공한다. + */ @Tag(name = "원장 관리") @RestController @RequestMapping("/api/ledger") @@ -24,7 +32,7 @@ public class LedgerController { private final LedgerService service; private final ExcelService excelService; - // ===== 모집 ===== + // ===== 모집수수료 원장 ===== @GetMapping("/recruit") @RequirePermission(menu = "LEDGER_RECRUIT", perm = "READ") public ApiResponse> listRecruit(@ModelAttribute LedgerSearchParam param) { @@ -37,6 +45,7 @@ public class LedgerController { return ApiResponse.ok(service.detailRecruit(ledgerId)); } + /** 모집수수료 원장 엑셀 다운로드. 대용량 대비 커서로 한 건씩 흘려보내 메모리 폭증을 막는다. */ @GetMapping("/recruit/export") @RequirePermission(menu = "LEDGER_RECRUIT", perm = "EXPORT") public void exportRecruit(@ModelAttribute LedgerSearchParam param, HttpServletResponse res) { @@ -44,7 +53,7 @@ public class LedgerController { () -> service.exportRecruitCursor(param), res); } - // ===== 유지 ===== + // ===== 유지수수료 원장 ===== @GetMapping("/maintain") @RequirePermission(menu = "LEDGER_MAINTAIN", perm = "READ") public ApiResponse> listMaintain(@ModelAttribute LedgerSearchParam param) { @@ -57,6 +66,7 @@ public class LedgerController { return ApiResponse.ok(service.detailMaintain(ledgerId)); } + /** 유지수수료 원장 엑셀 다운로드(커서 스트리밍). */ @GetMapping("/maintain/export") @RequirePermission(menu = "LEDGER_MAINTAIN", perm = "EXPORT") public void exportMaintain(@ModelAttribute LedgerSearchParam param, HttpServletResponse res) { @@ -64,7 +74,7 @@ public class LedgerController { () -> service.exportMaintainCursor(param), res); } - // ===== 예외 ===== + // ===== 예외수수료 원장(수기 등록 + 승인) ===== @GetMapping("/exception") @RequirePermission(menu = "LEDGER_EXCEPTION", perm = "READ") public ApiResponse> listException(@ModelAttribute LedgerSearchParam param) { @@ -77,6 +87,7 @@ public class LedgerController { return ApiResponse.ok(service.detailException(ledgerId)); } + /** 예외수수료 원장 수기 등록. 등록 직후에는 승인대기(PENDING) 상태다. */ @PostMapping("/exception") @RequirePermission(menu = "LEDGER_EXCEPTION", perm = "CREATE") @DataChangeLog(menu = "LEDGER_EXCEPTION", table = "exception_ledger") @@ -84,6 +95,7 @@ public class LedgerController { return ApiResponse.ok(service.createException(req)); } + /** 예외수수료 원장 승인/반려 처리(body 의 status: APPROVED 또는 REJECTED). */ @PutMapping("/exception/{ledgerId}/approve") @RequirePermission(menu = "LEDGER_EXCEPTION", perm = "APPROVE") @DataChangeLog(menu = "LEDGER_EXCEPTION", table = "exception_ledger") diff --git a/ga-api/src/main/java/com/ga/api/controller/license/AgentLicenseController.java b/ga-api/src/main/java/com/ga/api/controller/license/AgentLicenseController.java index 454eb0b..0cb5deb 100644 --- a/ga-api/src/main/java/com/ga/api/controller/license/AgentLicenseController.java +++ b/ga-api/src/main/java/com/ga/api/controller/license/AgentLicenseController.java @@ -15,6 +15,13 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +/** + * 설계사 자격(License) 관리 API. + * + *

보험설계사는 판매 가능한 상품군별로 자격(생명/손해 등 licenseType)을 보유해야 한다. + * 각 자격은 발급일(issuedDate)~만료일(expiresDate)을 가지며 갱신(renewedFrom) 이력이 연결될 수 있다. + * 만료 임박 자격을 조회해 갱신을 독려하는 기능이 핵심이다. 메뉴 AGENT_LICENSE 권한으로 보호된다.

+ */ @Tag(name = "자격 관리") @RestController @RequestMapping("/api/agent-licenses") @@ -23,18 +30,21 @@ public class AgentLicenseController { private final AgentLicenseService service; + /** 자격 목록 조회(페이징). READ 권한 필요. */ @GetMapping @RequirePermission(menu = "AGENT_LICENSE", perm = "READ") public ApiResponse> list(@ModelAttribute AgentLicenseSearchParam param) { return ApiResponse.ok(service.list(param)); } + /** 자격 단건 상세 조회. READ 권한 필요. */ @GetMapping("/{licenseId}") @RequirePermission(menu = "AGENT_LICENSE", perm = "READ") public ApiResponse detail(@PathVariable Long licenseId) { return ApiResponse.ok(service.detail(licenseId)); } + /** 만료 임박 자격 조회(기본 30일 이내). 갱신 알림/관리에 사용한다. READ 권한 필요. */ @GetMapping("/expiring") @RequirePermission(menu = "AGENT_LICENSE", perm = "READ") public ApiResponse> expiring( @@ -42,6 +52,7 @@ public class AgentLicenseController { return ApiResponse.ok(service.expiring(days)); } + /** 자격 등록. 등록 시 ACTIVE 상태로 시작한다. CREATE 권한 필요. */ @PostMapping @RequirePermission(menu = "AGENT_LICENSE", perm = "CREATE") @DataChangeLog(menu = "AGENT_LICENSE", table = "agent_license") @@ -49,6 +60,7 @@ public class AgentLicenseController { return ApiResponse.ok(service.create(req)); } + /** 자격 수정. UPDATE 권한 필요. */ @PutMapping("/{licenseId}") @RequirePermission(menu = "AGENT_LICENSE", perm = "UPDATE") @DataChangeLog(menu = "AGENT_LICENSE", table = "agent_license") @@ -58,6 +70,7 @@ public class AgentLicenseController { return ApiResponse.ok(); } + /** 자격 레코드 삭제. DELETE 권한 필요. */ @DeleteMapping("/{licenseId}") @RequirePermission(menu = "AGENT_LICENSE", perm = "DELETE") @DataChangeLog(menu = "AGENT_LICENSE", table = "agent_license") diff --git a/ga-api/src/main/java/com/ga/api/controller/mobile/MobileMeController.java b/ga-api/src/main/java/com/ga/api/controller/mobile/MobileMeController.java index c6a8719..4d99a29 100644 --- a/ga-api/src/main/java/com/ga/api/controller/mobile/MobileMeController.java +++ b/ga-api/src/main/java/com/ga/api/controller/mobile/MobileMeController.java @@ -38,22 +38,26 @@ public class MobileMeController { private final MobileMeService service; + /** 내 홈 요약(명세서 수/출금 신청 수/미읽음 알림 수). */ @GetMapping("/summary") public ApiResponse summary() { return ApiResponse.ok(service.summary()); } + /** 내 수수료 명세서 목록(서버가 본인 agentId로 강제 필터). */ @GetMapping("/statements") public ApiResponse> statements( @ModelAttribute CommissionStatementSearchParam param) { return ApiResponse.ok(service.statements(param)); } + /** 내 명세서 단건 상세. 본인 소유가 아니면 FORBIDDEN. */ @GetMapping("/statements/{statementId}") public ApiResponse statementDetail(@PathVariable Long statementId) { return ApiResponse.ok(service.statementDetail(statementId)); } + /** 내 명세서 엑셀 다운로드. 본인 소유 검증 후 파일 스트림을 내려준다. */ @GetMapping("/statements/{statementId}/download") public void downloadStatement(@PathVariable Long statementId, HttpServletResponse response) throws IOException { byte[] content = service.downloadStatement(statementId); @@ -65,33 +69,39 @@ public class MobileMeController { response.getOutputStream().write(content); } + /** 출금 신청 가능한 내 정산 마스터 목록(출금 폼 드롭다운용). */ @GetMapping("/settle-masters") public ApiResponse> withdrawableSettleMasters() { return ApiResponse.ok(service.withdrawableSettleMasters()); } + /** 특정 정산 마스터의 출금 가능 잔액 조회(출금 폼 미리 표시용). 본인 소유 검증 포함. */ @GetMapping("/settle-masters/{settleMasterId}/withdrawable-balance") public ApiResponse withdrawableBalance(@PathVariable Long settleMasterId) { return ApiResponse.ok(service.withdrawableBalance(settleMasterId)); } + /** 내 출금 신청 목록(서버가 본인 agentId로 강제 필터). */ @GetMapping("/withdraw-requests") public ApiResponse> withdrawRequests( @ModelAttribute WithdrawRequestSearchParam param) { return ApiResponse.ok(service.withdrawRequests(param)); } + /** 출금 신청 등록. agentId/신청자는 토큰에서 강제 결정하고 잔액 한도를 검증한다. */ @PostMapping("/withdraw-requests") public ApiResponse createWithdrawRequest(@Valid @RequestBody MobileWithdrawSaveReq req) { return ApiResponse.ok(service.createWithdrawRequest(req)); } + /** 내 출금 신청 취소. 본인 소유 검증 후 취소한다. */ @PostMapping("/withdraw-requests/{requestId}/cancel") public ApiResponse cancelWithdrawRequest(@PathVariable Long requestId) { service.cancelMyWithdrawRequest(requestId); return ApiResponse.ok(); } + /** 내 전화번호 직접 수정(관리자 거치지 않음). */ @PutMapping("/profile/phone") public ApiResponse updateMyPhone(@RequestBody PhoneReq req) { service.updateMyPhone(req.getPhone()); @@ -103,18 +113,21 @@ public class MobileMeController { private String phone; } + /** 내 알림 목록(서버가 본인 userId로 강제 필터). */ @GetMapping("/notifications") public ApiResponse> notifications( @ModelAttribute UserNotificationSearchParam param) { return ApiResponse.ok(service.notifications(param)); } + /** 내 알림 단건 읽음 처리. */ @PostMapping("/notifications/{notificationId}/read") public ApiResponse markRead(@PathVariable Long notificationId) { service.markNotificationRead(notificationId); return ApiResponse.ok(); } + /** 내 알림 전체 읽음 처리(처리 건수 반환). */ @PostMapping("/notifications/read-all") public ApiResponse markAllRead() { return ApiResponse.ok(service.markAllNotificationsRead()); diff --git a/ga-api/src/main/java/com/ga/api/controller/notice/NoticeController.java b/ga-api/src/main/java/com/ga/api/controller/notice/NoticeController.java index 5199c11..8ff9e4b 100644 --- a/ga-api/src/main/java/com/ga/api/controller/notice/NoticeController.java +++ b/ga-api/src/main/java/com/ga/api/controller/notice/NoticeController.java @@ -14,6 +14,16 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +/** + * 공지사항 및 사용자 알림 API. + * + *

두 가지 도메인을 함께 다룬다. + *

    + *
  • 공지사항(notice): 관리자가 등록하는 게시물. 기간(startDate~endDate)과 활성여부로 노출을 제어한다.
  • + *
  • 사용자 알림(user-notification): 개인에게 발송되는 알림. 읽음 처리/미읽음 개수 조회 등을 제공한다.
  • + *
+ * 모두 메뉴 NOTICE 권한으로 보호된다.

+ */ @Tag(name = "공지사항 & 알림") @RestController @RequiredArgsConstructor @@ -23,24 +33,28 @@ public class NoticeController { // ── Notice ──────────────────────────────────────────────────────────────── + /** 공지사항 목록 조회(페이징). READ 권한 필요. */ @GetMapping("/api/notices") @RequirePermission(menu = "NOTICE", perm = "READ") public ApiResponse> list(@ModelAttribute NoticeSearchParam param) { return ApiResponse.ok(service.list(param)); } + /** 현재 노출 기간이고 활성 상태인 공지만 조회. READ 권한 필요. */ @GetMapping("/api/notices/active") @RequirePermission(menu = "NOTICE", perm = "READ") public ApiResponse> activeList() { return ApiResponse.ok(service.activeList()); } + /** 공지사항 단건 상세 조회. READ 권한 필요. */ @GetMapping("/api/notices/{noticeId}") @RequirePermission(menu = "NOTICE", perm = "READ") public ApiResponse detail(@PathVariable Long noticeId) { return ApiResponse.ok(service.detail(noticeId)); } + /** 공지사항 등록. 등록 시 활성(isActive=true) 상태로 시작한다. CREATE 권한 필요. */ @PostMapping("/api/notices") @RequirePermission(menu = "NOTICE", perm = "CREATE") @DataChangeLog(menu = "NOTICE", table = "notice") @@ -48,6 +62,7 @@ public class NoticeController { return ApiResponse.ok(service.create(req)); } + /** 공지사항 수정. UPDATE 권한 필요. */ @PutMapping("/api/notices/{noticeId}") @RequirePermission(menu = "NOTICE", perm = "UPDATE") @DataChangeLog(menu = "NOTICE", table = "notice") @@ -57,6 +72,7 @@ public class NoticeController { return ApiResponse.ok(); } + /** 공지사항 삭제. DELETE 권한 필요. */ @DeleteMapping("/api/notices/{noticeId}") @RequirePermission(menu = "NOTICE", perm = "DELETE") @DataChangeLog(menu = "NOTICE", table = "notice") @@ -67,6 +83,7 @@ public class NoticeController { // ── UserNotification ────────────────────────────────────────────────────── + /** 사용자 알림 목록 조회(페이징). READ 권한 필요. */ @GetMapping("/api/user-notifications") @RequirePermission(menu = "NOTICE", perm = "READ") public ApiResponse> listNotifications( @@ -74,6 +91,7 @@ public class NoticeController { return ApiResponse.ok(service.listNotifications(param)); } + /** 미읽음 알림 개수 조회. userId 미지정 시 현재 로그인 사용자 기준. READ 권한 필요. */ @GetMapping("/api/user-notifications/unread-count") @RequirePermission(menu = "NOTICE", perm = "READ") public ApiResponse unreadCount(@RequestParam(required = false) Long userId) { @@ -81,6 +99,7 @@ public class NoticeController { return ApiResponse.ok(service.unreadCount(target)); } + /** 알림 단건 읽음 처리. userId 미지정 시 현재 로그인 사용자 기준. READ 권한 필요. */ @PostMapping("/api/user-notifications/{notificationId}/read") @RequirePermission(menu = "NOTICE", perm = "READ") public ApiResponse markRead(@PathVariable Long notificationId, @@ -90,6 +109,7 @@ public class NoticeController { return ApiResponse.ok(); } + /** 해당 사용자의 모든 알림을 읽음 처리. READ 권한 필요. */ @PostMapping("/api/user-notifications/read-all") @RequirePermission(menu = "NOTICE", perm = "READ") public ApiResponse markAllRead(@RequestParam Long userId) { diff --git a/ga-api/src/main/java/com/ga/api/controller/org/AgentController.java b/ga-api/src/main/java/com/ga/api/controller/org/AgentController.java index 0fce39e..ef9cc7b 100644 --- a/ga-api/src/main/java/com/ga/api/controller/org/AgentController.java +++ b/ga-api/src/main/java/com/ga/api/controller/org/AgentController.java @@ -16,6 +16,15 @@ import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; +/** + * 설계사(보험모집인) 관리 API. + * + *

설계사는 GA(보험대리점)에 소속되어 보험계약을 모집하는 사람이며, 수수료 정산의 기본 단위다. + * 각 설계사는 특정 조직(지점/본부)과 직급(grade)에 소속되며, 입사/전배/승진/퇴사 등의 이력이 관리된다.

+ * + *

모든 엔드포인트는 {@code @RequirePermission}으로 메뉴(ORG_AGENT)별 권한(READ/CREATE/UPDATE/EXPORT)을 + * 검증하며, 데이터 변경 작업은 {@code @DataChangeLog}로 변경 이력을 자동 적재한다.

+ */ @Tag(name = "설계사 관리") @RestController @RequestMapping("/api/agents") @@ -25,18 +34,21 @@ public class AgentController { private final AgentService agentService; private final ExcelService excelService; + /** 설계사 목록 조회(검색조건 + 페이징). READ 권한 필요. */ @GetMapping @RequirePermission(menu = "ORG_AGENT", perm = "READ") public ApiResponse> list(@ModelAttribute AgentSearchParam param) { return ApiResponse.ok(agentService.list(param)); } + /** 설계사 단건 상세 조회. READ 권한 필요. */ @GetMapping("/{agentId}") @RequirePermission(menu = "ORG_AGENT", perm = "READ") public ApiResponse detail(@PathVariable Long agentId) { return ApiResponse.ok(agentService.detail(agentId)); } + /** 신규 설계사 등록(입사). 등록 시 입사 이력이 함께 생성된다. CREATE 권한 필요. */ @PostMapping @RequirePermission(menu = "ORG_AGENT", perm = "CREATE") @DataChangeLog(menu = "ORG_AGENT", table = "agent") @@ -44,6 +56,7 @@ public class AgentController { return ApiResponse.ok(agentService.create(req)); } + /** 설계사 정보 수정. 조직/직급이 바뀌면 전배/승진 이력이 자동 기록된다. UPDATE 권한 필요. */ @PutMapping("/{agentId}") @RequirePermission(menu = "ORG_AGENT", perm = "UPDATE") @DataChangeLog(menu = "ORG_AGENT", table = "agent") @@ -52,6 +65,7 @@ public class AgentController { return ApiResponse.ok(); } + /** 설계사 상태 변경(예: ACTIVE 재직 → 퇴사/휴직 등). body의 "status" 값을 코드로 검증한다. UPDATE 권한 필요. */ @PutMapping("/{agentId}/status") @RequirePermission(menu = "ORG_AGENT", perm = "UPDATE") @DataChangeLog(menu = "ORG_AGENT", table = "agent") @@ -60,12 +74,14 @@ public class AgentController { return ApiResponse.ok(); } + /** 설계사 조직/직급 변경 이력(입사·전배·승진) 조회. READ 권한 필요. */ @GetMapping("/{agentId}/history") @RequirePermission(menu = "ORG_AGENT", perm = "READ") public ApiResponse> history(@PathVariable Long agentId) { return ApiResponse.ok(agentService.history(agentId)); } + /** 설계사 목록 엑셀 다운로드. 대용량 대비 커서 스트리밍으로 출력한다. EXPORT 권한 필요. */ @GetMapping("/export") @RequirePermission(menu = "ORG_AGENT", perm = "EXPORT") public void export(@ModelAttribute AgentSearchParam param, HttpServletResponse response) { diff --git a/ga-api/src/main/java/com/ga/api/controller/org/OrganizationController.java b/ga-api/src/main/java/com/ga/api/controller/org/OrganizationController.java index a45acef..1f82c83 100644 --- a/ga-api/src/main/java/com/ga/api/controller/org/OrganizationController.java +++ b/ga-api/src/main/java/com/ga/api/controller/org/OrganizationController.java @@ -14,6 +14,14 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +/** + * 조직(본부/지점 등) 관리 API. + * + *

조직은 부모-자식 관계의 계층(트리) 구조를 가진다. 설계사는 말단 조직에 소속되며, + * 수수료 정산·실적 집계는 이 조직 계층을 따라 상위로 롤업된다.

+ * + *

메뉴 ORG_TREE 권한으로 보호되며, 삭제는 하위 조직/소속 설계사가 없을 때만 허용된다(아래 서비스 참고).

+ */ @Tag(name = "조직 관리") @RestController @RequestMapping("/api/orgs") @@ -22,12 +30,14 @@ public class OrganizationController { private final OrganizationService service; + /** 조직 전체를 트리(계층) 구조로 조회. READ 권한 필요. */ @GetMapping("/tree") @RequirePermission(menu = "ORG_TREE", perm = "READ") public ApiResponse> tree() { return ApiResponse.ok(service.getTree()); } + /** 조직 목록을 평면(flat) 리스트로 조회(검색어/조직유형/활성여부 필터). READ 권한 필요. */ @GetMapping @RequirePermission(menu = "ORG_TREE", perm = "READ") public ApiResponse> list(@RequestParam(required = false) String keyword, @@ -36,12 +46,14 @@ public class OrganizationController { return ApiResponse.ok(service.list(keyword, orgType, isActive)); } + /** 조직 단건 상세 조회. READ 권한 필요. */ @GetMapping("/{orgId}") @RequirePermission(menu = "ORG_TREE", perm = "READ") public ApiResponse detail(@PathVariable Long orgId) { return ApiResponse.ok(service.get(orgId)); } + /** 신규 조직 등록. CREATE 권한 필요. */ @PostMapping @RequirePermission(menu = "ORG_TREE", perm = "CREATE") @DataChangeLog(menu = "ORG_TREE", table = "organization") @@ -49,6 +61,7 @@ public class OrganizationController { return ApiResponse.ok(service.create(req)); } + /** 조직 정보 수정. UPDATE 권한 필요. */ @PutMapping("/{orgId}") @RequirePermission(menu = "ORG_TREE", perm = "UPDATE") @DataChangeLog(menu = "ORG_TREE", table = "organization") @@ -57,6 +70,7 @@ public class OrganizationController { return ApiResponse.ok(); } + /** 조직 삭제. 하위 조직 또는 소속 설계사가 있으면 삭제 불가(서비스에서 검증). DELETE 권한 필요. */ @DeleteMapping("/{orgId}") @RequirePermission(menu = "ORG_TREE", perm = "DELETE") @DataChangeLog(menu = "ORG_TREE", table = "organization") diff --git a/ga-api/src/main/java/com/ga/api/controller/product/CompanyController.java b/ga-api/src/main/java/com/ga/api/controller/product/CompanyController.java index 4507099..1083886 100644 --- a/ga-api/src/main/java/com/ga/api/controller/product/CompanyController.java +++ b/ga-api/src/main/java/com/ga/api/controller/product/CompanyController.java @@ -11,6 +11,14 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +/** + * 보험사(원수사) 관리 REST 컨트롤러. + * 우리 GA가 상품을 위탁판매하는 보험회사(예: 삼성생명, KB손해보험 등) 마스터를 관리한다. + * 모든 상품/요율/수수료 계산의 출발점이 되는 기준 데이터이다. + * + * - @RequirePermission: 메뉴/권한(READ·CREATE·UPDATE) 체크. 권한 없으면 호출 거부. + * - @DataChangeLog: 데이터 변경 시 감사 로그(누가/언제/무엇을 바꿨는지) 자동 기록. + */ @Tag(name = "보험사 관리") @RestController @RequestMapping("/api/companies") @@ -19,18 +27,21 @@ public class CompanyController { private final InsuranceCompanyService companyService; + /** 보험사 목록 조회. activeOnly=true 면 사용중(활성) 보험사만 반환. */ @GetMapping @RequirePermission(menu = "COMPANY", perm = "READ") public ApiResponse> list(@RequestParam(required = false, defaultValue = "false") boolean activeOnly) { return ApiResponse.ok(companyService.list(activeOnly)); } + /** 보험사 단건 상세 조회 (URL 경로의 companyId 기준). */ @GetMapping("/{companyId}") @RequirePermission(menu = "COMPANY", perm = "READ") public ApiResponse detail(@PathVariable Long companyId) { return ApiResponse.ok(companyService.detail(companyId)); } + /** 보험사 신규 등록. 생성된 PK(companyId)를 반환. */ @PostMapping @RequirePermission(menu = "COMPANY", perm = "CREATE") @DataChangeLog(menu = "COMPANY", table = "insurance_company") @@ -38,6 +49,7 @@ public class CompanyController { return ApiResponse.ok(companyService.create(vo)); } + /** 보험사 정보 수정 (경로 companyId 대상). */ @PutMapping("/{companyId}") @RequirePermission(menu = "COMPANY", perm = "UPDATE") @DataChangeLog(menu = "COMPANY", table = "insurance_company") diff --git a/ga-api/src/main/java/com/ga/api/controller/product/ContractController.java b/ga-api/src/main/java/com/ga/api/controller/product/ContractController.java index 3a5bfeb..fb5baf9 100644 --- a/ga-api/src/main/java/com/ga/api/controller/product/ContractController.java +++ b/ga-api/src/main/java/com/ga/api/controller/product/ContractController.java @@ -15,6 +15,11 @@ import org.springframework.web.bind.annotation.*; import java.util.Map; +/** + * 보험계약 관리 REST 컨트롤러. + * 계약(contract)은 "어떤 설계사가 어떤 고객에게 어떤 상품을 팔았는가"를 나타내는 핵심 거래 데이터로, + * 이후 수수료 계산·분급(installment)·차등환수(chargeback)의 기준이 된다. + */ @Tag(name = "계약 관리") @RestController @RequestMapping("/api/contracts") @@ -23,18 +28,21 @@ public class ContractController { private final ContractService service; + /** 계약 목록 조회. @ModelAttribute 로 검색조건(증권번호·기간 등)을 받아 페이징 결과를 반환. */ @GetMapping @RequirePermission(menu = "CONTRACT_LIST", perm = "READ") public ApiResponse> list(@ModelAttribute ContractSearchParam param) { return ApiResponse.ok(service.list(param)); } + /** 계약 단건 상세 조회. */ @GetMapping("/{contractId}") @RequirePermission(menu = "CONTRACT_LIST", perm = "READ") public ApiResponse detail(@PathVariable Long contractId) { return ApiResponse.ok(service.detail(contractId)); } + /** 계약 신규 등록. @Valid 로 필수값 검증 후 생성된 PK(contractId) 반환. */ @PostMapping @RequirePermission(menu = "CONTRACT_LIST", perm = "CREATE") @DataChangeLog(menu = "CONTRACT_LIST", table = "contract") @@ -42,6 +50,7 @@ public class ContractController { return ApiResponse.ok(service.create(req)); } + /** 계약 정보 수정. */ @PutMapping("/{contractId}") @RequirePermission(menu = "CONTRACT_LIST", perm = "UPDATE") @DataChangeLog(menu = "CONTRACT_LIST", table = "contract") @@ -50,6 +59,7 @@ public class ContractController { return ApiResponse.ok(); } + /** 계약 상태 변경(예: 정상 ACTIVE → 해지 CANCELLED). body 의 "status" 값만 사용. 해지 시 차등환수 계산 대상이 됨. */ @PutMapping("/{contractId}/status") @RequirePermission(menu = "CONTRACT_LIST", perm = "UPDATE") @DataChangeLog(menu = "CONTRACT_LIST", table = "contract") diff --git a/ga-api/src/main/java/com/ga/api/controller/product/ProductController.java b/ga-api/src/main/java/com/ga/api/controller/product/ProductController.java index 1462591..02316c9 100644 --- a/ga-api/src/main/java/com/ga/api/controller/product/ProductController.java +++ b/ga-api/src/main/java/com/ga/api/controller/product/ProductController.java @@ -11,6 +11,12 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 보험상품 관리 REST 컨트롤러. + * 상품(product)은 보험사가 판매하는 개별 상품으로, 각 상품에는 + * company_rate(보험사가 GA에 주는 요율)·payout_rate(GA가 설계사에게 지급하는 요율) 등 + * 수수료 계산의 핵심 요율 정보가 붙는다. + */ @Tag(name = "상품 관리") @RestController @RequestMapping("/api/products") @@ -19,6 +25,7 @@ public class ProductController { private final ProductService productService; + /** 상품 목록 조회. 보험사/보험종류/키워드/활성여부로 필터링하며 페이징 처리(pageNum/pageSize)된다. */ @GetMapping @RequirePermission(menu = "PRODUCT", perm = "READ") public ApiResponse> list(@RequestParam(defaultValue = "1") int pageNum, @@ -30,12 +37,14 @@ public class ProductController { return ApiResponse.ok(productService.list(pageNum, pageSize, companyId, insuranceType, keyword, isActive)); } + /** 상품 단건 상세 조회. */ @GetMapping("/{productId}") @RequirePermission(menu = "PRODUCT", perm = "READ") public ApiResponse detail(@PathVariable Long productId) { return ApiResponse.ok(productService.detail(productId)); } + /** 상품 신규 등록. 생성된 PK(productId) 반환. */ @PostMapping @RequirePermission(menu = "PRODUCT", perm = "CREATE") @DataChangeLog(menu = "PRODUCT", table = "product") @@ -43,6 +52,7 @@ public class ProductController { return ApiResponse.ok(productService.create(vo)); } + /** 상품 정보(요율 등) 수정. */ @PutMapping("/{productId}") @RequirePermission(menu = "PRODUCT", perm = "UPDATE") @DataChangeLog(menu = "PRODUCT", table = "product") @@ -51,6 +61,7 @@ public class ProductController { return ApiResponse.ok(); } + /** 상품 삭제. */ @DeleteMapping("/{productId}") @RequirePermission(menu = "PRODUCT", perm = "DELETE") @DataChangeLog(menu = "PRODUCT", table = "product") diff --git a/ga-api/src/main/java/com/ga/api/controller/receive/ReceiveController.java b/ga-api/src/main/java/com/ga/api/controller/receive/ReceiveController.java index cdde4c1..838a57d 100644 --- a/ga-api/src/main/java/com/ga/api/controller/receive/ReceiveController.java +++ b/ga-api/src/main/java/com/ga/api/controller/receive/ReceiveController.java @@ -12,6 +12,19 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +/** + * [데이터 수신 매핑/원본 관리 API] + * + * 신입 개발자 안내: + * - 보험사마다 보내주는 수수료 데이터의 컬럼명/코드값이 제각각이다. 이를 우리 표준으로 바꾸기 위한 설정이 "매핑"이다. + * - 구성 요소: + * company_profile : 보험사별 수신 설정(파일 형식, 헤더 위치 등) + * company_field_mapping : 보험사 컬럼 → 우리 DB 필드 매핑 + * code_mapping : 보험사 코드값 → 우리 표준 코드값 매핑 + * raw_commission_data : 수신한 원본 데이터(파싱 상태 포함) + * parse_error_log : 파싱 실패 건과 그 처리(resolve) 상태 + * - 업로드 템플릿(upload)이 "엑셀 수동 적재"라면, 여기 매핑은 "보험사별 자동 수신/파싱 규칙"에 가깝다. + */ @Tag(name = "데이터 수신") @RestController @RequestMapping("/api/receive") @@ -82,6 +95,7 @@ public class ReceiveController { } // 원본 데이터 + 에러 + // raw: 수신한 원본 행 목록(보험사/정산월/파싱상태로 필터). errors: 파싱 실패 로그. resolve: 실패 건을 처리완료로 표시. @GetMapping("/raw") @RequirePermission(menu = "RECEIVE_DATA", perm = "READ") public ApiResponse> rawList(@RequestParam(required = false) String companyCode, diff --git a/ga-api/src/main/java/com/ga/api/controller/receive/ResolveErrorReq.java b/ga-api/src/main/java/com/ga/api/controller/receive/ResolveErrorReq.java index f35c12b..78852fd 100644 --- a/ga-api/src/main/java/com/ga/api/controller/receive/ResolveErrorReq.java +++ b/ga-api/src/main/java/com/ga/api/controller/receive/ResolveErrorReq.java @@ -4,6 +4,11 @@ import jakarta.validation.constraints.NotBlank; import lombok.Getter; import lombok.Setter; +/** + * 파싱 에러 처리(resolve) 요청 본문. + * status: 처리 결과 상태(예: 해결됨/보류 등) — 필수. + * note : 처리 사유/메모 — 선택. + */ @Getter @Setter public class ResolveErrorReq { diff --git a/ga-api/src/main/java/com/ga/api/controller/regulatory/ContractLimitCheckReq.java b/ga-api/src/main/java/com/ga/api/controller/regulatory/ContractLimitCheckReq.java index 0a9de8d..620aeba 100644 --- a/ga-api/src/main/java/com/ga/api/controller/regulatory/ContractLimitCheckReq.java +++ b/ga-api/src/main/java/com/ga/api/controller/regulatory/ContractLimitCheckReq.java @@ -7,12 +7,18 @@ import lombok.Data; import java.math.BigDecimal; import java.time.LocalDate; +/** + * 계약 단위 규제 한도(예: 1200%룰) 위반 여부 체크 요청 DTO. + * "이 계약에 수수료를 additionalAmount 만큼 더 지급해도 규제 한도를 넘지 않는가?"를 묻는다. + */ @Data public class ContractLimitCheckReq { + /** 한도 체크 대상 계약 ID. */ @NotNull private Long contractId; + /** 추가로 지급하려는 수수료 금액(양수만 허용). 이 금액을 더했을 때 한도 초과 여부를 검사한다. */ @NotNull @Positive private BigDecimal additionalAmount; diff --git a/ga-api/src/main/java/com/ga/api/controller/regulatory/RegulatoryLimitController.java b/ga-api/src/main/java/com/ga/api/controller/regulatory/RegulatoryLimitController.java index 2a82b87..62aed9d 100644 --- a/ga-api/src/main/java/com/ga/api/controller/regulatory/RegulatoryLimitController.java +++ b/ga-api/src/main/java/com/ga/api/controller/regulatory/RegulatoryLimitController.java @@ -17,6 +17,13 @@ import org.springframework.web.bind.annotation.*; import java.time.LocalDate; +/** + * 규제 한도 관리 REST 컨트롤러. + * 금융당국 규제(대표적으로 보험모집 수수료의 '1200%룰': 초년도 수수료를 월납보험료의 12배 이내로 제한)를 + * 한도마스터(regulatory_limit) 테이블로 관리하고, 계약별 위반 여부를 점검한다. + * - limitService : 한도 마스터 CRUD + 활성/비활성 토글 + * - limitChecker : 실제 계약에 적용해 한도 초과 여부 계산(별도 상세 주석 파일) + */ @Tag(name = "규제 한도 관리") @RestController @RequestMapping("/api/regulatory-limits") @@ -26,18 +33,21 @@ public class RegulatoryLimitController { private final RegulatoryLimitService limitService; private final RegulatoryLimitChecker limitChecker; + /** 한도 마스터 목록 페이징 조회. */ @GetMapping @RequirePermission(menu = "REGULATORY_LIMIT", perm = "READ") public ApiResponse> list(RegulatoryLimitSearchParam param) { return ApiResponse.ok(limitService.list(param)); } + /** 한도 마스터 단건 상세 조회. */ @GetMapping("/{limitId}") @RequirePermission(menu = "REGULATORY_LIMIT", perm = "READ") public ApiResponse detail(@PathVariable Long limitId) { return ApiResponse.ok(limitService.detail(limitId)); } + /** 한도 마스터 신규 등록. */ @PostMapping @RequirePermission(menu = "REGULATORY_LIMIT", perm = "CREATE") @DataChangeLog(menu = "REGULATORY_LIMIT", table = "regulatory_limit") @@ -46,6 +56,7 @@ public class RegulatoryLimitController { return ApiResponse.ok(); } + /** 한도 마스터 수정. */ @PutMapping("/{limitId}") @RequirePermission(menu = "REGULATORY_LIMIT", perm = "UPDATE") @DataChangeLog(menu = "REGULATORY_LIMIT", table = "regulatory_limit") @@ -55,6 +66,7 @@ public class RegulatoryLimitController { return ApiResponse.ok(); } + /** 한도 마스터 비활성화(삭제 대신 사용 중지 처리). 비활성 한도는 위반 체크에서 제외된다. */ @PutMapping("/{limitId}/deactivate") @RequirePermission(menu = "REGULATORY_LIMIT", perm = "UPDATE") @DataChangeLog(menu = "REGULATORY_LIMIT", table = "regulatory_limit") @@ -63,6 +75,7 @@ public class RegulatoryLimitController { return ApiResponse.ok(); } + /** 한도 마스터 재활성화. 같은 유형의 활성 한도가 이미 있으면 중복 오류. */ @PutMapping("/{limitId}/activate") @RequirePermission(menu = "REGULATORY_LIMIT", perm = "UPDATE") @DataChangeLog(menu = "REGULATORY_LIMIT", table = "regulatory_limit") @@ -71,6 +84,11 @@ public class RegulatoryLimitController { return ApiResponse.ok(); } + /** + * 특정 계약에 수수료를 더 지급했을 때 규제 한도 위반 여부를 점검한다. + * limitType 이 FIRST_YEAR_RATIO 면 초년도(첫 1년) 기준, 그 외(TOTAL_RATIO)면 계약 전체 누적 기준으로 검사. + * baseDate 가 없으면 오늘 날짜를 기준일로 사용한다. + */ @PostMapping("/check-contract") @RequirePermission(menu = "REGULATORY_LIMIT", perm = "READ") public ApiResponse checkContract(@Valid @RequestBody ContractLimitCheckReq req) { diff --git a/ga-api/src/main/java/com/ga/api/controller/report/ReportController.java b/ga-api/src/main/java/com/ga/api/controller/report/ReportController.java index 7ebe46e..cac8918 100644 --- a/ga-api/src/main/java/com/ga/api/controller/report/ReportController.java +++ b/ga-api/src/main/java/com/ga/api/controller/report/ReportController.java @@ -20,6 +20,16 @@ import java.util.List; /** * 리포트 — 실적 / 조직별 / 환수위험. 읽기전용 집계 (신규 테이블 없음). * 각 화면의 조회 + 엑셀 export 엔드포인트 제공. + * + * [신입 개발자 안내] + * - 모든 리포트는 기존 정산/지급 데이터를 SQL 로 모아 보여주는 "읽기 전용 집계"다(저장 테이블을 새로 만들지 않음). + * - 각 리포트마다 화면 조회용(JSON 목록)과 엑셀 다운로드용(/export, Cursor 스트리밍) 두 개가 한 쌍이다. + * - year/settleMonth 파라미터가 없으면 defaultYear/defaultMonth 로 "올해/이번 달"을 기본 적용한다. + * - 리포트 종류: + * performance : 설계사/연도별 실적 + * org : 조직(지점)별 월 집계 + * chargeback-risk : 환수(이미 준 수수료를 도로 회수) 위험 목록 + * simplified-payment : 사업소득 간이지급명세서(국세청 제출용 월별 자료) */ @Tag(name = "리포트") @RestController diff --git a/ga-api/src/main/java/com/ga/api/controller/rule/RuleController.java b/ga-api/src/main/java/com/ga/api/controller/rule/RuleController.java index be2894b..50dfc2d 100644 --- a/ga-api/src/main/java/com/ga/api/controller/rule/RuleController.java +++ b/ga-api/src/main/java/com/ga/api/controller/rule/RuleController.java @@ -11,6 +11,16 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +/** + * 수수료 규정(룰) 관리 REST 컨트롤러. + * 수수료 계산에 쓰이는 각종 기준표를 CRUD 한다. 다섯 가지 룰 그룹으로 구성된다. + * - commission_rate : 상품별 보험사 지급요율(company_rate) 기준표 + * - payout_rule : 설계사 등급(grade)별 지급요율(payout_rate) 규칙 + * - override_rule : 상위 관리자에게 주는 오버라이드(육성) 수당 규칙 + * - chargeback_rule : 조기해지 시 환수율(차등환수) 규칙 + * - exception_type_code : 예외 처리 유형 코드 + * 이 기준표들을 잘못 등록하면 전 직원 수수료 계산이 틀어지므로 권한·감사로그가 엄격히 걸려 있다. + */ @Tag(name = "수수료 규정") @RestController @RequestMapping("/api/rules") @@ -19,7 +29,7 @@ public class RuleController { private final RuleService ruleService; - // ========== commission_rate ========== + // ========== commission_rate : 상품별 보험사 지급요율(company_rate) 기준표 CRUD ========== @GetMapping("/commission-rates") @RequirePermission(menu = "RULE_COMMISSION", perm = "READ") public ApiResponse> listCommissionRates(@RequestParam(required = false) Long productId, @@ -50,7 +60,7 @@ public class RuleController { return ApiResponse.ok(); } - // ========== payout_rule ========== + // ========== payout_rule : 설계사 등급(grade)별 지급요율(payout_rate) 규칙 CRUD ========== @GetMapping("/payout") @RequirePermission(menu = "RULE_PAYOUT", perm = "READ") public ApiResponse> listPayoutRules(@RequestParam(required = false) Long gradeId, @@ -81,7 +91,7 @@ public class RuleController { return ApiResponse.ok(); } - // ========== override_rule ========== + // ========== override_rule : 상위 관리자 오버라이드(육성) 수당 규칙 CRUD ========== @GetMapping("/override") @RequirePermission(menu = "RULE_OVERRIDE", perm = "READ") public ApiResponse> listOverrideRules() { @@ -111,7 +121,7 @@ public class RuleController { return ApiResponse.ok(); } - // ========== chargeback_rule ========== + // ========== chargeback_rule : 조기해지 환수율(차등환수) 규칙 CRUD ========== @GetMapping("/chargeback") @RequirePermission(menu = "RULE_CHARGEBACK", perm = "READ") public ApiResponse> listChargebackRules(@RequestParam(required = false) String companyCode) { @@ -141,7 +151,7 @@ public class RuleController { return ApiResponse.ok(); } - // ========== exception_type_code ========== + // ========== exception_type_code : 예외 처리 유형 코드 관리(코드값 기반) ========== @GetMapping("/exception-codes") @RequirePermission(menu = "RULE_EXCEPTION", perm = "READ") public ApiResponse> listExceptionCodes() { diff --git a/ga-api/src/main/java/com/ga/api/controller/settle/CarrierReconciliationOutController.java b/ga-api/src/main/java/com/ga/api/controller/settle/CarrierReconciliationOutController.java index edb2022..824a798 100644 --- a/ga-api/src/main/java/com/ga/api/controller/settle/CarrierReconciliationOutController.java +++ b/ga-api/src/main/java/com/ga/api/controller/settle/CarrierReconciliationOutController.java @@ -13,6 +13,14 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 보험사 회신(carrier_reconciliation_out) 관리 컨트롤러. + * + *

GA가 보험사로 보낸 정산 자료에 대해 "어떤 파일을 언제 회신/전송했는지" 이력을 관리하는 화면입니다. + * 한 건은 "보험사 1곳 × 정산월 1개"로 유일하며, 전송 실패(FAIL) 건은 retry 로 다시 READY 로 돌려 배치가 재전송합니다. + * + *

상태: READY(전송대기) → (배치 처리) → 성공/FAIL. 권한(menu=RECON_OUT): 조회 READ, 등록 CREATE, 재전송 UPDATE. + */ @Tag(name = "보험사 회신 관리") @RestController @RequestMapping("/api/carrier-reconciliation") @@ -21,18 +29,21 @@ public class CarrierReconciliationOutController { private final CarrierReconciliationOutService service; + /** 보험사 회신 이력 목록 조회(검색 + 페이징). */ @GetMapping @RequirePermission(menu = "RECON_OUT", perm = "READ") public ApiResponse> list(@ModelAttribute CarrierReconciliationOutSearchParam param) { return ApiResponse.ok(service.list(param)); } + /** 회신 이력 단건 상세 조회. */ @GetMapping("/{reconOutId}") @RequirePermission(menu = "RECON_OUT", perm = "READ") public ApiResponse detail(@PathVariable Long reconOutId) { return ApiResponse.ok(service.detail(reconOutId)); } + /** 회신 이력 신규 등록(READY 상태로 시작). 같은 보험사+정산월 중복 등록은 거부됩니다. */ @PostMapping @RequirePermission(menu = "RECON_OUT", perm = "CREATE") @DataChangeLog(menu = "RECON_OUT", table = "carrier_reconciliation_out") @@ -40,6 +51,7 @@ public class CarrierReconciliationOutController { return ApiResponse.ok(service.create(req)); } + /** 전송 실패(FAIL) 건 재전송. 상태를 READY 로 되돌려 배치가 다시 처리하도록 합니다. */ @PostMapping("/{reconOutId}/retry") @RequirePermission(menu = "RECON_OUT", perm = "UPDATE") @DataChangeLog(menu = "RECON_OUT", table = "carrier_reconciliation_out") diff --git a/ga-api/src/main/java/com/ga/api/controller/settle/ChargebackController.java b/ga-api/src/main/java/com/ga/api/controller/settle/ChargebackController.java index b306be0..1708a12 100644 --- a/ga-api/src/main/java/com/ga/api/controller/settle/ChargebackController.java +++ b/ga-api/src/main/java/com/ga/api/controller/settle/ChargebackController.java @@ -17,6 +17,18 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; +/** + * 환수(chargeback) 자료 관리 컨트롤러. + * + *

"환수"란 이미 설계사에게 지급한 수수료를 도로 거둬들이는 것입니다. 계약이 일찍 해지/실효되면 + * 보험사가 GA에게 수수료를 돌려달라 하고, GA는 그만큼을 설계사 정산에서 회수합니다. + * + *

주 업무는 보험사가 보내준 환수 엑셀(증권번호·환수금액·실효월·환수율)을 업로드(upload)해 + * chargeback 테이블에 적재하는 것입니다. 존재하지 않는 증권번호나 잘못된 금액은 에러행으로 돌려주고, + * 같은 계약+정산월 중복은 건너뜁니다(스킵). + * + *

권한(menu=CHARGEBACK_DATA): 조회 READ, 엑셀 EXPORT, 업로드 CREATE. + */ @Tag(name = "환수자료 관리") @Validated @RestController diff --git a/ga-api/src/main/java/com/ga/api/controller/settle/CommissionDisputeController.java b/ga-api/src/main/java/com/ga/api/controller/settle/CommissionDisputeController.java index 544c221..b4bfa98 100644 --- a/ga-api/src/main/java/com/ga/api/controller/settle/CommissionDisputeController.java +++ b/ga-api/src/main/java/com/ga/api/controller/settle/CommissionDisputeController.java @@ -16,6 +16,18 @@ import org.springframework.web.bind.annotation.*; import java.math.BigDecimal; import java.util.Map; +/** + * 수수료 이의신청(commission_dispute) 관리 컨트롤러. + * + *

설계사가 "내 수수료 정산 금액이 이상하다"고 문제를 제기하면 이의신청 건이 만들어집니다. + * 담당자는 검토를 시작(startReview)하고, 타당하면 승인(approve)하며 보정 금액을 정정(correction)과 연결하거나, + * 타당하지 않으면 기각(reject)합니다. + * + *

상태 흐름(DisputeStatus): OPEN(접수) → IN_REVIEW(검토중) → APPROVED(승인)/REJECTED(기각). + * 승인/기각은 IN_REVIEW 상태에서만 가능합니다. + * + *

권한(menu=DISPUTE): 조회 READ, 접수 CREATE, 검토시작 UPDATE, 승인/기각 APPROVE. + */ @Tag(name = "수수료 이의신청 관리") @RestController @RequestMapping("/api/disputes") @@ -24,18 +36,21 @@ public class CommissionDisputeController { private final CommissionDisputeService service; + /** 이의신청 목록 조회(검색 + 페이징). */ @GetMapping @RequirePermission(menu = "DISPUTE", perm = "READ") public ApiResponse> list(@ModelAttribute CommissionDisputeSearchParam param) { return ApiResponse.ok(service.list(param)); } + /** 이의신청 단건 상세 조회. */ @GetMapping("/{disputeId}") @RequirePermission(menu = "DISPUTE", perm = "READ") public ApiResponse detail(@PathVariable Long disputeId) { return ApiResponse.ok(service.detail(disputeId)); } + /** 이의신청 접수(OPEN 상태로 생성). */ @PostMapping @RequirePermission(menu = "DISPUTE", perm = "CREATE") @DataChangeLog(menu = "DISPUTE", table = "commission_dispute") @@ -43,6 +58,7 @@ public class CommissionDisputeController { return ApiResponse.ok(service.create(req)); } + /** 검토 시작(OPEN → IN_REVIEW). */ @PostMapping("/{disputeId}/review") @RequirePermission(menu = "DISPUTE", perm = "UPDATE") @DataChangeLog(menu = "DISPUTE", table = "commission_dispute") @@ -51,6 +67,10 @@ public class CommissionDisputeController { return ApiResponse.ok(); } + /** + * 이의신청 승인(IN_REVIEW → APPROVED). + * body 의 resolution(처리내용), adjustmentAmount(보정금액, 선택), correctionId(연결된 정산정정ID, 선택)를 받습니다. + */ @PostMapping("/{disputeId}/approve") @RequirePermission(menu = "DISPUTE", perm = "APPROVE") @DataChangeLog(menu = "DISPUTE", table = "commission_dispute") @@ -64,6 +84,7 @@ public class CommissionDisputeController { return ApiResponse.ok(); } + /** 이의신청 기각(IN_REVIEW → REJECTED). body 의 resolution 에 기각 사유를 담습니다. */ @PostMapping("/{disputeId}/reject") @RequirePermission(menu = "DISPUTE", perm = "APPROVE") @DataChangeLog(menu = "DISPUTE", table = "commission_dispute") diff --git a/ga-api/src/main/java/com/ga/api/controller/settle/IncentivePaymentController.java b/ga-api/src/main/java/com/ga/api/controller/settle/IncentivePaymentController.java index d425180..f0b0bb5 100644 --- a/ga-api/src/main/java/com/ga/api/controller/settle/IncentivePaymentController.java +++ b/ga-api/src/main/java/com/ga/api/controller/settle/IncentivePaymentController.java @@ -15,6 +15,17 @@ import org.springframework.web.bind.annotation.*; import java.util.Map; +/** + * 시책 지급(incentive_payment) 관리 컨트롤러. + * + *

"시책"은 보험사/GA가 특정 기간·상품에 거는 추가 인센티브(판매 장려금)입니다. + * 시책 프로그램(incentive_program) 조건을 충족한 설계사에게 지급할 금액을 건별로 만들어 확정/취소합니다. + * + *

상태 흐름(IncentivePayStatus): CALCULATED(산출) → CONFIRMED(확정) → PAID(지급완료), 또는 CANCELLED(취소). + * 확정은 CALCULATED 상태에서만, 취소는 PAID 가 아닐 때만 가능합니다. 생성·확정 시 정산월 마감 여부를 확인합니다. + * + *

권한(menu=INCENTIVE_PAY): 조회 READ, 생성 CREATE, 확정 APPROVE, 취소 UPDATE. + */ @Tag(name = "시책 지급 관리") @RestController @RequestMapping("/api/incentive-payments") @@ -23,18 +34,21 @@ public class IncentivePaymentController { private final IncentivePaymentService service; + /** 시책 지급 목록 조회(검색 + 페이징). */ @GetMapping @RequirePermission(menu = "INCENTIVE_PAY", perm = "READ") public ApiResponse> list(@ModelAttribute IncentivePaymentSearchParam param) { return ApiResponse.ok(service.list(param)); } + /** 시책 지급 단건 상세 조회. */ @GetMapping("/{incentivePayId}") @RequirePermission(menu = "INCENTIVE_PAY", perm = "READ") public ApiResponse detail(@PathVariable Long incentivePayId) { return ApiResponse.ok(service.detail(incentivePayId)); } + /** 시책 지급 생성(CALCULATED 상태). 해당 정산월이 마감되어 있으면 거부됩니다. */ @PostMapping @RequirePermission(menu = "INCENTIVE_PAY", perm = "CREATE") @DataChangeLog(menu = "INCENTIVE_PAY", table = "incentive_payment") @@ -42,6 +56,7 @@ public class IncentivePaymentController { return ApiResponse.ok(service.create(req)); } + /** 시책 지급 확정(CALCULATED → CONFIRMED). 마감 가드 적용. APPROVE 권한 필요. */ @PostMapping("/{incentivePayId}/confirm") @RequirePermission(menu = "INCENTIVE_PAY", perm = "APPROVE") @DataChangeLog(menu = "INCENTIVE_PAY", table = "incentive_payment") @@ -50,6 +65,7 @@ public class IncentivePaymentController { return ApiResponse.ok(); } + /** 시책 지급 취소(→ CANCELLED). body 의 cancelReason 에 취소 사유를 담습니다. 지급완료건은 취소 불가. */ @PostMapping("/{incentivePayId}/cancel") @RequirePermission(menu = "INCENTIVE_PAY", perm = "UPDATE") @DataChangeLog(menu = "INCENTIVE_PAY", table = "incentive_payment") diff --git a/ga-api/src/main/java/com/ga/api/controller/settle/IncentiveProgramController.java b/ga-api/src/main/java/com/ga/api/controller/settle/IncentiveProgramController.java index b21183b..c5eacd7 100644 --- a/ga-api/src/main/java/com/ga/api/controller/settle/IncentiveProgramController.java +++ b/ga-api/src/main/java/com/ga/api/controller/settle/IncentiveProgramController.java @@ -15,6 +15,16 @@ import org.springframework.web.bind.annotation.*; import java.util.Map; +/** + * 시책 프로그램(incentive_program) 관리 컨트롤러. + * + *

시책 "프로그램"은 인센티브 지급의 규칙 정의입니다. 대상(등급/조직/상품군), 기간(시작월~종료월), + * 지급 방식(정률 payRate 또는 정액 payAmountFixed), 예산(budgetAmount) 등을 설정합니다. + * 실제 지급(incentive_payment)은 이 프로그램 조건에 따라 산출됩니다. + * + *

활성/비활성(isActive)은 toggleActive 로 전환합니다. 비활성 프로그램은 신규 지급 대상에서 제외됩니다. + * 권한(menu=INCENTIVE_PROG): 조회 READ, 생성 CREATE, 수정/활성토글 UPDATE. + */ @Tag(name = "시책 프로그램 관리") @RestController @RequestMapping("/api/incentive-programs") @@ -23,18 +33,21 @@ public class IncentiveProgramController { private final IncentiveProgramService service; + /** 시책 프로그램 목록 조회(검색 + 페이징). */ @GetMapping @RequirePermission(menu = "INCENTIVE_PROG", perm = "READ") public ApiResponse> list(@ModelAttribute IncentiveProgramSearchParam param) { return ApiResponse.ok(service.list(param)); } + /** 시책 프로그램 단건 상세 조회. */ @GetMapping("/{programId}") @RequirePermission(menu = "INCENTIVE_PROG", perm = "READ") public ApiResponse detail(@PathVariable Long programId) { return ApiResponse.ok(service.detail(programId)); } + /** 시책 프로그램 신규 등록(기본 활성 상태로 생성). */ @PostMapping @RequirePermission(menu = "INCENTIVE_PROG", perm = "CREATE") @DataChangeLog(menu = "INCENTIVE_PROG", table = "incentive_program") @@ -42,6 +55,7 @@ public class IncentiveProgramController { return ApiResponse.ok(service.create(req)); } + /** 시책 프로그램 수정(전체 필드 갱신). */ @PutMapping("/{programId}") @RequirePermission(menu = "INCENTIVE_PROG", perm = "UPDATE") @DataChangeLog(menu = "INCENTIVE_PROG", table = "incentive_program") @@ -50,6 +64,7 @@ public class IncentiveProgramController { return ApiResponse.ok(); } + /** 활성/비활성 토글. body 의 isActive(true/false)로 사용 여부를 전환합니다. */ @PutMapping("/{programId}/active") @RequirePermission(menu = "INCENTIVE_PROG", perm = "UPDATE") @DataChangeLog(menu = "INCENTIVE_PROG", table = "incentive_program") diff --git a/ga-api/src/main/java/com/ga/api/controller/settle/PaymentBatchController.java b/ga-api/src/main/java/com/ga/api/controller/settle/PaymentBatchController.java index 77dc984..069b426 100644 --- a/ga-api/src/main/java/com/ga/api/controller/settle/PaymentBatchController.java +++ b/ga-api/src/main/java/com/ga/api/controller/settle/PaymentBatchController.java @@ -16,6 +16,18 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 지급대상 배치(payment_batch) 관리 컨트롤러. + * + *

"배치"는 한 정산월에 실제로 돈을 보낼 대상 설계사들을 한 묶음으로 모은 작업 단위입니다. + * 담당자는 배치를 만들어(create) 대상 명단을 확인하고, 빼야 할 사람은 제외(updateItem)한 뒤, + * 최종 확정(finalize)하여 이후 이체 처리로 넘깁니다. + * + *

상태 흐름(PaymentBatchStatus): DRAFT(작성중) → FINALIZED(확정) / CANCELLED(취소). + * 수정(updateItem)은 DRAFT 상태에서만 가능합니다. + * + *

권한(menu=PAYMENT_TARGET): 생성 CREATE, 조회/엑셀 READ, 항목수정 UPDATE, 확정 EXECUTE. + */ @Tag(name = "지급대상 배치 관리") @RestController @RequestMapping("/api/payment-batches") diff --git a/ga-api/src/main/java/com/ga/api/controller/settle/PaymentController.java b/ga-api/src/main/java/com/ga/api/controller/settle/PaymentController.java index d676c37..e1096bf 100644 --- a/ga-api/src/main/java/com/ga/api/controller/settle/PaymentController.java +++ b/ga-api/src/main/java/com/ga/api/controller/settle/PaymentController.java @@ -16,6 +16,19 @@ import lombok.RequiredArgsConstructor; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +/** + * 지급(payment) 관리 컨트롤러. + * + *

정산 마스터가 "이번 달 줄 금액"을 확정하면, 실제 계좌로 보낼 한 건 한 건이 payment(지급) 레코드가 됩니다. + * 이 컨트롤러는 지급 건 목록 조회, 상태 변경, 세금 계산, 차감 적용, 은행 이체파일 생성을 담당합니다. + * + *

지급 한 건의 금액 계산 순서(도메인): + * payAmount(지급원금) → 세금(소득세·지방세·부가세) 차감 → 각종 공제(차감) 차감 → netAmount(실지급액). + * 이 netAmount 가 최종적으로 설계사 통장에 들어가는 금액입니다. + * + *

@Validated + @Pattern 으로 settleMonth 는 YYYYMM(숫자 6자리) 형식만 허용합니다. + * 권한(menu=PAYMENT): 조회 READ, 상태/세금/차감 변경 UPDATE, 이체파일 생성 EXPORT. + */ @Tag(name = "지급 관리") @Validated @RestController @@ -25,12 +38,14 @@ public class PaymentController { private final PaymentService paymentService; + /** 지급 건 목록 조회(검색 + 페이징). */ @GetMapping @RequirePermission(menu = "PAYMENT", perm = "READ") public ApiResponse> list(@ModelAttribute SettleSearchParam param) { return ApiResponse.ok(paymentService.list(param)); } + /** 지급 상태 수동 변경(예: 실패 처리). 실패 시 req.failReason 에 사유를 담아 기록합니다. */ @PutMapping("/{paymentId}/status") @RequirePermission(menu = "PAYMENT", perm = "UPDATE") @DataChangeLog(menu = "PAYMENT", table = "payment") diff --git a/ga-api/src/main/java/com/ga/api/controller/settle/PaymentStatusUpdateReq.java b/ga-api/src/main/java/com/ga/api/controller/settle/PaymentStatusUpdateReq.java index d0db4d5..1f618f6 100644 --- a/ga-api/src/main/java/com/ga/api/controller/settle/PaymentStatusUpdateReq.java +++ b/ga-api/src/main/java/com/ga/api/controller/settle/PaymentStatusUpdateReq.java @@ -4,6 +4,10 @@ import jakarta.validation.constraints.NotBlank; import lombok.Getter; import lombok.Setter; +/** + * 지급 상태 변경 요청 DTO. + * status: 변경할 상태값(필수). failReason: 실패 처리 시 사유(선택). + */ @Getter @Setter public class PaymentStatusUpdateReq { diff --git a/ga-api/src/main/java/com/ga/api/controller/settle/PeriodCloseController.java b/ga-api/src/main/java/com/ga/api/controller/settle/PeriodCloseController.java index b2f9717..c450b70 100644 --- a/ga-api/src/main/java/com/ga/api/controller/settle/PeriodCloseController.java +++ b/ga-api/src/main/java/com/ga/api/controller/settle/PeriodCloseController.java @@ -15,6 +15,15 @@ import org.springframework.web.bind.annotation.*; import java.util.Map; +/** + * 정산 마감(period_close) 관리 컨트롤러. + * + *

"마감"은 특정 정산월의 정산 데이터를 잠그는 작업입니다. 마감된 월에는 정정·시책지급 등 금액을 바꾸는 + * 작업이 막혀(마감 가드), 이미 확정된 숫자가 사후에 흔들리는 것을 방지합니다. + * + *

마감(close) 후 정정이 필요하면 재오픈(reopen)으로 잠금을 풀 수 있으며, 재오픈 사유를 함께 기록합니다. + * 권한(menu=PERIOD_CLOSE): 조회 READ, 마감 CREATE, 재오픈 UPDATE. + */ @Tag(name = "정산 마감 관리") @RestController @RequestMapping("/api/period-close") @@ -23,18 +32,21 @@ public class PeriodCloseController { private final PeriodCloseService service; + /** 마감 이력 목록 조회(검색 + 페이징). */ @GetMapping @RequirePermission(menu = "PERIOD_CLOSE", perm = "READ") public ApiResponse> list(@ModelAttribute PeriodCloseSearchParam param) { return ApiResponse.ok(service.list(param)); } + /** 마감 이력 단건 상세 조회. */ @GetMapping("/{closeId}") @RequirePermission(menu = "PERIOD_CLOSE", perm = "READ") public ApiResponse detail(@PathVariable Long closeId) { return ApiResponse.ok(service.detail(closeId)); } + /** 정산월 마감 실행. 이미 마감된 월이면 거부됩니다. */ @PostMapping @RequirePermission(menu = "PERIOD_CLOSE", perm = "CREATE") @DataChangeLog(menu = "PERIOD_CLOSE", table = "period_close") @@ -43,6 +55,7 @@ public class PeriodCloseController { return ApiResponse.ok(); } + /** 마감 재오픈(잠금 해제). body 의 reopenReason 에 재오픈 사유를 담습니다. */ @PostMapping("/{closeId}/reopen") @RequirePermission(menu = "PERIOD_CLOSE", perm = "UPDATE") @DataChangeLog(menu = "PERIOD_CLOSE", table = "period_close") diff --git a/ga-api/src/main/java/com/ga/api/controller/settle/SettleController.java b/ga-api/src/main/java/com/ga/api/controller/settle/SettleController.java index f27abd6..4924e12 100644 --- a/ga-api/src/main/java/com/ga/api/controller/settle/SettleController.java +++ b/ga-api/src/main/java/com/ga/api/controller/settle/SettleController.java @@ -14,6 +14,18 @@ import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; +/** + * 정산 마스터(settle_master) 관리 컨트롤러. + * + *

정산 마스터는 "설계사 1명 × 정산월 1개" 단위로 그 달에 줄 수수료 합계를 모아둔 표(원장)입니다. + * 매달 배치가 각종 원장을 집계해 settle_master 한 행을 만들고, 담당자가 이 화면에서 확인/승인합니다. + * + *

상태 흐름(SettleStatus): CALCULATED(산출완료) → CONFIRMED(확정) → PAID(지급완료). + * 중간에 문제가 있으면 HOLD(보류)로 묶었다가 release 로 다시 CALCULATED 로 풀 수 있습니다. + * + *

권한(menu=SETTLE_LIST): 조회는 READ, 확정/보류/해제 같은 상태변경은 APPROVE 권한이 필요합니다. + * 상태변경 엔드포인트에는 @DataChangeLog 가 붙어 누가 무엇을 바꿨는지 변경이력이 자동 기록됩니다. + */ @Tag(name = "정산 관리") @RestController @RequestMapping("/api/settle") @@ -22,12 +34,14 @@ public class SettleController { private final SettleService service; + /** 정산 마스터 목록 조회(검색조건 + 페이징). 요청: SettleSearchParam, 응답: 페이지 형태의 정산 요약 목록. */ @GetMapping @RequirePermission(menu = "SETTLE_LIST", perm = "READ") public ApiResponse> list(@ModelAttribute SettleSearchParam param) { return ApiResponse.ok(service.list(param)); } + /** 정산 마스터 단건 상세 조회. 키는 "설계사ID + 정산월(YYYYMM)" 조합입니다. */ @GetMapping("/{agentId}/months/{settleMonth}") @RequirePermission(menu = "SETTLE_LIST", perm = "READ") public ApiResponse detail(@PathVariable Long agentId, @@ -35,18 +49,21 @@ public class SettleController { return ApiResponse.ok(service.detail(agentId, settleMonth)); } + /** 특정 정산월 전체 합계 요약(총 인원, 총 지급액 등 대시보드용 집계). */ @GetMapping("/summary") @RequirePermission(menu = "SETTLE_LIST", perm = "READ") public ApiResponse> summary(@RequestParam String settleMonth) { return ApiResponse.ok(service.summary(settleMonth)); } + /** 특정 정산월의 조직(지점)별 합계 요약. summary 를 조직 단위로 쪼갠 목록입니다. */ @GetMapping("/org-summary") @RequirePermission(menu = "SETTLE_LIST", perm = "READ") public ApiResponse>> orgSummary(@RequestParam String settleMonth) { return ApiResponse.ok(service.orgSummary(settleMonth)); } + /** 정산 확정(CONFIRMED). 확정 후에는 금액이 잠겨 지급 단계로 넘어갑니다. APPROVE 권한 필요. */ @PutMapping("/{settleId}/confirm") @RequirePermission(menu = "SETTLE_LIST", perm = "APPROVE") @DataChangeLog(menu = "SETTLE_LIST", table = "settle_master") @@ -55,6 +72,7 @@ public class SettleController { return ApiResponse.ok(); } + /** 정산 보류(HOLD). body 의 reason(사유)·reasonCode(사유코드)를 함께 기록합니다. 지급완료건은 보류 불가. */ @PutMapping("/{settleId}/hold") @RequirePermission(menu = "SETTLE_LIST", perm = "APPROVE") @DataChangeLog(menu = "SETTLE_LIST", table = "settle_master") @@ -63,6 +81,7 @@ public class SettleController { return ApiResponse.ok(); } + /** 보류 해제(release). HOLD 상태를 다시 CALCULATED 로 되돌려 정상 흐름에 복귀시킵니다. */ @PutMapping("/{settleId}/release") @RequirePermission(menu = "SETTLE_LIST", perm = "APPROVE") @DataChangeLog(menu = "SETTLE_LIST", table = "settle_master") diff --git a/ga-api/src/main/java/com/ga/api/controller/settle/SettleCorrectionController.java b/ga-api/src/main/java/com/ga/api/controller/settle/SettleCorrectionController.java index aa295d4..9c68ae6 100644 --- a/ga-api/src/main/java/com/ga/api/controller/settle/SettleCorrectionController.java +++ b/ga-api/src/main/java/com/ga/api/controller/settle/SettleCorrectionController.java @@ -16,6 +16,18 @@ import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; +/** + * 정산 정정(settle_correction) 관리 컨트롤러. + * + *

이미 산출된 정산 금액을 사후에 바로잡는 화면입니다. 원래 정산(originalSettleId)을 기준으로 + * 변경 전(beforeAmount)·후(afterAmount) 금액과 차액(diffAmount), 사유를 기록합니다. + * + *

상태 흐름(CorrectionStatus): DRAFT(작성) → APPROVED(승인) → APPLIED(반영완료), 또는 취소. + * 승인은 DRAFT 상태에서만, 취소는 이미 반영(APPLIED)되지 않은 건만 가능합니다. + * 생성 시 해당 정산월이 마감되어 있으면 거부됩니다(마감 가드). + * + *

권한(menu=SETTLE_CORRECTION): 조회 READ, 생성 CREATE, 승인 APPROVE, 취소 UPDATE. + */ @Tag(name = "정산 정정 관리") @RestController @RequestMapping("/api/settle-corrections") @@ -24,24 +36,28 @@ public class SettleCorrectionController { private final SettleCorrectionService service; + /** 정정 목록 조회(검색 + 페이징). */ @GetMapping @RequirePermission(menu = "SETTLE_CORRECTION", perm = "READ") public ApiResponse> list(@ModelAttribute SettleCorrectionSearchParam param) { return ApiResponse.ok(service.list(param)); } + /** 정정 단건 상세 조회. */ @GetMapping("/{correctionId}") @RequirePermission(menu = "SETTLE_CORRECTION", perm = "READ") public ApiResponse detail(@PathVariable Long correctionId) { return ApiResponse.ok(service.detail(correctionId)); } + /** 특정 원본 정산(originalSettleId)에 달린 정정 이력 전체 조회. */ @GetMapping("/by-settle/{originalSettleId}") @RequirePermission(menu = "SETTLE_CORRECTION", perm = "READ") public ApiResponse> listByOriginalSettle(@PathVariable Long originalSettleId) { return ApiResponse.ok(service.listByOriginalSettle(originalSettleId)); } + /** 정정 생성(DRAFT 상태). 차액(diffAmount)은 후-전 금액으로 자동 계산됩니다. 마감 가드 적용. */ @PostMapping @RequirePermission(menu = "SETTLE_CORRECTION", perm = "CREATE") @DataChangeLog(menu = "SETTLE_CORRECTION", table = "settle_correction") @@ -49,6 +65,7 @@ public class SettleCorrectionController { return ApiResponse.ok(service.create(req)); } + /** 정정 승인(DRAFT → APPROVED). APPROVE 권한 필요. */ @PostMapping("/{correctionId}/approve") @RequirePermission(menu = "SETTLE_CORRECTION", perm = "APPROVE") @DataChangeLog(menu = "SETTLE_CORRECTION", table = "settle_correction") @@ -57,6 +74,7 @@ public class SettleCorrectionController { return ApiResponse.ok(); } + /** 정정 취소. body 의 cancelReason 에 사유를 담습니다. 이미 반영(APPLIED)된 정정은 취소 불가. */ @PostMapping("/{correctionId}/cancel") @RequirePermission(menu = "SETTLE_CORRECTION", perm = "UPDATE") @DataChangeLog(menu = "SETTLE_CORRECTION", table = "settle_correction") diff --git a/ga-api/src/main/java/com/ga/api/controller/settle/TaxInvoiceController.java b/ga-api/src/main/java/com/ga/api/controller/settle/TaxInvoiceController.java index ed7a0a1..fa59fa4 100644 --- a/ga-api/src/main/java/com/ga/api/controller/settle/TaxInvoiceController.java +++ b/ga-api/src/main/java/com/ga/api/controller/settle/TaxInvoiceController.java @@ -15,6 +15,15 @@ import org.springframework.web.bind.annotation.*; import java.util.Map; +/** + * 세금계산서(tax_invoice) 관리 컨트롤러. + * + *

수수료 지급/거래에 대한 세금계산서 발행 이력을 관리합니다. 공급가액(supplyAmount) + 세액(taxAmount) = + * 합계(totalAmount)로 구성되며, 거래 상대방(상호·사업자번호·대표·주소) 정보를 함께 보관합니다. + * + *

발행번호(invoiceNo)는 유일해야 하며 중복 시 등록이 거부됩니다. 잘못 발행한 계산서는 취소(cancel)로 처리합니다. + * 권한(menu=TAX_INVOICE): 조회 READ, 발행 CREATE, 취소 UPDATE. + */ @Tag(name = "세금계산서 관리") @RestController @RequestMapping("/api/tax-invoices") @@ -23,18 +32,21 @@ public class TaxInvoiceController { private final TaxInvoiceService service; + /** 세금계산서 목록 조회(검색 + 페이징). */ @GetMapping @RequirePermission(menu = "TAX_INVOICE", perm = "READ") public ApiResponse> list(@ModelAttribute TaxInvoiceSearchParam param) { return ApiResponse.ok(service.list(param)); } + /** 세금계산서 단건 상세 조회. */ @GetMapping("/{invoiceId}") @RequirePermission(menu = "TAX_INVOICE", perm = "READ") public ApiResponse detail(@PathVariable Long invoiceId) { return ApiResponse.ok(service.detail(invoiceId)); } + /** 세금계산서 발행(등록). 발행번호 중복은 거부됩니다. 합계금액은 공급가액+세액으로 자동 계산됩니다. */ @PostMapping @RequirePermission(menu = "TAX_INVOICE", perm = "CREATE") @DataChangeLog(menu = "TAX_INVOICE", table = "tax_invoice") @@ -42,6 +54,7 @@ public class TaxInvoiceController { return ApiResponse.ok(service.create(req)); } + /** 세금계산서 취소. body 의 cancelReason 에 취소 사유를 담습니다(취소일은 서버 기준 오늘). */ @PostMapping("/{invoiceId}/cancel") @RequirePermission(menu = "TAX_INVOICE", perm = "UPDATE") @DataChangeLog(menu = "TAX_INVOICE", table = "tax_invoice") diff --git a/ga-api/src/main/java/com/ga/api/controller/settle/TerminationSettlementController.java b/ga-api/src/main/java/com/ga/api/controller/settle/TerminationSettlementController.java index ef44f19..611c907 100644 --- a/ga-api/src/main/java/com/ga/api/controller/settle/TerminationSettlementController.java +++ b/ga-api/src/main/java/com/ga/api/controller/settle/TerminationSettlementController.java @@ -13,6 +13,17 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 해촉 정산(termination_settlement) 관리 컨트롤러. + * + *

"해촉"은 설계사 위촉계약이 끝나는 것입니다. 그만둘 때 미지급 수수료(모집/분할/시책)는 정산해 주고, + * 향후 발생할 환수 예상액 등은 차감하여 최종 지급액(finalPayAmount)을 산출하는 일회성 정산입니다. + * + *

상태 흐름(TermSettleStatus): CALCULATING(산출중) → CONFIRMED(확정), 또는 CANCELLED(취소). + * 수정·확정은 CALCULATING 상태에서만 가능합니다. 한 설계사에 진행 중인 해촉 정산이 이미 있으면 중복 생성이 거부됩니다. + * + *

권한(menu=TERM_SETTLE): 조회 READ, 생성/process CREATE, 수정 UPDATE, 확정 APPROVE. + */ @Tag(name = "해촉 정산 관리") @RestController @RequestMapping("/api/termination-settlements") @@ -21,18 +32,21 @@ public class TerminationSettlementController { private final TerminationSettlementService service; + /** 해촉 정산 목록 조회(검색 + 페이징). */ @GetMapping @RequirePermission(menu = "TERM_SETTLE", perm = "READ") public ApiResponse> list(@ModelAttribute TerminationSettlementSearchParam param) { return ApiResponse.ok(service.list(param)); } + /** 해촉 정산 단건 상세 조회. */ @GetMapping("/{termSettleId}") @RequirePermission(menu = "TERM_SETTLE", perm = "READ") public ApiResponse detail(@PathVariable Long termSettleId) { return ApiResponse.ok(service.detail(termSettleId)); } + /** 해촉 정산 생성(CALCULATING 상태). 진행 중인 해촉 정산이 있으면 거부됩니다. */ @PostMapping @RequirePermission(menu = "TERM_SETTLE", perm = "CREATE") @DataChangeLog(menu = "TERM_SETTLE", table = "termination_settlement") @@ -40,6 +54,7 @@ public class TerminationSettlementController { return ApiResponse.ok(service.create(req)); } + /** 해촉 정산 금액 수정. 산출 중(CALCULATING) 상태에서만 가능합니다. */ @PutMapping("/{termSettleId}") @RequirePermission(menu = "TERM_SETTLE", perm = "UPDATE") @DataChangeLog(menu = "TERM_SETTLE", table = "termination_settlement") @@ -48,6 +63,7 @@ public class TerminationSettlementController { return ApiResponse.ok(); } + /** 해촉 발생 시 자동 처리 진입점. 내부적으로 create 와 동일하게 CALCULATING 레코드를 만듭니다. */ @PostMapping("/process") @RequirePermission(menu = "TERM_SETTLE", perm = "CREATE") @DataChangeLog(menu = "TERM_SETTLE", table = "termination_settlement") @@ -55,6 +71,7 @@ public class TerminationSettlementController { return ApiResponse.ok(service.process(req)); } + /** 해촉 정산 확정(CALCULATING → CONFIRMED). APPROVE 권한 필요. */ @PostMapping("/{termSettleId}/confirm") @RequirePermission(menu = "TERM_SETTLE", perm = "APPROVE") @DataChangeLog(menu = "TERM_SETTLE", table = "termination_settlement") diff --git a/ga-api/src/main/java/com/ga/api/controller/statement/CommissionStatementController.java b/ga-api/src/main/java/com/ga/api/controller/statement/CommissionStatementController.java index 9f7b9bd..d9b2c0c 100644 --- a/ga-api/src/main/java/com/ga/api/controller/statement/CommissionStatementController.java +++ b/ga-api/src/main/java/com/ga/api/controller/statement/CommissionStatementController.java @@ -21,6 +21,15 @@ import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +/** + * [수수료 명세서 API] + * + * 신입 개발자 안내: + * - 설계사에게 "이번 달 얼마를 어떻게 지급했는지"를 정리한 문서가 수수료 명세서다. + * - 데이터 출처: 지급(payment) 내역을 설계사+정산월 단위로 합산(StatementAggregator)하여 + * 총지급액/원천세/지방소득세/부가세/공제/실수령액을 산출하고, 엑셀 파일로 만들어 보관한다. + * - 발급: 단건(generate) 또는 월별 일괄(generateForMonth). 다운로드 시 상태가 DOWNLOADED 로 바뀐다. + */ @Tag(name = "수수료 명세서") @Validated @RestController @@ -30,12 +39,14 @@ public class CommissionStatementController { private final CommissionStatementService statementService; + // 명세서 목록 (검색/페이징) @GetMapping @RequirePermission(menu = "STATEMENT", perm = "READ") public ApiResponse> list(@ModelAttribute CommissionStatementSearchParam param) { return ApiResponse.ok(statementService.list(param)); } + // 명세서 1건 상세 @GetMapping("/{statementId}") @RequirePermission(menu = "STATEMENT", perm = "READ") public ApiResponse detail(@PathVariable Long statementId) { @@ -61,6 +72,7 @@ public class CommissionStatementController { return ApiResponse.ok(); } + // 발급된 명세서 엑셀 파일을 내려준다. 한글 파일명은 RFC5987(filename*=UTF-8'')로 인코딩해 깨짐을 방지. @Operation(summary = "명세서 다운로드") @GetMapping("/{statementId}/download") @RequirePermission(menu = "STATEMENT", perm = "READ") diff --git a/ga-api/src/main/java/com/ga/api/controller/succession/ContractSuccessionController.java b/ga-api/src/main/java/com/ga/api/controller/succession/ContractSuccessionController.java index ad337ac..0ab816c 100644 --- a/ga-api/src/main/java/com/ga/api/controller/succession/ContractSuccessionController.java +++ b/ga-api/src/main/java/com/ga/api/controller/succession/ContractSuccessionController.java @@ -13,6 +13,15 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 계약 승계 관리 API. + * + *

승계란 한 계약의 담당(귀속) 설계사를 전임(fromAgent)에서 후임(toAgent)으로 넘기는 것을 말한다. + * 퇴사·조직이동 등으로 담당자가 바뀌면, 승계일(successionDate) 이후의 유지수수료는 후임 설계사에게 귀속된다. + * 정산 배치가 이 승계 정보를 참고해 원장의 agent를 이관한다.

+ * + *

메뉴 CONTRACT_SUCCESSION 권한으로 보호된다.

+ */ @Tag(name = "계약 승계") @RestController @RequestMapping("/api/contract-successions") @@ -21,6 +30,7 @@ public class ContractSuccessionController { private final ContractSuccessionService service; + /** 승계 내역 목록 조회(페이징). READ 권한 필요. */ @GetMapping @RequirePermission(menu = "CONTRACT_SUCCESSION", perm = "READ") public ApiResponse> list( @@ -28,12 +38,14 @@ public class ContractSuccessionController { return ApiResponse.ok(service.list(param)); } + /** 승계 단건 상세 조회. READ 권한 필요. */ @GetMapping("/{successionId}") @RequirePermission(menu = "CONTRACT_SUCCESSION", perm = "READ") public ApiResponse detail(@PathVariable Long successionId) { return ApiResponse.ok(service.detail(successionId)); } + /** 승계 등록(담당 설계사 이관 예약). 전임/후임이 같으면 거부된다. CREATE 권한 필요. */ @PostMapping @RequirePermission(menu = "CONTRACT_SUCCESSION", perm = "CREATE") @DataChangeLog(menu = "CONTRACT_SUCCESSION", table = "contract_succession") @@ -41,6 +53,7 @@ public class ContractSuccessionController { return ApiResponse.ok(service.create(req)); } + /** 승계 취소(status=CANCELED). 취소 후 정산월부터 유지수수료는 다시 직전 귀속 설계사에게 돌아간다. UPDATE 권한 필요. */ @PostMapping("/{successionId}/cancel") @RequirePermission(menu = "CONTRACT_SUCCESSION", perm = "UPDATE") @DataChangeLog(menu = "CONTRACT_SUCCESSION", table = "contract_succession") @@ -49,6 +62,7 @@ public class ContractSuccessionController { return ApiResponse.ok(); } + /** 승계 레코드 물리 삭제. DELETE 권한 필요. */ @DeleteMapping("/{successionId}") @RequirePermission(menu = "CONTRACT_SUCCESSION", perm = "DELETE") @DataChangeLog(menu = "CONTRACT_SUCCESSION", table = "contract_succession") diff --git a/ga-api/src/main/java/com/ga/api/controller/system/RoleController.java b/ga-api/src/main/java/com/ga/api/controller/system/RoleController.java index d301446..f0a1282 100644 --- a/ga-api/src/main/java/com/ga/api/controller/system/RoleController.java +++ b/ga-api/src/main/java/com/ga/api/controller/system/RoleController.java @@ -12,6 +12,13 @@ import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; +/** + * 역할(Role) 및 권한 관리 API. + * + *

역할은 메뉴별 권한(menuId + permCode)의 묶음이다. 사용자에게 역할을 부여해 + * 접근 권한을 통제한다(RBAC). 역할별 권한 매핑은 savePermissions에서 전체 교체 방식으로 저장된다. + * 메뉴 SYSTEM_ROLE 권한으로 보호된다.

+ */ @Tag(name = "역할 관리") @RestController @RequestMapping("/api/system/roles") @@ -20,24 +27,28 @@ public class RoleController { private final RoleService roleService; + /** 역할 전체 목록 조회. READ 권한 필요. */ @GetMapping @RequirePermission(menu = "SYSTEM_ROLE", perm = "READ") public ApiResponse> list() { return ApiResponse.ok(roleService.list()); } + /** 역할 단건 상세 조회. READ 권한 필요. */ @GetMapping("/{roleId}") @RequirePermission(menu = "SYSTEM_ROLE", perm = "READ") public ApiResponse detail(@PathVariable Long roleId) { return ApiResponse.ok(roleService.detail(roleId)); } + /** 역할에 부여된 메뉴별 권한 목록 조회. READ 권한 필요. */ @GetMapping("/{roleId}/permissions") @RequirePermission(menu = "SYSTEM_ROLE", perm = "READ") public ApiResponse>> permissions(@PathVariable Long roleId) { return ApiResponse.ok(roleService.permissions(roleId)); } + /** 역할 신규 등록. CREATE 권한 필요. */ @PostMapping @RequirePermission(menu = "SYSTEM_ROLE", perm = "CREATE") @DataChangeLog(menu = "SYSTEM_ROLE", table = "role") @@ -45,6 +56,7 @@ public class RoleController { return ApiResponse.ok(roleService.create(vo)); } + /** 역할 정보 수정. UPDATE 권한 필요. */ @PutMapping("/{roleId}") @RequirePermission(menu = "SYSTEM_ROLE", perm = "UPDATE") @DataChangeLog(menu = "SYSTEM_ROLE", table = "role") @@ -53,6 +65,7 @@ public class RoleController { return ApiResponse.ok(); } + /** 역할 삭제. DELETE 권한 필요. */ @DeleteMapping("/{roleId}") @RequirePermission(menu = "SYSTEM_ROLE", perm = "DELETE") @DataChangeLog(menu = "SYSTEM_ROLE", table = "role") @@ -61,6 +74,7 @@ public class RoleController { return ApiResponse.ok(); } + /** 역할의 권한 매핑 일괄 저장(기존 전체 삭제 후 재삽입). UPDATE 권한 필요. */ @PutMapping("/{roleId}/permissions") @RequirePermission(menu = "SYSTEM_ROLE", perm = "UPDATE") @DataChangeLog(menu = "SYSTEM_ROLE", table = "role_permission") diff --git a/ga-api/src/main/java/com/ga/api/controller/system/UserController.java b/ga-api/src/main/java/com/ga/api/controller/system/UserController.java index 2fe9ae6..c0f0fe9 100644 --- a/ga-api/src/main/java/com/ga/api/controller/system/UserController.java +++ b/ga-api/src/main/java/com/ga/api/controller/system/UserController.java @@ -13,6 +13,13 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 시스템 사용자(로그인 계정) 관리 API. + * + *

사용자 계정의 생성·수정·비밀번호 초기화·잠금 해제·전화번호 정정을 제공한다. + * 사용자는 역할(role)을 통해 권한을 부여받는다(roleIds). 로그인 ID는 중복 불가다. + * 메뉴 SYSTEM_USER 권한으로 보호된다.

+ */ @Tag(name = "사용자 관리") @RestController @RequestMapping("/api/system/users") @@ -21,18 +28,21 @@ public class UserController { private final UserService service; + /** 사용자 목록 조회(페이징). READ 권한 필요. */ @GetMapping @RequirePermission(menu = "SYSTEM_USER", perm = "READ") public ApiResponse> list(@ModelAttribute UserSearchParam param) { return ApiResponse.ok(service.list(param)); } + /** 사용자 단건 상세 조회(역할 코드 포함). READ 권한 필요. */ @GetMapping("/{userId}") @RequirePermission(menu = "SYSTEM_USER", perm = "READ") public ApiResponse detail(@PathVariable Long userId) { return ApiResponse.ok(service.detail(userId)); } + /** 사용자 신규 등록. 비밀번호 미지정 시 임시 비밀번호로 생성하고 역할을 부여한다. CREATE 권한 필요. */ @PostMapping @RequirePermission(menu = "SYSTEM_USER", perm = "CREATE") @DataChangeLog(menu = "SYSTEM_USER", table = "users") @@ -40,6 +50,7 @@ public class UserController { return ApiResponse.ok(service.create(req)); } + /** 사용자 정보 수정. roleIds가 오면 역할을 전체 교체한다. UPDATE 권한 필요. */ @PutMapping("/{userId}") @RequirePermission(menu = "SYSTEM_USER", perm = "UPDATE") @DataChangeLog(menu = "SYSTEM_USER", table = "users") @@ -48,6 +59,7 @@ public class UserController { return ApiResponse.ok(); } + /** 비밀번호 초기화(임시 비밀번호 발급). UPDATE 권한 필요. */ @PutMapping("/{userId}/reset-password") @RequirePermission(menu = "SYSTEM_USER", perm = "UPDATE") public ApiResponse resetPassword(@PathVariable Long userId) { @@ -55,6 +67,7 @@ public class UserController { return ApiResponse.ok(); } + /** 계정 잠금 해제(로그인 실패 횟수 초기화 + ACTIVE 복귀). UPDATE 권한 필요. */ @PutMapping("/{userId}/unlock") @RequirePermission(menu = "SYSTEM_USER", perm = "UPDATE") public ApiResponse unlock(@PathVariable Long userId) { diff --git a/ga-api/src/main/java/com/ga/api/controller/tax/VatReportController.java b/ga-api/src/main/java/com/ga/api/controller/tax/VatReportController.java index b8e4beb..7315c29 100644 --- a/ga-api/src/main/java/com/ga/api/controller/tax/VatReportController.java +++ b/ga-api/src/main/java/com/ga/api/controller/tax/VatReportController.java @@ -17,6 +17,14 @@ import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; +/** + * [부가세 분기신고 API] + * + * 신입 개발자 안내: + * - 부가세(VAT)는 분기 단위(1~4분기)로 신고한다. 과세사업자인 설계사의 지급분에 붙은 부가세를 분기별로 모은다. + * - 흐름: regenerate(분기 재집계 → vat_report UPSERT) → 목록/상세 확인 → complete(신고 제출 완료 표시). + * - settleQuarter 형식은 보통 "YYYYQ"(예: 2026년 1분기)처럼 분기를 식별하는 값이다. + */ @Tag(name = "부가세 분기신고") @RestController @RequestMapping("/api/vat-reports") @@ -48,6 +56,7 @@ public class VatReportController { return ApiResponse.ok(service.regenerate(req)); } + // 선택한 신고건들의 상태를 "신고 완료(FILED)"로 변경 (실제 국세청 제출은 외부 절차, 여기선 상태만 갱신) @PutMapping("/complete") @Operation(summary = "부가세 분기신고 제출 완료 처리") @RequirePermission(menu = "VAT_REPORT", perm = "EXECUTE") diff --git a/ga-api/src/main/java/com/ga/api/controller/tax/WithholdingTaxReportController.java b/ga-api/src/main/java/com/ga/api/controller/tax/WithholdingTaxReportController.java index 818105b..31ff8b7 100644 --- a/ga-api/src/main/java/com/ga/api/controller/tax/WithholdingTaxReportController.java +++ b/ga-api/src/main/java/com/ga/api/controller/tax/WithholdingTaxReportController.java @@ -17,6 +17,14 @@ import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; +/** + * [원천세 분기신고 API] + * + * 신입 개발자 안내: + * - 원천세(원천징수세)는 설계사에게 지급할 때 회사가 미리 떼어 국세청에 대신 납부하는 세금이다. + * 사업소득은 3.3%(소득세 3% + 지방소득세 0.3%), 기타소득은 8.8%(소득세 8% + 지방소득세 0.8%)가 일반적이다. + * - 분기별로 떼어둔 원천세를 모아 신고한다. 흐름: regenerate(분기 재집계) → 목록/상세 → complete(제출 완료 표시). + */ @Tag(name = "원천세 분기신고") @RestController @RequestMapping("/api/withholding-tax-reports") @@ -48,6 +56,7 @@ public class WithholdingTaxReportController { return ApiResponse.ok(service.regenerate(req)); } + // 선택한 신고건들의 상태를 "신고 완료(FILED)"로 변경 (상태 갱신만, 실제 제출은 외부 절차) @PutMapping("/complete") @Operation(summary = "원천세 분기신고 제출 완료 처리") @RequirePermission(menu = "WITHHOLDING_TAX", perm = "EXECUTE") diff --git a/ga-api/src/main/java/com/ga/api/controller/training/AgentTrainingController.java b/ga-api/src/main/java/com/ga/api/controller/training/AgentTrainingController.java index 350e6f6..f986cc8 100644 --- a/ga-api/src/main/java/com/ga/api/controller/training/AgentTrainingController.java +++ b/ga-api/src/main/java/com/ga/api/controller/training/AgentTrainingController.java @@ -14,6 +14,17 @@ import org.springframework.web.bind.annotation.*; import java.math.BigDecimal; import java.util.List; +/** + * 보수교육(자격 유지교육) 관리 API. + * + *

보험설계사는 법정 의무로 매년 일정 시간의 교육을 이수해야 한다. 이 도메인은 두 부분으로 나뉜다. + *

    + *
  • 교육 이수 내역(agent_training): 설계사가 들은 개별 교육(과정/일자/시간/수료증 등).
  • + *
  • 연도별 의무 기준(training_requirement): 해당 연도에 요구되는 최소 이수 시간.
  • + *
+ * annual-hours로 설계사의 연간 누적 이수 시간을 조회해 의무 충족 여부를 판단한다. + * 메뉴 AGENT_TRAINING 권한으로 보호된다.

+ */ @Tag(name = "보수교육 관리") @RestController @RequiredArgsConstructor @@ -23,18 +34,21 @@ public class AgentTrainingController { // ── Training ────────────────────────────────────────────────────────────── + /** 교육 이수 내역 목록 조회(페이징). READ 권한 필요. */ @GetMapping("/api/agent-trainings") @RequirePermission(menu = "AGENT_TRAINING", perm = "READ") public ApiResponse> list(@ModelAttribute AgentTrainingSearchParam param) { return ApiResponse.ok(service.list(param)); } + /** 교육 이수 단건 상세 조회. READ 권한 필요. */ @GetMapping("/api/agent-trainings/{trainingId}") @RequirePermission(menu = "AGENT_TRAINING", perm = "READ") public ApiResponse detail(@PathVariable Long trainingId) { return ApiResponse.ok(service.detail(trainingId)); } + /** 설계사의 특정 연도 누적 이수 시간 조회(의무 충족 판단용). READ 권한 필요. */ @GetMapping("/api/agent-trainings/annual-hours") @RequirePermission(menu = "AGENT_TRAINING", perm = "READ") public ApiResponse annualHours(@RequestParam Long agentId, @@ -42,6 +56,7 @@ public class AgentTrainingController { return ApiResponse.ok(service.annualHours(agentId, year)); } + /** 교육 이수 내역 등록. CREATE 권한 필요. */ @PostMapping("/api/agent-trainings") @RequirePermission(menu = "AGENT_TRAINING", perm = "CREATE") @DataChangeLog(menu = "AGENT_TRAINING", table = "agent_training") @@ -49,6 +64,7 @@ public class AgentTrainingController { return ApiResponse.ok(service.create(req)); } + /** 교육 이수 내역 수정. UPDATE 권한 필요. */ @PutMapping("/api/agent-trainings/{trainingId}") @RequirePermission(menu = "AGENT_TRAINING", perm = "UPDATE") @DataChangeLog(menu = "AGENT_TRAINING", table = "agent_training") @@ -58,6 +74,7 @@ public class AgentTrainingController { return ApiResponse.ok(); } + /** 교육 이수 내역 삭제. DELETE 권한 필요. */ @DeleteMapping("/api/agent-trainings/{trainingId}") @RequirePermission(menu = "AGENT_TRAINING", perm = "DELETE") @DataChangeLog(menu = "AGENT_TRAINING", table = "agent_training") @@ -68,6 +85,7 @@ public class AgentTrainingController { // ── Requirement ─────────────────────────────────────────────────────────── + /** 연도별 의무 교육시간 기준 목록 조회. READ 권한 필요. */ @GetMapping("/api/training-requirements") @RequirePermission(menu = "AGENT_TRAINING", perm = "READ") public ApiResponse> listRequirements( @@ -75,12 +93,14 @@ public class AgentTrainingController { return ApiResponse.ok(service.listRequirements(param)); } + /** 특정 연도의 의무 교육시간 기준 조회. READ 권한 필요. */ @GetMapping("/api/training-requirements/{year}") @RequirePermission(menu = "AGENT_TRAINING", perm = "READ") public ApiResponse requirementByYear(@PathVariable int year) { return ApiResponse.ok(service.requirementByYear(year)); } + /** 연도별 의무 교육시간 기준 저장(있으면 수정, 없으면 신규 = upsert). UPDATE 권한 필요. */ @PostMapping("/api/training-requirements") @RequirePermission(menu = "AGENT_TRAINING", perm = "UPDATE") @DataChangeLog(menu = "AGENT_TRAINING", table = "training_requirement") diff --git a/ga-api/src/main/java/com/ga/api/controller/upload/UploadController.java b/ga-api/src/main/java/com/ga/api/controller/upload/UploadController.java index e7f21e3..6cd97ad 100644 --- a/ga-api/src/main/java/com/ga/api/controller/upload/UploadController.java +++ b/ga-api/src/main/java/com/ga/api/controller/upload/UploadController.java @@ -18,15 +18,27 @@ import org.springframework.web.multipart.MultipartFile; import java.util.List; import java.util.Map; +/** + * [엑셀 업로드 API] + * + * 신입 개발자 안내: + * - 보험사/내부에서 받은 엑셀을 "업로드 템플릿(templateCode)"의 규칙대로 파싱·변환하여 DB에 적재한다. + * - 템플릿은 "엑셀의 어느 컬럼(A,B,C..)을 어느 DB 필드(target_field)에 어떻게 변환해 넣을지"를 정의한다. + * - 흐름: (1) 샘플 헤더 추출 → (2) 미리보기로 변환 결과 확인 → (3) 실제 업로드(DB INSERT). + * - 실제 적재/검증 로직은 UploadService 가 담당하고, 여기서는 HTTP 입출력만 연결한다. + */ @Tag(name = "엑셀 업로드") @Validated @RestController @RequiredArgsConstructor public class UploadController { + // 업로드 파싱·변환·적재 핵심 서비스 private final UploadService uploadService; + // target_table 별 사용 가능한 컬럼 메타(화이트리스트 + 프론트 옵션) 제공 서비스 private final TargetFieldMetaService metaService; + // [실제 업로드] 파일을 템플릿 규칙대로 파싱·변환·검증 후 배치 INSERT 하고 처리 결과(성공/오류 건수 등)를 돌려준다. @Operation(summary = "엑셀 업로드 처리", description = "templateCode 에 해당하는 템플릿으로 파일을 파싱 · 변환 · 배치 INSERT 한다.") @PostMapping("/api/upload/{templateCode}") @@ -37,6 +49,7 @@ public class UploadController { return ApiResponse.ok(result); } + // [미리보기] DB에 저장하지 않고 상위 N행을 템플릿대로 변환만 해 보여준다. 매핑이 맞는지 사전 확인용. @Operation(summary = "업로드 미리보기 (상위 5행)", description = "DB 에 저장하지 않고 변환 결과만 반환한다.") @PostMapping("/api/upload-templates/{templateCode}/preview") @@ -49,6 +62,7 @@ public class UploadController { return ApiResponse.ok(rows); } + // [헤더 추출] 템플릿을 만들기 전 단계. 샘플 엑셀의 헤더 행과 몇 줄을 읽어 프론트가 컬럼 매핑 UI를 자동 구성하게 한다. @Operation(summary = "샘플 엑셀 헤더 추출", description = "템플릿 등록 전에 샘플 파일 첫 행 헤더 + 미리보기 행을 추출한다. " + "프론트는 이 결과로 sourceColumn(A,B,C..) + sourceHeader 를 자동 채운다.") @@ -62,6 +76,7 @@ public class UploadController { return ApiResponse.ok(uploadService.extractHeaders(file, headerRow, sheetIndex, previewRows)); } + // [컬럼 메타] 특정 대상 테이블에 넣을 수 있는 DB 필드 목록(한글명/타입/필수여부)을 반환. 매핑 시 어떤 필드를 고를지 안내. @Operation(summary = "target_table 별 사용 가능한 컬럼 메타", description = "프론트의 target_field Select 옵션 + 타입 자동 채우기에 사용한다.") @GetMapping("/api/upload-templates/target-fields/{targetTable}") @@ -70,6 +85,7 @@ public class UploadController { return ApiResponse.ok(metaService.getFields(targetTable)); } + // [대상 테이블 목록] 업로드로 적재 가능한 테이블 이름들(화이트리스트)을 반환. @Operation(summary = "사용 가능한 target_table 목록") @GetMapping("/api/upload-templates/target-tables") @RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ") diff --git a/ga-api/src/main/java/com/ga/api/controller/upload/UploadTemplateController.java b/ga-api/src/main/java/com/ga/api/controller/upload/UploadTemplateController.java index 4ce00e4..4e559a6 100644 --- a/ga-api/src/main/java/com/ga/api/controller/upload/UploadTemplateController.java +++ b/ga-api/src/main/java/com/ga/api/controller/upload/UploadTemplateController.java @@ -14,6 +14,16 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +/** + * [업로드 템플릿 관리 API] + * + * 신입 개발자 안내: + * - "업로드 템플릿"이란 엑셀 → DB 적재 규칙의 묶음이다. + * 템플릿 본체(upload_template) + 컬럼 매핑 정의(upload_template_column) + 업로드 이력(upload_history)으로 구성된다. + * - 컬럼 매핑은 "엑셀 컬럼(sourceColumn) → 변환규칙(transformRule) → DB 필드(targetField)"를 한 줄씩 정의한다. + * - 실제 엑셀 파싱/적재는 UploadController + UploadService 가 담당하고, 여기는 템플릿 CRUD/이력 조회만 다룬다. + * - @DataChangeLog: 변경(등록/수정/삭제)은 감사 로그로 남긴다. + */ @Tag(name = "업로드 템플릿 관리") @RestController @RequestMapping("/api/upload-templates") @@ -22,6 +32,7 @@ public class UploadTemplateController { private final UploadTemplateService templateService; + // 템플릿 목록 (검색/페이징) @Operation(summary = "템플릿 목록 조회") @GetMapping @RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ") @@ -29,6 +40,7 @@ public class UploadTemplateController { return ApiResponse.ok(templateService.list(param)); } + // 템플릿 1건 + 그 컬럼 매핑 정의까지 함께 조회 @Operation(summary = "템플릿 상세 조회 (컬럼 포함)") @GetMapping("/{templateId}") @RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ") @@ -36,6 +48,7 @@ public class UploadTemplateController { return ApiResponse.ok(templateService.detail(templateId)); } + // 템플릿 신규 등록 (templateCode 중복 불가). 컬럼 정의를 함께 보내면 같이 저장된다. @Operation(summary = "템플릿 등록") @PostMapping @RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "CREATE") @@ -44,6 +57,7 @@ public class UploadTemplateController { return ApiResponse.ok(templateService.create(req)); } + // 템플릿 수정. 컬럼 정의가 함께 오면 기존 컬럼을 지우고 재등록한다(전체 교체). @Operation(summary = "템플릿 수정") @PutMapping("/{templateId}") @RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "UPDATE") @@ -54,6 +68,7 @@ public class UploadTemplateController { return ApiResponse.ok(); } + // 템플릿 삭제 (컬럼 정의도 함께 삭제) @Operation(summary = "템플릿 삭제") @DeleteMapping("/{templateId}") @RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "DELETE") @@ -63,6 +78,7 @@ public class UploadTemplateController { return ApiResponse.ok(); } + // 템플릿의 컬럼 매핑 정의만 따로 조회 @Operation(summary = "템플릿 컬럼 목록 조회") @GetMapping("/{templateId}/columns") @RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ") @@ -70,6 +86,7 @@ public class UploadTemplateController { return ApiResponse.ok(templateService.columns(templateId)); } + // 컬럼 매핑만 통째로 교체(기존 전체 삭제 후 새로 등록). 매핑 편집 화면 저장에 사용. @Operation(summary = "템플릿 컬럼 전체 갱신 (기존 삭제 후 재등록)") @PutMapping("/{templateId}/columns") @RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "UPDATE") @@ -80,6 +97,7 @@ public class UploadTemplateController { return ApiResponse.ok(); } + // 이 템플릿으로 수행한 과거 업로드 이력(성공/오류 건수, 상태 등) 조회 @Operation(summary = "템플릿 업로드 이력 조회") @GetMapping("/{templateId}/history") @RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ") diff --git a/ga-api/src/main/java/com/ga/api/controller/withdraw/WithdrawRequestController.java b/ga-api/src/main/java/com/ga/api/controller/withdraw/WithdrawRequestController.java index 406afae..73f9a83 100644 --- a/ga-api/src/main/java/com/ga/api/controller/withdraw/WithdrawRequestController.java +++ b/ga-api/src/main/java/com/ga/api/controller/withdraw/WithdrawRequestController.java @@ -13,6 +13,17 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; +/** + * 출금 신청(withdraw_request) 컨트롤러. + * + *

설계사가 자기 정산 잔액에서 돈을 빼달라고 요청하는 화면입니다. 신청하면 결재(승인) 절차가 자동으로 연동되고, + * 승인 후 펌뱅킹(은행 이체)으로 실제 입금이 처리됩니다. + * + *

상태 흐름(WithdrawStatus): REQUESTED(신청) → APPROVED(승인) → SENT(이체요청) → COMPLETED(입금완료). + * 취소는 아직 신청(REQUESTED) 상태일 때만 가능합니다. 응답의 계좌번호(accountNo)는 뒤 4자리만 보이도록 마스킹됩니다. + * + *

권한(menu=WITHDRAW): 조회 READ, 신청 CREATE, 취소 UPDATE. + */ @Tag(name = "출금 신청") @RestController @RequestMapping("/api/withdraw-requests") @@ -21,18 +32,21 @@ public class WithdrawRequestController { private final WithdrawRequestService service; + /** 출금 신청 목록 조회(검색 + 페이징). 계좌번호는 마스킹되어 반환됩니다. */ @GetMapping @RequirePermission(menu = "WITHDRAW", perm = "READ") public ApiResponse> list(@ModelAttribute WithdrawRequestSearchParam param) { return ApiResponse.ok(service.list(param)); } + /** 출금 신청 단건 상세 조회. 계좌번호는 마스킹되어 반환됩니다. */ @GetMapping("/{requestId}") @RequirePermission(menu = "WITHDRAW", perm = "READ") public ApiResponse detail(@PathVariable Long requestId) { return ApiResponse.ok(service.detail(requestId)); } + /** 출금 신청 등록(REQUESTED 상태). 등록과 동시에 결재 요청이 자동 생성됩니다. */ @PostMapping @RequirePermission(menu = "WITHDRAW", perm = "CREATE") @DataChangeLog(menu = "WITHDRAW", table = "withdraw_request") @@ -40,6 +54,7 @@ public class WithdrawRequestController { return ApiResponse.ok(service.create(req)); } + /** 출금 신청 취소. 신청(REQUESTED) 상태에서만 가능합니다. */ @PostMapping("/{requestId}/cancel") @RequirePermission(menu = "WITHDRAW", perm = "UPDATE") @DataChangeLog(menu = "WITHDRAW", table = "withdraw_request") diff --git a/ga-api/src/main/java/com/ga/api/service/accounting/ContractAccountingService.java b/ga-api/src/main/java/com/ga/api/service/accounting/ContractAccountingService.java index 033742e..926d6f6 100644 --- a/ga-api/src/main/java/com/ga/api/service/accounting/ContractAccountingService.java +++ b/ga-api/src/main/java/com/ga/api/service/accounting/ContractAccountingService.java @@ -19,6 +19,13 @@ import java.time.LocalDate; import java.util.ArrayList; import java.util.List; +/** + * 계약 회계 분개(contract_accounting_entry) 서비스. + * + *

수수료 지급(payment) 건을 복식부기 분개로 자동 변환합니다. + * 분개 1건 = 차변(8350 지급수수료) / 대변(2530 미지급금), 금액은 실지급액(netAmount, 없으면 payAmount)을 사용합니다. + * generateFromPayments 로 분개를 일괄 생성하고, closeJournal 로 전표번호를 부여해 마감합니다. + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -30,11 +37,13 @@ public class ContractAccountingService { private final ContractAccountingEntryMapper entryMapper; private final PaymentMapper paymentMapper; + /** 분개 엔트리 목록 조회(페이징). */ public PageResponse list(ContractAccountingEntrySearchParam param) { param.startPage(); return PageResponse.of(entryMapper.selectList(param)); } + /** 지급건들을 분개로 변환해 일괄 INSERT. 대상이 하나도 없으면 NOT_FOUND. 반환값은 생성된 분개 수. */ @Transactional public int generateFromPayments(GenerateEntriesReq req) { List payments = resolvePayments(req); @@ -47,6 +56,7 @@ public class ContractAccountingService { return entryMapper.insertBatch(entries); } + /** 지정한 분개들에 전표번호를 부여해 마감. 반환값은 마감 처리된 분개 수. */ @Transactional public int closeJournal(CloseJournalReq req) { if (req.getEntryIds() == null || req.getEntryIds().isEmpty()) { @@ -55,6 +65,7 @@ public class ContractAccountingService { return entryMapper.closeJournal(req.getEntryIds(), req.getJournalNo()); } + /** 분개 대상 지급건 결정: paymentIds 우선, 없으면 settleMonth 전체, 둘 다 없으면 빈 목록. */ private List resolvePayments(GenerateEntriesReq req) { if (req.getPaymentIds() != null && !req.getPaymentIds().isEmpty()) { List result = new ArrayList<>(); @@ -70,6 +81,7 @@ public class ContractAccountingService { return List.of(); } + /** 지급 1건 → 분개 VO 변환(분개일=오늘, 금액=netAmount 우선). */ private ContractAccountingEntryVO buildEntry(PaymentVO p) { ContractAccountingEntryVO vo = new ContractAccountingEntryVO(); vo.setPaymentId(p.getPaymentId()); diff --git a/ga-api/src/main/java/com/ga/api/service/approval/ApprovalService.java b/ga-api/src/main/java/com/ga/api/service/approval/ApprovalService.java index 813acc9..12543e8 100644 --- a/ga-api/src/main/java/com/ga/api/service/approval/ApprovalService.java +++ b/ga-api/src/main/java/com/ga/api/service/approval/ApprovalService.java @@ -33,6 +33,21 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.List; +/** + * 전자결재 도메인 비즈니스 로직. + * + *

결재선(line)과 결재 단계(step) 템플릿을 관리하고, 결재 요청(request)을 단계별로 진행시킨다. + * 핵심 흐름: + *

    + *
  • 상신: createRequest → 상태 PENDING, currentStep=1, 1단계 결재자 알림.
  • + *
  • 진행: advance → 이력 INSERT 후 APPROVE/REJECT 처리. REJECT면 즉시 REJECTED 종료, + * APPROVE면 다음 단계로 진행하거나(현재단계 < maxStep) 마지막 단계면 APPROVED 후 콜백.
  • + *
  • 최종 승인 콜백(handleApprovalCallback): targetType별로 출금/정산/이의신청/시책 도메인 상태를 갱신. + * 출금(WITHDRAW)은 추가로 펌뱅킹 어댑터에 이체를 요청한다(어댑터 실패해도 결재 흐름은 깨지지 않음).
  • + *
+ * 외부 발송(SMS/펌뱅킹)은 비핵심(non-critical)으로 처리해 실패 시 로깅만 하고 결재 트랜잭션은 진행한다. + * 클래스 전체는 읽기 전용이며 쓰기 메서드만 별도 트랜잭션을 건다.

+ */ @Slf4j @Service @RequiredArgsConstructor @@ -58,21 +73,25 @@ public class ApprovalService { // ── Line ────────────────────────────────────────────────────────────────── + /** 결재선 목록 조회(페이징). */ public PageResponse listLines(ApprovalLineSearchParam param) { param.startPage(); return PageResponse.of(lineMapper.selectList(param)); } + /** 결재선 상세 조회. 없으면 NOT_FOUND. */ public ApprovalLineResp detailLine(Long lineId) { ApprovalLineResp resp = lineMapper.selectById(lineId); if (resp == null) throw new BizException(ErrorCode.NOT_FOUND); return resp; } + /** 특정 결재선의 단계 목록 조회. */ public List stepsOfLine(Long lineId) { return stepMapper.selectByLine(lineId); } + /** 결재선 등록. 활성 상태로 시작하고 작성자를 기록한다. */ @Transactional public Long createLine(ApprovalLineSaveReq req) { ApprovalLineVO vo = new ApprovalLineVO(); @@ -86,6 +105,7 @@ public class ApprovalService { return vo.getLineId(); } + /** 결재선 수정. 존재 검증 후 갱신한다. */ @Transactional public void updateLine(Long lineId, ApprovalLineSaveReq req) { ApprovalLineResp existing = lineMapper.selectById(lineId); @@ -99,6 +119,7 @@ public class ApprovalService { lineMapper.update(vo); } + /** 결재선에 단계(step) 추가. 단계 순번(stepNo)과 결재 역할(roleCode)을 저장한다. */ @Transactional public void addStep(ApprovalLineStepSaveReq req) { ApprovalLineStepVO vo = new ApprovalLineStepVO(); @@ -110,6 +131,7 @@ public class ApprovalService { // ── Request ─────────────────────────────────────────────────────────────── + /** 결재 요청 전체 목록 조회(페이징). */ public PageResponse listRequests(ApprovalRequestSearchParam param) { param.startPage(); return PageResponse.of(requestMapper.selectList(param)); @@ -129,12 +151,14 @@ public class ApprovalService { return PageResponse.of(requestMapper.selectList(param)); } + /** 결재 요청 상세 조회. 없으면 NOT_FOUND. */ public ApprovalRequestResp detailRequest(Long requestId) { ApprovalRequestResp resp = requestMapper.selectById(requestId); if (resp == null) throw new BizException(ErrorCode.NOT_FOUND); return resp; } + /** 결재 요청의 단계별 처리 이력 조회. */ public List historyOfRequest(Long requestId) { return historyMapper.selectByRequest(requestId); } diff --git a/ga-api/src/main/java/com/ga/api/service/attachment/AttachmentService.java b/ga-api/src/main/java/com/ga/api/service/attachment/AttachmentService.java index 27c5a94..ec13884 100644 --- a/ga-api/src/main/java/com/ga/api/service/attachment/AttachmentService.java +++ b/ga-api/src/main/java/com/ga/api/service/attachment/AttachmentService.java @@ -22,6 +22,13 @@ import java.nio.file.Paths; import java.util.List; import java.util.UUID; +/** + * 첨부파일 도메인 비즈니스 로직. + * + *

파일은 {@code app.attachment.path}(기본 ./uploads) 아래 refType별 폴더에 UUID 접두 파일명으로 저장하고, + * 메타는 DB에 적재한다. 파일 저장이 실패해도 메타는 남기되 경로를 {@code [STORAGE_FAILED]} 로 표시해 + * 다운로드를 막는다(데이터 유실 추적 가능). 클래스 전체는 읽기 전용이며 쓰기 메서드만 별도 트랜잭션을 건다.

+ */ @Slf4j @Service @RequiredArgsConstructor @@ -33,15 +40,18 @@ public class AttachmentService { @Value("${app.attachment.path:./uploads}") private String uploadBasePath; + /** 첨부 메타 목록 조회(페이징). */ public PageResponse list(AttachmentSearchParam param) { param.startPage(); return PageResponse.of(mapper.selectList(param)); } + /** 특정 대상(refType+refId)에 연결된 첨부 목록 조회. */ public List listByRef(String refType, Long refId) { return mapper.selectByRef(refType, refId); } + /** 첨부 메타 단건 조회. 없으면 NOT_FOUND. */ public AttachmentResp detail(Long attachmentId) { AttachmentResp resp = mapper.selectById(attachmentId); if (resp == null) throw new BizException(ErrorCode.NOT_FOUND); diff --git a/ga-api/src/main/java/com/ga/api/service/chargeback/ChargebackCalculator.java b/ga-api/src/main/java/com/ga/api/service/chargeback/ChargebackCalculator.java index 10ebaa9..cd382fc 100644 --- a/ga-api/src/main/java/com/ga/api/service/chargeback/ChargebackCalculator.java +++ b/ga-api/src/main/java/com/ga/api/service/chargeback/ChargebackCalculator.java @@ -11,26 +11,41 @@ import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; +/** + * 차등환수 금액 계산기. + * 조기해지 시 "경과개월수 → 적용 환수율 → 환수액" 순으로 계산한다. + * 핵심 공식: 환수액 = 원수수료 × 환수율(%) / 100 (소수점 반올림). + */ @Component @RequiredArgsConstructor public class ChargebackCalculator { + /** 환수 등급의 유효기간 비교에 쓰는 날짜 포맷. */ private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); private final ChargebackGradeMapper gradeMapper; + /** + * 환수액을 계산한다. + * 1) 계약일~해지일의 경과개월수(monthsElapsed)를 구한다. + * 2) 상품분류·경과월·기준일에 맞는 환수 등급(환수율 구간)을 조회한다. + * 3) 해당 구간이 없으면 환수 0원, 있으면 원수수료 × 환수율로 환수액을 산출한다. + */ 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) { + // 적용할 환수 구간이 없으면 환수하지 않음(0원) return new ChargebackResult(BigDecimal.ZERO, monthsElapsed, BigDecimal.ZERO, "적용 환수 구간 없음"); } + // 환수액 = 원수수료 × 환수율 / 100, 원 단위 반올림(HALF_UP) BigDecimal chargebackAmount = originalCommission .multiply(grade.getChargebackPercent()) .divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP); diff --git a/ga-api/src/main/java/com/ga/api/service/chargeback/ChargebackGradeService.java b/ga-api/src/main/java/com/ga/api/service/chargeback/ChargebackGradeService.java index 0b82da2..be21b8f 100644 --- a/ga-api/src/main/java/com/ga/api/service/chargeback/ChargebackGradeService.java +++ b/ga-api/src/main/java/com/ga/api/service/chargeback/ChargebackGradeService.java @@ -15,20 +15,28 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.time.format.DateTimeFormatter; +/** + * 차등환수 등급(chargeback_grade) 마스터 비즈니스 로직 서비스. + * "경과개월 구간별 환수율(%)" 기준표를 CRUD 하고 활성/비활성을 관리한다. + * 이 기준표가 ChargebackCalculator 의 환수율 조회 대상이 된다. + */ @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 list(ChargebackGradeSearchParam param) { param.startPage(); return PageResponse.of(gradeMapper.selectList(param)); } + /** 환수 등급 상세 조회. 없으면 NOT_FOUND. */ @Transactional(readOnly = true) public ChargebackGradeResp detail(Long gradeId) { ChargebackGradeResp resp = gradeMapper.selectDetailById(gradeId); @@ -36,6 +44,7 @@ public class ChargebackGradeService { return resp; } + /** 환수 등급 신규 등록. 같은 상품분류의 시작개월(monthsFrom) 구간이 이미 활성이면 중복 오류. */ @Transactional public void create(ChargebackGradeSaveReq req) { String today = LocalDate.now().format(DATE_FMT); @@ -47,6 +56,7 @@ public class ChargebackGradeService { gradeMapper.insert(toVO(req)); } + /** 환수 등급 수정. 활성 여부(isActive)는 기존 값을 유지(수정 API로는 변경하지 않음). */ @Transactional public void update(Long gradeId, ChargebackGradeSaveReq req) { ChargebackGradeVO existing = gradeMapper.selectById(gradeId); @@ -57,6 +67,7 @@ public class ChargebackGradeService { gradeMapper.update(vo); } + /** 환수 등급 비활성화. 이미 비활성이면 잘못된 요청 처리. */ @Transactional public void deactivate(Long gradeId) { ChargebackGradeVO existing = gradeMapper.selectById(gradeId); @@ -67,11 +78,13 @@ public class ChargebackGradeService { gradeMapper.updateStatus(gradeId, false); } + /** 상품분류·경과월·기준일에 해당하는 환수율 구간 1건 조회(계산기가 사용). */ @Transactional(readOnly = true) public ChargebackGradeVO selectByMonths(String productCategory, Integer monthsElapsed, String baseDate) { return gradeMapper.selectByMonths(productCategory, monthsElapsed, baseDate); } + /** 저장 요청 DTO(SaveReq)를 DB 엔티티(VO)로 변환하는 내부 헬퍼. 신규는 항상 활성(true). */ private ChargebackGradeVO toVO(ChargebackGradeSaveReq req) { ChargebackGradeVO vo = new ChargebackGradeVO(); vo.setProductCategory(req.getProductCategory()); diff --git a/ga-api/src/main/java/com/ga/api/service/chargeback/ChargebackResult.java b/ga-api/src/main/java/com/ga/api/service/chargeback/ChargebackResult.java index 57a2065..351bd75 100644 --- a/ga-api/src/main/java/com/ga/api/service/chargeback/ChargebackResult.java +++ b/ga-api/src/main/java/com/ga/api/service/chargeback/ChargebackResult.java @@ -2,6 +2,13 @@ package com.ga.api.service.chargeback; import java.math.BigDecimal; +/** + * 차등환수 계산 결과를 담는 불변 레코드(record). + * - chargebackAmount : 환수(회수)할 금액 + * - monthsElapsed : 계약일~해지일 경과개월수 + * - appliedPercent : 적용된 환수율(%) + * - reason : 계산 근거 설명 문구 + */ public record ChargebackResult( BigDecimal chargebackAmount, int monthsElapsed, diff --git a/ga-api/src/main/java/com/ga/api/service/commission/AdvancePaymentService.java b/ga-api/src/main/java/com/ga/api/service/commission/AdvancePaymentService.java index cddb6be..62f03a0 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/AdvancePaymentService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/AdvancePaymentService.java @@ -14,6 +14,15 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; +/** + * 선지급정산 서비스. + * + * 선지급(advance): 미래 수수료를 미리 지급(가불)하고 추후 회수하는 제도. + * 잔액(balance) = 원금(principal) - 회수누계(recovered). 상태는 OUTSTANDING/PARTIAL/CLEARED/WAIVED. + * + * 클래스에 @Transactional(readOnly=true)가 걸려 있어 기본은 읽기 전용이고, + * 데이터를 바꾸는 메서드만 @Transactional 로 쓰기 트랜잭션을 연다. + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -21,11 +30,13 @@ public class AdvancePaymentService { private final AdvancePaymentMapper mapper; + /** 선지급 목록 조회. startPage()로 PageHelper 페이징을 건 뒤 결과를 PageResponse 로 감싼다. */ public PageResponse list(AdvancePaymentSearchParam param) { param.startPage(); return PageResponse.of(mapper.selectList(param)); } + /** 선지급 단건 상세 조회. 없으면 NOT_FOUND 예외. */ public AdvancePaymentResp detail(Long advanceId) { AdvancePaymentResp resp = mapper.selectById(advanceId); if (resp == null) throw new BizException(ErrorCode.NOT_FOUND); @@ -33,7 +44,8 @@ public class AdvancePaymentService { } /** - * 선지급 등록 — balance = principal, status = OUTSTANDING. + * 선지급 등록 — 처음에는 아직 한 푼도 회수하지 않았으므로 + * 회수누계(recovered)=0, 잔액(balance)=원금(principal), 상태=OUTSTANDING(미상환)으로 시작한다. */ @Transactional public Long create(AdvancePaymentSaveReq req) { @@ -50,12 +62,14 @@ public class AdvancePaymentService { } /** - * 선지급 면제 — status = WAIVED, balance = 0. + * 선지급 면제 — 남은 잔액 회수를 포기한다. 상태=WAIVED, 잔액=0 으로 바꾼다. + * 이미 완전회수(CLEARED)됐거나 이미 면제(WAIVED)된 건은 면제할 수 없어 INVALID_PARAMETER 예외. */ @Transactional public void waive(Long advanceId) { AdvancePaymentResp resp = mapper.selectById(advanceId); if (resp == null) throw new BizException(ErrorCode.NOT_FOUND); + // 이미 종료(CLEARED) 또는 면제(WAIVED) 상태면 다시 면제 불가 if ("CLEARED".equals(resp.getStatus()) || "WAIVED".equals(resp.getStatus())) { throw new BizException(ErrorCode.INVALID_PARAMETER); } diff --git a/ga-api/src/main/java/com/ga/api/service/commission/AwardService.java b/ga-api/src/main/java/com/ga/api/service/commission/AwardService.java index 410ef8f..bdc3edb 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/AwardService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/AwardService.java @@ -14,6 +14,15 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDate; +/** + * 시상(award) 서비스. + * + * 시상: 실적 조건 달성 설계사에게 주는 포상금. 규칙(Rule)에 산식이 정의된다. + * - calcType=FIXED: 정액(award_fixed) 지급 / calcType=RATE: 정률(실적 base x award_rate) 지급 + * - thresholdAmount: 이 금액 이상 실적이어야 시상 발동(미달이면 시상 없음) + * - netAmount(실지급액) = awardAmount(시상액) - clawbackAmount(환수액) + * 상태: CALCULATED(산출) → CLAWED_BACK(환수적용) / CANCELLED(취소). + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -22,7 +31,7 @@ public class AwardService { private final AwardRuleMapper ruleMapper; private final AwardLedgerMapper ledgerMapper; - // ── Rule ────────────────────────────────────────────────────────────────── + // ── Rule (시상 규칙) ──────────────────────────────────────────────────── public PageResponse listRules(AwardRuleSearchParam param) { param.startPage(); @@ -72,7 +81,7 @@ public class AwardService { ruleMapper.delete(ruleId); } - // ── Ledger ──────────────────────────────────────────────────────────────── + // ── Ledger (시상 원장) ────────────────────────────────────────────────── public PageResponse listLedgers(AwardLedgerSearchParam param) { param.startPage(); @@ -85,9 +94,16 @@ public class AwardService { return resp; } + /** + * 시상 원장 생성(시상금 산출). + * 1) 시상유형(awardType)에 해당하는 현재 유효한 규칙을 찾는다. + * 2) 기준 실적(baseAmount)이 시상 기준(thresholdAmount) 미달이면 시상하지 않고 예외. + * 3) 규칙의 계산 방식(FIXED/RATE)에 따라 시상액(awardAmount)을 계산한다. + * 4) 환수액(clawback)을 빼서 실지급액(netAmount)을 구하고 상태 CALCULATED 로 저장한다. + */ @Transactional public Long createLedger(AwardLedgerSaveReq req) { - // 룰 조회로 calc_type 결정 + // 룰 조회로 calc_type 결정 — 현재 날짜에 유효한(effective) 규칙을 가져온다 AwardRuleVO rule = ruleMapper.selectActiveRule(req.getAwardType(), LocalDate.now()); if (rule == null) throw new BizException(ErrorCode.NOT_FOUND); @@ -101,21 +117,24 @@ public class AwardService { BigDecimal awardAmount; if ("FIXED".equals(rule.getCalcType())) { + // 정액 시상: 규칙에 정해진 고정 금액(award_fixed)을 그대로 지급 if (rule.getAwardFixed() == null) { throw new BizException(ErrorCode.BAD_REQUEST, "FIXED 시상 룰에 award_fixed 가 설정되지 않았습니다"); } awardAmount = rule.getAwardFixed().setScale(2, RoundingMode.HALF_UP); } else { - // RATE: base × award_rate + // RATE: 정률 시상 → 시상액 = 기준 실적(base) × 시상률(award_rate) awardAmount = req.getBaseAmount() .multiply(rule.getAwardRate()) .setScale(2, RoundingMode.HALF_UP); } + // 환수액이 없으면 0으로 두고, 환수액이 시상액보다 크면 음수 지급이 되므로 금지 BigDecimal clawback = req.getClawbackAmount() != null ? req.getClawbackAmount() : BigDecimal.ZERO; if (clawback.compareTo(awardAmount) > 0) { throw new BizException(ErrorCode.INVALID_PARAMETER, "환수액이 시상액을 초과합니다"); } + // 실지급액 = 시상액 - 환수액 BigDecimal netAmount = awardAmount.subtract(clawback).setScale(2, RoundingMode.HALF_UP); AwardLedgerVO vo = new AwardLedgerVO(); @@ -131,6 +150,11 @@ public class AwardService { return vo.getLedgerId(); } + /** + * 이미 산출된 시상금에 환수(clawback)를 적용한다. + * 취소(CANCELLED)된 원장에는 적용 불가, 환수액은 시상액을 넘을 수 없다. + * 실지급액(net) = 시상액 - 환수액 으로 다시 계산하고 상태를 CLAWED_BACK 으로 바꾼다. + */ @Transactional public void applyClawback(Long ledgerId, com.ga.api.controller.commission.AwardClawbackReq req) { AwardLedgerResp ledger = ledgerMapper.selectById(ledgerId); @@ -141,6 +165,7 @@ public class AwardService { if (req.getClawbackAmount().compareTo(ledger.getAwardAmount()) > 0) { throw new BizException(ErrorCode.INVALID_PARAMETER, "환수액이 시상액을 초과합니다"); } + // 환수 적용 후 실지급액 = 시상액 - 환수액 BigDecimal netAmount = ledger.getAwardAmount() .subtract(req.getClawbackAmount()) .setScale(2, RoundingMode.HALF_UP); diff --git a/ga-api/src/main/java/com/ga/api/service/commission/BranchAllowanceService.java b/ga-api/src/main/java/com/ga/api/service/commission/BranchAllowanceService.java index d29adf7..01de9e5 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/BranchAllowanceService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/BranchAllowanceService.java @@ -13,6 +13,13 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.math.RoundingMode; +/** + * 지점장수당 서비스. + * + * 지점장수당: 지점 실적(baseAmount)에 지급률(allowanceRate)을 곱해 지점장에게 주는 수당. + * 단, 상한(capAmount)이 있으면 그 이상은 지급하지 않는다 → allowance = min(base x rate, cap). + * (cap 이 NULL 이면 상한 없음). 규칙(Rule)에 직급별 지급률/상한을 정의한다. + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -21,7 +28,7 @@ public class BranchAllowanceService { private final BranchMgrAllowanceRuleMapper ruleMapper; private final BranchMgrAllowanceLedgerMapper ledgerMapper; - // ── Rule ────────────────────────────────────────────────────────────────── + // ── Rule (수당 규칙) ──────────────────────────────────────────────────── public PageResponse listRules(BranchMgrAllowanceRuleSearchParam param) { param.startPage(); @@ -67,7 +74,7 @@ public class BranchAllowanceService { ruleMapper.delete(ruleId); } - // ── Ledger ──────────────────────────────────────────────────────────────── + // ── Ledger (수당 원장) ────────────────────────────────────────────────── public PageResponse listLedgers(BranchMgrAllowanceLedgerSearchParam param) { param.startPage(); @@ -80,9 +87,13 @@ public class BranchAllowanceService { return resp; } + /** + * 지점장수당 원장 생성(수당 산출). + * 수당액 = min(기준실적 × 지급률, 상한). 상한(cap)이 없으면 그대로 지급. + */ @Transactional public Long createLedger(BranchMgrAllowanceLedgerSaveReq req) { - // 수당액 = min(base × rate, cap) — cap NULL이면 상한 없음 + // 1단계: 상한 적용 전 원래 수당액 raw = 기준실적(base) × 지급률(rate) BigDecimal raw = req.getBaseAmount() .multiply(req.getAllowanceRate()) .setScale(2, RoundingMode.HALF_UP); diff --git a/ga-api/src/main/java/com/ga/api/service/commission/CarrierReconciliationInService.java b/ga-api/src/main/java/com/ga/api/service/commission/CarrierReconciliationInService.java index e8bf239..e38b228 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/CarrierReconciliationInService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/CarrierReconciliationInService.java @@ -13,6 +13,13 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; +/** + * 정산대사(입수) 서비스. + * + * 보험사가 보내준 정산 명세(reportedTotal)와 우리 시스템이 계산한 수입수수료 합계(systemTotal)를 맞춰 보고 + * 그 차이(variance = reported - system)를 확인한다. + * 상태 흐름: RECEIVED(자료 입수) → reconcile() → MATCHED(차이<1, 일치) 또는 VARIANCE(차이 있음) → approve() → APPROVED(확정). + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -52,8 +59,10 @@ public class CarrierReconciliationInService { } /** - * 대사 실행 — system_total = income_commission 집계, variance = reported − system. - * |variance| < 1 이면 MATCHED, 아니면 VARIANCE. + * 대사 실행 — 시스템 합계(system_total)는 해당 보험사·정산월의 수입수수료를 집계해 구하고, + * 차이(variance) = 보험사 명세 합계(reported) - 시스템 합계(system) 로 계산한다. + * 차이의 절댓값이 1원 미만이면 사실상 일치로 보아 MATCHED, 그 외에는 VARIANCE 상태로 둔다. + * 이미 승인(APPROVED)된 건은 다시 대사할 수 없다. */ @Transactional public void reconcile(Long reconId) { @@ -61,11 +70,14 @@ public class CarrierReconciliationInService { if (resp == null) throw new BizException(ErrorCode.NOT_FOUND); if ("APPROVED".equals(resp.getStatus())) throw new BizException(ErrorCode.INVALID_PARAMETER); + // 우리 시스템이 계산한 수입수수료 합계(없으면 0) BigDecimal systemTotal = incomeMapper.sumByCarrierAndMonth( resp.getCompanyId(), resp.getSettleMonth()); if (systemTotal == null) systemTotal = BigDecimal.ZERO; + // 차이 = 보험사가 보고한 금액 - 시스템 계산 금액 BigDecimal variance = resp.getReportedTotal().subtract(systemTotal); + // |차이| < 1원 이면 일치(MATCHED), 아니면 차이남(VARIANCE) String newStatus = variance.abs().compareTo(BigDecimal.ONE) < 0 ? "MATCHED" : "VARIANCE"; CarrierReconciliationInVO vo = buildVO(resp); @@ -76,7 +88,8 @@ public class CarrierReconciliationInService { } /** - * 승인 처리 — status = APPROVED. + * 승인 처리 — 대사 결과를 확정(status=APPROVED)한다. + * 아직 대사를 돌리지 않은 RECEIVED 상태에서는 바로 승인할 수 없다(먼저 reconcile 필요). */ @Transactional public void approve(Long reconId) { @@ -88,6 +101,7 @@ public class CarrierReconciliationInService { mapper.update(vo); } + /** 조회용 Resp 를 업데이트용 VO 로 그대로 복사하는 헬퍼(상태/금액만 바꿔 update 에 넘기기 위함). */ private CarrierReconciliationInVO buildVO(CarrierReconciliationInResp resp) { CarrierReconciliationInVO vo = new CarrierReconciliationInVO(); vo.setReconId(resp.getReconId()); diff --git a/ga-api/src/main/java/com/ga/api/service/commission/ChargebackWaiverService.java b/ga-api/src/main/java/com/ga/api/service/commission/ChargebackWaiverService.java index 75f4a9b..5361cff 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/ChargebackWaiverService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/ChargebackWaiverService.java @@ -12,6 +12,13 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +/** + * 환수감면신청 서비스. + * + * 환수(chargeback): 지급했던 수수료를 회수하는 것. 감면(waiver): 그 환수액을 줄여 달라는 신청. + * 신청액(waiverAmount)은 원래 환수액(originalChargeback)을 넘을 수 없다. + * 승인 상태 흐름: REQUESTED(신청) → APPROVED(승인) 또는 REJECTED(반려). 승인/반려는 REQUESTED 상태에서만 가능. + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -31,10 +38,12 @@ public class ChargebackWaiverService { } /** - * 감면 신청 등록 — waiver_amount <= original_chargeback 검증. + * 감면 신청 등록. 감면 신청액은 원래 환수액 이하여야 한다(초과 시 INVALID_PARAMETER). + * 등록 시 승인 상태는 REQUESTED(신청 접수)로 시작하며, 신청자(현재 사용자)를 기록한다. */ @Transactional public Long create(ChargebackWaiverRequestSaveReq req) { + // 감면액이 원래 환수액보다 크면 말이 안 되므로 거절 if (req.getWaiverAmount().compareTo(req.getOriginalChargeback()) > 0) { throw new BizException(ErrorCode.INVALID_PARAMETER); } @@ -52,7 +61,8 @@ public class ChargebackWaiverService { } /** - * 승인 처리 — approval_status = APPROVED, approver_id = 현재 사용자, approved_at = now. + * 승인 처리 — 신청을 승인한다. 승인 상태를 APPROVED 로 바꾸고 승인자(현재 사용자)와 승인 시각을 기록한다. + * REQUESTED(신청) 상태가 아니면(이미 승인/반려됨) 처리할 수 없다. */ @Transactional public void approve(Long requestId) { @@ -63,7 +73,7 @@ public class ChargebackWaiverService { } /** - * 반려 처리 — approval_status = REJECTED. + * 반려 처리 — 신청을 반려한다(승인 상태 REJECTED). REQUESTED 상태에서만 가능. */ @Transactional public void reject(Long requestId) { diff --git a/ga-api/src/main/java/com/ga/api/service/commission/ClawbackInstallmentService.java b/ga-api/src/main/java/com/ga/api/service/commission/ClawbackInstallmentService.java index 36f6f17..ab242b0 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/ClawbackInstallmentService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/ClawbackInstallmentService.java @@ -16,6 +16,12 @@ import java.math.RoundingMode; import java.util.ArrayList; import java.util.List; +/** + * 환수분할상환 서비스. + * + * 환수채권(받아야 할 환수금)을 N개월에 걸쳐 나눠 갚도록 회차별 상환 계획을 만든다. + * 회차 상태: SCHEDULED(예정) → 납입 시 별도 처리, 또는 CANCELLED(취소). + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -41,8 +47,10 @@ public class ClawbackInstallmentService { /** * 환수채권을 N회차로 균등 분할상환 계획 생성. - * 마지막 회차는 잔차(총액 - 앞 회차 합계) 흡수. - * 기준월: baseMonth(YYYYMM), due_month = baseMonth + (회차-1) + * 1원 단위 반올림 오차 때문에 회차를 똑같이 나누면 합계가 안 맞으므로, + * 앞 회차는 unit(버림 분할)로 채우고 마지막 회차가 잔차(총액 - 앞 회차 합계)를 흡수해 합계를 정확히 맞춘다. + * 회차별 납기(due_month) = baseMonth + (회차 - 1)개월. + * 이미 SCHEDULED 계획이 있으면 중복 생성 금지(DUPLICATE_DATA). */ @Transactional public void generate(Long receivableId, int count, String baseMonth) { @@ -57,14 +65,17 @@ public class ClawbackInstallmentService { ClawbackReceivableResp receivableResp = receivableMapper.selectById(receivableId); if (receivableResp == null) throw new BizException(ErrorCode.NOT_FOUND); + // 분할 대상 총액 = 환수채권 잔액. 0 이하이면 분할할 게 없으므로 예외 BigDecimal total = receivableResp.getBalance(); if (total == null || total.compareTo(BigDecimal.ZERO) <= 0) throw new BizException(ErrorCode.INVALID_PARAMETER); + // 한 회차 금액(unit) = 총액 / 회차수 (소수점 버림), 마지막 회차(last)가 잔차를 흡수 BigDecimal unit = total.divide(BigDecimal.valueOf(count), 2, RoundingMode.DOWN); BigDecimal sumBefore = unit.multiply(BigDecimal.valueOf(count - 1)); BigDecimal last = total.subtract(sumBefore); + // 1회차부터 count회차까지 SCHEDULED 상태로 생성(마지막 회차만 last 금액) List plans = new ArrayList<>(); for (int i = 1; i <= count; i++) { ClawbackInstallmentPlanVO vo = new ClawbackInstallmentPlanVO(); @@ -79,7 +90,7 @@ public class ClawbackInstallmentService { } /** - * 분할상환 계획 취소 (SCHEDULED → CANCELLED). + * 분할상환 회차 취소. 아직 예정(SCHEDULED) 상태인 회차만 CANCELLED 로 바꿀 수 있다. */ @Transactional public void cancel(Long planId) { diff --git a/ga-api/src/main/java/com/ga/api/service/commission/ClawbackReceivableExcelRow.java b/ga-api/src/main/java/com/ga/api/service/commission/ClawbackReceivableExcelRow.java index 4da2491..7e3e0e9 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/ClawbackReceivableExcelRow.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/ClawbackReceivableExcelRow.java @@ -7,6 +7,11 @@ import java.math.BigDecimal; /** * 환수채권 엑셀 export 행. + * + * 환수채권(받아야 할 환수금) 목록을 엑셀로 내려받을 때 한 줄(row)에 들어갈 데이터를 담는 DTO. + * 각 필드의 @ExcelColumn(header/order/width)이 엑셀의 열 제목/순서/너비를 정의하고, + * codeGroup 이 붙은 필드(status)는 코드값을 한글 라벨로 변환해 출력한다. + * * ga-core ClawbackReceivableResp 에 @ExcelColumn 없으므로 ga-api 전용 프로젝션. * TODO: ga-core 에 ClawbackReceivableExcelVO + selectCursorForExcel 추가 후 이 클래스 제거 가능. */ diff --git a/ga-api/src/main/java/com/ga/api/service/commission/ClawbackReceivableService.java b/ga-api/src/main/java/com/ga/api/service/commission/ClawbackReceivableService.java index c04df06..c8248e1 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/ClawbackReceivableService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/ClawbackReceivableService.java @@ -18,6 +18,12 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +/** + * 환수채권 관리 서비스. + * + * 환수채권(받아야 할 환수금) 조회·집계·대손처리·엑셀export 담당. + * 채권 상태 흐름: OUTSTANDING(미회수)/RECOVERING(회수중) → CLEARED(완전회수) 또는 WRITTEN_OFF(대손상각, 손실처리). + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -42,19 +48,23 @@ public class ClawbackReceivableService { } /** - * 대손/직접상환 처리. - * OUTSTANDING 또는 RECOVERING 채권만 허용, 전이 상태는 CLEARED/WRITTEN_OFF 만 가능. + * 대손/직접상환 처리(채권 종결). + * 아직 살아있는 채권(OUTSTANDING/RECOVERING)만 종결할 수 있고, + * 바꿀 수 있는 종결 상태는 CLEARED(전액 회수 완료) 또는 WRITTEN_OFF(회수 불가 → 손실처리)뿐이다. + * userId(처리자)와 종결 사유를 함께 기록한다. */ @Transactional public void writeOff(Long id, ClawbackReceivableSaveReq req, Long userId) { ClawbackReceivableResp target = mapper.selectById(id); if (target == null) throw new BizException(ErrorCode.NOT_FOUND); + // 이미 종결(CLEARED/WRITTEN_OFF)된 채권은 다시 처리 불가 String currentStatus = target.getStatus(); if (!"OUTSTANDING".equals(currentStatus) && !"RECOVERING".equals(currentStatus)) { throw new BizException(ErrorCode.INVALID_PARAMETER, "이미 종결된 채권입니다"); } + // 요청한 새 상태가 허용된 종결 상태인지 검증 String newStatus = req.getStatus(); if (!"CLEARED".equals(newStatus) && !"WRITTEN_OFF".equals(newStatus)) { throw new BizException(ErrorCode.INVALID_PARAMETER, "전이 가능한 상태는 CLEARED 또는 WRITTEN_OFF 입니다"); diff --git a/ga-api/src/main/java/com/ga/api/service/commission/CollectionCommissionService.java b/ga-api/src/main/java/com/ga/api/service/commission/CollectionCommissionService.java index d20fa09..e483362 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/CollectionCommissionService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/CollectionCommissionService.java @@ -10,6 +10,12 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * 수금수수료 서비스. + * + * 수금수수료: 설계사가 보험료를 정상 수금한 것에 대한 수수료. 계산식 = 보험료(premium) x 수수료율(rate). + * 규칙(Rule)은 상품·납입주기별 수수료율을 정의하고, 원장(Ledger)에 실제 산출액을 기록한다. + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -18,7 +24,7 @@ public class CollectionCommissionService { private final CollectionCommissionRuleMapper ruleMapper; private final CollectionCommissionLedgerMapper ledgerMapper; - // ── Rule ────────────────────────────────────────────────────────────────── + // ── Rule (수금수수료 규칙) ────────────────────────────────────────────── public PageResponse listRules(CollectionCommissionRuleSearchParam param) { param.startPage(); @@ -64,7 +70,7 @@ public class CollectionCommissionService { ruleMapper.delete(ruleId); } - // ── Ledger ──────────────────────────────────────────────────────────────── + // ── Ledger (수금수수료 원장) ──────────────────────────────────────────── public PageResponse listLedgers(CollectionCommissionLedgerSearchParam param) { param.startPage(); @@ -77,6 +83,7 @@ public class CollectionCommissionService { return resp; } + /** 수금수수료 원장 생성. 수수료액 = 보험료 × 수수료율. 상태는 CALCULATED 로 저장. */ @Transactional public Long createLedger(CollectionCommissionLedgerSaveReq req) { CollectionCommissionLedgerVO vo = new CollectionCommissionLedgerVO(); diff --git a/ga-api/src/main/java/com/ga/api/service/commission/CommissionMasterService.java b/ga-api/src/main/java/com/ga/api/service/commission/CommissionMasterService.java index 2138b88..8a753c3 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/CommissionMasterService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/CommissionMasterService.java @@ -12,6 +12,12 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +/** + * 수수료 마스터 서비스. + * + * 수수료 마스터: 설계사·정산월 단위로 각종 수수료를 모아 최종 지급액을 관리하는 핵심 원장. + * 조회(목록/상세/설계사월별/월집계)와 상태 변경, 삭제를 담당한다. + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -19,25 +25,30 @@ public class CommissionMasterService { private final CommissionMasterMapper mapper; + /** 수수료 마스터 목록 조회(검색 + 페이징). */ public PageResponse list(CommissionMasterSearchParam param) { param.startPage(); return PageResponse.of(mapper.selectList(param)); } + /** 수수료 마스터 단건 상세. 없으면 NOT_FOUND. */ public CommissionMasterResp detail(Long masterId) { CommissionMasterResp resp = mapper.selectById(masterId); if (resp == null) throw new BizException(ErrorCode.NOT_FOUND); return resp; } + /** 특정 설계사의 특정 정산월 수수료 내역 조회. */ public List listByAgentAndMonth(Long agentId, String settleMonth) { return mapper.selectByAgentAndMonth(agentId, settleMonth); } + /** 정산월 단위 전체 집계(설계사별 합계 등) 조회. */ public List aggregatedByMonth(String settleMonth) { return mapper.selectAggregatedByMonth(settleMonth); } + /** 정산 상태 변경(예: CALCULATED → CONFIRMED → PAID 등 운영 단계 전이). 대상이 없으면 NOT_FOUND. */ @Transactional public void updateStatus(Long masterId, String status) { CommissionMasterResp resp = mapper.selectById(masterId); @@ -45,6 +56,7 @@ public class CommissionMasterService { mapper.updateStatus(masterId, status); } + /** 수수료 마스터 삭제. 대상이 없으면 NOT_FOUND. */ @Transactional public void delete(Long masterId) { CommissionMasterResp resp = mapper.selectById(masterId); diff --git a/ga-api/src/main/java/com/ga/api/service/commission/CommissionSimulationService.java b/ga-api/src/main/java/com/ga/api/service/commission/CommissionSimulationService.java index 6d77fe9..fb842d6 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/CommissionSimulationService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/CommissionSimulationService.java @@ -16,12 +16,20 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.math.RoundingMode; +/** + * 수수료 시뮬레이터 서비스. + * + * 실제 정산 전에 "이 조건이면 설계사가 얼마 받을까"를 미리 계산해 결과를 저장·반환한다(실지급에는 영향 없음). + * 계산 단계: 보험사지급액 → 설계사수수료 → 원천징수세(3.3%) 차감 → 실수령액, 그리고 예상 환수액까지 추정한다. + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class CommissionSimulationService { + /** 사업소득 원천징수 세율 3.3% (설계사 수수료에서 떼는 세금). */ private static final BigDecimal WITHHOLDING_TAX_RATE = new BigDecimal("0.033"); + /** 예상 환수액 기본 추정 비율 50% (입력값이 없을 때 13개월 유지 가정으로 사용). */ private static final BigDecimal DEFAULT_CHARGEBACK_RATE = new BigDecimal("0.5"); private final CommissionSimulationMapper simulationMapper; @@ -48,22 +56,27 @@ public class CommissionSimulationService { */ @Transactional public CommissionSimulationResp simulate(CommissionSimulationSaveReq req) { + // 1) 보험사지급액 = 보험료 × 보험사요율 (GA가 보험사로부터 받는 돈) BigDecimal companyPayment = req.getPremiumAmount() .multiply(req.getCompanyRate()) .setScale(2, RoundingMode.HALF_UP); + // 2) 설계사수수료 = 보험사지급액 × 설계사 지급률 BigDecimal agentCommission = companyPayment .multiply(req.getPayoutRate()) .setScale(2, RoundingMode.HALF_UP); + // 3) 원천징수세 = 설계사수수료 × 3.3% BigDecimal withholdingTax = agentCommission .multiply(WITHHOLDING_TAX_RATE) .setScale(2, RoundingMode.HALF_UP); + // 4) 실수령액 = 설계사수수료 - 원천징수세 BigDecimal netAmount = agentCommission .subtract(withholdingTax) .setScale(2, RoundingMode.HALF_UP); + // 5) 예상 환수액: 입력값이 있으면 그대로, 없으면 설계사수수료 × 50%로 추정 BigDecimal expectedChargeback; if (req.getExpectedChargeback() != null) { expectedChargeback = req.getExpectedChargeback().setScale(2, RoundingMode.HALF_UP); diff --git a/ga-api/src/main/java/com/ga/api/service/commission/GradeEvaluationService.java b/ga-api/src/main/java/com/ga/api/service/commission/GradeEvaluationService.java index 437b9e9..3e2c5f9 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/GradeEvaluationService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/GradeEvaluationService.java @@ -19,6 +19,12 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; +/** + * 등급평가 서비스. + * + * 설계사 실적점수(performanceScore)를 규칙(Rule: 등급별 최소점수 minScore)에 맞춰 등급을 매긴다. + * 평가 상태: EVALUATED(평가 완료, 미반영) → applyEval() → APPLIED(실제 등급에 반영). + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -27,7 +33,7 @@ public class GradeEvaluationService { private final GradeEvalRuleMapper ruleMapper; private final AgentGradeEvaluationMapper evalMapper; - // ── Rule ────────────────────────────────────────────────────────────────── + // ── Rule (평가 규칙) ──────────────────────────────────────────────────── public PageResponse listRules(GradeEvalRuleSearchParam param) { param.startPage(); @@ -71,7 +77,7 @@ public class GradeEvaluationService { ruleMapper.delete(ruleId); } - // ── Evaluation ──────────────────────────────────────────────────────────── + // ── Evaluation (평가 결과) ────────────────────────────────────────────── public PageResponse listEvals(AgentGradeEvaluationSearchParam param) { param.startPage(); @@ -85,15 +91,17 @@ public class GradeEvaluationService { } /** - * 등급평가 생성. - * selectActiveRuleByScore(score, 기준일)로 evaluated_grade_id 결정. - * 매칭 룰 없으면 prevGradeId 유지 또는 null. + * 등급평가 생성. 실적점수에 해당하는 유효 규칙을 찾아 평가등급(evaluatedGradeId)을 결정한다. + * 점수에 맞는 규칙이 없으면 기존 등급(prevGradeId)을 그대로 유지한다(없으면 null). + * 생성 시 상태는 EVALUATED(아직 미반영). */ @Transactional public Long createEval(AgentGradeEvaluationSaveReq req) { + // 오늘 기준 유효한 규칙 중, 이 실적점수가 충족하는 등급 규칙을 찾는다 LocalDate baseDate = LocalDate.now(); GradeEvalRuleVO matchedRule = ruleMapper.selectActiveRuleByScore(req.getPerformanceScore(), baseDate); + // 매칭 규칙이 있으면 그 등급, 없으면 기존 등급 유지 Long evaluatedGradeId = (matchedRule != null) ? matchedRule.getGradeId() : req.getPrevGradeId(); AgentGradeEvaluationVO vo = new AgentGradeEvaluationVO(); @@ -108,7 +116,8 @@ public class GradeEvaluationService { } /** - * 등급평가 반영 액션 (EVALUATED → APPLIED). + * 등급평가 반영. 평가된 등급을 실제 설계사 등급에 적용한다(상태 EVALUATED → APPLIED). + * 이미 반영(APPLIED)된 건은 중복 반영할 수 없다. */ @Transactional public void applyEval(Long evalId) { @@ -120,6 +129,7 @@ public class GradeEvaluationService { evalMapper.updateStatus(evalId, "APPLIED"); } + /** 등급평가 삭제. 대상이 없으면 NOT_FOUND. */ @Transactional public void deleteEval(Long evalId) { AgentGradeEvaluationResp resp = evalMapper.selectById(evalId); diff --git a/ga-api/src/main/java/com/ga/api/service/commission/GuaranteeInsuranceService.java b/ga-api/src/main/java/com/ga/api/service/commission/GuaranteeInsuranceService.java index 8d90a0f..7388e0d 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/GuaranteeInsuranceService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/GuaranteeInsuranceService.java @@ -12,6 +12,12 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * 보증보험 서비스. + * + * 보증보험: 설계사가 선지급금 등 채무를 못 갚을 때를 대비한 보험. 설계사별 가입 정보(증권번호/보장한도/기간)를 관리. + * 등록 시 상태는 ACTIVE(유효)로 시작한다. + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -30,6 +36,7 @@ public class GuaranteeInsuranceService { return resp; } + /** 보증보험 가입 등록. 상태 ACTIVE 로 시작. 응답: 생성된 guaranteeId. */ @Transactional public Long create(AgentGuaranteeInsuranceSaveReq req) { AgentGuaranteeInsuranceVO vo = new AgentGuaranteeInsuranceVO(); diff --git a/ga-api/src/main/java/com/ga/api/service/commission/HqSettleService.java b/ga-api/src/main/java/com/ga/api/service/commission/HqSettleService.java index 6ee785a..ec1c4da 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/HqSettleService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/HqSettleService.java @@ -15,6 +15,12 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; +/** + * 본사정산 서비스. + * + * 본사 손익 항목(item)을 정산월·유형(INCOME 수입/EXPENSE 비용)별로 관리하고 손익을 집계한다. + * 본사손익(profit) = 수입 합계(income) - 비용 합계(expense). 항목 등록 시 상태는 REGISTERED. + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -22,6 +28,7 @@ public class HqSettleService { private final HqSettleItemMapper itemMapper; + /** 본사손익 요약 응답 DTO: 정산월, 수입 합계, 비용 합계, 손익(수입-비용). */ @Data public static class HqSummaryResp { private final String settleMonth; @@ -42,18 +49,20 @@ public class HqSettleService { } /** - * 본사손익 집계: income − expense. - * 둘 다 null이면 0으로 처리. + * 본사손익 집계 — 해당 정산월의 수입(INCOME)/비용(EXPENSE) 합계를 구해 손익(수입-비용)을 계산한다. + * 집계 결과가 없으면(null) 0으로 처리한다. */ public HqSummaryResp summary(String settleMonth) { BigDecimal income = itemMapper.sumByMonthAndType(settleMonth, "INCOME"); BigDecimal expense = itemMapper.sumByMonthAndType(settleMonth, "EXPENSE"); if (income == null) income = BigDecimal.ZERO; if (expense == null) expense = BigDecimal.ZERO; + // 손익 = 수입 합계 - 비용 합계 BigDecimal profit = income.subtract(expense); return new HqSummaryResp(settleMonth, income, expense, profit); } + /** 본사정산 항목 신규 등록. 상태 REGISTERED 로 시작. 응답: 생성된 itemId. */ @Transactional public Long create(HqSettleItemSaveReq req) { HqSettleItemVO vo = new HqSettleItemVO(); diff --git a/ga-api/src/main/java/com/ga/api/service/commission/IncomeCommissionService.java b/ga-api/src/main/java/com/ga/api/service/commission/IncomeCommissionService.java index 810d1d0..dcc86c0 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/IncomeCommissionService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/IncomeCommissionService.java @@ -13,6 +13,12 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.math.RoundingMode; +/** + * 수입수수료/수지차 서비스. + * + * 수입수수료(income) = 보험료 × 보험사요율 (GA가 보험사로부터 받는 돈). + * 수지차(margin) = 정산월 수입수수료 합 - 설계사 지급액 합 (GA의 마진). 양수면 흑자, 음수면 적자. + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -34,6 +40,7 @@ public class IncomeCommissionService { return resp; } + /** 수입수수료 원장 생성. 수입액 = 보험료 × 보험사요율. 상태 CALCULATED 로 저장. */ @Transactional public Long createLedger(IncomeCommissionLedgerSaveReq req) { IncomeCommissionLedgerVO vo = new IncomeCommissionLedgerVO(); @@ -74,18 +81,23 @@ public class IncomeCommissionService { */ @Transactional public Long recalcMargin(String settleMonth, Long agentId) { + // 수입 합계: 해당 정산월의 수입수수료 총합(없으면 0) BigDecimal incomeTotal = marginMapper.sumIncomeByMonth(settleMonth); if (incomeTotal == null) incomeTotal = BigDecimal.ZERO; + // 지급 합계: 해당 정산월의 설계사 지급액 총합(agentId 지정 시 그 설계사만, 없으면 0) BigDecimal payoutTotal = marginMapper.sumPayoutByMonth(settleMonth, agentId); if (payoutTotal == null) payoutTotal = BigDecimal.ZERO; + // 수지차 = 수입 합계 - 지급 합계 BigDecimal marginAmount = incomeTotal.subtract(payoutTotal).setScale(2, RoundingMode.HALF_UP); + // 수지차율 = 수지차 / 수입 합계 (0으로 나누기 방지: 수입이 0보다 클 때만 계산, 아니면 0) BigDecimal marginRate = BigDecimal.ZERO; if (incomeTotal.compareTo(BigDecimal.ZERO) > 0) { marginRate = marginAmount.divide(incomeTotal, 4, RoundingMode.HALF_UP); } + // 같은 (정산월, 설계사) 집계가 이미 있으면 갱신(update), 없으면 신규 생성(insert) — upsert 패턴 MarginSummaryVO existing = marginMapper.selectByMonthAndAgent(settleMonth, agentId); if (existing != null) { existing.setIncomeTotal(incomeTotal); diff --git a/ga-api/src/main/java/com/ga/api/service/commission/JointContractService.java b/ga-api/src/main/java/com/ga/api/service/commission/JointContractService.java index 17db645..e7281f7 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/JointContractService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/JointContractService.java @@ -14,6 +14,12 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; +/** + * 공동계약 서비스. + * + * 한 계약을 여러 설계사가 함께 모집했을 때 지분(share_rate)을 나눠 갖는 구조. + * 핵심 규칙: 한 계약에 속한 모든 설계사의 지분율 합이 1.0(=100%)을 넘으면 안 된다(초과 시 예외). + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -80,9 +86,11 @@ public class JointContractService { * sumShareByContract는 계약 전체 합을 반환하므로, 수정 시 기존 자신의 share_rate를 차감. */ private void validateShareRate(Long contractId, Long excludeJointId, BigDecimal newShareRate) { + // 이 계약에 이미 등록된 지분율 합계(없으면 0) BigDecimal existingSum = jointContractMapper.sumShareByContract(contractId); if (existingSum == null) existingSum = BigDecimal.ZERO; + // 수정(update)일 때는 자기 자신의 기존 지분을 합계에서 빼야 중복 합산되지 않는다 if (excludeJointId != null) { JointContractResp existing = jointContractMapper.selectById(excludeJointId); if (existing != null) { @@ -90,6 +98,7 @@ public class JointContractService { } } + // (기존 합 + 새 지분)이 1.0(100%)을 넘으면 지분 초과로 거절 if (existingSum.add(newShareRate).compareTo(BigDecimal.ONE) > 0) { throw new BizException(ErrorCode.INVALID_PARAMETER, "계약별 지분율 합이 1을 초과합니다."); } diff --git a/ga-api/src/main/java/com/ga/api/service/commission/LapsedReinstatementService.java b/ga-api/src/main/java/com/ga/api/service/commission/LapsedReinstatementService.java index 6efe8df..6cba61a 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/LapsedReinstatementService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/LapsedReinstatementService.java @@ -9,6 +9,12 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * 익월부활재지급 서비스. + * + * 실효 시 환수했던 수수료(withheldAmount = 보류해 둔 금액)를 익월 부활 시 다시 지급(재지급/회수)한다. + * 상태 흐름: PENDING(부활 등록, 재지급 대기) → REPAID(재지급 완료) 또는 CANCELLED(취소). + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -28,7 +34,8 @@ public class LapsedReinstatementService { } /** - * 부활 등록 — withheld_amount 입력, status = PENDING. + * 부활 재지급 건 등록 — 실효 시 보류했던 금액(withheld_amount)을 기록하고, + * 아직 재지급 전이므로 재지급액(repaid)=0, 상태=PENDING 으로 시작한다. */ @Transactional public Long create(LapsedReinstatementRecoverySaveReq req) { @@ -45,7 +52,8 @@ public class LapsedReinstatementService { } /** - * 재지급 처리 — status = REPAID, repaid_amount = withheld_amount. + * 재지급 처리 — 부활이 확정되어 보류했던 금액 전액을 다시 지급한다. + * 상태를 REPAID 로 바꾸고 재지급액(repaid)을 보류금액(withheld)과 같게 만든다. PENDING 상태에서만 가능. */ @Transactional public void repay(Long recoveryId) { @@ -56,7 +64,8 @@ public class LapsedReinstatementService { } /** - * 취소 — status = CANCELLED. + * 취소 — 재지급을 하지 않고 종료한다(상태 CANCELLED, 재지급액 0). + * 이미 재지급(REPAID)됐거나 이미 취소(CANCELLED)된 건은 취소할 수 없다. */ @Transactional public void cancel(Long recoveryId) { diff --git a/ga-api/src/main/java/com/ga/api/service/commission/LifeSupportService.java b/ga-api/src/main/java/com/ga/api/service/commission/LifeSupportService.java index 298ce80..f560b09 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/LifeSupportService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/LifeSupportService.java @@ -13,6 +13,12 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.math.RoundingMode; +/** + * 생보운영지원수수료 서비스. + * + * 지원수수료 = 기준액(base) × 지원율(support_rate). 규칙(Rule)은 설계사 등급별 지원율을 정의한다. + * 실지급액(net) = 지원수수료 - 환수액. 환수액은 0 이상이며 지원수수료를 넘을 수 없다(실지급 음수 방지). + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -21,7 +27,7 @@ public class LifeSupportService { private final LifeSupportRuleMapper ruleMapper; private final LifeSupportLedgerMapper ledgerMapper; - // ── Rule ────────────────────────────────────────────────────────────────── + // ── Rule (지원수수료 규칙) ────────────────────────────────────────────── public PageResponse listRules(LifeSupportRuleSearchParam param) { param.startPage(); @@ -65,7 +71,7 @@ public class LifeSupportService { ruleMapper.delete(ruleId); } - // ── Ledger ──────────────────────────────────────────────────────────────── + // ── Ledger (지원수수료 원장) ──────────────────────────────────────────── public PageResponse listLedgers(LifeSupportLedgerSearchParam param) { param.startPage(); @@ -78,6 +84,7 @@ public class LifeSupportService { return resp; } + /** 지원수수료 원장 생성. 지원수수료=base×rate, 실지급액=지원수수료-환수액. 상태 CALCULATED 로 저장. */ @Transactional public Long createLedger(LifeSupportLedgerSaveReq req) { // 지원수수료 = base × support_rate diff --git a/ga-api/src/main/java/com/ga/api/service/commission/OverrideLedgerService.java b/ga-api/src/main/java/com/ga/api/service/commission/OverrideLedgerService.java index f651b18..916552b 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/OverrideLedgerService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/OverrideLedgerService.java @@ -12,6 +12,13 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +/** + * 오버라이드 원장 서비스. + * + * 오버라이드: 상위 직급자가 하위 설계사 실적에 대해 일정 비율로 추가로 받는 수수료. + * 이 서비스는 조회·집계 전용이다(생성/계산은 배치 등 다른 로직에서 처리). + * summaryByAgentAndMonth 는 특정 수령자(toAgentId)의 정산월 집계를 반환한다. + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) diff --git a/ga-api/src/main/java/com/ga/api/service/commission/PersistencyBonusService.java b/ga-api/src/main/java/com/ga/api/service/commission/PersistencyBonusService.java index cf63c57..a2dd489 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/PersistencyBonusService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/PersistencyBonusService.java @@ -13,6 +13,13 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.math.RoundingMode; +/** + * 유지보너스(유지보수수당) 서비스. + * + * 계약이 일정 회차(retentionMonth)까지 잘 유지되고 유지율(retentionRate)이 기준(threshold) 이상일 때 지급하는 보너스. + * 규칙(Rule)이 FIXED형이면 지급액은 규칙의 정액(bonus_value)으로 결정된다. + * bonus 저장은 (설계사, 기준월, 유지회차) 유니크 키 기준 upsert(있으면 갱신, 없으면 생성). + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -21,7 +28,7 @@ public class PersistencyBonusService { private final PersistencyBonusRuleMapper ruleMapper; private final PersistencyBonusMapper bonusMapper; - // ── Rule ────────────────────────────────────────────────────────────────── + // ── Rule (유지보너스 규칙) ────────────────────────────────────────────── public PageResponse listRules(PersistencyBonusRuleSearchParam param) { param.startPage(); @@ -71,7 +78,7 @@ public class PersistencyBonusService { ruleMapper.delete(ruleId); } - // ── Bonus ───────────────────────────────────────────────────────────────── + // ── Bonus (유지보너스 산출 내역) ──────────────────────────────────────── public PageResponse listBonuses(PersistencyBonusSearchParam param) { param.startPage(); diff --git a/ga-api/src/main/java/com/ga/api/service/commission/RecognizedPerformanceService.java b/ga-api/src/main/java/com/ga/api/service/commission/RecognizedPerformanceService.java index 48a3881..a4335d7 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/RecognizedPerformanceService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/RecognizedPerformanceService.java @@ -13,6 +13,13 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.math.RoundingMode; +/** + * 인정실적 서비스. + * + * 인정실적 = 환산보험료 × 인정률. 환산보험료 = 보험료 × 상품환산율(conversion_rate). + * 즉 보험료에 상품별 환산율을 곱해 "환산보험료"를 만들고, 거기에 인정률을 곱해 "회사가 인정하는 실적"을 산출한다. + * 규칙(Rate)은 상품별 환산율을 정의한다. + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -21,7 +28,7 @@ public class RecognizedPerformanceService { private final ProductConversionRateMapper conversionRateMapper; private final RecognizedPerformanceLedgerMapper ledgerMapper; - // ── Product Conversion Rate (rule) ──────────────────────────────────────── + // ── Product Conversion Rate (상품 환산율 규칙) ────────────────────────── public PageResponse listRates(ProductConversionRateSearchParam param) { param.startPage(); @@ -65,7 +72,7 @@ public class RecognizedPerformanceService { conversionRateMapper.delete(rateId); } - // ── Ledger ──────────────────────────────────────────────────────────────── + // ── Ledger (인정실적 원장) ────────────────────────────────────────────── public PageResponse listLedgers(RecognizedPerformanceLedgerSearchParam param) { param.startPage(); @@ -78,6 +85,7 @@ public class RecognizedPerformanceService { return resp; } + /** 인정실적 원장 생성. 환산보험료=보험료×환산율, 인정실적=환산보험료×인정률. 상태 CALCULATED 로 저장. */ @Transactional public Long createLedger(RecognizedPerformanceLedgerSaveReq req) { // 환산보험료 = premium × conversion_rate diff --git a/ga-api/src/main/java/com/ga/api/service/commission/RecruiterCommissionService.java b/ga-api/src/main/java/com/ga/api/service/commission/RecruiterCommissionService.java index 5d8020c..0d8950f 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/RecruiterCommissionService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/RecruiterCommissionService.java @@ -14,6 +14,13 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.util.List; +/** + * 도입수수료 서비스. + * + * 도입수수료: 새 설계사를 데려온(도입한) 사람에게 지급하는 수수료. + * 규칙(Rule)의 계산방식에 따라: RATE = 기준액(base) × 수수료율, FIXED = 정액(fixed_amount). + * 유효한 규칙이 없으면 요청에 들어온 수동 입력 금액(commissionAmount)을 그대로 사용한다. + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -22,7 +29,7 @@ public class RecruiterCommissionService { private final RecruiterCommissionRuleMapper ruleMapper; private final RecruiterCommissionLedgerMapper ledgerMapper; - // ── Rule ────────────────────────────────────────────────────────────────── + // ── Rule (도입수수료 규칙) ────────────────────────────────────────────── public PageResponse listRules(RecruiterCommissionRuleSearchParam param) { param.startPage(); @@ -70,7 +77,7 @@ public class RecruiterCommissionService { ruleMapper.delete(ruleId); } - // ── Ledger ──────────────────────────────────────────────────────────────── + // ── Ledger (도입수수료 원장) ──────────────────────────────────────────── public PageResponse listLedgers(RecruiterCommissionLedgerSearchParam param) { param.startPage(); diff --git a/ga-api/src/main/java/com/ga/api/service/commission/ReferralCommissionService.java b/ga-api/src/main/java/com/ga/api/service/commission/ReferralCommissionService.java index 8c84747..e57aab7 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/ReferralCommissionService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/ReferralCommissionService.java @@ -13,6 +13,13 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.math.RoundingMode; +/** + * 소개·이관수수료 서비스. + * + * 소개수수료: 고객/계약을 소개해 준 사람에게 주는 수수료. 규칙(Rule)의 계산방식에 따라 + * RATE = 기준액(base) × 수수료율, FIXED = 정액(fixed_amount)으로 산출한다. + * 유효 규칙이 없으면 요청에 들어온 수수료율(commissionRate)로 수동 계산한다(둘 다 없으면 예외). + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -21,7 +28,7 @@ public class ReferralCommissionService { private final ReferralCommissionRuleMapper ruleMapper; private final ReferralCommissionLedgerMapper ledgerMapper; - // ── Rule ────────────────────────────────────────────────────────────────── + // ── Rule (소개수수료 규칙) ────────────────────────────────────────────── public PageResponse listRules(ReferralCommissionRuleSearchParam param) { param.startPage(); @@ -69,7 +76,7 @@ public class ReferralCommissionService { ruleMapper.delete(ruleId); } - // ── Ledger ──────────────────────────────────────────────────────────────── + // ── Ledger (소개수수료 원장) ──────────────────────────────────────────── public PageResponse listLedgers(ReferralCommissionLedgerSearchParam param) { param.startPage(); diff --git a/ga-api/src/main/java/com/ga/api/service/commission/ReinstatementCommissionService.java b/ga-api/src/main/java/com/ga/api/service/commission/ReinstatementCommissionService.java index 9614f1b..1b45bae 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/ReinstatementCommissionService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/ReinstatementCommissionService.java @@ -12,6 +12,13 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; +/** + * 부활수수료 서비스. + * + * 부활(실효 계약이 다시 살아남) 시 지급하는 수수료. 규칙(Rule)에 상품·지급방식(payType)별 요율/정액을 정의한다. + * 실수령 수수료(netCommission) = 수수료액(commissionAmount) + 환수복원액(chargebackReversal). + * 환수복원: 실효 때 환수했던 금액을 부활로 되돌려 주는 부분. + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -20,7 +27,7 @@ public class ReinstatementCommissionService { private final ReinstatementCommissionRuleMapper ruleMapper; private final ReinstatementCommissionLedgerMapper ledgerMapper; - // ── Rule ────────────────────────────────────────────────────────────────── + // ── Rule (부활수수료 규칙) ────────────────────────────────────────────── public PageResponse listRules(ReinstatementCommissionRuleSearchParam param) { param.startPage(); @@ -70,7 +77,7 @@ public class ReinstatementCommissionService { ruleMapper.delete(ruleId); } - // ── Ledger ──────────────────────────────────────────────────────────────── + // ── Ledger (부활수수료 원장) ──────────────────────────────────────────── public PageResponse listLedgers(ReinstatementCommissionLedgerSearchParam param) { param.startPage(); diff --git a/ga-api/src/main/java/com/ga/api/service/commission/RenewalCommissionService.java b/ga-api/src/main/java/com/ga/api/service/commission/RenewalCommissionService.java index 38720c6..4673323 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/RenewalCommissionService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/RenewalCommissionService.java @@ -10,6 +10,12 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * 갱신수수료 서비스. + * + * 계약 갱신 시 지급하는 수수료. 규칙(Rule)에 상품·지급방식(payType)별 요율/정액을 정의한다. + * 원장 생성은 요청에 들어온 수수료액(commissionAmount)을 그대로 기록한다(상태 CALCULATED). + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -18,7 +24,7 @@ public class RenewalCommissionService { private final RenewalCommissionRuleMapper ruleMapper; private final RenewalCommissionLedgerMapper ledgerMapper; - // ── Rule ────────────────────────────────────────────────────────────────── + // ── Rule (갱신수수료 규칙) ────────────────────────────────────────────── public PageResponse listRules(RenewalCommissionRuleSearchParam param) { param.startPage(); @@ -66,7 +72,7 @@ public class RenewalCommissionService { ruleMapper.delete(ruleId); } - // ── Ledger ──────────────────────────────────────────────────────────────── + // ── Ledger (갱신수수료 원장) ──────────────────────────────────────────── public PageResponse listLedgers(RenewalCommissionLedgerSearchParam param) { param.startPage(); diff --git a/ga-api/src/main/java/com/ga/api/service/commission/ReserveFundService.java b/ga-api/src/main/java/com/ga/api/service/commission/ReserveFundService.java index 37b08da..f99539c 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/ReserveFundService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/ReserveFundService.java @@ -15,6 +15,13 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.math.RoundingMode; +/** + * 적립금 서비스. + * + * 적립금: 환수 등에 대비해 수수료 일부를 떼어 모아 두는 돈. 월별로 잔액(balance)을 이월·누적 관리한다. + * 잔액 = 직전월 잔액 + 이번달 적립(reserveIn) - 이번달 사용(reserveOut). + * 같은 (설계사, 정산월) 레코드가 있으면 갱신, 없으면 신규 생성(upsert). + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -40,16 +47,19 @@ public class ReserveFundService { */ @Transactional public Long create(AgentReserveFundSaveReq req) { + // 직전월 잔액을 가져와 이월(없으면 0부터 시작) AgentReserveFundVO prevMonth = reserveFundMapper.selectLatestBefore(req.getAgentId(), req.getSettleMonth()); BigDecimal prevBalance = (prevMonth != null && prevMonth.getBalance() != null) ? prevMonth.getBalance() : BigDecimal.ZERO; + // 이번달 잔액 = 직전월 잔액 + 적립 - 사용 BigDecimal balance = prevBalance .add(req.getReserveIn()) .subtract(req.getReserveOut()) .setScale(2, RoundingMode.HALF_UP); + // 같은 (설계사, 정산월) 레코드가 있으면 갱신, 없으면 아래에서 신규 생성 AgentReserveFundVO existing = reserveFundMapper.selectByAgentAndMonth(req.getAgentId(), req.getSettleMonth()); if (existing != null) { existing.setReserveIn(req.getReserveIn()); diff --git a/ga-api/src/main/java/com/ga/api/service/commission/SettlingSupportService.java b/ga-api/src/main/java/com/ga/api/service/commission/SettlingSupportService.java index 7118dcf..7784822 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/SettlingSupportService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/SettlingSupportService.java @@ -13,6 +13,13 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.math.RoundingMode; +/** + * 정착지원금 서비스. + * + * 신입 설계사 안착을 위해 일정 기간 지급하는 지원금. 규칙(Rule)은 등급별 지원금액/지급기간(durationMonths)/ + * 환수대상기간(clawbackMonths)을 정의한다. + * 실지급액(net) = 지원금(supportAmount) - 환수액. 환수액은 0 이상이며 지원금을 넘을 수 없다(실지급 음수 방지). + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -21,7 +28,7 @@ public class SettlingSupportService { private final SettlingSupportRuleMapper ruleMapper; private final SettlingSupportLedgerMapper ledgerMapper; - // ── Rule ────────────────────────────────────────────────────────────────── + // ── Rule (정착지원금 규칙) ────────────────────────────────────────────── public PageResponse listRules(SettlingSupportRuleSearchParam param) { param.startPage(); @@ -69,7 +76,7 @@ public class SettlingSupportService { ruleMapper.delete(ruleId); } - // ── Ledger ──────────────────────────────────────────────────────────────── + // ── Ledger (정착지원금 원장) ──────────────────────────────────────────── public PageResponse listLedgers(SettlingSupportLedgerSearchParam param) { param.startPage(); diff --git a/ga-api/src/main/java/com/ga/api/service/commission/StatuteService.java b/ga-api/src/main/java/com/ga/api/service/commission/StatuteService.java index a3cc083..60a0f3e 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/StatuteService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/StatuteService.java @@ -14,6 +14,12 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +/** + * 환수시효관리 서비스. + * + * 시효(statute): 환수채권을 회수할 수 있는 법적 기한. 설정(config)에 대상유형별 만료개월(expirationMonths)을 둔다. + * expireOverdue: 기준월에서 만료개월을 뺀 시점(beforeMonth)보다 오래된 채권을 일괄 대손(EXPIRED) 처리한다. + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -56,15 +62,18 @@ public class StatuteService { /** * 만료 대상 채권 일괄 대손 처리. - * beforeMonth = baseMonth - expiration_months (YYYYMM 연산). - * 처리 건수 반환. + * 환수채권용 시효설정이 활성(Y)일 때만 동작한다(없거나 비활성이면 NOT_FOUND). + * 기준월(baseMonth)에서 만료개월수를 뺀 시점(beforeMonth) 이전에 발생한 채권을 EXPIRED 로 일괄 대손 처리하고 + * 처리 건수를 반환한다. */ @Transactional public int expireOverdue(String baseMonth) { + // 환수채권(CLAWBACK_RECEIVABLE) 시효 설정 조회 — 비활성이면 처리하지 않음 StatuteConfigResp config = configMapper.selectByEntityType("CLAWBACK_RECEIVABLE"); if (config == null || !"Y".equals(config.getIsActive())) { throw new BizException(ErrorCode.NOT_FOUND); } + // 만료 기준 시점 = 기준월 - 만료개월수 (이보다 오래된 채권이 시효 만료 대상) String beforeMonth = DateUtil.addMonth(baseMonth, -config.getExpirationMonths()); return receivableMapper.writeOffExpired(beforeMonth, "EXPIRED"); } diff --git a/ga-api/src/main/java/com/ga/api/service/commission/StepCloseService.java b/ga-api/src/main/java/com/ga/api/service/commission/StepCloseService.java index eca9af4..929fa0d 100644 --- a/ga-api/src/main/java/com/ga/api/service/commission/StepCloseService.java +++ b/ga-api/src/main/java/com/ga/api/service/commission/StepCloseService.java @@ -15,6 +15,13 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +/** + * 단계마감 서비스. + * + * 월 정산을 단계(step)별로 나눠 마감(잠금)/해제(다시 열기)한다. + * 상태 흐름: OPEN(열림, 수정 가능) → close() → CLOSED(마감, 잠금) → reopen() → OPEN. + * 마감/해제 시 처리자(현재 로그인 사용자)를 기록한다. + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -37,6 +44,7 @@ public class StepCloseService { return resp; } + /** 단계마감 항목 생성. 처음에는 열린 상태(OPEN)로 시작한다. */ @Transactional public Long create(StepCloseSaveReq req) { StepCloseVO vo = new StepCloseVO(); @@ -48,6 +56,7 @@ public class StepCloseService { return vo.getStepCloseId(); } + /** 단계 마감(잠금). 이미 마감(CLOSED)된 단계는 다시 마감할 수 없다. 처리자 기록. */ @Transactional public void close(Long stepCloseId) { StepCloseResp resp = stepCloseMapper.selectById(stepCloseId); @@ -58,6 +67,7 @@ public class StepCloseService { stepCloseMapper.updateStatus(stepCloseId, "CLOSED", SecurityUtil.getCurrentUserId()); } + /** 단계 마감 해제(다시 열기). 이미 열린(OPEN) 단계는 다시 열 수 없다. 처리자 기록. */ @Transactional public void reopen(Long stepCloseId) { StepCloseResp resp = stepCloseMapper.selectById(stepCloseId); diff --git a/ga-api/src/main/java/com/ga/api/service/complaint/ComplaintService.java b/ga-api/src/main/java/com/ga/api/service/complaint/ComplaintService.java index 92fd1e3..78b680d 100644 --- a/ga-api/src/main/java/com/ga/api/service/complaint/ComplaintService.java +++ b/ga-api/src/main/java/com/ga/api/service/complaint/ComplaintService.java @@ -9,6 +9,13 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * 민원 도메인 비즈니스 로직. + * + *

상태 전이: 접수 시 OPEN → 처리완료 시 CLOSED. 한 번 CLOSED가 되면 다시 close 할 수 없다. + * 심각도(severity) 기본값은 MID. 환수 유발 플래그(chargebackTriggered)는 여기서 저장만 하고, + * 실제 수수료 환수 원장(chargeback_ledger) 생성은 정산 배치(P5)가 담당한다.

+ */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -16,17 +23,20 @@ public class ComplaintService { private final ComplaintMapper mapper; + /** 민원 목록 조회(페이징). */ public PageResponse list(ComplaintSearchParam param) { param.startPage(); return PageResponse.of(mapper.selectList(param)); } + /** 민원 상세 조회. 없으면 NOT_FOUND. */ public ComplaintResp detail(Long complaintId) { ComplaintResp resp = mapper.selectById(complaintId); if (resp == null) throw new BizException(ErrorCode.NOT_FOUND); return resp; } + /** 민원 접수. 심각도 미지정 시 MID, 상태는 OPEN으로 시작한다. */ @Transactional public Long create(ComplaintSaveReq req) { ComplaintVO vo = new ComplaintVO(); @@ -45,6 +55,7 @@ public class ComplaintService { return vo.getComplaintId(); } + /** 민원 내용 수정. 존재 검증 후 일반 정보를 갱신하며 상태는 건드리지 않는다. */ @Transactional public void update(Long complaintId, ComplaintSaveReq req) { ComplaintResp existing = mapper.selectById(complaintId); @@ -77,6 +88,7 @@ public class ComplaintService { mapper.updateStatus(complaintId, ComplaintStatus.CLOSED.name(), resolution); } + /** 민원 레코드 삭제. 존재 검증 후 삭제한다. */ @Transactional public void delete(Long complaintId) { ComplaintResp existing = mapper.selectById(complaintId); diff --git a/ga-api/src/main/java/com/ga/api/service/contract/AgentContractService.java b/ga-api/src/main/java/com/ga/api/service/contract/AgentContractService.java index 6c38e05..20afe74 100644 --- a/ga-api/src/main/java/com/ga/api/service/contract/AgentContractService.java +++ b/ga-api/src/main/java/com/ga/api/service/contract/AgentContractService.java @@ -11,6 +11,12 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +/** + * 위촉계약(설계사-보험사 모집 위탁) 도메인 비즈니스 로직. + * + *

상태 전이: 등록 시 ACTIVE → 해촉 시 TERMINATED. 한 번 TERMINATED가 된 계약은 다시 해촉할 수 없다. + * 클래스 전체는 읽기 전용이며 쓰기 메서드만 별도 트랜잭션을 건다.

+ */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -18,17 +24,20 @@ public class AgentContractService { private final AgentContractMapper mapper; + /** 위촉계약 목록 조회(페이징). */ public PageResponse list(AgentContractSearchParam param) { param.startPage(); return PageResponse.of(mapper.selectList(param)); } + /** 위촉계약 상세 조회. 없으면 NOT_FOUND. */ public AgentContractResp detail(Long contractId) { AgentContractResp resp = mapper.selectById(contractId); if (resp == null) throw new BizException(ErrorCode.NOT_FOUND); return resp; } + /** 위촉계약 신규 등록. 상태를 ACTIVE로 고정해 저장한다. */ @Transactional public Long create(AgentContractSaveReq req) { AgentContractVO vo = new AgentContractVO(); @@ -44,6 +53,7 @@ public class AgentContractService { return vo.getContractId(); } + /** 위촉계약 수정. 존재 검증 후 일반 정보만 갱신하며 상태(status)는 건드리지 않는다. */ @Transactional public void update(Long contractId, AgentContractSaveReq req) { AgentContractResp existing = mapper.selectById(contractId); @@ -60,6 +70,7 @@ public class AgentContractService { mapper.update(vo); } + /** 위촉계약 해촉. 이미 TERMINATED면 상태 오류로 막고, 그렇지 않으면 사유와 함께 TERMINATED로 전이한다. */ @Transactional public void terminate(Long contractId, String reason) { AgentContractResp existing = mapper.selectById(contractId); @@ -70,6 +81,7 @@ public class AgentContractService { mapper.updateStatus(contractId, AgentContractStatus.TERMINATED.name(), reason); } + /** 위촉계약 레코드 삭제. 존재 검증 후 삭제한다. */ @Transactional public void delete(Long contractId) { AgentContractResp existing = mapper.selectById(contractId); diff --git a/ga-api/src/main/java/com/ga/api/service/deduction/DeductionService.java b/ga-api/src/main/java/com/ga/api/service/deduction/DeductionService.java index b4f60cb..da2d5ee 100644 --- a/ga-api/src/main/java/com/ga/api/service/deduction/DeductionService.java +++ b/ga-api/src/main/java/com/ga/api/service/deduction/DeductionService.java @@ -17,6 +17,16 @@ import java.math.BigDecimal; import java.util.List; import java.util.stream.Collectors; +/** + * 공제/차감(agent_deduction) 서비스. + * + *

설계사 수수료에서 미리 빼야 할 금액(차감)을 등록/수정/취소하고, 지급 처리에 필요한 PENDING 차감 조회와 + * "적용됨(APPLIED)" 상태 전환을 제공합니다. PaymentService 가 지급 시 이 PENDING 차감을 가져가 실지급액에서 빼고 + * markAsApplied 로 상태를 마무리합니다. + * + *

상태(DeductionStatus): PENDING → APPLIED, 또는 CANCELLED. + * 같은 설계사+정산월+차감유형 조합은 중복 등록 불가. 수정·취소는 PENDING(미적용) 상태에서만 가능합니다. + */ @Service @RequiredArgsConstructor public class DeductionService { @@ -75,22 +85,26 @@ public class DeductionService { mapper.updateStatus(deductionId, DeductionStatus.CANCELLED.name()); } + /** 특정 설계사·정산월의 PENDING 차감 합계(없으면 0). 지급 예상액 미리보기 등에 사용. */ @Transactional(readOnly = true) public BigDecimal sumPendingByAgent(Long agentId, String settleMonth) { BigDecimal sum = mapper.sumPendingByAgent(agentId, settleMonth); return sum != null ? sum : BigDecimal.ZERO; } + /** 정산월 전체의 PENDING 차감 목록. */ @Transactional(readOnly = true) public List selectPendingByMonth(String settleMonth) { return mapper.selectPendingByMonth(settleMonth); } + /** 특정 설계사·정산월의 PENDING 차감 목록. 지급 처리(PaymentService.applyDeductions)에서 호출. */ @Transactional(readOnly = true) public List selectPendingByAgent(Long agentId, String settleMonth) { return mapper.selectPendingByAgent(agentId, settleMonth); } + /** 차감들을 APPLIED(적용완료) 상태로 일괄 전환. 지급에 반영된 직후 호출됩니다. */ @Transactional public void markAsApplied(List deductionIds) { if (deductionIds.isEmpty()) return; diff --git a/ga-api/src/main/java/com/ga/api/service/dict/DataDictionaryService.java b/ga-api/src/main/java/com/ga/api/service/dict/DataDictionaryService.java index dc32194..c9e89ff 100644 --- a/ga-api/src/main/java/com/ga/api/service/dict/DataDictionaryService.java +++ b/ga-api/src/main/java/com/ga/api/service/dict/DataDictionaryService.java @@ -15,31 +15,42 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +/** + * 데이터 사전 도메인 비즈니스 로직. + * + *

테이블/컬럼 메타데이터 항목의 CRUD를 담당한다. 항목에 도메인 코드를 지정할 경우 + * 실존하는 도메인인지(countDomainByCode) 검증해 깨진 참조를 막는다.

+ */ @Service @RequiredArgsConstructor public class DataDictionaryService { private final DataDictionaryMapper mapper; + /** 데이터 사전 항목 목록 조회(페이징). */ public PageResponse list(DataDictionarySearchParam param) { param.startPage(); return PageResponse.of(mapper.selectList(param)); } + /** 사전에 등록된 테이블명 목록 조회. */ public List tables() { return mapper.selectTables(); } + /** 사전 뷰 기반 통합 조회(테이블명 선택). 컬럼+도메인 결합 정보를 반환한다. */ public List full(String tableName) { return mapper.selectFromView(tableName); } + /** 데이터 사전 항목 단건 조회. 없으면 NOT_FOUND. */ public DataDictionaryResp detail(Long dictId) { DataDictionaryResp resp = mapper.selectById(dictId); if (resp == null) throw new BizException(ErrorCode.NOT_FOUND); return resp; } + /** 항목 등록. 도메인 코드가 있으면 실존 여부를 검증한 뒤 저장한다. */ @Transactional public void create(DataDictionarySaveReq req) { if (req.getDomainCode() != null && !req.getDomainCode().isBlank()) { @@ -50,6 +61,7 @@ public class DataDictionaryService { mapper.insert(req.toVO()); } + /** 항목 수정. 존재 검증 + 도메인 코드 실존 검증 후 갱신한다. */ @Transactional public void update(Long dictId, DataDictionarySaveReq req) { DataDictionaryResp existing = mapper.selectById(dictId); @@ -64,6 +76,7 @@ public class DataDictionaryService { mapper.update(vo); } + /** 항목 삭제. 삭제된 행이 없으면(없는 ID) NOT_FOUND. */ @Transactional public void delete(Long dictId) { if (mapper.delete(dictId) == 0) { diff --git a/ga-api/src/main/java/com/ga/api/service/dict/DataDomainService.java b/ga-api/src/main/java/com/ga/api/service/dict/DataDomainService.java index 760a93a..87e5798 100644 --- a/ga-api/src/main/java/com/ga/api/service/dict/DataDomainService.java +++ b/ga-api/src/main/java/com/ga/api/service/dict/DataDomainService.java @@ -15,27 +15,38 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +/** + * 데이터 도메인 사전 도메인 비즈니스 로직. + * + *

도메인 코드(domainCode)를 PK로 하는 메타데이터의 CRUD를 담당한다. + * 등록 시 코드 중복을 막고, 삭제 시 이 도메인을 참조하는 컬럼이 남아 있으면(countUsageByCode) + * 의존 데이터 오류로 막아 참조 무결성을 지킨다.

+ */ @Service @RequiredArgsConstructor public class DataDomainService { private final DataDomainMapper mapper; + /** 도메인 목록 조회(페이징). */ public PageResponse list(DataDomainSearchParam param) { param.startPage(); return PageResponse.of(mapper.selectList(param)); } + /** 선택 옵션용 도메인 전체 목록 조회. */ public List options() { return mapper.selectAll(); } + /** 도메인 단건 조회(코드 기준). 없으면 NOT_FOUND. */ public DataDomainResp detail(String domainCode) { DataDomainResp resp = mapper.selectByCode(domainCode); if (resp == null) throw new BizException(ErrorCode.NOT_FOUND); return resp; } + /** 도메인 등록. 동일 코드가 이미 있으면 중복 오류. */ @Transactional public void create(DataDomainSaveReq req) { if (mapper.selectByCode(req.getDomainCode()) != null) { @@ -44,6 +55,7 @@ public class DataDomainService { mapper.insert(req.toVO()); } + /** 도메인 수정. 존재 검증 후 갱신한다(코드는 경로 값으로 고정). */ @Transactional public void update(String domainCode, DataDomainSaveReq req) { if (mapper.selectByCode(domainCode) == null) { @@ -54,6 +66,7 @@ public class DataDomainService { mapper.update(vo); } + /** 도메인 삭제. 참조 중인 컬럼이 있으면 의존 데이터 오류로 막고, 없으면 삭제한다. */ @Transactional public void delete(String domainCode) { int usageCount = mapper.countUsageByCode(domainCode); diff --git a/ga-api/src/main/java/com/ga/api/service/endorse/ContractEndorsementService.java b/ga-api/src/main/java/com/ga/api/service/endorse/ContractEndorsementService.java index b1131b3..786726e 100644 --- a/ga-api/src/main/java/com/ga/api/service/endorse/ContractEndorsementService.java +++ b/ga-api/src/main/java/com/ga/api/service/endorse/ContractEndorsementService.java @@ -9,6 +9,13 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * 계약 변경(배서) 도메인 비즈니스 로직. + * + *

배서 레코드의 등록/조회/처리완료/삭제만 담당한다. 보험료 변동에 따른 수수료 재계산은 + * 여기서 수행하지 않고 정산 배치(P5)가 담당하며, 이 서비스의 process()는 처리 완료 플래그만 올린다. + * 클래스 전체는 읽기 전용이고 쓰기 메서드만 별도 트랜잭션을 건다.

+ */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -16,17 +23,20 @@ public class ContractEndorsementService { private final ContractEndorsementMapper mapper; + /** 배서 목록 조회(페이징). */ public PageResponse list(ContractEndorsementSearchParam param) { param.startPage(); return PageResponse.of(mapper.selectList(param)); } + /** 배서 상세 조회. 없으면 NOT_FOUND. */ public ContractEndorsementResp detail(Long endorsementId) { ContractEndorsementResp resp = mapper.selectById(endorsementId); if (resp == null) throw new BizException(ErrorCode.NOT_FOUND); return resp; } + /** 배서 등록. 변경 전/후 값과 수수료 재계산 필요 여부를 저장하고 미처리(processed=false) 상태로 시작한다. */ @Transactional public Long create(ContractEndorsementSaveReq req) { ContractEndorsementVO vo = new ContractEndorsementVO(); @@ -53,6 +63,7 @@ public class ContractEndorsementService { mapper.markProcessed(endorsementId); } + /** 배서 레코드 삭제. 존재 검증 후 삭제한다. */ @Transactional public void delete(Long endorsementId) { ContractEndorsementResp existing = mapper.selectById(endorsementId); diff --git a/ga-api/src/main/java/com/ga/api/service/external/ExternalCallLogService.java b/ga-api/src/main/java/com/ga/api/service/external/ExternalCallLogService.java index 724562d..76cc982 100644 --- a/ga-api/src/main/java/com/ga/api/service/external/ExternalCallLogService.java +++ b/ga-api/src/main/java/com/ga/api/service/external/ExternalCallLogService.java @@ -30,12 +30,14 @@ public class ExternalCallLogService { mapper.insertOne(vo); } + /** 외부 호출 이력 목록 조회(페이징). */ @Transactional(readOnly = true) public PageResponse list(ExternalCallLogSearchParam param) { param.startPage(); return PageResponse.of(mapper.selectList(param)); } + /** 외부 호출 이력 단건 조회. */ @Transactional(readOnly = true) public ExternalCallLogResp detail(Long logId) { return mapper.selectById(logId); diff --git a/ga-api/src/main/java/com/ga/api/service/income/AgentAnnualIncomeService.java b/ga-api/src/main/java/com/ga/api/service/income/AgentAnnualIncomeService.java index 2f3580a..27a45bb 100644 --- a/ga-api/src/main/java/com/ga/api/service/income/AgentAnnualIncomeService.java +++ b/ga-api/src/main/java/com/ga/api/service/income/AgentAnnualIncomeService.java @@ -10,6 +10,13 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * [연간 사업소득 서비스] + * + * 신입 개발자 안내: + * - 조회(list/detail)와 연도 단위 재집계(regenerate=UPSERT)만 제공하는 단순 서비스. + * - 실제 합산 SQL 은 매퍼의 upsertAggregate 가 수행한다(월별 지급 → 연간 합계). + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) diff --git a/ga-api/src/main/java/com/ga/api/service/income/YearEndStatementService.java b/ga-api/src/main/java/com/ga/api/service/income/YearEndStatementService.java index 6b608c7..1aa14e5 100644 --- a/ga-api/src/main/java/com/ga/api/service/income/YearEndStatementService.java +++ b/ga-api/src/main/java/com/ga/api/service/income/YearEndStatementService.java @@ -14,6 +14,15 @@ import org.apache.ibatis.cursor.Cursor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * [연말 지급명세서 서비스] + * + * 신입 개발자 안내: + * - regenerate: 연도(+선택 설계사들) 기준으로 연간소득 집계를 명세서 테이블에 UPSERT. + * - generate: 명세서 1건의 파일(XLSX/PDF)을 만들고 file_path 저장 + 상태 GENERATED 로 전이. + * - send: 대상 명세서들을 발송 처리(상태/수신자 기록). + * - exportCursor: 엑셀 대량 다운로드용 Cursor 반환(컨트롤러의 ExcelService 와 함께 트랜잭션 내 소비). + */ @Slf4j @Service @RequiredArgsConstructor diff --git a/ga-api/src/main/java/com/ga/api/service/installment/InstallmentPlanGenerator.java b/ga-api/src/main/java/com/ga/api/service/installment/InstallmentPlanGenerator.java index ab0b9d4..7243653 100644 --- a/ga-api/src/main/java/com/ga/api/service/installment/InstallmentPlanGenerator.java +++ b/ga-api/src/main/java/com/ga/api/service/installment/InstallmentPlanGenerator.java @@ -26,16 +26,26 @@ public class InstallmentPlanGenerator { /** * 계약 체결일 기준 1~7차년 분급 계획 생성. * ratioPercent 합이 100%가 안 되는 경우 각 연차별 비율만큼 지급. + * + * [신입용 설명] 분급은 1200%룰 한도 초과분을 여러 해에 나눠 지급하기 위한 예정표다. + * ga-batch의 BatchInstallmentPlanGenerator와 동일 로직(온라인/배치 결과 일관성). + * + * @param contractId 계약 ID + * @param totalCommission 분급(이연)할 총액 + * @param contractDate 계약 체결일(각 회차 도래 정산월 계산 기준) + * @param productCategory 상품군(적용 분급 비율 마스터 선택 키) + * @return SCHEDULED 상태 분급 계획 목록 */ public List generate(Long contractId, BigDecimal totalCommission, LocalDate contractDate, String productCategory) { String baseDate = contractDate.format(DATE_FMT); + // 계약일 시점 유효한 해당 상품군의 활성 분급 비율을 연차순으로 조회. List ratios = ratioMapper.selectAllActiveRatios(productCategory, baseDate); // 비율 합이 정확히 100%인 정규 스케줄이면 마지막 회차가 반올림 잔차를 흡수 // → 분급 합계 == 원 수수료 보장 (부분지급 스케줄은 회차별 반올림 유지). boolean fullSchedule = sumRatios(ratios).compareTo(BigDecimal.valueOf(100)) == 0; - BigDecimal allocated = BigDecimal.ZERO; + BigDecimal allocated = BigDecimal.ZERO; // 배분 누계(마지막 회차 잔차 흡수용) List plans = new ArrayList<>(); for (int i = 0; i < ratios.size(); i++) { @@ -45,8 +55,10 @@ public class InstallmentPlanGenerator { BigDecimal amount; if (fullSchedule && i == ratios.size() - 1) { + // 정규(합 100%) 스케줄의 마지막 회차: 남은 전액 배정 → 반올림 잔차 흡수, 합계=원금 보장. amount = totalCommission.subtract(allocated); } else { + // 일반 회차: 총액 × 연차비율 ÷ 100, 원 단위 반올림. amount = totalCommission.multiply(ratio.getRatioPercent()) .divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP); } @@ -54,18 +66,20 @@ public class InstallmentPlanGenerator { InstallmentPlanVO plan = new InstallmentPlanVO(); plan.setContractId(contractId); - plan.setPlanYear(ratio.getPlanYear()); - plan.setPlanMonth(planMonth); + plan.setPlanYear(ratio.getPlanYear()); // 몇 차년 지급분 + plan.setPlanMonth(planMonth); // 그 해의 몇 월 회차 + // 도래 정산월 = 계약일 + planYear년 + (planMonth-1)개월("yyyyMM"). 지급 시점 판정 기준. plan.setSettleMonth( contractDate.plusYears(ratio.getPlanYear()).plusMonths(planMonth - 1L).format(MONTH_FMT) ); - plan.setExpectedAmount(amount); - plan.setStatus(InstallmentStatus.SCHEDULED.code()); + plan.setExpectedAmount(amount); // 회차 예정 지급액 + plan.setStatus(InstallmentStatus.SCHEDULED.code()); // 미지급(예정) 상태로 생성 plans.add(plan); } return plans; } + /** 분급 비율 합(%). 100%면 정규 스케줄(마지막 회차가 잔차 흡수)로 판정. */ private BigDecimal sumRatios(List ratios) { return ratios.stream() .map(InstallmentRatioVO::getRatioPercent) diff --git a/ga-api/src/main/java/com/ga/api/service/installment/InstallmentService.java b/ga-api/src/main/java/com/ga/api/service/installment/InstallmentService.java index 07cd9b5..7c2806b 100644 --- a/ga-api/src/main/java/com/ga/api/service/installment/InstallmentService.java +++ b/ga-api/src/main/java/com/ga/api/service/installment/InstallmentService.java @@ -22,6 +22,19 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; +/** + * 분급(이연 차액) 계획 관리 서비스. + * + * [신입용 설명] + * - "분급(installment)"은 1200%룰 한도를 넘어 당월에 다 줄 수 없는 모집수수료 차액을 + * 여러 해에 나눠 지급하기 위한 "지급 예정표(installment_plan)"다. + * - 이 서비스는 분급 계획의 조회/생성과 상태 전이(예정→지급/유예/취소)를 담당한다. + * + * [분급 상태(InstallmentStatus)] + * SCHEDULED(예정) → PAID(지급완료) : 정상 흐름. 도래월에 지급되면 PAID. + * SCHEDULED → DEFERRED(유예) : 사정상 그 달 지급을 미룸. 이후 PAID 가능. + * SCHEDULED/DEFERRED → CANCELLED(취소): 환수/해지 등으로 더 이상 지급하지 않음. PAID는 취소 불가. + */ @Service @RequiredArgsConstructor public class InstallmentService { @@ -45,6 +58,7 @@ public class InstallmentService { return resp; } + /** 특정 정산월에 지급 예정(SCHEDULED)인 분급 목록 조회. 도래월 지급 처리의 입력으로 쓰인다. */ @Transactional(readOnly = true) public List selectScheduledForMonth(String settleMonth) { return planMapper.selectScheduledByMonth(settleMonth); @@ -60,6 +74,10 @@ public class InstallmentService { // ---- Plan 생성 ---- + /** + * 이연할 총액(totalCommission)을 연차별로 쪼개 분급 계획을 만들어 저장. + * 1200%룰 초과분을 분급으로 돌릴 때 호출된다. 생성된 계획이 없으면 INSERT 생략. + */ @Transactional public void createPlan(Long contractId, BigDecimal totalCommission, LocalDate contractDate, String productCategory) { @@ -71,6 +89,10 @@ public class InstallmentService { // ---- Plan 상태 전이 ---- + /** + * 분급 1건을 지급 완료(PAID)로 전이. + * 예정(SCHEDULED) 또는 유예(DEFERRED) 상태만 지급 가능 — 이미 PAID/CANCELLED면 거부(중복지급 방지). + */ @Transactional public void payPlan(Long planId, Long paymentId, BigDecimal paidAmount) { InstallmentPlanVO plan = requirePlan(planId); @@ -85,6 +107,7 @@ public class InstallmentService { planMapper.update(plan); } + /** 분급 1건을 유예(DEFERRED)로 전이. 예정(SCHEDULED) 상태만 유예 가능. */ @Transactional public void deferPlan(Long planId) { InstallmentPlanVO plan = requirePlan(planId); @@ -94,6 +117,7 @@ public class InstallmentService { planMapper.updateStatus(planId, InstallmentStatus.DEFERRED.code()); } + /** 분급 1건을 취소(CANCELLED)로 전이. 이미 지급 완료(PAID)된 건은 취소 불가(돈이 이미 나갔으므로). */ @Transactional public void cancelPlan(Long planId) { InstallmentPlanVO plan = requirePlan(planId); @@ -103,6 +127,11 @@ public class InstallmentService { planMapper.updateStatus(planId, InstallmentStatus.CANCELLED.code()); } + /** + * 해당 정산월에 도래한 예정 분급들을 일괄 지급 처리. + * 각 건의 예정금액(expectedAmount)을 그대로 지급액으로 확정하고 PAID로 전이한다. + * (배치/도래월 지급 흐름에서 사용. 한 건씩 처리하는 payPlan과 달리 결제 ID 없이 예정금액 그대로 지급.) + */ @Transactional public void payScheduledForMonth(String settleMonth) { List scheduled = planMapper.selectScheduledByMonth(settleMonth); diff --git a/ga-api/src/main/java/com/ga/api/service/kpi/ForecastKpiMonthlyService.java b/ga-api/src/main/java/com/ga/api/service/kpi/ForecastKpiMonthlyService.java index ad23282..4f2a2cf 100644 --- a/ga-api/src/main/java/com/ga/api/service/kpi/ForecastKpiMonthlyService.java +++ b/ga-api/src/main/java/com/ga/api/service/kpi/ForecastKpiMonthlyService.java @@ -17,12 +17,18 @@ import java.util.List; import java.util.Map; import java.util.Set; +/** + * 차월 KPI 예측 비즈니스 로직 서비스. + * 과거 실적의 단순이동평균(SMA) 등으로 다음 달 KPI를 예측해 저장한다. + * 예측 방식(method)에 따라 사용하는 과거 표본 개월수(sampleSize)가 달라진다. + */ @Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class ForecastKpiMonthlyService { + /** 예측 방식별 과거 표본 개월수: SMA3=3개월, SMA6=6개월, NAIVE=직전 1개월. */ private static final Map SAMPLE_SIZE = Map.of( "SMA3", 3, "SMA6", 6, @@ -31,6 +37,7 @@ public class ForecastKpiMonthlyService { private final ForecastKpiMonthlyMapper mapper; + /** 예측값 목록 페이징 조회. */ public PageResponse list(ForecastKpiMonthlySearchParam param) { param.startPage(); return PageResponse.of(mapper.selectList(param)); @@ -38,6 +45,7 @@ public class ForecastKpiMonthlyService { /** * 차월 예측 재산출. forecastMonth 기준 직전 N개월 평균/표준편차 → 4 metric UPSERT. + * (입력 검증 → 방식별 표본수 결정 → DB에서 예측 계산 → 결과 일괄 UPSERT 순서) */ @Transactional public int regenerate(ForecastKpiMonthlySaveReq req) { diff --git a/ga-api/src/main/java/com/ga/api/service/kpi/KpiService.java b/ga-api/src/main/java/com/ga/api/service/kpi/KpiService.java index 97aa686..1677a47 100644 --- a/ga-api/src/main/java/com/ga/api/service/kpi/KpiService.java +++ b/ga-api/src/main/java/com/ga/api/service/kpi/KpiService.java @@ -11,6 +11,11 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * KPI 대시보드 비즈니스 로직 서비스(읽기 전용). + * 실적 집계 쿼리를 관점별(월별/조직별/유지율/누적)로 호출해 페이징 결과를 돌려준다. + * 클래스 레벨 @Transactional(readOnly=true) 로 모든 조회 메서드가 읽기 전용 트랜잭션으로 동작한다. + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -18,21 +23,25 @@ public class KpiService { private final KpiMapper kpiMapper; + /** 월별 실적 요약 집계 조회. */ public PageResponse monthlySummary(KpiSearchParam param) { param.startPage(); return PageResponse.of(kpiMapper.selectMonthlySummary(param)); } + /** 조직별 실적 요약 집계 조회. */ public PageResponse orgSummary(KpiSearchParam param) { param.startPage(); return PageResponse.of(kpiMapper.selectOrgSummary(param)); } + /** 계약 유지율 지표 집계 조회. */ public PageResponse retention(KpiSearchParam param) { param.startPage(); return PageResponse.of(kpiMapper.selectRetention(param)); } + /** 누적 실적 추이 집계 조회. */ public PageResponse cumulative(KpiSearchParam param) { param.startPage(); return PageResponse.of(kpiMapper.selectCumulative(param)); diff --git a/ga-api/src/main/java/com/ga/api/service/kpi/RetentionAnomalyAlertService.java b/ga-api/src/main/java/com/ga/api/service/kpi/RetentionAnomalyAlertService.java index a169216..5a41e41 100644 --- a/ga-api/src/main/java/com/ga/api/service/kpi/RetentionAnomalyAlertService.java +++ b/ga-api/src/main/java/com/ga/api/service/kpi/RetentionAnomalyAlertService.java @@ -17,21 +17,28 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Set; +/** + * 유지율 이상치 알림 비즈니스 로직 서비스. + * 유지율이 평소 대비 비정상적으로 떨어진 케이스를 탐지·저장하고, 담당자의 검토 상태를 관리한다. + */ @Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class RetentionAnomalyAlertService { + /** 알림 처리 상태 허용값: NEW(신규)/REVIEWED(검토됨)/RESOLVED(완료). */ private static final Set VALID_STATUS = Set.of("NEW", "REVIEWED", "RESOLVED"); private final RetentionAnomalyAlertMapper mapper; + /** 이상치 알림 목록 페이징 조회. */ public PageResponse list(RetentionAnomalyAlertSearchParam param) { param.startPage(); return PageResponse.of(mapper.selectList(param)); } + /** 이상치 알림 상세 조회. 없으면 NOT_FOUND. */ public RetentionAnomalyAlertResp detail(Long alertId) { RetentionAnomalyAlertResp resp = mapper.selectDetailById(alertId); if (resp == null) throw new BizException(ErrorCode.NOT_FOUND); @@ -57,6 +64,7 @@ public class RetentionAnomalyAlertService { return n; } + /** 선택한 이상치들을 일괄 검토 처리. 상태 미지정 시 REVIEWED, 허용 상태값만 통과. 처리자/메모도 함께 기록. */ @Transactional public void review(RetentionAnomalyAlertSaveReq req) { if (req.getIds() == null || req.getIds().isEmpty()) { diff --git a/ga-api/src/main/java/com/ga/api/service/ledger/LedgerService.java b/ga-api/src/main/java/com/ga/api/service/ledger/LedgerService.java index 53f8214..f1036e1 100644 --- a/ga-api/src/main/java/com/ga/api/service/ledger/LedgerService.java +++ b/ga-api/src/main/java/com/ga/api/service/ledger/LedgerService.java @@ -16,6 +16,10 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * 수수료 원장(ledger) 비즈니스 로직 서비스. + * 모집·유지·예외 세 종류 원장을 각각의 매퍼로 조회/등록/엑셀추출하고, 예외원장 승인 절차를 처리한다. + */ @Service @RequiredArgsConstructor public class LedgerService { @@ -25,24 +29,28 @@ public class LedgerService { private final ExceptionLedgerMapper exceptionMapper; private final ExceptionLedgerMapStruct exceptionLedgerMapStruct; + /** 모집수수료 원장 목록 페이징 조회. */ @Transactional(readOnly = true) public PageResponse listRecruit(LedgerSearchParam param) { param.startPage(); return PageResponse.of(recruitMapper.selectList(param)); } + /** 유지수수료 원장 목록 페이징 조회. */ @Transactional(readOnly = true) public PageResponse listMaintain(LedgerSearchParam param) { param.startPage(); return PageResponse.of(maintainMapper.selectList(param)); } + /** 예외수수료 원장 목록 페이징 조회. */ @Transactional(readOnly = true) public PageResponse listException(LedgerSearchParam param) { param.startPage(); return PageResponse.of(exceptionMapper.selectList(param)); } + /** 모집수수료 원장 상세 조회. 없으면 NOT_FOUND. */ @Transactional(readOnly = true) public LedgerResp detailRecruit(Long ledgerId) { LedgerResp r = recruitMapper.selectDetailById(ledgerId); @@ -50,6 +58,7 @@ public class LedgerService { return r; } + /** 유지수수료 원장 상세 조회. 없으면 NOT_FOUND. */ @Transactional(readOnly = true) public LedgerResp detailMaintain(Long ledgerId) { LedgerResp r = maintainMapper.selectDetailById(ledgerId); @@ -57,6 +66,7 @@ public class LedgerService { return r; } + /** 예외수수료 원장 상세 조회. 없으면 NOT_FOUND. */ @Transactional(readOnly = true) public ExceptionLedgerResp detailException(Long ledgerId) { ExceptionLedgerResp r = exceptionMapper.selectDetailById(ledgerId); @@ -64,6 +74,11 @@ public class LedgerService { return r; } + /** + * 예외수수료 원장 수기 등록. + * recurringLeft(남은 반복 지급 횟수)를 입력한 반복개월수로 초기화하고, + * 승인상태=PENDING(승인대기), 처리상태=NEW(신규)로 저장한다. + */ @Transactional public Long createException(ExceptionLedgerSaveReq req) { ExceptionLedgerVO vo = exceptionLedgerMapStruct.toVO(req); @@ -74,14 +89,20 @@ public class LedgerService { return vo.getLedgerId(); } + /** 모집원장 엑셀 추출용 커서(한 건씩 스트리밍) 반환. */ public Cursor exportRecruitCursor(LedgerSearchParam param) { return recruitMapper.selectCursorForExcel(param); } + /** 유지원장 엑셀 추출용 커서(한 건씩 스트리밍) 반환. */ public Cursor exportMaintainCursor(LedgerSearchParam param) { return maintainMapper.selectCursorForExcel(param); } + /** + * 예외수수료 원장 승인/반려 처리. + * status 는 APPROVED/REJECTED 만 허용하고, 로그인한 사용자를 승인자(approverId)로 기록한다. + */ @Transactional public void approveException(Long ledgerId, String status) { if (!ApproveStatus.APPROVED.name().equals(status) && !ApproveStatus.REJECTED.name().equals(status)) { diff --git a/ga-api/src/main/java/com/ga/api/service/license/AgentLicenseService.java b/ga-api/src/main/java/com/ga/api/service/license/AgentLicenseService.java index fcb1849..fafd9a2 100644 --- a/ga-api/src/main/java/com/ga/api/service/license/AgentLicenseService.java +++ b/ga-api/src/main/java/com/ga/api/service/license/AgentLicenseService.java @@ -11,6 +11,12 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +/** + * 설계사 자격(License) 도메인 비즈니스 로직. + * + *

자격 등록 시 ACTIVE 상태로 시작한다. expiring()은 만료일이 지정 일수 이내인 자격을 조회해 + * 갱신 관리에 활용한다. 클래스 전체는 읽기 전용이며 쓰기 메서드만 별도 트랜잭션을 건다.

+ */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -18,21 +24,25 @@ public class AgentLicenseService { private final AgentLicenseMapper mapper; + /** 자격 목록 조회(페이징). */ public PageResponse list(AgentLicenseSearchParam param) { param.startPage(); return PageResponse.of(mapper.selectList(param)); } + /** 자격 상세 조회. 없으면 NOT_FOUND. */ public AgentLicenseResp detail(Long licenseId) { AgentLicenseResp resp = mapper.selectById(licenseId); if (resp == null) throw new BizException(ErrorCode.NOT_FOUND); return resp; } + /** 만료일이 지정 일수(days) 이내인 자격 목록 조회. */ public List expiring(int days) { return mapper.selectExpiringWithin(days); } + /** 자격 등록. 상태를 ACTIVE로 고정해 저장한다. */ @Transactional public Long create(AgentLicenseSaveReq req) { AgentLicenseVO vo = new AgentLicenseVO(); @@ -47,6 +57,7 @@ public class AgentLicenseService { return vo.getLicenseId(); } + /** 자격 수정. 존재 검증 후 갱신하며 상태는 건드리지 않는다. */ @Transactional public void update(Long licenseId, AgentLicenseSaveReq req) { AgentLicenseResp existing = mapper.selectById(licenseId); @@ -62,6 +73,7 @@ public class AgentLicenseService { mapper.update(vo); } + /** 자격 레코드 삭제. 존재 검증 후 삭제한다. */ @Transactional public void delete(Long licenseId) { AgentLicenseResp existing = mapper.selectById(licenseId); diff --git a/ga-api/src/main/java/com/ga/api/service/mobile/MobileMeService.java b/ga-api/src/main/java/com/ga/api/service/mobile/MobileMeService.java index db0d464..e64dc5c 100644 --- a/ga-api/src/main/java/com/ga/api/service/mobile/MobileMeService.java +++ b/ga-api/src/main/java/com/ga/api/service/mobile/MobileMeService.java @@ -70,6 +70,7 @@ public class MobileMeService { deviceTokenMapper.deactivate(token, currentUserId()); } + /** 홈 요약 집계(명세서 수/출금 신청 수/미읽음 알림 수). agent 미연결이면 건수는 0. */ @Transactional(readOnly = true) public MeSummary summary() { Long userId = currentUserId(); @@ -103,6 +104,7 @@ public class MobileMeService { .build(); } + /** 내 명세서 목록. 클라이언트가 보낸 agentId는 무시하고 본인 것으로 강제 덮어쓴다. */ @Transactional(readOnly = true) public PageResponse statements(CommissionStatementSearchParam param) { Long agentId = requireAgentId(); @@ -111,6 +113,7 @@ public class MobileMeService { return PageResponse.of(statementMapper.selectList(param)); } + /** 내 출금 신청 목록. agentId를 본인 것으로 강제 덮어쓴다. */ @Transactional(readOnly = true) public PageResponse withdrawRequests(WithdrawRequestSearchParam param) { Long agentId = requireAgentId(); @@ -119,6 +122,7 @@ public class MobileMeService { return PageResponse.of(withdrawMapper.selectList(param)); } + /** 내 알림 목록. userId를 본인 것으로 강제 덮어쓴다. */ @Transactional(readOnly = true) public PageResponse notifications(UserNotificationSearchParam param) { Long userId = currentUserId(); @@ -127,6 +131,7 @@ public class MobileMeService { return PageResponse.of(notificationMapper.selectList(param)); } + /** 내 알림 단건 읽음 처리(본인 userId 기준). */ @Transactional public void markNotificationRead(Long notificationId) { Long userId = currentUserId(); diff --git a/ga-api/src/main/java/com/ga/api/service/notice/NoticeService.java b/ga-api/src/main/java/com/ga/api/service/notice/NoticeService.java index 49be7de..e5dc843 100644 --- a/ga-api/src/main/java/com/ga/api/service/notice/NoticeService.java +++ b/ga-api/src/main/java/com/ga/api/service/notice/NoticeService.java @@ -13,6 +13,13 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +/** + * 공지사항 + 사용자 알림 도메인 비즈니스 로직. + * + *

공지사항(notice)은 등록 시 활성 상태로 시작하며, activeList()는 노출 기간/활성 조건을 만족하는 것만 반환한다. + * 사용자 알림(user_notification)은 읽음 처리/미읽음 개수 집계를 담당한다. + * 클래스 전체는 읽기 전용이며 쓰기 메서드만 별도 트랜잭션을 건다.

+ */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -23,21 +30,25 @@ public class NoticeService { // ── Notice ──────────────────────────────────────────────────────────────── + /** 공지사항 목록 조회(페이징). */ public PageResponse list(NoticeSearchParam param) { param.startPage(); return PageResponse.of(noticeMapper.selectList(param)); } + /** 공지사항 상세 조회. 없으면 NOT_FOUND. */ public NoticeResp detail(Long noticeId) { NoticeResp resp = noticeMapper.selectById(noticeId); if (resp == null) throw new BizException(ErrorCode.NOT_FOUND); return resp; } + /** 현재 노출 가능한(기간 내 + 활성) 공지 목록 조회. */ public List activeList() { return noticeMapper.selectActive(); } + /** 공지사항 등록. 활성(isActive=true)으로 시작하고 작성자를 기록한다. */ @Transactional public Long create(NoticeSaveReq req) { NoticeVO vo = new NoticeVO(); @@ -52,6 +63,7 @@ public class NoticeService { return vo.getNoticeId(); } + /** 공지사항 수정. 존재 검증 후 갱신한다. */ @Transactional public void update(Long noticeId, NoticeSaveReq req) { NoticeResp existing = noticeMapper.selectById(noticeId); @@ -66,6 +78,7 @@ public class NoticeService { noticeMapper.update(vo); } + /** 공지사항 삭제. 존재 검증 후 삭제한다. */ @Transactional public void delete(Long noticeId) { NoticeResp existing = noticeMapper.selectById(noticeId); @@ -75,21 +88,25 @@ public class NoticeService { // ── UserNotification ────────────────────────────────────────────────────── + /** 사용자 알림 목록 조회(페이징). */ public PageResponse listNotifications(UserNotificationSearchParam param) { param.startPage(); return PageResponse.of(notificationMapper.selectList(param)); } + /** 특정 사용자의 미읽음 알림 개수 조회. */ public int unreadCount(Long userId) { return notificationMapper.selectUnreadCount(userId); } + /** 알림 단건 읽음 처리. 본인 소유 알림이 아니거나 없으면(영향 행 0) NOT_FOUND. */ @Transactional public void markRead(Long notificationId, Long userId) { int affected = notificationMapper.markRead(notificationId, userId); if (affected == 0) throw new BizException(ErrorCode.NOT_FOUND); } + /** 해당 사용자의 모든 알림을 일괄 읽음 처리. */ @Transactional public void markAllRead(Long userId) { notificationMapper.markAllRead(userId); diff --git a/ga-api/src/main/java/com/ga/api/service/org/AgentService.java b/ga-api/src/main/java/com/ga/api/service/org/AgentService.java index e16e679..e3a4aba 100644 --- a/ga-api/src/main/java/com/ga/api/service/org/AgentService.java +++ b/ga-api/src/main/java/com/ga/api/service/org/AgentService.java @@ -15,6 +15,18 @@ import org.apache.ibatis.cursor.Cursor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * 설계사 도메인 비즈니스 로직. + * + *

핵심 규칙: + *

    + *
  • 사번(licenseNo) 중복 등록 불가.
  • + *
  • 신규 등록 시 상태는 ACTIVE(재직)로 시작하며, 입사 이력(JOIN)을 함께 남긴다.
  • + *
  • 수정 시 조직/직급이 바뀌면 이력을 자동 기록한다 — 직급이 바뀌면 PROMOTE(승진), + * 그 외(조직만 변경)는 TRANSFER(전배).
  • + *
  • 세무 구분(taxType)이 사업소득이면 사업자번호(businessNo)가 필요하다(누락 시 경고 로그).
  • + *
+ */ @Slf4j @Service @RequiredArgsConstructor @@ -23,12 +35,14 @@ public class AgentService { private final AgentMapper mapper; private final CommonCodeService codeService; + /** 설계사 목록 조회(페이징). */ @Transactional(readOnly = true) public PageResponse list(AgentSearchParam param) { param.startPage(); return PageResponse.of(mapper.selectList(param)); } + /** 설계사 상세 조회. 없으면 NOT_FOUND. */ @Transactional(readOnly = true) public AgentResp detail(Long agentId) { AgentResp resp = mapper.selectDetailById(agentId); @@ -36,6 +50,7 @@ public class AgentService { return resp; } + /** 설계사 신규 등록(입사). 사번 중복 검증 후 ACTIVE 상태로 저장하고 JOIN 이력을 남긴다. */ @Transactional public Long create(AgentSaveReq req) { if (req.getLicenseNo() != null && mapper.existsByLicenseNo(req.getLicenseNo()) > 0) { @@ -55,6 +70,7 @@ public class AgentService { return vo.getAgentId(); } + /** 설계사 수정. 상태값은 기존 값을 유지하고, 조직/직급 변경 시 PROMOTE/TRANSFER 이력을 기록한다. */ @Transactional public void update(Long agentId, AgentSaveReq req) { AgentVO old = mapper.selectById(agentId); @@ -78,21 +94,25 @@ public class AgentService { } } + /** 설계사 상태 변경. 공통코드(AGENT_STATUS)에 정의된 값인지 검증한 뒤 반영한다. */ @Transactional public void changeStatus(Long agentId, String status) { codeService.validateCode("AGENT_STATUS", status); mapper.updateStatus(agentId, status); } + /** 설계사 조직/직급 변경 이력(입사·전배·승진) 조회. */ @Transactional(readOnly = true) public java.util.List history(Long agentId) { return mapper.selectHistoryResp(agentId); } + /** 엑셀 다운로드용 커서. 대용량을 메모리에 모두 올리지 않고 스트리밍 처리한다. */ public Cursor exportCursor(AgentSearchParam param) { return mapper.selectCursorForExcel(param); } + /** 요청 DTO를 영속 VO로 변환. 세무/부가세 구분 기본값을 채우고 사업소득 시 사업자번호 누락을 경고한다. */ private AgentVO toVO(AgentSaveReq req, Long agentId) { AgentVO vo = new AgentVO(); vo.setAgentId(agentId); diff --git a/ga-api/src/main/java/com/ga/api/service/org/OrganizationService.java b/ga-api/src/main/java/com/ga/api/service/org/OrganizationService.java index b2b28c5..56d5779 100644 --- a/ga-api/src/main/java/com/ga/api/service/org/OrganizationService.java +++ b/ga-api/src/main/java/com/ga/api/service/org/OrganizationService.java @@ -12,12 +12,19 @@ import org.springframework.transaction.annotation.Transactional; import java.util.*; +/** + * 조직 도메인 비즈니스 로직. + * + *

조직은 parentOrgId로 연결된 계층(트리) 구조다. 평면 목록을 받아 메모리에서 트리를 조립하며, + * 삭제는 하위 조직/소속 설계사가 없는 말단 조직만 허용해 데이터 정합성을 지킨다.

+ */ @Service @RequiredArgsConstructor public class OrganizationService { private final OrganizationMapper mapper; + /** 전체 조직을 트리 구조로 반환. DB에서 평면 목록을 받아 buildTree로 계층을 조립한다. */ @Transactional(readOnly = true) public List getTree() { List flat = mapper.selectAllForTree(); @@ -64,6 +71,7 @@ public class OrganizationService { return vo; } + /** 조직 삭제. 하위 조직 또는 소속 설계사가 하나라도 있으면 의존 데이터 오류로 막는다. */ @Transactional public void delete(Long orgId) { if (mapper.countChildren(orgId) > 0) { @@ -75,6 +83,7 @@ public class OrganizationService { mapper.deleteById(orgId); } + /** 평면 목록을 트리로 변환. parentOrgId가 없으면 루트, 있으면 부모의 children에 매달고, 부모를 못 찾으면 루트로 처리한다. */ private List buildTree(List flat) { Map byId = new LinkedHashMap<>(); for (OrganizationResp o : flat) byId.put(o.getOrgId(), o); diff --git a/ga-api/src/main/java/com/ga/api/service/product/ContractService.java b/ga-api/src/main/java/com/ga/api/service/product/ContractService.java index 1cef9a7..275af1d 100644 --- a/ga-api/src/main/java/com/ga/api/service/product/ContractService.java +++ b/ga-api/src/main/java/com/ga/api/service/product/ContractService.java @@ -14,6 +14,12 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * 보험계약 비즈니스 로직 서비스. + * 컨트롤러의 요청을 받아 DB 매퍼(ContractMapper)와 연동하며, 조회/등록/수정/상태변경을 처리한다. + * - @Transactional(readOnly=true): 조회 전용 트랜잭션(쓰기 없음). + * - ContractMapStruct: 요청 DTO(ContractSaveReq) ↔ 엔티티(ContractVO) 변환기. + */ @Service @RequiredArgsConstructor public class ContractService { @@ -21,12 +27,14 @@ public class ContractService { private final ContractMapper mapper; private final ContractMapStruct contractMapStruct; + /** 검색조건으로 계약 목록 페이징 조회. param.startPage() 가 PageHelper 페이징을 켠다. */ @Transactional(readOnly = true) public PageResponse list(ContractSearchParam param) { param.startPage(); return PageResponse.of(mapper.selectList(param)); } + /** 계약 상세 조회. 없으면 NOT_FOUND 예외. */ @Transactional(readOnly = true) public ContractResp detail(Long contractId) { ContractResp resp = mapper.selectDetailById(contractId); @@ -34,6 +42,7 @@ public class ContractService { return resp; } + /** 계약 신규 등록. 증권번호(policyNo) 중복이면 거부하고, 신규 계약은 ACTIVE 상태로 저장한다. */ @Transactional public Long create(ContractSaveReq req) { if (mapper.existsByPolicyNo(req.getPolicyNo()) > 0) { @@ -45,6 +54,7 @@ public class ContractService { return vo.getContractId(); } + /** 계약 수정. 기존 VO를 조회한 뒤 요청값으로 덮어써(updateVO) 저장한다. */ @Transactional public void update(Long contractId, ContractSaveReq req) { ContractVO vo = mapper.selectById(contractId); @@ -53,6 +63,7 @@ public class ContractService { mapper.update(vo); } + /** 계약 상태만 변경(예: 해지 처리). 해지된 계약은 이후 차등환수(chargeback) 산정 대상이 된다. */ @Transactional public void changeStatus(Long contractId, String status) { mapper.updateStatus(contractId, status); diff --git a/ga-api/src/main/java/com/ga/api/service/product/InsuranceCompanyService.java b/ga-api/src/main/java/com/ga/api/service/product/InsuranceCompanyService.java index cec7ef9..b8f8e9e 100644 --- a/ga-api/src/main/java/com/ga/api/service/product/InsuranceCompanyService.java +++ b/ga-api/src/main/java/com/ga/api/service/product/InsuranceCompanyService.java @@ -10,17 +10,23 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +/** + * 보험사(원수사) 마스터 비즈니스 로직 서비스. + * 상품·요율·수수료 계산의 기준이 되는 보험사 정보의 조회/등록/수정을 담당한다. + */ @Service @RequiredArgsConstructor public class InsuranceCompanyService { private final InsuranceCompanyMapper mapper; + /** 보험사 목록 조회. activeOnly=true 면 활성 보험사만, 아니면 전체 조회. */ @Transactional(readOnly = true) public List list(boolean activeOnly) { return activeOnly ? mapper.selectActive() : mapper.selectAll(); } + /** 보험사 상세 조회. 없으면 NOT_FOUND 예외. */ @Transactional(readOnly = true) public InsuranceCompanyVO detail(Long companyId) { InsuranceCompanyVO vo = mapper.selectById(companyId); @@ -28,12 +34,14 @@ public class InsuranceCompanyService { return vo; } + /** 보험사 신규 등록. 생성된 PK(companyId) 반환. */ @Transactional public Long create(InsuranceCompanyVO vo) { mapper.insert(vo); return vo.getCompanyId(); } + /** 보험사 수정. 존재 여부 확인 후 경로의 companyId 를 강제 세팅하여 저장(URL 기준 일관성 보장). */ @Transactional public void update(Long companyId, InsuranceCompanyVO vo) { if (mapper.selectById(companyId) == null) throw new BizException(ErrorCode.NOT_FOUND); diff --git a/ga-api/src/main/java/com/ga/api/service/product/ProductService.java b/ga-api/src/main/java/com/ga/api/service/product/ProductService.java index 7f150f3..9236a7f 100644 --- a/ga-api/src/main/java/com/ga/api/service/product/ProductService.java +++ b/ga-api/src/main/java/com/ga/api/service/product/ProductService.java @@ -11,18 +11,25 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * 보험상품 비즈니스 로직 서비스. + * 상품에는 company_rate(보험사→GA 요율)·payout_rate(GA→설계사 요율)가 붙으며, + * 이 값들이 이후 수수료 계산의 기본 단가가 된다. + */ @Service @RequiredArgsConstructor public class ProductService { private final ProductMapper mapper; + /** 상품 목록 페이징 조회. PageHelper.startPage 로 다음 쿼리에 페이징을 적용한다. */ @Transactional(readOnly = true) public PageResponse list(int pageNum, int pageSize, Long companyId, String insuranceType, String keyword, String isActive) { PageHelper.startPage(pageNum, pageSize); return PageResponse.of(mapper.selectList(companyId, insuranceType, keyword, isActive)); } + /** 상품 상세 조회. 없으면 NOT_FOUND 예외. */ @Transactional(readOnly = true) public ProductResp detail(Long productId) { ProductResp r = mapper.selectDetailById(productId); @@ -30,6 +37,7 @@ public class ProductService { return r; } + /** 상품 신규 등록. 같은 보험사 내 상품코드(productCode) 중복이면 거부. */ @Transactional public Long create(ProductVO vo) { if (mapper.existsByCode(vo.getCompanyId(), vo.getProductCode()) > 0) { @@ -39,6 +47,7 @@ public class ProductService { return vo.getProductId(); } + /** 상품 수정. 존재 확인 후 경로 productId 를 강제 세팅하여 저장. */ @Transactional public void update(Long productId, ProductVO vo) { if (mapper.selectById(productId) == null) throw new BizException(ErrorCode.NOT_FOUND); @@ -46,6 +55,7 @@ public class ProductService { mapper.update(vo); } + /** 상품 삭제. 존재 확인 후 삭제. */ @Transactional public void delete(Long productId) { if (mapper.selectById(productId) == null) throw new BizException(ErrorCode.NOT_FOUND); diff --git a/ga-api/src/main/java/com/ga/api/service/receive/ReceiveService.java b/ga-api/src/main/java/com/ga/api/service/receive/ReceiveService.java index 393f673..9e6044a 100644 --- a/ga-api/src/main/java/com/ga/api/service/receive/ReceiveService.java +++ b/ga-api/src/main/java/com/ga/api/service/receive/ReceiveService.java @@ -9,13 +9,21 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +/** + * [데이터 수신 매핑/원본 서비스] + * + * 신입 개발자 안내: + * - 보험사 프로파일 / 필드 매핑 / 코드 매핑 설정과, 수신 원본 데이터 및 파싱 에러 처리를 담당한다. + * - 대부분 단순 CRUD 위임이며, 화면 설정값을 DB에 보관/조회하는 역할이다. + * - saveProfile 은 존재하면 update, 없으면 insert 하는 "있으면 갱신/없으면 생성" 패턴이다. + */ @Service @RequiredArgsConstructor public class ReceiveService { private final ReceiveMapper mapper; - // === company_profile === + // === company_profile (보험사별 수신 설정) === @Transactional(readOnly = true) public List listProfiles() { diff --git a/ga-api/src/main/java/com/ga/api/service/regulatory/RegulatoryLimitChecker.java b/ga-api/src/main/java/com/ga/api/service/regulatory/RegulatoryLimitChecker.java index f89f449..dccfac4 100644 --- a/ga-api/src/main/java/com/ga/api/service/regulatory/RegulatoryLimitChecker.java +++ b/ga-api/src/main/java/com/ga/api/service/regulatory/RegulatoryLimitChecker.java @@ -14,6 +14,16 @@ import java.math.RoundingMode; import java.time.LocalDate; import java.time.format.DateTimeFormatter; +/** + * ga-api(온라인/조회 영역)용 1200%룰 한도 체커. + * 배치의 BatchRegulatoryLimitChecker와 동일 로직을 공유한다(검증 결과 일관성 목적). + * + * [신입용 요약] + * - 1200%룰: 보험업감독규정 제5-15조의5. 계약 첫 1년 모집수수료 총액을 연환산보험료의 + * 100%(=월납보험료의 1200%) 이내로 제한. + * - 여기서는 regulatory_limit 테이블의 PERCENT 한도값을 연환산보험료에 곱해 KRW 한도로 바꾼 뒤, + * 지금까지 지급 누계 + 이번 추가지급액이 그 한도를 넘는지 판정만 한다(실제 clip/이연은 호출부 책임). + */ @Component @RequiredArgsConstructor public class RegulatoryLimitChecker { @@ -28,7 +38,13 @@ public class RegulatoryLimitChecker { private final MaintainLedgerMapper maintainLedgerMapper; private final ContractMapper contractMapper; - /** 누적 수수료율 한도 체크 (1200%). 신계약 + 유지 원장 합산. */ + /** + * 누적 수수료율 한도 체크 (1200%). 신계약 + 유지 원장 합산. + * + * 지금까지 이 계약에 지급한 누계(currentTotal=모집+유지)에 이번 추가지급액(additionalAmount)을 + * 더한 값이 1200% 한도(연환산보험료 × 1200% / 100 = 1년치 보험료)를 넘으면 위반으로 본다. + * baseDate(보통 계약일)로 그 시점에 유효한 한도 설정을 고른다. 설정이 없으면 규제 미적용(ok). + */ public ViolationResult checkContractTotal(Long contractId, BigDecimal additionalAmount, LocalDate baseDate) { String baseDateStr = baseDate.format(DATE_FMT); RegulatoryLimitVO limit = limitMapper.selectActiveByType(TOTAL_RATIO, null, baseDateStr); @@ -36,9 +52,9 @@ public class RegulatoryLimitChecker { return ViolationResult.ok(additionalAmount, null, TOTAL_RATIO); } - BigDecimal limitKrw = resolveLimitKrw(limit, contractId); - BigDecimal currentTotal = sumPaidByContract(contractId); - BigDecimal projected = currentTotal.add(additionalAmount); + BigDecimal limitKrw = resolveLimitKrw(limit, contractId); // 비율 한도 → KRW 한도 환산 + BigDecimal currentTotal = sumPaidByContract(contractId); // 기존 지급 누계(모집+유지) + BigDecimal projected = currentTotal.add(additionalAmount); // 이번 지급 반영 후 예상 누계 if (projected.compareTo(limitKrw) > 0) { return ViolationResult.violated( @@ -52,7 +68,12 @@ public class RegulatoryLimitChecker { return ViolationResult.ok(currentTotal, limitKrw, TOTAL_RATIO); } - /** 1차년 수수료율 한도 체크. 신계약 원장 1차년도만 (유지는 1차년 개념 없음). */ + /** + * 1차년 수수료율 한도 체크. 신계약 원장 1차년도만 (유지는 1차년 개념 없음). + * + * 1200% 누적 한도와 별개로, 계약 첫 1년에 몰아줄 수 있는 모집수수료 상한 + * (FIRST_YEAR_RATIO, 기본 35%)을 검사한다. 누계는 모집 원장의 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); @@ -60,8 +81,8 @@ public class RegulatoryLimitChecker { return ViolationResult.ok(additionalAmount, null, FIRST_YEAR_RATIO); } - BigDecimal limitKrw = resolveLimitKrw(limit, contractId); - BigDecimal currentTotal = sumFirstYearPaidByContract(contractId); + BigDecimal limitKrw = resolveLimitKrw(limit, contractId); // 1차년 한도 → KRW 환산 + BigDecimal currentTotal = sumFirstYearPaidByContract(contractId); // 1차년에 이미 지급한 모집수수료 누계 BigDecimal projected = currentTotal.add(additionalAmount); if (projected.compareTo(limitKrw) > 0) { @@ -76,28 +97,38 @@ public class RegulatoryLimitChecker { return ViolationResult.ok(currentTotal, limitKrw, FIRST_YEAR_RATIO); } - /** 비율 한도(PERCENT)를 계약 연환산보험료 기준 KRW 한도로 환산. */ + /** + * 비율 한도(PERCENT)를 계약 연환산보험료 기준 KRW 한도로 환산. + * + * limit_value(1200, 35 등)는 비율(%)이라 금액과 직접 비교 불가. + * PERCENT면: 연환산보험료 × 비율 / 100 으로 KRW 한도를 만든다(원 단위 소수 2자리 반올림). + * PERCENT가 아니면(KRW 등): limit_value를 그대로 절대금액 한도로 사용. + */ private BigDecimal resolveLimitKrw(RegulatoryLimitVO limit, Long contractId) { if (!UNIT_PERCENT.equalsIgnoreCase(limit.getUnit())) { - return limit.getLimitValue(); + return limit.getLimitValue(); // 이미 절대금액 → 환산 불필요 } ContractVO contract = contractMapper.selectById(contractId); + // 계약/보험료가 없으면 기준이 0 → 한도도 0(사실상 전액 이연)이 된다. BigDecimal annualPremium = (contract != null && contract.getPremium() != null) ? contract.getPremium() : BigDecimal.ZERO; return annualPremium.multiply(limit.getLimitValue()) .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); } + /** 계약 누적 지급액 = 모집 원장 합 + 유지 원장 합. 1200% 누적 한도 비교용. */ private BigDecimal sumPaidByContract(Long contractId) { BigDecimal recruit = recruitLedgerMapper.sumByContract(contractId); BigDecimal maintain = maintainLedgerMapper.sumByContract(contractId); return nullSafe(recruit).add(nullSafe(maintain)); } + /** 1차년 한도 비교용: 모집 원장의 1차년분 누계만 합산(유지는 제외). */ private BigDecimal sumFirstYearPaidByContract(Long contractId) { return nullSafe(recruitLedgerMapper.sumFirstYearByContract(contractId)); } + /** 집계 결과가 NULL(지급 이력 없음)이면 0으로 치환. */ private BigDecimal nullSafe(BigDecimal value) { return value != null ? value : BigDecimal.ZERO; } diff --git a/ga-api/src/main/java/com/ga/api/service/regulatory/RegulatoryLimitService.java b/ga-api/src/main/java/com/ga/api/service/regulatory/RegulatoryLimitService.java index f9a7154..50bc389 100644 --- a/ga-api/src/main/java/com/ga/api/service/regulatory/RegulatoryLimitService.java +++ b/ga-api/src/main/java/com/ga/api/service/regulatory/RegulatoryLimitService.java @@ -16,20 +16,29 @@ import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.List; +/** + * 규제 한도 마스터(regulatory_limit) 비즈니스 로직 서비스. + * 1200%룰 등 금융당국 규제 한도를 CRUD 하고 활성/비활성 상태를 관리한다. + * 핵심 불변식: "같은 limitType + productCategory 조합으로 동시에 활성인 한도는 단 하나"여야 한다. + * → 그래서 등록/재활성화 시 selectActiveByType 으로 중복을 검사한다. + */ @Service @RequiredArgsConstructor public class RegulatoryLimitService { + /** 한도 유효기간 비교에 쓰는 날짜 포맷(yyyy-MM-dd). */ private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); private final RegulatoryLimitMapper limitMapper; + /** 한도 마스터 목록 페이징 조회. */ @Transactional(readOnly = true) public PageResponse list(RegulatoryLimitSearchParam param) { param.startPage(); return PageResponse.of(limitMapper.selectList(param)); } + /** 한도 마스터 상세 조회. 없으면 NOT_FOUND. */ @Transactional(readOnly = true) public RegulatoryLimitResp detail(Long limitId) { RegulatoryLimitResp resp = limitMapper.selectDetailById(limitId); @@ -39,6 +48,7 @@ public class RegulatoryLimitService { return resp; } + /** 한도 마스터 신규 등록. 같은 유형의 활성 한도가 이미 있으면 중복 오류. 새 한도는 활성(true) 상태로 저장. */ @Transactional public void create(RegulatoryLimitSaveReq req) { // 동일 limitType + productCategory + 기간 중복 체크 @@ -54,6 +64,7 @@ public class RegulatoryLimitService { limitMapper.insert(vo); } + /** 한도 마스터 수정. 활성 상태(isActive)는 기존 값을 그대로 유지(수정 API로는 바꾸지 않음). */ @Transactional public void update(Long limitId, RegulatoryLimitSaveReq req) { RegulatoryLimitVO existing = limitMapper.selectById(limitId); @@ -67,6 +78,7 @@ public class RegulatoryLimitService { limitMapper.update(vo); } + /** 한도 비활성화. 이미 비활성이면 잘못된 요청 처리. */ @Transactional public void deactivate(Long limitId) { RegulatoryLimitVO existing = limitMapper.selectById(limitId); @@ -79,6 +91,7 @@ public class RegulatoryLimitService { limitMapper.updateStatus(limitId, false); } + /** 한도 재활성화. 이미 활성이면 잘못된 요청, 같은 유형의 활성 한도가 존재하면 중복 오류(불변식 보장). */ @Transactional public void activate(Long limitId) { RegulatoryLimitVO existing = limitMapper.selectById(limitId); @@ -97,16 +110,19 @@ public class RegulatoryLimitService { limitMapper.updateStatus(limitId, true); } + /** 특정 유형/상품군에 대해 기준일에 유효한 활성 한도 1건 조회(한도 체커가 사용). */ @Transactional(readOnly = true) public RegulatoryLimitVO selectActiveByType(String limitType, String productCategory, String baseDate) { return limitMapper.selectActiveByType(limitType, productCategory, baseDate); } + /** 기준일에 유효한 모든 활성 한도 목록 조회(배치 일괄 점검 등에서 사용). */ @Transactional(readOnly = true) public List selectAllActive(String baseDate) { return limitMapper.selectAllActive(baseDate); } + /** 저장 요청 DTO(SaveReq)를 DB 엔티티(VO)로 변환하는 내부 헬퍼. */ private RegulatoryLimitVO toVO(RegulatoryLimitSaveReq req) { RegulatoryLimitVO vo = new RegulatoryLimitVO(); vo.setLimitType(req.getLimitType()); diff --git a/ga-api/src/main/java/com/ga/api/service/regulatory/ViolationResult.java b/ga-api/src/main/java/com/ga/api/service/regulatory/ViolationResult.java index 1e96ad2..18d3ee2 100644 --- a/ga-api/src/main/java/com/ga/api/service/regulatory/ViolationResult.java +++ b/ga-api/src/main/java/com/ga/api/service/regulatory/ViolationResult.java @@ -2,6 +2,15 @@ package com.ga.api.service.regulatory; import java.math.BigDecimal; +/** + * 규제 한도 위반 점검 결과를 담는 불변 레코드(record). + * - violated : 한도 초과 여부(true 면 위반) + * - currentTotal : 추가분 포함 현재 누적 금액(또는 비율) + * - limitAmount : 허용 한도 값 + * - limitType : 적용된 한도 유형(TOTAL_RATIO / FIRST_YEAR_RATIO) + * - message : 위반 시 안내 메시지(정상이면 null) + * 정적 팩토리 ok()/violated() 로 가독성 있게 생성한다. + */ public record ViolationResult( boolean violated, BigDecimal currentTotal, @@ -9,10 +18,12 @@ public record ViolationResult( 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); } diff --git a/ga-api/src/main/java/com/ga/api/service/report/ReportService.java b/ga-api/src/main/java/com/ga/api/service/report/ReportService.java index 953d41c..5ab7c06 100644 --- a/ga-api/src/main/java/com/ga/api/service/report/ReportService.java +++ b/ga-api/src/main/java/com/ga/api/service/report/ReportService.java @@ -15,6 +15,13 @@ import java.util.List; /** * 리포트 서비스 — 기존 정산 데이터 위 읽기전용 집계. * Cursor 변형은 엑셀 export 용 (ExcelService.exportLargeExcel 가 트랜잭션 내에서 소비). + * + * [신입 개발자 안내] + * - 리포트 종류마다 메서드가 두 벌이다: + * 화면 조회용 (List 반환, @Transactional(readOnly) — 결과를 한 번에 메모리에 담아 JSON 응답) + * 엑셀용 (Cursor 반환 — 한 행씩 흘려보내 대용량도 적은 메모리로 처리) + * - Cursor 메서드에 @Transactional 이 없는 이유: 트랜잭션 경계를 컨트롤러의 ExcelService 가 잡고 + * 그 안에서 커서를 끝까지 읽어야 하기 때문이다(여기서 트랜잭션을 닫으면 커서가 닫혀버림). */ @Service @RequiredArgsConstructor diff --git a/ga-api/src/main/java/com/ga/api/service/rule/RuleService.java b/ga-api/src/main/java/com/ga/api/service/rule/RuleService.java index 51b88fc..b0d2d2f 100644 --- a/ga-api/src/main/java/com/ga/api/service/rule/RuleService.java +++ b/ga-api/src/main/java/com/ga/api/service/rule/RuleService.java @@ -10,13 +10,18 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +/** + * 수수료 규정(룰) 비즈니스 로직 서비스. + * 다섯 가지 기준표(commission_rate / payout_rule / override_rule / chargeback_rule / exception_type_code)의 + * 조회·등록·수정·삭제를 RuleMapper 를 통해 처리한다. 이 기준표들이 수수료 계산 엔진의 입력값이 된다. + */ @Service @RequiredArgsConstructor public class RuleService { private final RuleMapper mapper; - // === commission_rate === + // === commission_rate : 상품별 보험사 지급요율(company_rate) 기준표 === @Transactional(readOnly = true) public List listCommissionRates(Long productId, Integer year) { @@ -40,7 +45,7 @@ public class RuleService { mapper.deleteCommissionRate(rateId); } - // === payout_rule === + // === payout_rule : 설계사 등급(grade)별 지급요율(payout_rate) 규칙 === @Transactional(readOnly = true) public List listPayoutRules(Long gradeId, String insuranceType) { @@ -64,7 +69,7 @@ public class RuleService { mapper.deletePayoutRule(ruleId); } - // === override_rule === + // === override_rule : 상위 관리자 오버라이드(육성) 수당 규칙 === @Transactional(readOnly = true) public List listOverrideRules() { @@ -88,7 +93,7 @@ public class RuleService { mapper.deleteOverrideRule(overrideId); } - // === chargeback_rule === + // === chargeback_rule : 조기해지 환수율(차등환수) 규칙 === @Transactional(readOnly = true) public List listChargebackRules(String companyCode) { @@ -112,7 +117,7 @@ public class RuleService { mapper.deleteChargebackRule(cbRuleId); } - // === exception_type_code === + // === exception_type_code : 예외 처리 유형 코드(코드값이 PK라 등록 시 중복 체크) === @Transactional(readOnly = true) public List listExceptionCodes() { diff --git a/ga-api/src/main/java/com/ga/api/service/settle/CarrierReconciliationOutService.java b/ga-api/src/main/java/com/ga/api/service/settle/CarrierReconciliationOutService.java index 5be9c3e..fff023f 100644 --- a/ga-api/src/main/java/com/ga/api/service/settle/CarrierReconciliationOutService.java +++ b/ga-api/src/main/java/com/ga/api/service/settle/CarrierReconciliationOutService.java @@ -13,6 +13,13 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * 보험사 회신(carrier_reconciliation_out) 서비스. + * + *

보험사로 보낼/보낸 정산 회신 파일 이력을 관리합니다. 등록은 "보험사+정산월"이 유일해야 하며(중복 거부), + * 신규 등록은 READY(전송대기) 상태로 시작합니다. 전송 실패(FAIL) 건은 retry 로 다시 READY 로 돌려 + * 배치가 재처리하도록 합니다. + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) diff --git a/ga-api/src/main/java/com/ga/api/service/settle/ChargebackService.java b/ga-api/src/main/java/com/ga/api/service/settle/ChargebackService.java index ae7bd4b..e10994d 100644 --- a/ga-api/src/main/java/com/ga/api/service/settle/ChargebackService.java +++ b/ga-api/src/main/java/com/ga/api/service/settle/ChargebackService.java @@ -18,6 +18,12 @@ import java.time.LocalDateTime; import java.util.Iterator; import java.util.List; +/** + * 환수(chargeback) 조회/엑셀 서비스. + * + *

이미 지급한 수수료를 도로 거둬들이는 "환수" 자료를 조회하고 엑셀로 내려받는 기능을 담당합니다. + * (환수 자료 적재는 ChargebackUploadService 가 담당) + */ @Service @RequiredArgsConstructor public class ChargebackService { diff --git a/ga-api/src/main/java/com/ga/api/service/settle/CommissionDisputeService.java b/ga-api/src/main/java/com/ga/api/service/settle/CommissionDisputeService.java index a7cfb1e..5082e56 100644 --- a/ga-api/src/main/java/com/ga/api/service/settle/CommissionDisputeService.java +++ b/ga-api/src/main/java/com/ga/api/service/settle/CommissionDisputeService.java @@ -16,6 +16,14 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; +/** + * 수수료 이의신청(commission_dispute) 서비스. + * + *

설계사가 제기한 정산 금액 이의를 접수→검토→승인/기각으로 처리합니다. + * 상태(DisputeStatus) 전이: OPEN → IN_REVIEW → APPROVED/REJECTED. + * startReview 는 OPEN 에서만, approve/reject 는 IN_REVIEW 에서만 가능하도록 가드합니다. + * 승인 시 보정금액(adjustmentAmount)과 연결 정정ID(correctionId)를 함께 기록할 수 있습니다. + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) diff --git a/ga-api/src/main/java/com/ga/api/service/settle/IncentivePaymentService.java b/ga-api/src/main/java/com/ga/api/service/settle/IncentivePaymentService.java index 80b160a..40749a8 100644 --- a/ga-api/src/main/java/com/ga/api/service/settle/IncentivePaymentService.java +++ b/ga-api/src/main/java/com/ga/api/service/settle/IncentivePaymentService.java @@ -14,6 +14,14 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * 시책 지급(incentive_payment) 서비스. + * + *

시책 프로그램 조건을 충족한 설계사에게 지급할 인센티브 건을 생성/확정/취소합니다. + * 상태(IncentivePayStatus) 전이: CALCULATED → CONFIRMED → PAID, 또는 CANCELLED. + * 생성·확정 시 PeriodCloseService.assertNotClosed 로 해당 정산월이 마감되지 않았는지 확인합니다(마감 가드). + * 확정은 CALCULATED 에서만, 취소는 PAID 가 아닐 때만 가능합니다. + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) diff --git a/ga-api/src/main/java/com/ga/api/service/settle/IncentiveProgramService.java b/ga-api/src/main/java/com/ga/api/service/settle/IncentiveProgramService.java index eda8629..33b0dd3 100644 --- a/ga-api/src/main/java/com/ga/api/service/settle/IncentiveProgramService.java +++ b/ga-api/src/main/java/com/ga/api/service/settle/IncentiveProgramService.java @@ -13,6 +13,12 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * 시책 프로그램(incentive_program) 서비스. + * + *

인센티브 지급 규칙(대상·기간·지급방식·예산)을 등록/수정하고 활성 여부를 토글합니다. + * 신규 프로그램은 isActive=true(활성)로 생성됩니다. 실제 지급 산출은 IncentivePaymentService 가 담당합니다. + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) diff --git a/ga-api/src/main/java/com/ga/api/service/settle/PaymentBatchService.java b/ga-api/src/main/java/com/ga/api/service/settle/PaymentBatchService.java index 6180b4b..89c56cf 100644 --- a/ga-api/src/main/java/com/ga/api/service/settle/PaymentBatchService.java +++ b/ga-api/src/main/java/com/ga/api/service/settle/PaymentBatchService.java @@ -27,6 +27,16 @@ import java.math.BigDecimal; import java.util.Iterator; import java.util.List; +/** + * 지급대상 배치(payment_batch) 서비스. + * + *

한 정산월의 지급대상(payable_amount > 0)을 한 묶음(배치)으로 만들어 검토/확정하는 흐름을 담당합니다. + * 배치 헤더(payment_batch)와 각 설계사별 항목(payment_batch_item)으로 구성되며, + * 항목의 포함/제외가 바뀔 때마다 recalcTotals 로 합계(건수·금액)를 다시 집계합니다. + * + *

상태(PaymentBatchStatus): DRAFT(작성중) → FINALIZED(확정) / CANCELLED(취소). + * 수정은 DRAFT 에서만, 확정은 포함건이 1건 이상일 때만 가능합니다. + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) diff --git a/ga-api/src/main/java/com/ga/api/service/settle/PaymentService.java b/ga-api/src/main/java/com/ga/api/service/settle/PaymentService.java index acb91d4..dbb1a80 100644 --- a/ga-api/src/main/java/com/ga/api/service/settle/PaymentService.java +++ b/ga-api/src/main/java/com/ga/api/service/settle/PaymentService.java @@ -29,6 +29,25 @@ import java.time.LocalDateTime; import java.util.List; import java.util.stream.Collectors; +/** + * 지급(payment) 서비스 — 실제 지급될 한 건의 금액을 완성하는 핵심 로직. + * + *

지급 한 건의 금액 계산 흐름(도메인): + *

+ *   payAmount(지급원금)
+ *     - incomeTax(소득세) - localTax(지방세) (+ vat 부가세, 사업자 유형에 따라)
+ *     = (세후금액)
+ *     - deductionTotal(각종 차감 합계)
+ *     = netAmount(실지급액)  ← 설계사 통장에 실제로 들어가는 돈
+ * 
+ * + *

주요 메서드: + *

    + *
  • calculateTaxes / calculateTaxesForMonth: 설계사 세금 유형(TaxType)·부가세 유형(VatType)에 따라 세금 산출.
  • + *
  • applyDeductions / applyDeductionsForMonth: PENDING 차감을 지급건에 반영하고 명세를 남긴 뒤 netAmount 차감.
  • + *
  • generateTransferFile: 정산월+은행 기준 은행 이체파일 생성(TransferFileService 위임).
  • + *
+ */ @Slf4j @Service @RequiredArgsConstructor @@ -41,21 +60,25 @@ public class PaymentService { private final TaxCalculator taxCalculator; private final DeductionService deductionService; + /** 지급 건 목록 조회(페이징). */ @Transactional(readOnly = true) public PageResponse list(SettleSearchParam param) { param.startPage(); return PageResponse.of(mapper.selectList(param)); } + /** 지급 상태 수동 변경. 실패 시 failReason 을 함께 저장합니다. */ @Transactional public void updateStatus(Long paymentId, String status, String failReason) { mapper.updateStatus(paymentId, status, failReason); } + /** 은행 이체파일 생성. 실제 작업은 TransferFileService 에 위임합니다. */ public TransferFileResp generateTransferFile(String settleMonth, String bankCode) { return transferFileService.generate(settleMonth, bankCode); } + /** 지급 1건의 세금 재계산 후 DB 반영(소득세·지방세·부가세·실지급액). */ @Transactional public void calculateTaxes(Long paymentId) { PaymentVO payment = mapper.selectById(paymentId); @@ -66,6 +89,7 @@ public class PaymentService { payment.getVatAmount(), payment.getNetAmount()); } + /** 정산월 전체 지급건의 세금 일괄 재계산 후 한 번에 batch UPDATE. */ @Transactional public void calculateTaxesForMonth(String settleMonth) { List payments = mapper.selectBySettleMonth(settleMonth); @@ -77,6 +101,12 @@ public class PaymentService { } } + /** + * 지급 1건에 차감(공제)을 적용. + * 흐름: 해당 설계사의 PENDING 차감 조회 → 차감 명세(payment_deduction_detail) 기록 + * → 차감 상태를 APPLIED 로 전이 → 차감 합계만큼 netAmount(실지급액) 감소. + * 적용할 PENDING 차감이 없으면 아무 것도 하지 않고 종료합니다. + */ @Transactional public void applyDeductions(Long paymentId, String settleMonth) { PaymentVO payment = mapper.selectById(paymentId); @@ -111,6 +141,7 @@ public class PaymentService { mapper.updateDeduction(paymentId, deductionTotal, newNet); } + /** 정산월 전체 지급건에 대해 차감을 순회 적용. */ @Transactional public void applyDeductionsForMonth(String settleMonth) { List payments = mapper.selectBySettleMonth(settleMonth); @@ -119,6 +150,10 @@ public class PaymentService { } } + /** + * 설계사 세무 유형에 따라 세금을 산출해 payment VO 에 채워 넣는다(아직 DB 저장 전). + * 사업소득(BUSINESS_INCOME)인데 사업자번호가 없으면 경고만 남기고 계산은 진행한다. + */ private void applyTaxes(PaymentVO payment) { AgentVO agent = agentMapper.selectById(payment.getAgentId()); TaxType taxType = parseTaxType(agent); @@ -135,6 +170,7 @@ public class PaymentService { payment.setNetAmount(breakdown.netAmount()); } + /** 설계사 세금 유형 문자열을 enum 으로 변환. 값이 없거나 알 수 없으면 기본값 BUSINESS_INCOME(사업소득). */ private TaxType parseTaxType(AgentVO agent) { if (agent != null && agent.getTaxType() != null) { try { return TaxType.valueOf(agent.getTaxType()); } catch (IllegalArgumentException ignored) {} @@ -142,6 +178,7 @@ public class PaymentService { return TaxType.BUSINESS_INCOME; } + /** 설계사 부가세 유형 문자열을 enum 으로 변환. 값이 없거나 알 수 없으면 기본값 NONE(부가세 없음). */ private VatType parseVatType(AgentVO agent) { if (agent != null && agent.getVatType() != null) { try { return VatType.valueOf(agent.getVatType()); } catch (IllegalArgumentException ignored) {} diff --git a/ga-api/src/main/java/com/ga/api/service/settle/PeriodCloseService.java b/ga-api/src/main/java/com/ga/api/service/settle/PeriodCloseService.java index cb07730..699efa1 100644 --- a/ga-api/src/main/java/com/ga/api/service/settle/PeriodCloseService.java +++ b/ga-api/src/main/java/com/ga/api/service/settle/PeriodCloseService.java @@ -13,6 +13,12 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * 정산 마감(period_close) 서비스. + * + *

정산월을 마감/재오픈하고, 다른 서비스가 "이 달이 마감됐는지"를 물어볼 수 있도록 마감 가드(assertNotClosed)를 제공합니다. + * 마감된 월은 정정·시책지급 등 금액 변경이 차단됩니다. 같은 월 중복 마감은 거부됩니다. + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) diff --git a/ga-api/src/main/java/com/ga/api/service/settle/SettleCorrectionService.java b/ga-api/src/main/java/com/ga/api/service/settle/SettleCorrectionService.java index 10ccc39..8cbf712 100644 --- a/ga-api/src/main/java/com/ga/api/service/settle/SettleCorrectionService.java +++ b/ga-api/src/main/java/com/ga/api/service/settle/SettleCorrectionService.java @@ -16,6 +16,14 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +/** + * 정산 정정(settle_correction) 서비스. + * + *

이미 산출된 정산 금액을 사후에 바로잡는 정정 건을 생성/승인/취소합니다. + * 생성 시 변경 전·후 금액으로 차액(diffAmount)을 자동 계산하고, 해당 정산월의 마감 여부를 확인합니다(마감 가드). + * 상태(CorrectionStatus) 전이: DRAFT → APPROVED → APPLIED(배치 반영), 또는 취소. + * 승인은 DRAFT 에서만, 취소는 이미 반영(APPLIED)되지 않은 건만 가능합니다. + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) diff --git a/ga-api/src/main/java/com/ga/api/service/settle/SettleService.java b/ga-api/src/main/java/com/ga/api/service/settle/SettleService.java index b620d16..eafe93b 100644 --- a/ga-api/src/main/java/com/ga/api/service/settle/SettleService.java +++ b/ga-api/src/main/java/com/ga/api/service/settle/SettleService.java @@ -16,12 +16,26 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Map; +/** + * 정산 마스터(settle_master) 서비스. + * + *

정산 마스터는 "설계사 1명 × 정산월 1개"의 수수료 합계 원장입니다. 이 서비스는 조회/요약과 함께 + * 상태 전이(확정/보류/해제)를 담당합니다. 상태 전이는 잘못된 순서로 넘어가지 못하도록 가드를 둡니다. + * + *

상태(SettleStatus) 전이 규칙: + *

    + *
  • confirm: 현재 상태가 CONFIRMED/PAID 가 아니면 CONFIRMED 로. (이미 확정/지급이면 중복 확정 거부)
  • + *
  • hold: PAID(지급완료)만 아니면 보류 가능(사유·사유코드 기록).
  • + *
  • release: HOLD 상태만 다시 CALCULATED 로 복귀.
  • + *
+ */ @Service @RequiredArgsConstructor public class SettleService { private final SettleMasterMapper masterMapper; + /** 정산 마스터 목록 조회(페이징). startPage 호출로 이후 SELECT 가 자동 페이징됩니다. */ @Transactional(readOnly = true) public PageResponse list(SettleSearchParam param) { param.startPage(); @@ -45,6 +59,7 @@ public class SettleService { return masterMapper.selectOrgSummary(settleMonth); } + /** 정산 확정. 이미 CONFIRMED/PAID 면 중복 확정으로 거부, 그 외에는 CONFIRMED 로 전이합니다. */ @Transactional public void confirm(Long settleId) { SettleMasterVO vo = masterMapper.selectById(settleId); @@ -55,6 +70,7 @@ public class SettleService { masterMapper.updateStatus(settleId, SettleStatus.CONFIRMED.name(), SecurityUtil.getCurrentUserId()); } + /** 정산 보류. 지급완료(PAID)건은 되돌릴 수 없으므로 보류를 막고, 그 외에는 사유와 함께 HOLD 처리합니다. */ @Transactional public void hold(Long settleId, String reason, String reasonCode) { SettleMasterVO vo = masterMapper.selectById(settleId); @@ -65,6 +81,7 @@ public class SettleService { masterMapper.updateHold(settleId, reason, reasonCode); } + /** 보류 해제. 반드시 HOLD 상태여야 하며, 정상 흐름의 출발점인 CALCULATED 로 되돌립니다. */ @Transactional public void release(Long settleId) { SettleMasterVO vo = masterMapper.selectById(settleId); diff --git a/ga-api/src/main/java/com/ga/api/service/settle/TaxInvoiceService.java b/ga-api/src/main/java/com/ga/api/service/settle/TaxInvoiceService.java index bb63288..2f26524 100644 --- a/ga-api/src/main/java/com/ga/api/service/settle/TaxInvoiceService.java +++ b/ga-api/src/main/java/com/ga/api/service/settle/TaxInvoiceService.java @@ -16,6 +16,12 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.time.LocalDate; +/** + * 세금계산서(tax_invoice) 서비스. + * + *

세금계산서 발행 이력을 등록/취소합니다. 발행번호(invoiceNo)는 유일해야 하며 중복 시 거부합니다. + * 합계금액(totalAmount)은 공급가액(supplyAmount) + 세액(taxAmount)으로 자동 계산됩니다. + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) diff --git a/ga-api/src/main/java/com/ga/api/service/settle/TerminationSettlementService.java b/ga-api/src/main/java/com/ga/api/service/settle/TerminationSettlementService.java index 3f69d0d..298f94e 100644 --- a/ga-api/src/main/java/com/ga/api/service/settle/TerminationSettlementService.java +++ b/ga-api/src/main/java/com/ga/api/service/settle/TerminationSettlementService.java @@ -14,6 +14,15 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * 해촉 정산(termination_settlement) 서비스. + * + *

설계사 위촉계약 종료(해촉) 시, 미지급 수수료(모집/분할/시책)를 정산하고 환수 예상액 등을 차감해 + * 최종 지급액(finalPayAmount)을 산출하는 일회성 정산을 처리합니다. + * 상태(TermSettleStatus) 전이: CALCULATING → CONFIRMED, 또는 CANCELLED. + * 한 설계사에 진행 중인(취소가 아닌) 해촉 정산이 이미 있으면 중복 생성을 거부하고, + * 수정·확정은 CALCULATING(산출 중) 상태에서만 가능합니다. + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) diff --git a/ga-api/src/main/java/com/ga/api/service/statement/CommissionStatementService.java b/ga-api/src/main/java/com/ga/api/service/statement/CommissionStatementService.java index 7d8afe3..597af54 100644 --- a/ga-api/src/main/java/com/ga/api/service/statement/CommissionStatementService.java +++ b/ga-api/src/main/java/com/ga/api/service/statement/CommissionStatementService.java @@ -23,6 +23,18 @@ import java.time.LocalDateTime; import java.util.List; import java.util.stream.Collectors; +/** + * [수수료 명세서 서비스] + * + * 신입 개발자 안내 — generate() 한 건 발급 흐름: + * 1) 설계사 존재 확인. + * 2) 집계(StatementAggregator): 해당 설계사+정산월의 지급(payment)들을 합산해 요약(StatementSummary) 생성. + * 3) 엑셀 생성(ExcelStatementGenerator) 후 파일 저장(file_storage), file_path 에는 fileId 를 보관. + * 4) 같은 (설계사,정산월,유형) 명세서가 있으면 UPDATE(재발급), 없으면 INSERT. + * - download(): 파일을 읽어 반환하고 상태를 DOWNLOADED 로 전이. + * - cancel(): 명세서 상태를 CANCELLED 로 전이. + * - 현재 PDF 미지원(엑셀만). + */ @Service @RequiredArgsConstructor public class CommissionStatementService { @@ -110,6 +122,7 @@ public class CommissionStatementService { } } + // 월별 일괄 발급: 해당 월에 지급이 있는 설계사들을 추려, 각 설계사마다 generate() 를 호출한다. @Transactional public void generateForMonth(String settleMonth, String statementType) { List allPayments = paymentMapper.selectBySettleMonth(settleMonth); diff --git a/ga-api/src/main/java/com/ga/api/service/statement/ExcelStatementGenerator.java b/ga-api/src/main/java/com/ga/api/service/statement/ExcelStatementGenerator.java index 391a2de..32842ba 100644 --- a/ga-api/src/main/java/com/ga/api/service/statement/ExcelStatementGenerator.java +++ b/ga-api/src/main/java/com/ga/api/service/statement/ExcelStatementGenerator.java @@ -14,10 +14,19 @@ import java.math.BigDecimal; import java.text.DecimalFormat; import java.util.List; +/** + * [수수료 명세서 엑셀 생성기] + * + * 신입 개발자 안내: + * - 상단에는 설계사 기본정보 + 세금 요약(총지급/원천세/지방소득세/부가세/공제/실수령)을 키-값으로 적고, + * 하단에는 지급 건별 표(지급ID/금액/세금/실수령)를 출력한다. + * - SXSSFWorkbook(스트리밍)을 써서 행이 많아도 메모리를 적게 쓴다. 금액은 천단위 콤마 포맷. + */ @Slf4j @Component public class ExcelStatementGenerator { + // 금액 표시 포맷: 천단위 콤마 (예: 1,234,567) private static final DecimalFormat AMOUNT_FMT = new DecimalFormat("#,##0"); public byte[] generate(AgentResp agent, StatementSummary summary, List payments) { diff --git a/ga-api/src/main/java/com/ga/api/service/statement/StatementAggregator.java b/ga-api/src/main/java/com/ga/api/service/statement/StatementAggregator.java index 5cbf44f..c7c93dc 100644 --- a/ga-api/src/main/java/com/ga/api/service/statement/StatementAggregator.java +++ b/ga-api/src/main/java/com/ga/api/service/statement/StatementAggregator.java @@ -8,6 +8,14 @@ import org.springframework.stereotype.Component; import java.math.BigDecimal; import java.util.List; +/** + * [명세서 집계기] + * + * 신입 개발자 안내: + * - 한 설계사의 특정 정산월 지급(payment) 건들을 모아 항목별로 합산한다. + * - 합산 항목: 총지급액 / 원천세(income) / 지방소득세(local) / 부가세(vat) / 공제 / 실수령액. + * - null 금액은 0으로 간주하고 더한다(sum 헬퍼). + */ @Component @RequiredArgsConstructor public class StatementAggregator { diff --git a/ga-api/src/main/java/com/ga/api/service/statement/StatementSummary.java b/ga-api/src/main/java/com/ga/api/service/statement/StatementSummary.java index f154ed6..4eea9e8 100644 --- a/ga-api/src/main/java/com/ga/api/service/statement/StatementSummary.java +++ b/ga-api/src/main/java/com/ga/api/service/statement/StatementSummary.java @@ -2,6 +2,15 @@ package com.ga.api.service.statement; import java.math.BigDecimal; +/** + * 명세서 요약 값(불변 record). + * grossAmount : 총 지급액(세전) + * incomeTaxAmount : 원천세(소득세) + * localTaxAmount : 지방소득세(원천세의 10%) + * vatAmount : 부가세(과세 대상일 때만) + * deductionAmount : 기타 공제 + * netAmount : 실수령액(= 총지급 - 세금/공제 + 부가세) + */ public record StatementSummary( BigDecimal grossAmount, BigDecimal incomeTaxAmount, diff --git a/ga-api/src/main/java/com/ga/api/service/succession/ContractSuccessionService.java b/ga-api/src/main/java/com/ga/api/service/succession/ContractSuccessionService.java index a2491df..229875f 100644 --- a/ga-api/src/main/java/com/ga/api/service/succession/ContractSuccessionService.java +++ b/ga-api/src/main/java/com/ga/api/service/succession/ContractSuccessionService.java @@ -13,6 +13,13 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * 계약 승계 도메인 비즈니스 로직. + * + *

승계는 계약의 담당(귀속) 설계사를 전임 → 후임으로 이관하는 정보다. 실제 원장의 agent 이관과 + * 수수료 귀속 변경은 정산 배치가 이 레코드를 참고해 수행한다(여기서는 승계 레코드의 CRUD/상태만 관리). + * 클래스 전체는 기본 읽기 전용이며, 쓰기 메서드만 별도로 {@code @Transactional}을 건다.

+ */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -20,17 +27,20 @@ public class ContractSuccessionService { private final ContractSuccessionMapper mapper; + /** 승계 목록 조회(페이징). */ public PageResponse list(ContractSuccessionSearchParam param) { param.startPage(); return PageResponse.of(mapper.selectList(param)); } + /** 승계 상세 조회. 없으면 NOT_FOUND. */ public ContractSuccessionResp detail(Long successionId) { ContractSuccessionResp resp = mapper.selectById(successionId); if (resp == null) throw new BizException(ErrorCode.NOT_FOUND); return resp; } + /** 승계 등록. 전임(from)과 후임(to)이 동일하면 잘못된 요청으로 거부하고, 등록자를 기록한다. */ @Transactional public Long create(ContractSuccessionSaveReq req) { if (req.getFromAgentId().equals(req.getToAgentId())) { @@ -56,6 +66,7 @@ public class ContractSuccessionService { mapper.cancel(successionId, SecurityUtil.getCurrentUserId()); } + /** 승계 레코드 물리 삭제. 존재 검증 후 삭제한다. */ @Transactional public void delete(Long successionId) { ContractSuccessionResp existing = mapper.selectById(successionId); diff --git a/ga-api/src/main/java/com/ga/api/service/system/RoleService.java b/ga-api/src/main/java/com/ga/api/service/system/RoleService.java index 3e1b003..621ad48 100644 --- a/ga-api/src/main/java/com/ga/api/service/system/RoleService.java +++ b/ga-api/src/main/java/com/ga/api/service/system/RoleService.java @@ -11,17 +11,25 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Map; +/** + * 역할(Role) 및 역할-권한 매핑 도메인 비즈니스 로직. + * + *

RBAC의 역할 CRUD와 권한 부여를 담당한다. savePermissions는 기존 매핑을 모두 지우고 + * 전달받은 목록으로 다시 채우는 전체 교체(replace) 방식이다(부분 갱신이 아님).

+ */ @Service @RequiredArgsConstructor public class RoleService { private final RoleMapper mapper; + /** 역할 전체 목록 조회. */ @Transactional(readOnly = true) public List list() { return mapper.selectAll(); } + /** 역할 상세 조회. 없으면 NOT_FOUND. */ @Transactional(readOnly = true) public RoleVO detail(Long roleId) { RoleVO vo = mapper.selectById(roleId); @@ -29,17 +37,20 @@ public class RoleService { return vo; } + /** 역할에 부여된 메뉴별 권한 목록 조회. */ @Transactional(readOnly = true) public List> 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); @@ -47,12 +58,14 @@ public class RoleService { mapper.update(vo); } + /** 역할 삭제. 존재 검증 후 삭제한다. */ @Transactional public void delete(Long roleId) { if (mapper.selectById(roleId) == null) throw new BizException(ErrorCode.NOT_FOUND); mapper.deleteById(roleId); } + /** 역할의 권한 매핑 전체 교체. 기존 매핑 삭제 후 전달 목록(menuId/permCode/isGranted)을 재삽입한다. */ @Transactional public void savePermissions(Long roleId, List> permissions) { mapper.deleteRolePermissions(roleId); diff --git a/ga-api/src/main/java/com/ga/api/service/system/UserService.java b/ga-api/src/main/java/com/ga/api/service/system/UserService.java index dd38d00..6e0fa38 100644 --- a/ga-api/src/main/java/com/ga/api/service/system/UserService.java +++ b/ga-api/src/main/java/com/ga/api/service/system/UserService.java @@ -18,6 +18,13 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +/** + * 시스템 사용자(로그인 계정) 도메인 비즈니스 로직. + * + *

계정 CRUD와 비밀번호 초기화/잠금 해제/전화번호 정정을 담당한다. 비밀번호는 항상 인코딩(해시)해 저장하고, + * 신규 등록 시 비밀번호 미입력이면 임시 비밀번호를 부여한다(로그인 후 변경 권장). 로그인 ID는 중복 불가다. + * 역할(roleIds)은 update 시 전체 교체 방식으로 재설정한다.

+ */ @Service @RequiredArgsConstructor public class UserService { @@ -27,12 +34,14 @@ public class UserService { private final SystemConfigService configService; private final UserMapStruct userMapStruct; + /** 사용자 목록 조회(페이징). */ @Transactional(readOnly = true) public PageResponse list(UserSearchParam param) { param.startPage(); return PageResponse.of(mapper.selectList(param)); } + /** 사용자 상세 조회(역할 코드 포함). 없으면 NOT_FOUND. */ @Transactional(readOnly = true) public UserResp detail(Long userId) { UserResp r = mapper.selectDetailById(userId); @@ -41,6 +50,7 @@ public class UserService { return r; } + /** 사용자 신규 등록. 로그인 ID 중복 검증, 임시/지정 비밀번호 해시 저장, 기본 상태 ACTIVE, 역할 부여. */ @Transactional public Long create(UserSaveReq req) { if (mapper.selectByLoginId(req.getLoginId()) != null) { @@ -61,6 +71,7 @@ public class UserService { return vo.getUserId(); } + /** 사용자 수정. 비밀번호는 건드리지 않고, roleIds가 오면 역할을 전체 교체한다. */ @Transactional public void update(Long userId, UserSaveReq req) { UserVO vo = mapper.selectById(userId); @@ -75,6 +86,7 @@ public class UserService { } } + /** 비밀번호 초기화. 최소 길이 정책(PASSWORD_MIN_LEN)을 만족하는 임시 비밀번호를 생성해 해시 저장한다. */ @Transactional public void resetPassword(Long userId) { int min = configService.getInt("PASSWORD_MIN_LEN", 8); @@ -83,12 +95,14 @@ public class UserService { mapper.updatePassword(userId, passwordEncoder.encode(temp)); } + /** 계정 잠금 해제. 로그인 실패 횟수를 초기화하고 상태를 ACTIVE로 되돌린다. */ @Transactional public void unlock(Long userId) { mapper.resetFailCount(userId); mapper.updateStatus(userId, UserStatus.ACTIVE.name()); } + /** 전화번호 단독 정정. 존재 검증 후 공백은 null로 정규화해 저장한다(SMS 발신용). */ @Transactional public void updatePhone(Long userId, String phone) { if (mapper.selectById(userId) == null) { @@ -98,6 +112,7 @@ public class UserService { mapper.updatePhone(userId, trimmed); } + /** 사용자의 역할 코드 목록 조회. */ @Transactional(readOnly = true) public List roleCodes(Long userId) { return mapper.selectRoleCodes(userId); diff --git a/ga-api/src/main/java/com/ga/api/service/tax/TaxBreakdown.java b/ga-api/src/main/java/com/ga/api/service/tax/TaxBreakdown.java index 93d00ea..36a584c 100644 --- a/ga-api/src/main/java/com/ga/api/service/tax/TaxBreakdown.java +++ b/ga-api/src/main/java/com/ga/api/service/tax/TaxBreakdown.java @@ -2,6 +2,13 @@ package com.ga.api.service.tax; import java.math.BigDecimal; +/** + * 세금 계산 결과(불변 record). + * incomeTax : 원천세(소득세) + * localTax : 지방소득세(원천세의 10%) + * vat : 부가세(과세사업자만, 아니면 0) + * netAmount : 실수령액(= 지급액 - 원천세 - 지방소득세 + 부가세) + */ public record TaxBreakdown( BigDecimal incomeTax, BigDecimal localTax, diff --git a/ga-api/src/main/java/com/ga/api/service/tax/TaxCalculator.java b/ga-api/src/main/java/com/ga/api/service/tax/TaxCalculator.java index bdebee4..3bb8ff1 100644 --- a/ga-api/src/main/java/com/ga/api/service/tax/TaxCalculator.java +++ b/ga-api/src/main/java/com/ga/api/service/tax/TaxCalculator.java @@ -9,6 +9,22 @@ import org.springframework.stereotype.Component; import java.math.BigDecimal; import java.math.RoundingMode; +/** + * [세금 계산기 — 한 건 지급액에 대한 세금 산출] + * + * 신입 개발자 안내: + * - 설계사에게 수수료를 줄 때 회사가 세금을 미리 떼고(원천징수) 실수령액을 지급한다. + * - 계산 항목: + * 원천세(소득세) = 지급액 × 소득세율 + * · 사업소득(BUSINESS_INCOME): 소득세 3% (기본값 3.3 은 지방세 포함 관례 표기, 여기선 소득세율 자체를 설정값으로 둠) + * · 기타소득(OTHER_INCOME) : 소득세 8% (기본값 8.8) + * 지방소득세 = 원천세 × 10% (소득세에 부가되는 세금) + * 부가세(VAT) = 지급액 × 10% (단, 과세사업자 VatType.TAXABLE 인 경우에만. 면세면 0) + * 실수령액 = 지급액 - 원천세 - 지방소득세 + 부가세 + * · 부가세는 설계사가 받아 나중에 납부하는 돈이라 실수령에 더해진다. + * - 세율은 하드코딩이 아니라 SystemConfig(시스템 설정)에서 읽는다(법 개정 시 코드 수정 없이 변경 가능). 괄호 안은 기본값. + * - 반올림은 모두 원 단위(소수점 0자리) HALF_UP. + */ @Component @RequiredArgsConstructor public class TaxCalculator { @@ -22,22 +38,28 @@ public class TaxCalculator { return new TaxBreakdown(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, base); } + // 소득 유형별 소득세율 / 지방소득세율(소득세의 %) / 부가세율 — 모두 시스템 설정값 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")); + // 원천세 = 지급액 × 소득세율 / 100 BigDecimal incomeTax = amount.multiply(incomeRate) .divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP); + // 지방소득세 = 원천세 × 지방세율(10%) / 100 BigDecimal localTax = incomeTax.multiply(localRate) .divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP); + // 부가세 = 과세사업자일 때만 지급액 × 부가세율, 아니면 0 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); } + // 소득 유형에 따라 소득세율 결정: 기타소득이면 8.8, 그 외(사업소득)는 3.3 (괄호값은 기본값) private BigDecimal resolveIncomeRate(TaxType taxType) { if (TaxType.OTHER_INCOME.equals(taxType)) { return configService.getDecimal("TAX_RATE_OTHER_INCOME", new BigDecimal("8.8")); diff --git a/ga-api/src/main/java/com/ga/api/service/tax/VatReportService.java b/ga-api/src/main/java/com/ga/api/service/tax/VatReportService.java index 472e1bf..c4dda2a 100644 --- a/ga-api/src/main/java/com/ga/api/service/tax/VatReportService.java +++ b/ga-api/src/main/java/com/ga/api/service/tax/VatReportService.java @@ -13,6 +13,15 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +/** + * [부가세 분기신고 서비스] + * + * 신입 개발자 안내: + * - list/detail 은 단순 조회. 핵심은 regenerate 와 complete. + * - regenerate: 지정 분기의 부가세를 다시 집계해 vat_report 에 UPSERT(있으면 갱신, 없으면 생성)하고 영향 건수 반환. + * - complete: 선택한 신고건들의 파일/신고 상태를 "제출 완료(FILED)"로 변경. + * - 클래스에 @Transactional(readOnly=true), 쓰기 메서드에만 @Transactional 을 덧붙여 쓰기 트랜잭션으로 승격. + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) diff --git a/ga-api/src/main/java/com/ga/api/service/tax/WithholdingTaxReportService.java b/ga-api/src/main/java/com/ga/api/service/tax/WithholdingTaxReportService.java index 79f36dd..83f6dea 100644 --- a/ga-api/src/main/java/com/ga/api/service/tax/WithholdingTaxReportService.java +++ b/ga-api/src/main/java/com/ga/api/service/tax/WithholdingTaxReportService.java @@ -13,6 +13,13 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +/** + * [원천세 분기신고 서비스] + * + * 신입 개발자 안내: + * - 구조는 부가세 서비스와 동일. regenerate(분기 재집계 UPSERT) + complete(제출 완료 상태 변경)가 핵심. + * - upsertQuarterlyAggregate 가 해당 분기의 원천세(소득세+지방소득세)를 다시 모아 report 테이블에 반영한다. + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) diff --git a/ga-api/src/main/java/com/ga/api/service/training/AgentTrainingService.java b/ga-api/src/main/java/com/ga/api/service/training/AgentTrainingService.java index a41e685..3a36f58 100644 --- a/ga-api/src/main/java/com/ga/api/service/training/AgentTrainingService.java +++ b/ga-api/src/main/java/com/ga/api/service/training/AgentTrainingService.java @@ -13,6 +13,14 @@ import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.util.List; +/** + * 보수교육 도메인 비즈니스 로직. + * + *

교육 이수 내역(agent_training)과 연도별 의무 기준(training_requirement)을 함께 다룬다. + * annualHours()는 설계사의 연간 누적 이수 시간을 합산하며(없으면 0), saveRequirement()는 + * 같은 연도 기준이 있으면 수정/없으면 신규 등록하는 upsert로 동작한다. + * 클래스 전체는 읽기 전용이며 쓰기 메서드만 별도 트랜잭션을 건다.

+ */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -23,11 +31,13 @@ public class AgentTrainingService { // ── Training ────────────────────────────────────────────────────────────── + /** 교육 이수 내역 목록 조회(페이징). */ public PageResponse list(AgentTrainingSearchParam param) { param.startPage(); return PageResponse.of(trainingMapper.selectList(param)); } + /** 교육 이수 단건 조회. 없으면 NOT_FOUND. */ public AgentTrainingResp detail(Long trainingId) { AgentTrainingResp resp = trainingMapper.selectById(trainingId); if (resp == null) throw new BizException(ErrorCode.NOT_FOUND); @@ -43,6 +53,7 @@ public class AgentTrainingService { return hours != null ? hours : BigDecimal.ZERO; } + /** 교육 이수 내역 등록. */ @Transactional public Long create(AgentTrainingSaveReq req) { AgentTrainingVO vo = new AgentTrainingVO(); @@ -57,6 +68,7 @@ public class AgentTrainingService { return vo.getTrainingId(); } + /** 교육 이수 내역 수정. 존재 검증 후 갱신한다. */ @Transactional public void update(Long trainingId, AgentTrainingSaveReq req) { AgentTrainingResp existing = trainingMapper.selectById(trainingId); @@ -73,6 +85,7 @@ public class AgentTrainingService { trainingMapper.update(vo); } + /** 교육 이수 내역 삭제. 존재 검증 후 삭제한다. */ @Transactional public void delete(Long trainingId) { AgentTrainingResp existing = trainingMapper.selectById(trainingId); @@ -82,16 +95,19 @@ public class AgentTrainingService { // ── Requirement ─────────────────────────────────────────────────────────── + /** 연도별 의무 교육시간 기준 목록 조회. */ public List listRequirements(TrainingRequirementSearchParam param) { return requirementMapper.selectList(param); } + /** 특정 연도의 의무 교육시간 기준 조회. 없으면 NOT_FOUND. */ public TrainingRequirementResp requirementByYear(int year) { TrainingRequirementResp resp = requirementMapper.selectByYear(year); if (resp == null) throw new BizException(ErrorCode.NOT_FOUND); return resp; } + /** 연도별 의무 기준 upsert. 같은 연도 기준이 있으면 수정, 없으면 신규 등록한다. */ @Transactional public void saveRequirement(TrainingRequirementSaveReq req) { TrainingRequirementVO existing = new TrainingRequirementVO(); diff --git a/ga-api/src/main/java/com/ga/api/service/transfer/TransferFileService.java b/ga-api/src/main/java/com/ga/api/service/transfer/TransferFileService.java index cf8d048..501a083 100644 --- a/ga-api/src/main/java/com/ga/api/service/transfer/TransferFileService.java +++ b/ga-api/src/main/java/com/ga/api/service/transfer/TransferFileService.java @@ -17,6 +17,17 @@ import java.math.BigDecimal; import java.util.List; import java.util.stream.Collectors; +/** + * 은행 이체파일 생성(transfer file) 서비스. + * + *

정산월+은행코드 기준으로 아직 보내지 않은(PENDING) 지급건들을 모아, 은행별 포맷의 이체파일(바이트)을 만들고 + * 저장한 뒤, 해당 지급건들을 "전송됨(SENT)" 상태로 갱신합니다. + * + *

트랜잭션 분리에 주의: (1) PENDING 조회는 readOnly, (2) 파일 생성·저장은 IO 라서 트랜잭션 밖, + * (3) 상태 갱신은 파일 저장 성공 후 별도 트랜잭션(markPaymentsSent)으로 처리해 + * "파일은 만들어졌는데 상태는 그대로"인 불일치를 방지합니다. + * 계좌번호는 EncryptTypeHandler 로 조회 시 자동 복호화됩니다. + */ @Service @RequiredArgsConstructor public class TransferFileService { @@ -26,6 +37,7 @@ public class TransferFileService { private final FileService fileService; private final EncryptUtil encryptUtil; + /** 정산월+은행 PENDING 지급건을 이체파일로 생성·저장하고 SENT 로 전이. 응답: 파일ID·파일명·건수·총금액. */ public TransferFileResp generate(String settleMonth, String bankCode) { // 1) PENDING payment 조회 — readOnly 트랜잭션 (accountNo는 EncryptTypeHandler로 자동복호화됨) List payments = paymentMapper.selectPendingByMonthAndBank(settleMonth, bankCode); diff --git a/ga-api/src/main/java/com/ga/api/service/upload/TargetFieldMetaService.java b/ga-api/src/main/java/com/ga/api/service/upload/TargetFieldMetaService.java index 8d81751..7935b58 100644 --- a/ga-api/src/main/java/com/ga/api/service/upload/TargetFieldMetaService.java +++ b/ga-api/src/main/java/com/ga/api/service/upload/TargetFieldMetaService.java @@ -10,6 +10,11 @@ import java.util.Map; /** * 업로드 템플릿의 target_table 별로 사용 가능한 컬럼(target_field) 메타데이터. * 화이트리스트 역할 + 프론트 Select 옵션 제공. + * + * [신입 개발자 안내] + * - "이 테이블에는 이런 DB 필드들을 넣을 수 있다"는 사전 정의를 코드에 박아둔 것이다(아래 META 맵). + * - 두 가지 목적: (1) 화면에서 매핑할 필드 목록/한글명/타입을 보여주고, (2) 허용된 필드만 받도록 막는 안전장치. + * - 새 대상 테이블이나 컬럼을 추가하려면 META 맵에 항목을 넣으면 된다. */ @Service public class TargetFieldMetaService { diff --git a/ga-api/src/main/java/com/ga/api/service/upload/TransformEngine.java b/ga-api/src/main/java/com/ga/api/service/upload/TransformEngine.java index 1e15cd5..8f0bad9 100644 --- a/ga-api/src/main/java/com/ga/api/service/upload/TransformEngine.java +++ b/ga-api/src/main/java/com/ga/api/service/upload/TransformEngine.java @@ -24,6 +24,19 @@ import java.util.Map; * * 지원 규칙: TRIM / UPPER / LOWER / DATE / DIVIDE / MULTIPLY / * LOOKUP / CODE_MAP / FIXED / REPLACE / SUBSTRING / CONCAT / DEFAULT + * + * [신입 개발자 안내] + * - 보험사마다 엑셀 값의 형식이 다르므로(예: 날짜 yyyyMMdd, 금액 천단위 콤마, 코드값) 컬럼별로 + * "변환 규칙(transformRule)"을 지정해 우리 DB 표준 형식으로 맞춘다. + * - 규칙 의미 예시: + * TRIM/UPPER/LOWER : 공백 제거 / 대소문자 통일 + * DATE : 문자열 날짜를 ISO(yyyy-MM-dd)로 파싱 + * DIVIDE/MULTIPLY : 단위 환산(예: 원→천원) + * LOOKUP : 다른 테이블에서 매핑값 찾기(예: 상품코드→상품ID) + * CODE_MAP : 공통코드(common_code) 매핑 + * FIXED : 원본 무시하고 고정값 + * DEFAULT : 값이 비었을 때만 기본값 + * REPLACE/SUBSTRING/CONCAT : 문자열 치환/잘라내기/여러 컬럼 합치기 */ @Slf4j @Component @@ -104,6 +117,8 @@ public class TransformEngine { // Raw 값 추출 // ========================================================= + // 엑셀에서 원본 값 1개를 꺼낸다. sourceType 이 FIXED/EXPRESSION 이면 셀이 아니라 고정값을 쓰고, + // COLUMN 이면 sourceColumn(0부터 시작하는 컬럼 인덱스) 위치의 셀을 읽는다. private Object extractRaw(Row row, UploadTemplateColumnVO col) { if ("FIXED".equalsIgnoreCase(col.getSourceType())) { return col.getFixedValue(); @@ -122,6 +137,7 @@ public class TransformEngine { return readCell(cell); } + // 셀 타입(문자/숫자/날짜/불리언/수식)에 맞게 값을 읽는다. 숫자 정수는 소수점 없이, 날짜는 LocalDate 로 반환. private Object readCell(Cell cell) { return switch (cell.getCellType()) { case STRING -> { @@ -151,6 +167,7 @@ public class TransformEngine { // 개별 변환 규칙 // ========================================================= + // DATE: 보험사 날짜 문자열(기본 yyyyMMdd)을 표준 ISO 문자열(yyyy-MM-dd)로 변환. 형식이 안 맞으면 파싱 오류. private Object applyDate(Object value, UploadTemplateColumnVO col) { if (value == null) return null; if (value instanceof LocalDate) return value.toString(); @@ -183,6 +200,7 @@ public class TransformEngine { return bd.multiply(BigDecimal.valueOf(multiplier)).stripTrailingZeros(); } + // LOOKUP: 미리 적재한 캐시에서 원본값(예: 상품코드)에 대응하는 값(예: 상품ID)을 찾는다. 못 찾으면 기본값. private Object applyLookup(Object value, UploadTemplateColumnVO col, Map> lookupData) { if (value == null) return null; @@ -268,6 +286,7 @@ public class TransformEngine { } } + // 별도 변환 규칙이 없을 때 targetType(NUMBER/DECIMAL/DATE 등)에 맞춰 형 변환. 콤마 제거 후 숫자 파싱 시도. private Object applyTargetTypeCast(Object value, UploadTemplateColumnVO col) { if (value == null) return null; String targetType = col.getTargetType(); diff --git a/ga-api/src/main/java/com/ga/api/service/upload/UploadService.java b/ga-api/src/main/java/com/ga/api/service/upload/UploadService.java index 7264540..2e4159c 100644 --- a/ga-api/src/main/java/com/ga/api/service/upload/UploadService.java +++ b/ga-api/src/main/java/com/ga/api/service/upload/UploadService.java @@ -38,6 +38,14 @@ import java.util.regex.Pattern; * 트랜잭션 경계: 파일 파싱은 트랜잭션 밖. history 저장/갱신과 배치 INSERT는 * UploadTxHelper를 통해 REQUIRES_NEW 독립 트랜잭션으로 처리하여 * 롱 트랜잭션으로 인한 커넥션 풀 고갈을 방지한다. + * + * [신입 개발자 안내 — 한 건 업로드가 처리되는 큰 그림] + * 1) 템플릿(어떤 규칙으로 적재할지) + 컬럼 매핑(엑셀 컬럼→DB 필드)을 DB에서 로드한다. + * 2) LOOKUP 변환에 필요한 참조 데이터를 미리 메모리에 적재한다(행마다 DB 조회 방지). + * 3) 엑셀을 한 행씩 스트리밍으로 읽어(메모리 절약) → 변환(TransformEngine) → 검증(필수/정규식) 한다. + * 4) 오류 없는 행만 모아 batchSize 단위로 DB에 나눠 INSERT 한다(청크 커밋). + * 5) 오류 행은 별도 엑셀로 만들어 파일로 저장하고, 전체 결과를 upload_history 에 기록한다. + * ※ 핵심 보안 규칙: 동적 테이블/컬럼 이름은 반드시 화이트리스트로 검증한다(SQL Injection 방지). */ @Slf4j @Service @@ -85,6 +93,7 @@ public class UploadService { int successCount = 0; int skipCount = 0; int totalCount = 0; + // 템플릿 설정값(없으면 기본값): 오류 허용 한도 / 배치 INSERT 묶음 크기 / 데이터 시작 행(헤더 제외) / 시트 인덱스 int maxErrors = template.getMaxErrorCount() != null ? template.getMaxErrorCount() : 100; int batchSize = template.getBatchSize() != null ? template.getBatchSize() : 1000; int dataStartRow = template.getDataStartRow() != null ? template.getDataStartRow() : 2; @@ -104,8 +113,9 @@ public class UploadService { for (Row row : sheet) { rowNum++; - if (rowNum < dataStartRow) continue; + if (rowNum < dataStartRow) continue; // 헤더 등 데이터 시작 전 행은 건너뜀 + // 빈 행 스킵 옵션이 켜져 있고 실제로 빈 행이면 처리 대상에서 제외 if ("Y".equalsIgnoreCase(template.getSkipEmptyRow()) && isEmptyRow(row)) { skipCount++; continue; @@ -113,15 +123,19 @@ public class UploadService { totalCount++; + // 오류가 허용 한도를 넘으면 이후 행은 처리하지 않고 스킵 카운트만 증가 if (errors.size() >= maxErrors) { skipCount++; continue; } try { + // 1) 변환: 엑셀 한 행 → { DB필드명 → 값 } Map Map mapped = transformEngine.transform(row, columns, lookupData); + // 2) 검증: 필수값/정규식 위배 시 errors 에 적립 validateRow(mapped, columns, rowNum, errors); + // 3) 이 행에 오류가 없을 때만 INSERT 대상 batch 에 담는다 final int currentRow = rowNum; boolean rowHasError = errors.stream().anyMatch(e -> e.getRowNumber() == currentRow); if (!rowHasError) { @@ -140,6 +154,7 @@ public class UploadService { } } + // 루프 종료 후 batch 에 남은 잔여 행을 마지막으로 INSERT if (!batch.isEmpty()) { successCount += txHelper.insertBatch(template.getTargetTable(), new ArrayList<>(batch)); batch.clear(); @@ -156,6 +171,7 @@ public class UploadService { throw new BizException(ErrorCode.EXCEL_PARSE_FAIL, e.getMessage()); } + // 처리 시간/오류 건수 집계 후 상태 결정: 오류 0=SUCCESS, 일부 성공=PARTIAL, 전부 실패=FAIL int durationSec = (int) ((System.currentTimeMillis() - startMs) / 1000); int errorCount = errors.size(); String status = errorCount == 0 ? "SUCCESS" : (successCount > 0 ? "PARTIAL" : "FAIL"); @@ -340,6 +356,12 @@ public class UploadService { return h; } + /** + * 한 행의 변환 결과를 컬럼 정의 기준으로 검증한다. + * - 필수 컬럼(isRequired=Y)인데 값이 비면 오류 + * - 정규식(validationRegex)이 있으면 패턴 불일치 시 오류 + * 오류는 즉시 던지지 않고 errors 목록에 모은다(해당 행은 INSERT 대상에서 제외됨). + */ private void validateRow(Map row, List columns, int rowNum, List errors) { for (UploadTemplateColumnVO col : columns) { diff --git a/ga-api/src/main/java/com/ga/api/service/upload/UploadTemplateService.java b/ga-api/src/main/java/com/ga/api/service/upload/UploadTemplateService.java index 87112f0..8823e39 100644 --- a/ga-api/src/main/java/com/ga/api/service/upload/UploadTemplateService.java +++ b/ga-api/src/main/java/com/ga/api/service/upload/UploadTemplateService.java @@ -14,6 +14,15 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +/** + * [업로드 템플릿 CRUD 서비스] + * + * 신입 개발자 안내: + * - 템플릿 본체(upload_template)와 컬럼 매핑(upload_template_column)은 부모-자식 관계다. + * - create/update 시 컬럼은 "전체 교체" 방식: 기존 컬럼을 모두 지우고 새로 insertBatch 한다. + * (부분 수정이 아니라 화면에서 받은 컬럼 목록 전체로 덮어쓴다는 점에 주의) + * - 모든 메서드는 @Transactional 로 묶여 트랜잭션 안에서 실행된다(조회는 readOnly). + */ @Service @RequiredArgsConstructor public class UploadTemplateService { diff --git a/ga-api/src/main/java/com/ga/api/service/withdraw/WithdrawRequestService.java b/ga-api/src/main/java/com/ga/api/service/withdraw/WithdrawRequestService.java index 1e45664..b54453d 100644 --- a/ga-api/src/main/java/com/ga/api/service/withdraw/WithdrawRequestService.java +++ b/ga-api/src/main/java/com/ga/api/service/withdraw/WithdrawRequestService.java @@ -15,6 +15,14 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +/** + * 출금 신청(withdraw_request) 서비스. + * + *

설계사의 정산 잔액 출금 신청을 등록/취소합니다. 신청 등록 시 결재(승인) 요청을 자동으로 함께 생성하되, + * 결재 생성이 실패하더라도 출금 신청 자체는 유지합니다(로그만 남김 — best-effort). + * 계좌번호는 DB 에는 암호화 원문으로 저장하고, 응답 시에만 뒤 4자리를 제외하고 마스킹합니다. + * 상태(WithdrawStatus): REQUESTED → APPROVED → SENT → COMPLETED. 취소는 REQUESTED 상태에서만 가능합니다. + */ @Slf4j @Service @RequiredArgsConstructor diff --git a/ga-batch/src/main/java/com/ga/batch/GaBatchApplication.java b/ga-batch/src/main/java/com/ga/batch/GaBatchApplication.java index 2ba164b..f8e65e4 100644 --- a/ga-batch/src/main/java/com/ga/batch/GaBatchApplication.java +++ b/ga-batch/src/main/java/com/ga/batch/GaBatchApplication.java @@ -4,6 +4,23 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cache.annotation.EnableCaching; +/** + * ga-batch 모듈 부트 진입점. + * + * 이 모듈은 GA(보험대리점)의 "월정산 엔진"이다. 매월 보험사로부터 받은 수수료 데이터를 + * 받아서(수신) → 표준화(변환) → 대사 → 모집/유지 수수료 계산 → 환수/오버라이드 계산 → + * 설계사별 합산(정산마스터 생성) → 회계분개 → (분기/연말) 세무집계 순서로 처리한다. + * + * 신입 참고 용어: + * - GA: General Agency. 여러 보험사 상품을 함께 파는 대형 보험대리점. + * - 정산(settlement): 한 달 동안 설계사가 번 수수료를 모아 세금·차감·환수를 반영해 + * "실지급액(payable)"을 산출하는 작업. + * + * 애너테이션 설명: + * - scanBasePackages = "com.ga": ga-core/ga-common 등 형제 모듈의 Bean(Mapper, 유틸)까지 + * 한 번에 컴포넌트 스캔하기 위해 루트 패키지를 지정. + * - @EnableCaching: SystemConfigService 등이 세율·설정값을 캐싱해 반복 DB 조회를 줄임. + */ @SpringBootApplication(scanBasePackages = {"com.ga"}) @EnableCaching public class GaBatchApplication { diff --git a/ga-batch/src/main/java/com/ga/batch/config/BatchConfig.java b/ga-batch/src/main/java/com/ga/batch/config/BatchConfig.java index b95146f..e51c469 100644 --- a/ga-batch/src/main/java/com/ga/batch/config/BatchConfig.java +++ b/ga-batch/src/main/java/com/ga/batch/config/BatchConfig.java @@ -15,9 +15,32 @@ import org.springframework.transaction.PlatformTransactionManager; import com.ga.batch.settlement.step.*; +/** + * Spring Batch 월정산 Job/Step 구성 클래스. + * + * 핵심 개념: + * - Job: 한 번의 월정산 실행 전체(13개 Step의 직렬 파이프라인). + * - Step: Job 안의 한 단계. 여기서는 전부 Tasklet 방식(한 메서드가 끝나면 그 Step 완료). + * - JobScope: Job 1회 실행 단위로 새 Bean을 만든다. 정산월 같은 실행별 파라미터를 주입받기 위함. + * + * 실행 순서(아래 monthlySettlementJob 의 start→next 체인): + * receiveData(1) → transformData(2) → reconcile(3) → calcRecruit(4) → payInstallment(4b) + * → calcMaintain(5) → chargebackException(6) → calcOverride(7) → aggregate(8) + * → accountingEntryGenerate(8b) → yearEndIncomeAggregate(9) → withholdingTaxQuarterly(11) + * → vatReportQuarterly(12). + * 앞 단계가 만든 원장(ledger) 데이터를 뒤 단계가 읽어 쓰므로 순서가 중요하다. + */ @Configuration public class BatchConfig { + /** + * Step 간 공유되는 실행 컨텍스트(SettlementContext) Bean. + * + * jobParameters 에서 값을 읽어 채운다: + * - settleMonth: 정산 대상 월(yyyyMM). 거의 모든 Step 이 이 값으로 데이터를 필터링한다. + * - jobLogId: 실행 로그 PK. SpEL '?: -1L' 은 파라미터가 없으면 -1L 로 폴백한다는 뜻. + * @JobScope 라서 Job 1회 실행마다 새로 생성된다(동시 실행 시 값이 섞이지 않음). + */ @Bean @JobScope public SettlementContext settlementContext(@Value("#{jobParameters['settleMonth']}") String settleMonth, @@ -28,6 +51,10 @@ public class BatchConfig { return ctx; } + /** + * 월정산 Job 정의. 주입받은 13개 Step(step1~step12)을 정해진 순서로 연결한다. + * start() 가 첫 Step, 이후 next() 가 직렬로 이어진다. 한 Step 이 실패하면 Job 도 중단된다. + */ @Bean public Job monthlySettlementJob(JobRepository jobRepository, PlatformTransactionManager tx, @@ -35,6 +62,7 @@ public class BatchConfig { TransformDataStep step2, ReconcileStep step3, CalcRecruitStep step4, + PayInstallmentStep step4b, CalcMaintainStep step5, ChargebackExceptionStep step6, CalcOverrideStep step7, @@ -48,6 +76,7 @@ public class BatchConfig { .next(taskletStep("transformData", step2, jobRepository, tx)) .next(taskletStep("reconcile", step3, jobRepository, tx)) .next(taskletStep("calcRecruit", step4, jobRepository, tx)) + .next(taskletStep("payInstallment", step4b, jobRepository, tx)) .next(taskletStep("calcMaintain", step5, jobRepository, tx)) .next(taskletStep("chargebackException", step6, jobRepository, tx)) .next(taskletStep("calcOverride", step7, jobRepository, tx)) @@ -59,6 +88,11 @@ public class BatchConfig { .build(); } + /** + * Tasklet 한 개를 단일 Step 으로 감싸는 헬퍼. + * 모든 Step 이 같은 패턴이라 중복을 줄이려고 분리했다. + * tx(트랜잭션 매니저)를 넘기므로 Tasklet 의 execute() 는 하나의 트랜잭션 안에서 실행된다. + */ private Step taskletStep(String name, Tasklet tasklet, JobRepository jobRepository, PlatformTransactionManager tx) { return new StepBuilder(name, jobRepository) diff --git a/ga-batch/src/main/java/com/ga/batch/controller/JobLauncherController.java b/ga-batch/src/main/java/com/ga/batch/controller/JobLauncherController.java index 9c8d3b6..d85eb68 100644 --- a/ga-batch/src/main/java/com/ga/batch/controller/JobLauncherController.java +++ b/ga-batch/src/main/java/com/ga/batch/controller/JobLauncherController.java @@ -28,25 +28,38 @@ public class JobLauncherController { private final Job monthlySettlementJob; private final BatchJobLogMapper jobLogMapper; + /** + * 월정산 Job 을 트리거하는 내부 엔드포인트. ga-api 가 사용자의 정산 실행 요청을 받아 + * 이 엔드포인트로 HTTP 호출하면 여기서 Spring Batch Job 을 기동한다. + * + * @param body 요청 본문. "settleMonth" 키에 정산월(yyyyMM, 예: "202503")이 담긴다. + * @return 생성된 배치 실행 로그 PK(jobId). 호출 측이 진행상태 조회에 사용. + */ @PostMapping("/monthly-settlement") public ApiResponse runMonthlySettlement(@RequestBody Map body) throws Exception { + // 1) 입력 검증: settleMonth 는 반드시 6자리 숫자(yyyyMM). 아니면 즉시 거부. String settleMonth = body.get("settleMonth"); if (settleMonth == null || !settleMonth.matches("\\d{6}")) { throw new BizException(ErrorCode.INVALID_PARAMETER, "settleMonth (yyyyMM)"); } + // 2) 실행 로그 선기록(STARTED). insert 후 log.jobId 에 자동 채번된 PK 가 채워진다. BatchJobLogVO log = new BatchJobLogVO(); log.setJobName("MONTHLY_SETTLEMENT"); log.setSettleMonth(settleMonth); log.setStatus("STARTED"); jobLogMapper.insert(log); + // 3) Job 파라미터 구성. settleMonth/jobLogId 는 Step 들이 사용. + // ts(현재시각 밀리초)는 "같은 정산월을 여러 번 재실행"할 수 있도록 매 실행을 고유하게 만든다. + // (Spring Batch 는 동일 파라미터 Job 재실행을 막으므로 유니크 키가 필요) JobParameters params = new JobParametersBuilder() .addString("settleMonth", settleMonth) .addLong("jobLogId", log.getJobId()) .addLong("ts", System.currentTimeMillis()) .toJobParameters(); + // 4) Job 기동. 기본 JobLauncher 는 동기 실행이라 Job 이 끝날 때까지 블로킹된다. jobLauncher.run(monthlySettlementJob, params); return ApiResponse.ok(log.getJobId()); } diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/SettlementContext.java b/ga-batch/src/main/java/com/ga/batch/settlement/SettlementContext.java index 8c120ee..20ba11e 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/SettlementContext.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/SettlementContext.java @@ -3,18 +3,26 @@ package com.ga.batch.settlement; import lombok.Data; /** - * Job 단위 컨텍스트. Step 간 공유 상태. + * Job 단위 컨텍스트. 한 번의 월정산 실행 동안 Step 들이 공유하는 상태 보관소. + * + * 역할: + * - settleMonth/jobLogId: 모든 Step 이 참조하는 실행 식별 정보(어느 달을, 어느 로그로). + * - *Count 필드들: 각 Step 이 처리한 건수를 기록해 두는 진행 카운터. + * (모니터링/로그용. 뒤 Step 의 계산 로직에 직접 쓰이지는 않음) + * + * @Data(Lombok)로 getter/setter 가 자동 생성된다. */ @Data public class SettlementContext { - private String settleMonth; // yyyyMM - private Long jobLogId; - private int receivedCount; - private int transformedCount; - private int reconcileDiffCount; - private int recruitCount; - private int maintainCount; - private int chargebackCount; - private int overrideCount; - private int aggregateCount; + private String settleMonth; // 정산 대상 월 (yyyyMM, 예: 202503) + private Long jobLogId; // batch_job_log PK (실행 로그 추적용) + private int receivedCount; // Step1: 수신한 raw 데이터 행 수 + private int transformedCount; // Step2: 표준 원장으로 변환 성공한 건수 + private int reconcileDiffCount; // Step3: 보험사 통보액 vs 계산액 불일치 회사 수 + private int recruitCount; // Step4: 생성한 모집수수료 원장 건수 + private int installmentPaidCount; // Step4b: 분급(선지급 분할) 지급 건수 + private int maintainCount; // Step5: 생성한 유지수수료 원장 건수 + private int chargebackCount; // Step6: 생성한 환수 건수 + private int overrideCount; // Step7: 생성한 오버라이드(상위수당) 건수 + private int aggregateCount; // Step8: settle_master 로 집계된 설계사 수 } diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/calc/CommissionCalculator.java b/ga-batch/src/main/java/com/ga/batch/settlement/calc/CommissionCalculator.java index f88b7c8..813606a 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/calc/CommissionCalculator.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/calc/CommissionCalculator.java @@ -15,22 +15,33 @@ import java.time.LocalDate; /** * 수수료 계산 핵심 로직. - * - 모집수수료: premium × company_rate × payout_rate - * - 유지수수료: 동일 공식, 회차에 따른 commission_year 만 다름 - * - 모든 금액은 BigDecimal HALF_UP, 소수점 2자리 + * + * 도메인 배경: + * - 모집수수료: 계약을 새로 따냈을 때 받는 수수료. 보통 1회차(첫 정산). + * - 유지수수료: 그 계약이 계속 유지될 때 회차(년차)마다 받는 수수료. + * 둘 다 계산식은 같고 commission_year(몇 년차냐)만 다르다. + * + * 2단계 비율 구조(중요): + * 1) company_rate(회사지급률): 보험사가 GA 에게 주는 비율. premium(보험료) 기준. + * 2) payout_rate(설계사지급률): GA 가 받은 금액 중 설계사 등급(grade)에 따라 떼어주는 비율. + * → 설계사가 받는 금액 = premium × company_rate × payout_rate. + * + * 모든 금액은 BigDecimal 로 계산하며 MoneyUtil 이 HALF_UP(반올림)·소수 2자리를 처리한다. */ @Slf4j @Component @RequiredArgsConstructor public class CommissionCalculator { - private final RuleMapper ruleMapper; - private final AgentMapper agentMapper; + private final RuleMapper ruleMapper; // 회사지급률/설계사지급률 등 수수료율 룰 조회 + private final AgentMapper agentMapper; // 설계사 등급(gradeId) 조회용 + /** 모집수수료(1회차) 원장 1건 생성. 귀속 설계사는 계약의 모집설계사. */ public LedgerVO calcRecruitLedger(ContractResp c, String settleMonth) { return buildLedger(c, settleMonth, 1, c.getAgentId()); } + /** 유지수수료 원장 1건 생성. commissionYear 로 몇 년차인지 지정. 귀속은 원 모집설계사. */ public LedgerVO calcMaintainLedger(ContractResp c, String settleMonth, int commissionYear) { return buildLedger(c, settleMonth, commissionYear, c.getAgentId()); } @@ -45,11 +56,27 @@ public class CommissionCalculator { effectiveAgentId != null ? effectiveAgentId : c.getAgentId()); } + /** + * 수수료 원장(LedgerVO) 1건을 만드는 실제 계산 본체. + * + * @param year commission_year. 수수료율 룰은 회차(년차)별로 다를 수 있어 조회 키로 쓰인다. + * @param agentId 수수료 귀속 설계사. 승계가 있으면 후임 설계사가 들어온다. + * + * 처리: + * 1) 설계사 조회(등급 gradeId 필요). 없으면 null 반환(계산 불가). + * 2) baseDate(=계약일, 없으면 오늘)로 시점에 유효한 수수료율 룰을 찾는다. + * 3) company_rate / payout_rate 를 곱해 회사수령액→설계사지급액 산출. + * 4) 원천세(tax) 미리 계산해 원장에 보관. + * + * 안전 폴백: 율이 없으면 0 으로 두고 경고 로그만 남긴다(계산 중단보다 과소지급 표시가 낫다는 정책). + */ private LedgerVO buildLedger(ContractResp c, String settleMonth, int year, Long agentId) { AgentVO agent = agentMapper.selectById(agentId); if (agent == null) return null; + // 율 조회 기준일: 계약일 우선. 없으면 오늘 날짜로 폴백. LocalDate baseDate = c.getContractDate() != null ? c.getContractDate() : LocalDate.now(); + // 회사지급률: 상품/회차/시점/판매채널에 맞는 율을 룰 테이블에서 조회. BigDecimal companyRate = ruleMapper.findCompanyRate(c.getProductId(), year, baseDate, c.getSalesChannel()); if (companyRate == null) { log.warn("[수수료율 누락] company_rate 없음 → 0 폴백(과소지급 위험): contractId={}, productId={}, year={}, baseDate={}", @@ -57,6 +84,7 @@ public class CommissionCalculator { companyRate = BigDecimal.ZERO; } + // 설계사지급률: 설계사 등급/보험종류/회차/시점/판매채널에 맞는 율을 조회. BigDecimal payoutRate = ruleMapper.findPayoutRate( agent.getGradeId(), c.getInsuranceType(), year, baseDate, c.getSalesChannel()); if (payoutRate == null) { @@ -65,10 +93,11 @@ public class CommissionCalculator { payoutRate = BigDecimal.ZERO; } - BigDecimal premium = MoneyUtil.zero(c.getPremium()); - BigDecimal companyAmount = MoneyUtil.pct(premium, companyRate); - BigDecimal agentAmount = MoneyUtil.pct(companyAmount, payoutRate); - BigDecimal taxAmount = MoneyUtil.tax(agentAmount); + // 핵심 계산식 (2단계 비율): + BigDecimal premium = MoneyUtil.zero(c.getPremium()); // 보험료(null 이면 0) + BigDecimal companyAmount = MoneyUtil.pct(premium, companyRate); // 보험사 → GA 수령액 + BigDecimal agentAmount = MoneyUtil.pct(companyAmount, payoutRate); // GA → 설계사 지급액 + BigDecimal taxAmount = MoneyUtil.tax(agentAmount); // 설계사 지급액 기준 원천세(미리 계산) LedgerVO led = new LedgerVO(); led.setContractId(c.getContractId()); diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchChargebackCalculator.java b/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchChargebackCalculator.java index 3771a1b..351ef0b 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchChargebackCalculator.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchChargebackCalculator.java @@ -12,8 +12,15 @@ import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; /** - * 배치용 차등환수 계산기. ga-api ChargebackCalculator와 동일 로직. - * chargeback_grade 테이블 기반 5단계 환수율 조회. + * 배치용 차등환수(差等還收) 계산기. ga-api ChargebackCalculator와 동일 로직을 배치 모듈에 복제한 것. + * + * 환수란? + * 계약이 단기에 해지/실효되면, 이미 지급한 모집수수료의 전부 또는 일부를 다시 회수하는 것. + * "차등환수"는 계약 후 경과 기간이 짧을수록 더 많이(높은 비율로) 환수하는 구조다. + * (예: 3개월 내 해지 → 100% 환수, 6개월 → 70%, ... 시간이 지날수록 환수율이 낮아짐) + * + * chargeback_grade 테이블에 (상품군, 경과월 구간) → 환수율(%) 이 단계별로 정의돼 있고, + * 경과월에 맞는 구간 1건을 조회해 환수율을 얻는다. */ @Component @RequiredArgsConstructor @@ -23,18 +30,30 @@ public class BatchChargebackCalculator { private final ChargebackGradeMapper gradeMapper; + /** + * 환수금액 계산. + * + * @param contractDate 계약일 + * @param terminationDate 해지/실효일 + * @param originalCommission 원래 지급했던 모집수수료(환수 원금) + * @param productCategory 상품군. 같은 경과월이라도 상품군에 따라 환수율이 다를 수 있어 조회 키. + * @return 환수금액 + 경과월 + 적용 환수율 + 사유 문자열 + */ 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); + String today = LocalDate.now().format(DATE_FMT); // 룰의 유효기간(시점) 판정용 ChargebackGradeVO grade = gradeMapper.selectByMonths(productCategory, monthsElapsed, today); + // 해당하는 환수 구간이 없으면 환수 0 (예: 환수기간을 이미 지난 오래된 계약). if (grade == null) { return new ChargebackResult(BigDecimal.ZERO, monthsElapsed, BigDecimal.ZERO, "적용 환수 구간 없음"); } + // 환수금액 = 원수수료 × 환수율(%) / 100. 원 단위 반올림(scale 0, HALF_UP). BigDecimal chargebackAmount = originalCommission .multiply(grade.getChargebackPercent()) .divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP); diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchInstallmentPlanGenerator.java b/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchInstallmentPlanGenerator.java index a761347..16c14a4 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchInstallmentPlanGenerator.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchInstallmentPlanGenerator.java @@ -17,6 +17,14 @@ import java.util.List; /** * 배치용 분급 계획 생성기. ga-api InstallmentPlanGenerator와 동일 로직. * 계약 체결일 기준 1~7차년 분급 계획 생성. + * + * [신입용 설명] + * - 분급(分給): 1200%룰 한도를 초과해 당월에 다 줄 수 없는 수수료 차액을, 여러 해(연차)에 + * 나눠 지급하도록 "지급 예정표"를 만드는 것. 이연(deferral)이라고도 한다. + * - 연차별 비율은 installment_ratio 마스터에 저장(상품군/시점별). 보험 표준은 7년에 + * 35/25/15/10/7/5/3(%) 형태(합 100%)지만, 마스터 값에 따라 달라질 수 있다. + * - 여기서는 금액만 쪼개고 status=SCHEDULED(지급 예정)로 둔다. 실제 지급은 도래월에 + * PayInstallmentStep이 이 계획을 읽어 처리한다. */ @Component @RequiredArgsConstructor @@ -27,15 +35,25 @@ public class BatchInstallmentPlanGenerator { private final InstallmentRatioMapper ratioMapper; + /** + * 이연 대상 금액(totalCommission)을 연차별 비율로 쪼개 분급 계획 목록을 만든다. + * + * @param contractId 계약 ID + * @param totalCommission 이연(분급)할 총액. CalcRecruitStep에서는 한도 초과분(excess)이 들어온다. + * @param contractDate 계약 체결일. 각 회차의 도래 정산월(settleMonth) 계산 기준. + * @param productCategory 상품군. 적용할 분급 비율 마스터를 고르는 키. + * @return SCHEDULED 상태의 분급 계획 목록(연차 수만큼). 호출부가 한 번에 INSERT한다. + */ public List generate(Long contractId, BigDecimal totalCommission, LocalDate contractDate, String productCategory) { String baseDate = contractDate.format(DATE_FMT); + // 계약일 시점에 유효한, 해당 상품군의 활성 분급 비율들을 연차 순서대로 가져온다. List ratios = ratioMapper.selectAllActiveRatios(productCategory, baseDate); // 비율 합이 정확히 100%인 정규 스케줄이면 마지막 회차가 반올림 잔차를 흡수 // → 분급 합계 == 원 수수료 보장 (부분지급 스케줄은 회차별 반올림 유지). boolean fullSchedule = sumRatios(ratios).compareTo(BigDecimal.valueOf(100)) == 0; - BigDecimal allocated = BigDecimal.ZERO; + BigDecimal allocated = BigDecimal.ZERO; // 지금까지 배분한 누계(마지막 회차 잔차 흡수 계산용) List plans = new ArrayList<>(); for (int i = 0; i < ratios.size(); i++) { @@ -45,8 +63,11 @@ public class BatchInstallmentPlanGenerator { BigDecimal amount; if (fullSchedule && i == ratios.size() - 1) { + // 정규 스케줄의 마지막 회차: 남은 전액을 배정 → 회차별 반올림으로 생긴 ±1원 잔차를 흡수. + // (예: 100원을 3등분하면 33/33/33=99원이 되는데, 마지막을 34원으로 맞춰 합계 100원 보장.) amount = totalCommission.subtract(allocated); } else { + // 일반 회차: 총액 × 해당 연차 비율 ÷ 100, 원 단위(소수 0자리) 반올림. amount = totalCommission.multiply(ratio.getRatioPercent()) .divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP); } @@ -54,18 +75,21 @@ public class BatchInstallmentPlanGenerator { InstallmentPlanVO plan = new InstallmentPlanVO(); plan.setContractId(contractId); - plan.setPlanYear(ratio.getPlanYear()); - plan.setPlanMonth(planMonth); + plan.setPlanYear(ratio.getPlanYear()); // 몇 차년(1~7) 지급분인지 + plan.setPlanMonth(planMonth); // 그 해의 몇 월 회차인지 + // 도래 정산월 = 계약일 + planYear년 + (planMonth-1)개월, "yyyyMM" 문자열. + // planMonth가 1이면 그 연차의 첫 달, 2면 한 달 뒤 식. PayInstallmentStep이 이 값으로 지급 시점을 찾는다. plan.setSettleMonth( contractDate.plusYears(ratio.getPlanYear()).plusMonths(planMonth - 1L).format(MONTH_FMT) ); - plan.setExpectedAmount(amount); - plan.setStatus(InstallmentStatus.SCHEDULED.code()); + plan.setExpectedAmount(amount); // 그 회차에 지급할 예정 금액 + plan.setStatus(InstallmentStatus.SCHEDULED.code()); // 아직 미지급(예정) 상태로 생성 plans.add(plan); } return plans; } + /** 분급 비율들의 합(%)을 구한다. 100%인지로 정규 스케줄(잔차 흡수 대상) 여부를 판정. */ private BigDecimal sumRatios(List ratios) { return ratios.stream() .map(InstallmentRatioVO::getRatioPercent) diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchRegulatoryLimitChecker.java b/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchRegulatoryLimitChecker.java index ae43036..8a58f51 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchRegulatoryLimitChecker.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchRegulatoryLimitChecker.java @@ -36,17 +36,29 @@ public class BatchRegulatoryLimitChecker { private final MaintainLedgerMapper maintainLedgerMapper; private final ContractMapper contractMapper; - /** 누적 수수료율 한도 체크 (1200%). 신계약 + 유지 원장 합산. */ + /** + * 누적 수수료율 한도 체크 (1200%). 신계약 + 유지 원장 합산. + * + * [의미] 이 계약에 대해 지금까지 지급한 모집·유지 수수료 누계(currentTotal)에 + * 이번에 추가로 줄 금액(additionalAmount)을 더한 값(projected)이 + * 1200% 한도(연환산보험료 × 1200% / 100 = 1년치 보험료)를 넘는지 본다. + * + * @param contractId 대상 계약 ID + * @param additionalAmount 이번에 추가로 지급하려는 모집수수료(규제 적용 전 원금액) + * @param baseDate 한도 기준일(보통 계약 체결일). 시점별 유효 한도를 고르는 데 사용. + * @return 한도 초과면 violated, 아니면 ok. limitAmount/currentTotal로 잔여 지급가능액 계산 가능. + */ public ViolationResult checkContractTotal(Long contractId, BigDecimal additionalAmount, LocalDate baseDate) { String baseDateStr = baseDate.format(DATE_FMT); + // baseDate 시점에 유효한 TOTAL_RATIO(1200%) 한도 설정을 조회. 설정이 없으면 규제 미적용 → ok. RegulatoryLimitVO limit = limitMapper.selectActiveByType(TOTAL_RATIO, null, baseDateStr); if (limit == null) { return ViolationResult.ok(additionalAmount, null, TOTAL_RATIO); } - BigDecimal limitKrw = resolveLimitKrw(limit, contractId); - BigDecimal currentTotal = sumPaidByContract(contractId); - BigDecimal projected = currentTotal.add(additionalAmount); + BigDecimal limitKrw = resolveLimitKrw(limit, contractId); // 비율 한도를 KRW 절대금액 한도로 환산 + BigDecimal currentTotal = sumPaidByContract(contractId); // 지금까지 지급한 누계(신계약+유지) + BigDecimal projected = currentTotal.add(additionalAmount); // 이번 지급까지 반영한 예상 누계 if (projected.compareTo(limitKrw) > 0) { return ViolationResult.violated( @@ -60,15 +72,23 @@ public class BatchRegulatoryLimitChecker { return ViolationResult.ok(currentTotal, limitKrw, TOTAL_RATIO); } - /** 1차년 수수료율 한도 체크. */ + /** + * 1차년 수수료율 한도 체크. + * + * [의미] 1200% 누적 한도와 별개로, 계약 "첫 1년"에 몰아줄 수 있는 모집수수료 상한 + * (FIRST_YEAR_RATIO, 기본 35%)을 검사한다. 1차년 누계는 신계약(모집) 원장의 1차년분만 합산한다 + * (유지수수료는 1차년 개념이 없으므로 제외). + */ public ViolationResult checkFirstYear(Long contractId, BigDecimal additionalAmount, LocalDate baseDate) { String baseDateStr = baseDate.format(DATE_FMT); + // baseDate 시점에 유효한 FIRST_YEAR_RATIO 한도 조회. 없으면 규제 미적용 → ok. RegulatoryLimitVO limit = limitMapper.selectActiveByType(FIRST_YEAR_RATIO, null, baseDateStr); if (limit == null) { return ViolationResult.ok(additionalAmount, null, FIRST_YEAR_RATIO); } - BigDecimal limitKrw = resolveLimitKrw(limit, contractId); + BigDecimal limitKrw = resolveLimitKrw(limit, contractId); // 1차년 한도를 KRW로 환산 + // 1차년에 이미 지급한 모집수수료 누계만 합산(유지수수료 제외). BigDecimal currentTotal = nullSafe(recruitLedgerMapper.sumFirstYearByContract(contractId)); BigDecimal projected = currentTotal.add(additionalAmount); @@ -84,23 +104,34 @@ public class BatchRegulatoryLimitChecker { return ViolationResult.ok(currentTotal, limitKrw, FIRST_YEAR_RATIO); } - /** 비율 한도(PERCENT)를 계약 연환산보험료 기준 KRW 한도로 환산. */ + /** + * 비율 한도(PERCENT)를 계약 연환산보험료 기준 KRW 한도로 환산. + * + * regulatory_limit.limit_value는 1200, 35 같은 "비율(%)" 값이므로 그대로 금액과 비교할 수 없다. + * 단위(unit)가 PERCENT면: 연환산보험료(annualPremium) × 비율 / 100 으로 실제 KRW 한도를 만든다. + * 예) 연환산보험료 1,200,000 × 1200% / 100 = 14,400,000원 (= 1년치 보험료의 1200%만큼) + * 단위가 PERCENT가 아니면(KRW 등): limit_value를 그대로 절대금액 한도로 사용. + */ private BigDecimal resolveLimitKrw(RegulatoryLimitVO limit, Long contractId) { if (!UNIT_PERCENT.equalsIgnoreCase(limit.getUnit())) { - return limit.getLimitValue(); + return limit.getLimitValue(); // 이미 KRW 등 절대금액 → 환산 불필요 } ContractVO contract = contractMapper.selectById(contractId); + // 계약/보험료가 없으면 한도 환산 기준이 0이 되어 한도도 0(=거의 전액 이연)이 된다. BigDecimal annualPremium = (contract != null && contract.getPremium() != null) ? contract.getPremium() : BigDecimal.ZERO; + // 연환산보험료 × 비율 ÷ 100, 원 단위 소수 2자리 반올림. return annualPremium.multiply(limit.getLimitValue()) .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); } + /** 계약별 누적 지급액 = 모집(신계약) 원장 합 + 유지 원장 합. 1200% 누적 한도 비교용. */ private BigDecimal sumPaidByContract(Long contractId) { return nullSafe(recruitLedgerMapper.sumByContract(contractId)) .add(nullSafe(maintainLedgerMapper.sumByContract(contractId))); } + /** 집계 결과가 NULL(지급 이력 없음)이면 0으로 치환해 NPE/계산오류를 막는다. */ private BigDecimal nullSafe(BigDecimal v) { return v != null ? v : BigDecimal.ZERO; } diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchTaxCalculator.java b/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchTaxCalculator.java index 87753ba..bb5ff43 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchTaxCalculator.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/domain/BatchTaxCalculator.java @@ -10,8 +10,21 @@ import java.math.BigDecimal; import java.math.RoundingMode; /** - * 배치용 세금 계산기. ga-api TaxCalculator와 동일 로직, ga-batch 모듈 독립. - * 사업소득 3.3% / 기타소득 8.8% + 지방소득세 10% + 부가세 10% (TAXABLE만). + * 배치용 세금 계산기. ga-api TaxCalculator와 동일 로직을 ga-batch 모듈에 독립 복제. + * + * 세금 구조(설계사 보수에 대한 원천징수): + * - 소득세(원천세): + * · 사업소득(BUSINESS_INCOME): 3.3% — 대부분의 전속/등록 설계사. + * · 기타소득(OTHER_INCOME): 8.8% — 일시적/비정규 소득. + * (※ 3.3% = 소득세 3% + 지방소득세 0.3% 처럼 흔히 합쳐 부르지만, 여기서는 + * 소득세 본세와 지방소득세를 분리 계산한다. 아래 참조.) + * - 지방소득세(localTax): 소득세(본세)의 10%. + * - 부가세(vat): 과세사업자(VatType.TAXABLE)인 설계사만 보수의 10% 가산. 면세면 0. + * + * net(실수령액) = 원금 - 소득세 - 지방소득세 + 부가세. + * (부가세는 설계사가 받아 국가에 납부하는 돈이라 지급액에 더해진다) + * + * 세율은 SystemConfigService 로 외부화되어 있어 운영 중 조정 가능(기본값은 코드의 폴백). */ @Component @RequiredArgsConstructor @@ -19,6 +32,12 @@ public class BatchTaxCalculator { private final SystemConfigService configService; + /** + * 세금 분해 계산. + * @param amount 과세 대상 금액(설계사 보수, gross). + * @param taxType 사업소득/기타소득 구분 → 소득세율 결정. + * @param vatType 과세/면세 구분 → 부가세 가산 여부 결정. + */ public TaxBreakdown calculate(BigDecimal amount, TaxType taxType, VatType vatType) { // 음수/0 정산(환수 우위 등)은 원천징수 대상이 아님 → 세금 0, net=원금(차월 이월). // 음수 원천세가 분기 원천세신고 SUM에 전파되는 것을 차단. @@ -27,22 +46,27 @@ public class BatchTaxCalculator { return new TaxBreakdown(BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, base); } - 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 incomeRate = resolveIncomeRate(taxType); // 3.3% 또는 8.8% + BigDecimal localRate = configService.getDecimal("TAX_RATE_LOCAL_INCOME", new BigDecimal("10.0")); // 소득세의 10% + BigDecimal vatRate = configService.getDecimal("TAX_RATE_VAT", new BigDecimal("10.0")); // 부가세 10% + // 소득세(본세) = 금액 × 소득세율 / 100. 원 단위 반올림. BigDecimal incomeTax = amount.multiply(incomeRate) .divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP); + // 지방소득세 = 소득세(본세) × 10% (금액이 아니라 소득세에 부과하는 점에 주의). BigDecimal localTax = incomeTax.multiply(localRate) .divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP); + // 부가세: 과세사업자만 금액의 10% 가산, 면세면 0. 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); } + /** 소득세율 결정: 기타소득이면 8.8%, 그 외(사업소득 등)는 3.3%. 값은 설정에서 조회. */ private BigDecimal resolveIncomeRate(TaxType taxType) { if (TaxType.OTHER_INCOME.equals(taxType)) { return configService.getDecimal("TAX_RATE_OTHER_INCOME", new BigDecimal("8.8")); diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/domain/ChargebackResult.java b/ga-batch/src/main/java/com/ga/batch/settlement/domain/ChargebackResult.java index f2a6b1c..51e6bf6 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/domain/ChargebackResult.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/domain/ChargebackResult.java @@ -2,7 +2,14 @@ package com.ga.batch.settlement.domain; import java.math.BigDecimal; -/** 차등환수 계산 결과. */ +/** + * 차등환수 계산 결과(불변 값 객체, record). + * + * @param chargebackAmount 환수금액(회수할 돈). 환수 대상이 아니면 0. + * @param monthsElapsed 계약일~해지일 경과 개월 수(환수율 구간 판정 근거). + * @param appliedPercent 실제 적용된 환수율(%). + * @param reason 판정 사유(로그/명세 표시용 사람이 읽는 설명). + */ public record ChargebackResult( BigDecimal chargebackAmount, int monthsElapsed, diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/domain/ClawbackInterestCalculator.java b/ga-batch/src/main/java/com/ga/batch/settlement/domain/ClawbackInterestCalculator.java index d56c944..ced463d 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/domain/ClawbackInterestCalculator.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/domain/ClawbackInterestCalculator.java @@ -9,10 +9,17 @@ import java.math.RoundingMode; /** * 환수채권 연체이자 계산기 (V125). - * 당월 적립 연체이자 = 잔액 × (연이자율 / 100 / 12), HALF_UP scale 0(원). - * 이자율 0(기본값) 이면 0 반환 → 기존 동작과 동일(안전 폴백). * - * 적립은 매 정산월 1개월분이며, 잔액 기준이므로 월복리로 누적된다. + * 배경: + * 환수금(설계사가 GA 에 돌려줘야 하는 돈)을 즉시 갚지 못하면 "환수채권(미수금)"으로 남는다. + * 이 미상환 채권 잔액에 매월 연체이자를 붙여 회수를 독려/보전한다. + * + * 계산식: + * 당월 적립 연체이자 = 잔액 × (연이자율 / 100 / 12), 원 단위 반올림(HALF_UP, scale 0). + * (1200 = 100[퍼센트→비율] × 12[연→월] 를 한 번에 나눈 것) + * + * 안전 폴백: 연이자율이 0(기본값)이면 항상 0 → 이자 기능을 켜기 전 기존 동작과 완전히 동일. + * 적립은 매 정산월 1개월분이며, 잔액(이자 포함) 기준이므로 월복리로 누적된다. */ @Component @RequiredArgsConstructor @@ -20,12 +27,17 @@ public class ClawbackInterestCalculator { private final SystemConfigService configService; - /** 당월 적립 연체이자. 잔액·이자율이 0 이하이면 0. */ + /** + * 당월 적립할 연체이자 금액. + * 잔액 또는 연이자율이 0 이하이면 0(이자 없음). + * @param balance 환수채권의 현재 미상환 잔액. + */ public BigDecimal monthlyInterest(BigDecimal balance) { BigDecimal annualRate = configService.getDecimal("CLAWBACK_OVERDUE_INTEREST_RATE", BigDecimal.ZERO); if (annualRate.signum() <= 0 || balance == null || balance.signum() <= 0) { return BigDecimal.ZERO; } + // 잔액 × 연이자율 ÷ 1200 = 1개월분 이자. return balance.multiply(annualRate) .divide(BigDecimal.valueOf(1200), 0, RoundingMode.HALF_UP); } diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/domain/TaxBreakdown.java b/ga-batch/src/main/java/com/ga/batch/settlement/domain/TaxBreakdown.java index 9ffdbbe..f22559a 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/domain/TaxBreakdown.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/domain/TaxBreakdown.java @@ -2,7 +2,14 @@ package com.ga.batch.settlement.domain; import java.math.BigDecimal; -/** 세금 계산 결과. ga-api TaxBreakdown과 동일 구조 (모듈 독립). */ +/** + * 세금 계산 결과(불변 값 객체, record). ga-api TaxBreakdown과 동일 구조(모듈 독립). + * + * @param incomeTax 소득세 본세(사업소득 3.3% 또는 기타소득 8.8%). + * @param localTax 지방소득세(소득세 본세의 10%). + * @param vat 부가세(과세사업자만, 보수의 10%; 면세면 0). + * @param netAmount 실수령액 = 원금 - 소득세 - 지방소득세 + 부가세. + */ public record TaxBreakdown( BigDecimal incomeTax, BigDecimal localTax, diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/domain/ViolationResult.java b/ga-batch/src/main/java/com/ga/batch/settlement/domain/ViolationResult.java index 4adf074..6a6fbd7 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/domain/ViolationResult.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/domain/ViolationResult.java @@ -2,7 +2,16 @@ package com.ga.batch.settlement.domain; import java.math.BigDecimal; -/** 1200%룰 검증 결과. */ +/** + * 1200%룰 검증 결과(불변 객체). + * + * [필드 의미] + * - violated : 한도 초과 여부(true면 초과 → 호출부가 clip+이연 처리). + * - currentTotal : 이번 지급 전까지 이미 지급한 누계(KRW). 잔여 지급가능액 = limitAmount - currentTotal. + * - limitAmount : KRW로 환산된 한도(연환산보험료 × 비율 / 100). 한도 설정이 없으면 null일 수 있음. + * - limitType : 어떤 한도인지(TOTAL_RATIO=누적 1200% / FIRST_YEAR_RATIO=1차년). + * - message : 위반 시 로그/안내용 설명 문자열(정상이면 null). + */ public record ViolationResult( boolean violated, BigDecimal currentTotal, @@ -10,10 +19,12 @@ public record ViolationResult( String limitType, String message ) { + /** 한도 이내(정상) 결과 생성. violated=false, message 없음. */ public static ViolationResult ok(BigDecimal currentTotal, BigDecimal limitAmount, String limitType) { return new ViolationResult(false, currentTotal, limitAmount, limitType, null); } + /** 한도 초과(위반) 결과 생성. violated=true, 사유 message 포함. */ public static ViolationResult violated(BigDecimal currentTotal, BigDecimal limitAmount, String limitType, String message) { return new ViolationResult(true, currentTotal, limitAmount, limitType, message); diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/receive/DataReceiver.java b/ga-batch/src/main/java/com/ga/batch/settlement/receive/DataReceiver.java index 26c1a40..559e113 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/receive/DataReceiver.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/receive/DataReceiver.java @@ -1,12 +1,17 @@ package com.ga.batch.settlement.receive; /** - * 보험사별 데이터 수신 전략. (Excel/CSV/EDI/API) + * 보험사별 데이터 수신 전략(Strategy 패턴). + * + * 보험사마다 수수료 명세를 주는 방식이 다르다(엑셀/CSV 수동 업로드, EDI, API 연동 등). + * 각 방식을 이 인터페이스의 구현체로 만들고, Step1(ReceiveDataStep)이 보험사별로 + * supports() 가 true 인 구현체를 골라 receive() 를 호출한다. + * 수신한 원천 데이터는 raw_commission_data 테이블에 그대로(raw) 적재된다. */ public interface DataReceiver { - /** 이 receiver 가 해당 보험사를 지원하는지 */ + /** 이 receiver 가 해당 보험사(companyCode)의 수신 방식을 지원하는지 여부. */ boolean supports(String companyCode); - /** 데이터 수신 → raw_commission_data 적재. 처리한 행 수 반환 */ + /** 데이터 수신 → raw_commission_data 적재. 처리한(적재/확인한) 행 수 반환. */ int receive(String companyCode, String settleMonth); } diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/receive/ManualReceiver.java b/ga-batch/src/main/java/com/ga/batch/settlement/receive/ManualReceiver.java index 33351a4..14eea70 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/receive/ManualReceiver.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/receive/ManualReceiver.java @@ -21,13 +21,15 @@ public class ManualReceiver implements DataReceiver { @Override public boolean supports(String companyCode) { + // 회사 프로필이 없거나(미설정) receiveMethod 가 "MANUAL" 이면 이 기본 receiver 가 담당. CompanyProfileVO p = mapper.selectProfile(companyCode); return p == null || "MANUAL".equalsIgnoreCase(p.getReceiveMethod()); } @Override public int receive(String companyCode, String settleMonth) { - // 수동 업로드는 별도 작업 없음. 기존 raw 데이터 건수만 반환. + // 수동 업로드는 이미 화면에서 raw_commission_data 에 적재돼 있으므로 추가 수신 작업이 없다. + // 처리 대상(PENDING) 건수만 세어 반환한다. return mapper.selectRawList(companyCode, settleMonth, "PENDING").size(); } } diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/step/AccountingEntryGenerateStep.java b/ga-batch/src/main/java/com/ga/batch/settlement/step/AccountingEntryGenerateStep.java index e698aea..5a302f8 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/step/AccountingEntryGenerateStep.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/step/AccountingEntryGenerateStep.java @@ -19,10 +19,14 @@ import java.util.List; /** * Step 8b — accountingEntryGenerate: 회계 자동분개 * - * 정산월 기준 recruit_ledger + maintain_ledger → contract_accounting_entry INSERT. - * - 차변(8350 지급수수료) / 대변(2530 미지급금) 한 쌍을 단일 행으로 저장. - * - journal_no는 NULL(미전기). 마감 UI에서 채번. - * - 멱등성: 재실행 시 해당 정산월의 미전기 분개를 먼저 삭제 후 재생성(마감 채번분은 보존). + * 정산으로 확정된 수수료를 회계 전표(분개)로 바꿔 contract_accounting_entry 에 적재한다. + * - 정산월 기준 recruit_ledger + maintain_ledger 를 읽어 분개 1행씩 생성. + * - 분개 원리: 차변(8350 지급수수료, 비용) / 대변(2530 미지급금, 부채) 한 쌍을 단일 행으로 저장. + * (아직 돈은 안 나갔지만 "줘야 할 비용"으로 인식 → 추후 지급 시 미지급금이 줄어듦) + * - journal_no(전표번호)는 NULL(미전기 상태). 회계 마감 UI 에서 사람이 확정하며 채번한다. + * + * 멱등성(중요): 같은 정산월을 다시 돌려도 중복 분개가 쌓이지 않도록, + * 재실행 시 "미전기" 분개만 먼저 지우고 재생성한다. 이미 마감(전표번호 부여)된 분개는 보존. */ @Slf4j @Component @@ -56,10 +60,11 @@ public class AccountingEntryGenerateStep implements Tasklet { List batch = new ArrayList<>(BATCH_SIZE); int total = 0; + // 금액 0 인 분개는 의미 없으므로 건너뛰고, 나머지를 BATCH_SIZE 단위로 모아 일괄 INSERT. for (ContractAccountingEntryVO entry : source) { if (entry.getAmount() == null || entry.getAmount().signum() == 0) continue; entry.setEntryDate(today); - entry.setJournalNo(null); + entry.setJournalNo(null); // 미전기 상태로 생성(마감 시 채번) batch.add(entry); if (batch.size() >= BATCH_SIZE) { diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/step/AggregateStep.java b/ga-batch/src/main/java/com/ga/batch/settlement/step/AggregateStep.java index 7f3d33c..00f15e5 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/step/AggregateStep.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/step/AggregateStep.java @@ -68,14 +68,27 @@ import java.util.Map; import java.util.Set; /** - * Step 8 — aggregate: 설계사별 통합 집계 → settle_master UPSERT + * Step 8 — aggregate: 설계사별 통합 집계 → settle_master UPSERT (월정산의 최종 합산 단계) * - * 통합 내용: - * - BatchTaxCalculator: 설계사 세금유형(TaxType/VatType)에 따라 정확한 세금 계산 - * (사업소득 3.3% / 기타소득 8.8% + 지방소득세 10% + 부가세 10%) - * - AgentDeductionMapper: PENDING 차감 합산 → netAmount에서 차감 후 APPLIED 상태로 전이 - * - CommissionStatementMapper: 설계사별 명세서 레코드(ISSUED 상태) INSERT - * - ClawbackReceivableMapper/ClawbackRecoveryMapper: 환수채권 이월/상계 (V111) + * 이 Step 이 하는 일(설계사 1명 기준): + * 1) 앞 Step 들이 만든 여러 원장(모집·유지·오버라이드·예외·환수 + 지급성 9종 등)을 + * 설계사별로 합산해 gross(세전 총액)를 만든다. + * 2) 세금 계산(BatchTaxCalculator): 사업소득 3.3% / 기타소득 8.8% + 지방소득세 + 부가세. + * 3) 차감(AgentDeduction): 대출상환·각종 공제 등 PENDING 차감을 빼고 APPLIED 로 전이. + * 4) 환수채권 FIFO 상계(V111): 과거 미상환 환수채권을 이번 달 지급액으로 우선 회수. + * net 이 음수면 그 음수분을 신규 환수채권으로 이월(carryover). + * 5) 선지급 FIFO 상계(MOD-2): 미리 당겨준 선지급금을 지급액에서 회수. + * 6) 최종 payable(실지급액)을 포함해 settle_master 에 UPSERT. + * 7) 명세서(commission_statement) 생성, 환수 원천세 역분개(V124), 연체이자 적립(V125) 등 부수효과. + * + * 멱등성: 같은 정산월 재실행 시 환수회수/선지급상계/이자적립/원천세역분개 이력을 먼저 + * reverse(역산·삭제)한 뒤 재계산하므로 중복이 발생하지 않는다. + * + * 용어: + * - 오버라이드(override): 하위 설계사 실적에 대해 상위 직급자에게 주는 상위수당. + * - 분급/선지급: 수수료를 미리 당겨 지급(advance)하고 이후 정산에서 상계 회수. + * - 원천세 역분개: 환수로 소득이 줄면 이미 떼어 신고한 원천세가 과대 신고가 되므로, + * 음(-)의 조정 분개를 적재해 분기 원천세 신고액을 바로잡는다. */ @Slf4j @Component @@ -123,6 +136,7 @@ public class AggregateStep implements Tasklet { public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { log.info("[Step8 aggregate] settleMonth={}", ctx.getSettleMonth()); + // 원장별 설계사 합계를 Map 으로 미리 모은다(이후 설계사 루프에서 O(1) 조회). Map recruitMap = toMap(recruitMapper.aggregateByAgent(ctx.getSettleMonth())); Map maintainMap = toMap(maintainMapper.aggregateByAgent(ctx.getSettleMonth())); Map chargebackMap = toMap(chargebackMapper.aggregateByAgent(ctx.getSettleMonth())); @@ -230,6 +244,8 @@ public class AggregateStep implements Tasklet { otherCommission = MoneyUtil.add(otherCommission, recruiterCommMap.getOrDefault(agentId, BigDecimal.ZERO)); otherCommission = MoneyUtil.add(otherCommission, lapsedReinstatMap.getOrDefault(agentId, BigDecimal.ZERO)); + // gross(세전 총액) = (모집 + 유지 + 오버라이드 + 예외가산) - 환수 - 예외차감 + 지급성9종. + // 환수(cb)·예외차감(em)은 빼는 항목. 음수가 될 수 있다(환수가 수입보다 클 때). BigDecimal gross = MoneyUtil.add(MoneyUtil.add(MoneyUtil.add(recruit, maintain), ov), ep) .subtract(cb).subtract(em); gross = MoneyUtil.add(gross, otherCommission); @@ -249,6 +265,7 @@ public class AggregateStep implements Tasklet { .map(AgentDeductionVO::getAmount) .reduce(BigDecimal.ZERO, BigDecimal::add); + // net = 세후 실수령 - 차감합계. 이 net 으로 아래에서 환수채권/선지급 상계를 수행. BigDecimal netAmount = tax.netAmount().subtract(deductionTotal); // 환수채권 이월/상계 계산 (V111) @@ -257,14 +274,17 @@ public class AggregateStep implements Tasklet { BigDecimal priorBalance = receivableMapper.outstandingBalance(agentId, ctx.getSettleMonth()); if (priorBalance == null) priorBalance = BigDecimal.ZERO; - BigDecimal recovered; - BigDecimal carryover; - BigDecimal payable; + BigDecimal recovered; // 이번 달 net 으로 회수한 과거 환수채권액 + BigDecimal carryover; // 다음 달로 넘길 신규 환수채권액 + BigDecimal payable; // 환수상계 후 남은 지급액(이후 선지급 상계로 더 줄 수 있음) if (netAmount.signum() >= 0) { + // net 이 양수: 받을 돈이 있으니 과거 채권을 net 한도 내에서 우선 회수. recovered = netAmount.min(priorBalance); // 기존 채권 FIFO 상계 (net 한도 내) carryover = BigDecimal.ZERO; payable = netAmount.subtract(recovered); } else { + // net 이 음수: 이번 달은 오히려 마이너스(환수 우위) → 줄 돈 없음. + // 음수분의 절대값을 새 환수채권으로 다음 달에 이월한다. recovered = BigDecimal.ZERO; carryover = netAmount.negate(); // 음수분 신규 채권화 payable = BigDecimal.ZERO; @@ -571,6 +591,7 @@ public class AggregateStep implements Tasklet { } } + /** 설계사 세금유형 파싱. 미설정/이상값이면 가장 흔한 사업소득(3.3%)으로 안전 폴백. */ private TaxType parseTaxType(AgentVO agent) { if (agent == null || agent.getTaxType() == null) return TaxType.BUSINESS_INCOME; try { @@ -580,6 +601,7 @@ public class AggregateStep implements Tasklet { } } + /** 설계사 부가세유형 파싱. 미설정/이상값이면 부가세 없음(NONE)으로 안전 폴백. */ private VatType parseVatType(AgentVO agent) { if (agent == null || agent.getVatType() == null) return VatType.NONE; try { @@ -589,6 +611,7 @@ public class AggregateStep implements Tasklet { } } + /** SQL 집계 결과(rows: {agentId, total} 목록)를 Map로 변환. */ private Map toMap(List> rows) { Map m = new HashMap<>(); for (Map r : rows) { @@ -598,6 +621,7 @@ public class AggregateStep implements Tasklet { return m; } + /** DB 에서 온 숫자 객체(null/BigDecimal/기타)를 안전하게 BigDecimal 로. null 이면 0. */ private BigDecimal toBd(Object v) { if (v == null) return BigDecimal.ZERO; if (v instanceof BigDecimal bd) return bd; diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcMaintainStep.java b/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcMaintainStep.java index af4b736..bf17bba 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcMaintainStep.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcMaintainStep.java @@ -29,8 +29,13 @@ import java.util.Map; /** * Step 5 — calcMaintain: 유지수수료 계산 (2회차~) * - * 유지수수료: 계약일로부터 N개월 경과한 활동 계약 (status=ACTIVE) 대상. - * - 1회차는 모집수수료에서 처리하므로 N >= 1 인 계약 대상. + * 유지수수료: 계약이 계속 살아있을 때(status=ACTIVE) 회차마다 추가로 지급하는 수수료. + * - 1회차(첫 달)는 모집수수료(Step4)에서 처리하므로, 여기서는 계약일로부터 1개월 이상 + * 경과한 계약만 대상으로 한다. + * - 납입주기(월납/분기납/연납/일시납)에 따라 해당 월에 유지수수료가 발생하는지 달라진다. + * - 계약 승계가 있으면 유지수수료 귀속을 후임 설계사로 옮긴다(아래 successorByContract). + * + * 멱등성: 시작 시 해당 정산월의 기존 maintain_ledger 를 삭제하고 다시 생성한다. */ @Slf4j @Component @@ -101,6 +106,11 @@ public class CalcMaintainStep implements Tasklet { return RepeatStatus.FINISHED; } + /** + * 이번 경과월에 유지수수료가 발생하는 납입주기인지 판정. + * 월납은 매월, 분기납은 3개월마다, 연납은 12개월마다 보험료를 내므로 그때만 수수료가 생긴다. + * 일시납(LUMP)은 1회 납입이라 모집회차 외에는 유지수수료가 없다. + */ private boolean matchesPayCycle(String cycle, int monthsElapsed) { if (cycle == null) return true; return switch (cycle) { diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcOverrideStep.java b/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcOverrideStep.java index 22705fe..9a9e389 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcOverrideStep.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcOverrideStep.java @@ -29,13 +29,20 @@ import java.util.List; import java.util.Map; /** - * Step 7 — calcOverride: 조직 트리 기반 오버라이드 계산. + * Step 7 — calcOverride: 조직 트리 기반 오버라이드(상위수당) 계산. + * + * 오버라이드란? + * 하위 설계사가 올린 실적의 일부를, 그를 관리하는 상위 직급자에게 추가로 주는 수당. + * (예: 팀원이 100만원 실적이면, 팀장에게 그 5%를 오버라이드로 지급) * * 정책: - * - 모든 ACTIVE 설계사를 순회하며 본인의 grade 와 일치하는 from_grade 규칙을 찾음 - * - 본인 조직 + 모든 상위 조직에서 to_grade 직급의 ACTIVE 설계사 1명을 찾아 분배 (가장 가까운 조직 우선) - * - base = 하위 설계사의 정산월 모집 + 유지 합계 - * - amount = base × override_pct (cap 적용) + * - 모든 ACTIVE 설계사(=하위)를 순회하며, 본인의 grade(직급)와 일치하는 from_grade 규칙을 찾음. + * - 본인 조직에서 출발해 상위 조직으로 올라가며 to_grade 직급의 ACTIVE 설계사 1명(=상위 수령자)을 + * 찾아 분배한다. 가장 가까운(낮은) 조직의 상위자를 우선. + * - base(기준금액) = 하위 설계사의 정산월 모집 + 유지 합계. + * - amount = base × override_pct. 룰에 상한(cap)이 있으면 그 이하로 자른다. + * + * 멱등성: 시작 시 해당 정산월 override_settle 를 삭제하고 재생성. */ @Slf4j @Component @@ -58,11 +65,12 @@ public class CalcOverrideStep implements Tasklet { overrideMapper.deleteBySettleMonth(ctx.getSettleMonth()); List rules = ruleMapper.selectOverrideRules(); + // 정산월 1일을 기준일로 삼아, 이 시점에 유효한(effectiveFrom~effectiveTo 범위 내) 룰만 남긴다. LocalDate baseDate = LocalDate.parse( ctx.getSettleMonth().substring(0, 4) + "-" + ctx.getSettleMonth().substring(4) + "-01"); - rules.removeIf(r -> r.getEffectiveFrom() != null && r.getEffectiveFrom().isAfter(baseDate)); - rules.removeIf(r -> r.getEffectiveTo() != null && r.getEffectiveTo().isBefore(baseDate)); + rules.removeIf(r -> r.getEffectiveFrom() != null && r.getEffectiveFrom().isAfter(baseDate)); // 아직 시작 전 룰 제외 + rules.removeIf(r -> r.getEffectiveTo() != null && r.getEffectiveTo().isBefore(baseDate)); // 이미 종료된 룰 제외 AgentSearchParam ap = new AgentSearchParam(); ap.setStatus("ACTIVE"); diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcRecruitStep.java b/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcRecruitStep.java index 1d7d559..daf1cd3 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcRecruitStep.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcRecruitStep.java @@ -30,10 +30,25 @@ import java.util.List; /** * Step 4 — calcRecruit: 모집 수수료 계산 (계약 첫 회차) * + * [신입용 도메인 설명] + * - 모집수수료(recruit commission): 보험 계약을 "모집(=판매)"한 대가로 설계사(에이전트)에게 + * 지급하는 수수료. 계약 체결 첫 회차에 가장 크게 발생한다. (이와 대비되는 개념이 + * 계약 유지 기간 동안 매달 지급되는 "유지수수료(maintain commission)"다.) + * - 이 Step은 "정산월(settleMonth)"에 계약일이 속하는 신규 계약들을 모아, 계약별로 모집수수료 + * 원장(ledger) 1건을 만들어 recruit_ledger 테이블에 적재하는 일을 한다. + * * 원칙: 계약시점의 commission_rate / payout_rule 적용 (effective_from 기준) + * → 수수료율·지급규칙은 시점마다 바뀔 수 있으므로, "계약 체결 당시" 유효했던 율/규칙을 쓴다. + * + * [1200%룰 = 이 Step의 핵심] + * 보험업감독규정 제5-15조의5. 첫 1년간 설계사에게 지급하는 모집수수료 총액을 + * "월납보험료의 1200%"(= 연환산보험료의 100%, 즉 1년치 보험료) 이내로 제한하는 규제. + * 과도한 선지급(시책)으로 인한 불완전판매·승환계약을 막기 위한 장치다. * * 추가: 1200%룰(BatchRegulatoryLimitChecker) 검증 후 한도 초과 계약은 * InstallmentPlan(분급 7년) 생성. Step 4.5 역할을 Step 4 내에서 수행. + * → 한도를 넘는 초과분은 당월에 다 주지 않고(=clip), 7년에 걸쳐 나눠 지급(=분급/이연)한다. + * 이연된 차액은 도래월에 별도 Step(PayInstallmentStep)이 installment_plan을 보고 지급한다. */ @Slf4j @Component @@ -51,11 +66,23 @@ public class CalcRecruitStep implements Tasklet { private static final int BATCH_SIZE = 1000; + /** + * 배치 실행 본체. 정산월의 신규 계약을 순회하며 모집수수료 원장을 만들고, + * 1200%룰 한도 초과분은 분급 계획으로 이연한 뒤 원장/분급을 일괄 저장한다. + * + * 처리 흐름: + * 1) 정산월에 계약일이 속하는 계약 조회 + * 2) 기존 정산월 모집원장 삭제(재계산 멱등성 확보) + * 3) 계약별로: 모집수수료 계산 → 1200%룰 clip → (초과 시) 분급 생성 → 원장 적재 + * 4) 원장/분급 배치 INSERT + */ @Override public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { log.info("[Step4 calcRecruit] settleMonth={}", ctx.getSettleMonth()); // 모집수수료 대상: 계약일이 정산월에 해당하는 계약 + // getMonthRange("yyyyMM") → [그 달 1일, 말일] 형태의 시작/종료일 문자열을 반환. + // pageSize=MAX_VALUE 로 정산월 전체 계약을 페이징 없이 한 번에 가져온다(배치 특성). String[] range = DateUtil.getMonthRange(ctx.getSettleMonth()); ContractSearchParam param = new ContractSearchParam(); param.setStartDate(range[0]); @@ -64,6 +91,7 @@ public class CalcRecruitStep implements Tasklet { List contracts = contractMapper.selectList(param); // 정산월 모집원장 초기화 (재계산 대비) + // → 같은 정산월을 다시 돌려도 중복 적재되지 않도록 먼저 지운다(멱등성). 배치 재실행 안전장치. recruitMapper.deleteBySettleMonth(ctx.getSettleMonth()); List batch = new ArrayList<>(BATCH_SIZE); @@ -72,42 +100,54 @@ public class CalcRecruitStep implements Tasklet { int violationCount = 0; for (ContractResp c : contracts) { + // calcRecruitLedger: 계약의 수수료율/지급규칙으로 이번 회차 모집수수료 원장을 계산. + // led == null(대상 아님) 또는 지급액 0원이면 원장을 만들 필요가 없으므로 건너뛴다. LedgerVO led = calculator.calcRecruitLedger(c, ctx.getSettleMonth()); if (led == null || MoneyUtil.isZero(led.getAgentAmount())) continue; // 1200%룰 검증: 1차년 한도(FIRST_YEAR) + 누적 한도(TOTAL 1200%) 중 // 더 제한적인 한도로 당월 지급액을 clip, 초과분은 분급(7년)으로 이연. + // - 두 한도 모두 검사하고, 각 한도가 허용하는 "잔여 지급가능액" 중 더 작은 값까지만 지급한다. + // - 즉 둘 중 어느 한 규제라도 더 빡빡하면 그쪽 한도가 당월 지급액을 결정한다(min 연산). if (c.getContractDate() != null) { - BigDecimal originalAmount = led.getAgentAmount(); - BigDecimal allowed = originalAmount; + BigDecimal originalAmount = led.getAgentAmount(); // 규제 적용 전, 원래 계산된 모집수수료 + BigDecimal allowed = originalAmount; // 당월 실제 지급 허용액(아래에서 한도로 깎아 나감) + // (1) 1차년 한도 검사: 계약 첫 1년 동안 지급한 모집수수료 누계가 한도(예: 연환산보험료×35%)를 넘는지. ViolationResult firstYearCheck = regulatoryChecker.checkFirstYear( c.getContractId(), originalAmount, c.getContractDate()); if (firstYearCheck.violated()) { + // 잔여 지급가능액 = 1차년 한도 - 이미 1차년에 지급한 누계 BigDecimal fyAllowed = MoneyUtil.sub( firstYearCheck.limitAmount(), firstYearCheck.currentTotal()); - if (fyAllowed.signum() < 0) fyAllowed = BigDecimal.ZERO; - allowed = allowed.min(fyAllowed); + if (fyAllowed.signum() < 0) fyAllowed = BigDecimal.ZERO; // 이미 한도를 넘었으면 더 줄 수 없음(음수 방지) + allowed = allowed.min(fyAllowed); // 더 작은 쪽으로 clip } + // (2) 누적 1200% 한도 검사: 신계약+유지 원장을 합산한 계약 전체 누계가 1200% 한도를 넘는지. ViolationResult totalCheck = regulatoryChecker.checkContractTotal( c.getContractId(), originalAmount, c.getContractDate()); if (totalCheck.violated()) { + // 잔여 지급가능액 = 1200% 한도 - 이미 지급한 누계(신계약+유지) BigDecimal totAllowed = MoneyUtil.sub( totalCheck.limitAmount(), totalCheck.currentTotal()); - if (totAllowed.signum() < 0) totAllowed = BigDecimal.ZERO; - allowed = allowed.min(totAllowed); + if (totAllowed.signum() < 0) totAllowed = BigDecimal.ZERO; // 음수 방지 + allowed = allowed.min(totAllowed); // 1차년 한도와 비교해 더 작은 쪽으로 clip } + // 초과분 = 원래 지급액 - 한도로 허용된 지급액. 양수면 한도를 넘은 것. BigDecimal excess = MoneyUtil.sub(originalAmount, allowed); if (excess.signum() > 0) { violationCount++; // 초과분만 분급 계획 생성 (7년 35/25/15/10/7/5/3) + // → 한도로 잘린 차액(excess)을 7개 연차로 쪼개 installment_plan에 미래 지급 예정으로 적재. + // 당월 지급분(allowed)은 분급 대상이 아니다(이미 이번 달에 지급되므로). List plans = installmentGenerator.generate( c.getContractId(), excess, c.getContractDate(), c.getInsuranceType()); installmentBatch.addAll(plans); // 당월 원장은 한도까지만 지급(초과분 차감) + 세액 재계산 + // → 지급액이 줄었으니 원천세(tax)도 줄어든 지급액 기준으로 다시 계산해야 일관성이 맞는다. led.setAgentAmount(allowed); led.setTaxAmount(MoneyUtil.tax(allowed)); log.warn("[Step4] 1200%룰 한도초과 → clip+이연: contractId={}, 지급(한도잔여)={}, 이연(분급)={}, firstYear={}, total={}", @@ -116,20 +156,24 @@ public class CalcRecruitStep implements Tasklet { } // 한도로 전액 이연되어 당월 지급액이 0이면 원장 미생성 + // → allowed가 0까지 깎인 경우(=전부 분급으로 넘어간 경우). 0원 원장을 남기지 않는다. + // 분급 계획은 위에서 이미 만들었으므로 금액 손실은 없다(나중 도래월에 지급됨). if (MoneyUtil.isZero(led.getAgentAmount())) continue; batch.add(led); + // BATCH_SIZE(1000)건씩 모아 한 번에 INSERT → DB 왕복 횟수를 줄여 대량 처리 성능 확보. if (batch.size() >= BATCH_SIZE) { recruitMapper.batchInsert(batch); total += batch.size(); batch.clear(); } } + // 루프 종료 후 1000건 미만으로 남은 잔여 원장 마저 적재. if (!batch.isEmpty()) { recruitMapper.batchInsert(batch); total += batch.size(); } - // 분급 계획 일괄 저장 + // 분급 계획 일괄 저장 (한도 초과 계약들에 대해 생성된 7년 분급 예정분을 한 번에 INSERT) if (!installmentBatch.isEmpty()) { installmentPlanMapper.insertBatch(installmentBatch); log.info("[Step4] installment plans created: {} (violations: {})", diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/step/ChargebackExceptionStep.java b/ga-batch/src/main/java/com/ga/batch/settlement/step/ChargebackExceptionStep.java index 5c4d80e..8c8f27d 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/step/ChargebackExceptionStep.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/step/ChargebackExceptionStep.java @@ -24,10 +24,13 @@ import java.util.ArrayList; import java.util.List; /** - * Step 6 — chargebackException: 환수 자동생성 + 예외금액 자동계산 + * Step 6 — chargebackException: 환수 자동생성 * - * 환수율 계산: BatchChargebackCalculator (chargeback_grade 5단계 차등환수) 사용. - * 환수 원금: 해당 계약의 모집 원장 합산 (recruit_ledger 기준). + * 이번 정산월에 실효(LAPSE)된 계약을 찾아, 단기 해지에 따른 환수금을 자동 생성한다. + * - 대상: status=LAPSE 이면서 실효월(lapseDate 의 월)이 이번 정산월인 계약. + * - 환수율: BatchChargebackCalculator (chargeback_grade 5단계 차등환수)로 계산. + * - 환수 원금: 그 계약에 이미 지급했던 모집수수료 합계(recruit_ledger 기준). + * - 결과: chargeback 레코드(status=NEW) 생성. 이후 Step8 에서 설계사 정산에 반영된다. */ @Slf4j @Component diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/step/PayInstallmentStep.java b/ga-batch/src/main/java/com/ga/batch/settlement/step/PayInstallmentStep.java new file mode 100644 index 0000000..2feb077 --- /dev/null +++ b/ga-batch/src/main/java/com/ga/batch/settlement/step/PayInstallmentStep.java @@ -0,0 +1,114 @@ +package com.ga.batch.settlement.step; + +import com.ga.batch.settlement.SettlementContext; +import com.ga.common.util.MoneyUtil; +import com.ga.core.mapper.installment.InstallmentPlanMapper; +import com.ga.core.mapper.ledger.RecruitLedgerMapper; +import com.ga.core.mapper.product.ContractMapper; +import com.ga.core.vo.installment.InstallmentPlanVO; +import com.ga.core.vo.installment.InstallmentStatus; +import com.ga.core.vo.ledger.LedgerVO; +import com.ga.core.vo.product.ContractVO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * Step 4.5 — payInstallment: 1200%룰 이연 차액(분급)의 도래월 지급. + * + * CalcRecruitStep(Step4)이 1차년/1200% 한도 초과분을 installment_plan(SCHEDULED, 7년)으로 + * 이연하지만, 도래월에 실제 지급하는 절차가 없으면 차액이 영구 미지급된다. 이 스텝은 + * 정산월에 도래(settle_month=정산월)한 SCHEDULED 분급을 recruit_ledger에 편입하여 + * Step8 aggregate(settle_master.recruitTotal/gross)로 흐르게 한다. + * + * 멱등성: 정산 Job 재실행 시 Step4의 deleteBySettleMonth(status='CALCULATED')가 이미 + * 당월 원장을 정리하므로, 이 스텝은 진입 시 PAID→SCHEDULED 역산 후 재지급한다. + * 분급 첫 회차는 plan_year≥1(계약 1년 후)이라 clip한 당월과 절대 겹치지 않는다. + * + * 한도와의 관계(의도된 동작): 이 스텝은 1200%룰 검사기(BatchRegulatoryLimitChecker)를 + * 호출하지 않는다. 따라서 한 번 이연된 분급은 자신의 7년 일정대로 끝까지 지급되며 다시 + * clip 되지 않는다. 지급된 분급은 recruit_ledger 행으로 남아 이후 같은 계약에 '새로' 들어오는 + * 모집수수료의 누적 한도(checkContractTotal=sumByContract) 계산에 포함되는데, 이는 계약이 + * 실제로 받은 금액을 누적에 반영하는 정상 동작이다(분급분을 한도에서 빼야 하는 버그가 아님). + */ +@Slf4j +@Component +@JobScope +@RequiredArgsConstructor +public class PayInstallmentStep implements Tasklet { + + private final SettlementContext ctx; + private final InstallmentPlanMapper planMapper; + private final ContractMapper contractMapper; + private final RecruitLedgerMapper recruitMapper; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + String settleMonth = ctx.getSettleMonth(); + log.info("[Step4.5 payInstallment] settleMonth={}", settleMonth); + + // 멱등 역산: 당월 기지급 분급을 SCHEDULED로 되돌림 (재실행 안전). + // 대응 원장은 Step4 deleteBySettleMonth가 이미 정리함. + planMapper.resetPaidToScheduledByMonth(settleMonth); + + List scheduled = planMapper.selectScheduledByMonth(settleMonth); + List ledgers = new ArrayList<>(); + int paid = 0; + + for (InstallmentPlanVO plan : scheduled) { + if (MoneyUtil.isZero(plan.getExpectedAmount())) continue; + + ContractVO contract = contractMapper.selectById(plan.getContractId()); + if (contract == null) { + log.warn("[Step4.5] 계약 미존재 → 분급 미지급 skip: planId={}, contractId={}", + plan.getPlanId(), plan.getContractId()); + continue; + } + + ledgers.add(toLedger(plan, contract, settleMonth)); + + plan.setStatus(InstallmentStatus.PAID.code()); + plan.setPaidAmount(plan.getExpectedAmount()); + plan.setPaidAt(LocalDateTime.now()); + planMapper.update(plan); + paid++; + } + + if (!ledgers.isEmpty()) { + recruitMapper.batchInsert(ledgers); + } + + ctx.setInstallmentPaidCount(paid); + log.info("[Step4.5] installment paid → recruit ledger: {} (expected total rows scheduled={})", + paid, scheduled.size()); + return RepeatStatus.FINISHED; + } + + /** 분급 계획 + 계약으로 모집원장 행 생성. agentAmount=이연 차액, status=CALCULATED(재실행 정리 대상). */ + private LedgerVO toLedger(InstallmentPlanVO plan, ContractVO contract, String settleMonth) { + LedgerVO led = new LedgerVO(); + led.setContractId(contract.getContractId()); + led.setAgentId(contract.getAgentId()); + led.setPolicyNo(contract.getPolicyNo()); + led.setPremium(contract.getPremium()); + led.setPayMethod(contract.getPayCycle()); + led.setCommissionYear(plan.getPlanYear()); + led.setAgentAmount(plan.getExpectedAmount()); + led.setTaxAmount(MoneyUtil.tax(plan.getExpectedAmount())); + // 분급 지급분은 시스템 내부 산출(이연 차액)이라 보험사 자료와 대사할 대상이 아니므로 + // MATCHED(자동 대사 완료)로 표기한다. 일반 모집원장(보험사 자료 기반)은 PENDING 유지. + led.setReconcileStatus("MATCHED"); + led.setSettleMonth(settleMonth); + led.setStatus("CALCULATED"); + return led; + } +} diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/step/ReceiveDataStep.java b/ga-batch/src/main/java/com/ga/batch/settlement/step/ReceiveDataStep.java index fa22294..4c11dee 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/step/ReceiveDataStep.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/step/ReceiveDataStep.java @@ -16,7 +16,11 @@ import org.springframework.stereotype.Component; import java.util.List; /** - * Step 1 — receiveData: 보험사별 DataReceiver 호출 → raw_commission_data 적재 + * Step 1 — receiveData: 보험사별 데이터 수신 + * + * 활성 보험사 목록을 돌면서, 각 보험사를 지원하는 DataReceiver(수신 전략)를 골라 호출한다. + * receiver 가 수수료 원천 데이터를 raw_commission_data 에 적재하고, 적재 행 수를 합산해 + * 컨텍스트에 기록한다. 정산 파이프라인의 입구(데이터 들여오기) 단계. */ @Slf4j @Component @@ -32,6 +36,7 @@ public class ReceiveDataStep implements Tasklet { public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { log.info("[Step1 receiveData] settleMonth={}", ctx.getSettleMonth()); int total = 0; + // 활성 보험사마다, 그 회사를 지원하는 첫 번째 receiver 를 찾아 수신 실행(찾으면 break). for (InsuranceCompanyVO c : companyMapper.selectActive()) { for (DataReceiver r : receivers) { if (r.supports(c.getCompanyCode())) { diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/step/ReconcileStep.java b/ga-batch/src/main/java/com/ga/batch/settlement/step/ReconcileStep.java index 80390b7..20bef26 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/step/ReconcileStep.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/step/ReconcileStep.java @@ -18,7 +18,14 @@ import org.springframework.stereotype.Component; import java.math.BigDecimal; /** - * Step 3 — reconcile: 보험사 통보액 vs 시스템 계산액 대사 + * Step 3 — reconcile: 대사(對査) + * + * 대사란 "보험사가 통보한 수수료 총액"과 "우리 시스템이 계산/집계한 총액"을 맞춰보는 검증이다. + * 회사별로 두 금액의 차이를 reconciliation 에 기록하고, 차이가 0 이면 MATCHED, 아니면 DIFF. + * DIFF 인 회사는 사람이 원인을 확인해야 한다. + * + * 주의(현재 구현 한계): companyTotal 을 systemTotal 과 동일하게 두어 항상 MATCHED 가 된다. + * 실제 운영에서는 raw_commission_data 의 통보액 합계로 companyTotal 을 산출해야 한다(아래 주석 참고). */ @Slf4j @Component diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/step/TransformDataStep.java b/ga-batch/src/main/java/com/ga/batch/settlement/step/TransformDataStep.java index 7ba13c1..11f9ab2 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/step/TransformDataStep.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/step/TransformDataStep.java @@ -16,7 +16,12 @@ import org.springframework.stereotype.Component; import java.util.List; /** - * Step 2 — transformData: company_field_mapping + code_mapping 으로 표준 변환 → 원장 적재 + * Step 2 — transformData: 원천 데이터 표준화 + * + * Step1 이 적재한 PENDING 상태 raw_commission_data 를 한 건씩 MappingEngine 으로 변환해 + * 표준 원장(recruit_ledger / maintain_ledger)에 적재한다. + * 변환 성공이면 raw 상태를 OK(+생성 원장 PK), 실패면 ERROR 로 갱신한다. + * 한 건의 변환 실패가 전체 배치를 멈추지 않도록 try/catch 로 건별 처리한다. */ @Slf4j @Component diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/step/VatReportQuarterlyStep.java b/ga-batch/src/main/java/com/ga/batch/settlement/step/VatReportQuarterlyStep.java index fb96277..f524389 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/step/VatReportQuarterlyStep.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/step/VatReportQuarterlyStep.java @@ -12,11 +12,13 @@ import org.springframework.batch.repeat.RepeatStatus; import org.springframework.stereotype.Component; /** - * Step 12 (conditional) — vatReportQuarterly: 분기 부가세 집계 + * Step 12 (조건부 실행) — vatReportQuarterly: 분기 부가세 집계 * - * 분기 마감월(3/6/9/12월)에만 실행 (settleMonth ends with "03"/"06"/"09"/"12"). - * tax_invoice 테이블 기준 해당 분기 SUPPLY/PURCHASE SUM → vat_report UPSERT (분기 1건). - * SQL은 VatReportMapper.upsertAggregate에 위임. + * 부가세 신고는 분기 단위로 한다. 이 Step 은 그 분기 부가세 신고용 집계를 만든다. + * - 분기 마감월(3/6/9/12월)일 때만 실행, 그 외 월은 건너뜀. + * - tax_invoice(세금계산서) 기준 해당 분기 매출(SUPPLY)/매입(PURCHASE) SUM → vat_report 에 + * 분기당 1건 UPSERT. (낼 부가세 = 매출세액 - 매입세액) + * - 실제 집계 SQL 은 VatReportMapper.upsertAggregate 에 위임. */ @Slf4j @Component diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/step/WithholdingTaxQuarterlyStep.java b/ga-batch/src/main/java/com/ga/batch/settlement/step/WithholdingTaxQuarterlyStep.java index c02eefe..1d82974 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/step/WithholdingTaxQuarterlyStep.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/step/WithholdingTaxQuarterlyStep.java @@ -12,11 +12,13 @@ import org.springframework.batch.repeat.RepeatStatus; import org.springframework.stereotype.Component; /** - * Step 11 (conditional) — withholdingTaxQuarterly: 분기 원천세 집계 + * Step 11 (조건부 실행) — withholdingTaxQuarterly: 분기 원천세 집계 * - * 분기 마감월(3/6/9/12월)에만 실행 (settleMonth ends with "03"/"06"/"09"/"12"). - * payment 테이블 기준 해당 분기 설계사별 SUM → withholding_tax_report UPSERT. - * SQL은 WithholdingTaxReportMapper.upsertQuarterlyAggregate에 위임. + * 원천세(설계사 보수에서 미리 떼어둔 소득세)는 분기 단위로 국세청에 신고·납부한다. + * 이 Step 은 그 분기 신고용 집계를 만든다. + * - 분기 마감월(3/6/9/12월)일 때만 실행, 그 외 월은 건너뜀. + * - payment 테이블 기준 해당 분기 설계사별 원천세 SUM → withholding_tax_report 에 UPSERT. + * - 실제 집계 SQL 은 WithholdingTaxReportMapper.upsertQuarterlyAggregate 에 위임. */ @Slf4j @Component diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/step/YearEndIncomeAggregateStep.java b/ga-batch/src/main/java/com/ga/batch/settlement/step/YearEndIncomeAggregateStep.java index 27c46e6..42e77c0 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/step/YearEndIncomeAggregateStep.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/step/YearEndIncomeAggregateStep.java @@ -12,11 +12,13 @@ import org.springframework.batch.repeat.RepeatStatus; import org.springframework.stereotype.Component; /** - * Step 9 (conditional) — yearEndIncomeAggregate: 연말 사업소득 집계 + * Step 9 (조건부 실행) — yearEndIncomeAggregate: 연말 사업소득 집계 * - * 12월 마감 시에만 실행 (settleMonth ends with "-12" 또는 "12"). - * payment 테이블 기준으로 해당 연도 설계사별 SUM → agent_annual_income UPSERT. - * SQL은 AgentAnnualIncomeMapper.upsertAggregate에 위임. + * 설계사에게 1년 동안 지급한 소득을 연 단위로 합산한다. 연말정산/지급명세서 제출 등 + * 세무 신고의 기초 자료가 된다. + * - 12월 마감일 때만 실행한다(그 외 월은 건너뜀). 한 해가 끝나야 연간 합계가 확정되기 때문. + * - payment 테이블 기준 해당 연도 설계사별 SUM → agent_annual_income 에 UPSERT. + * - 실제 집계 SQL 은 AgentAnnualIncomeMapper.upsertAggregate 에 위임(여기서는 호출만). */ @Slf4j @Component diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/transform/MappingEngine.java b/ga-batch/src/main/java/com/ga/batch/settlement/transform/MappingEngine.java index 2f47979..14cbbe3 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/transform/MappingEngine.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/transform/MappingEngine.java @@ -49,8 +49,15 @@ public class MappingEngine { private final MaintainLedgerMapper maintainMapper; private final ObjectMapper objectMapper; + /** + * raw 데이터 1건을 표준 원장 1건으로 변환·적재한다. + * 각 단계에서 실패하면 parse_error_log 에 사유를 남기고 null 을 반환한다(이 건은 건너뜀). + * 성공 시 생성된 원장 PK(ledgerId)를 반환. + * @Transactional: 원장 INSERT 가 한 트랜잭션으로 묶인다. + */ @Transactional public Long transform(RawCommissionDataVO raw) { + // 입력 기본 검증: 회사코드/원본내용이 없으면 변환 불가. if (raw.getCompanyCode() == null || raw.getRawContent() == null) { saveError(raw, "INVALID_INPUT", null, null, "company_code 또는 raw_content 누락"); return null; @@ -79,6 +86,7 @@ public class MappingEngine { return null; } + // 매핑 규칙대로 source_field 값을 꺼내 transform 적용 후 target_field 이름으로 모은다. Map mapped = new HashMap<>(); for (CompanyFieldMappingVO m : mappings) { JsonNode v = root.get(m.getSourceField()); @@ -97,6 +105,7 @@ public class MappingEngine { } } + // 증권번호(policy_no)로 우리 시스템의 계약을 매칭한다. 못 찾으면 적재 불가. String policyNo = (String) mapped.get("policy_no"); if (policyNo == null) { saveError(raw, "NO_POLICY", "policy_no", null, "policy_no 누락"); @@ -118,6 +127,7 @@ public class MappingEngine { return led.getLedgerId(); } + /** data_type 값을 적재 대상 원장 테이블명으로 매핑. 모집→recruit_ledger, 유지→maintain_ledger. */ private String resolveTargetTable(String dataType) { if (dataType == null) return null; return switch (dataType.toUpperCase()) { @@ -127,6 +137,11 @@ public class MappingEngine { }; } + /** + * 매핑 규칙(transform_rule)에 따라 원본 문자열 값을 가공한다. + * TRIM/UPPER/LOWER: 문자 정리, DATE: 날짜 파싱, DIVIDE/MULTIPLY: 단위 환산(예: 천원→원), + * CODE_MAP: 보험사 코드 → 표준 코드 매핑. 규칙이 없으면 dataType 에 맞춰 타입만 변환. + */ private Object applyTransform(String value, CompanyFieldMappingVO m, String companyCode) { if (value == null) { return convertType(m.getDefaultValue(), m.getDataType()); @@ -151,6 +166,7 @@ public class MappingEngine { }; } + /** 문자열을 dataType(STRING/NUMBER/INT/LONG/DATE)에 맞는 자바 타입으로 변환. 숫자는 천단위 콤마 제거. */ private Object convertType(String value, String dataType) { if (value == null) return null; if (dataType == null) return value; @@ -164,6 +180,7 @@ public class MappingEngine { }; } + /** 날짜 파싱. pattern 지정 시 그 형식, 없으면 yyyyMMdd 시도, 둘 다 실패하면 ISO(yyyy-MM-dd) 시도. */ private LocalDate parseDate(String s, String pattern) { if (s == null || s.isBlank()) return null; try { @@ -176,12 +193,14 @@ public class MappingEngine { } } + /** 단위 환산용. divide=true 면 나누기(2자리 반올림), false 면 곱하기. factor 없으면 1. */ private BigDecimal divideOrMultiply(String value, String factor, boolean divide) { BigDecimal num = new BigDecimal(value.replace(",", "")); BigDecimal f = new BigDecimal(factor != null && !factor.isBlank() ? factor : "1"); return divide ? num.divide(f, 2, RoundingMode.HALF_UP) : num.multiply(f); } + /** 증권번호로 계약 조회. 목록에서 첫 건의 PK 를 얻어 상세를 다시 읽는다(상세 필드 확보용). */ private ContractVO findContractByPolicyNo(String policyNo) { ContractSearchParam p = new ContractSearchParam(); p.setPolicyNo(policyNo); @@ -191,6 +210,11 @@ public class MappingEngine { return contractMapper.selectById(list.get(0).getContractId()); } + /** + * 매핑 결과(mapped) + 계약 정보로 원장 VO 를 채운다. + * 보험사가 이미 금액/율을 통보한 경우(외부 통보형)이므로 여기서는 그 값을 그대로 쓰고, + * 누락 시 계약값/0 으로 폴백한다. 세금(tax_amount)만 설계사지급액 기준으로 자체 계산. + */ private LedgerVO buildLedger(Map mapped, ContractVO contract, RawCommissionDataVO raw) { LedgerVO led = new LedgerVO(); led.setContractId(contract.getContractId()); @@ -225,6 +249,7 @@ public class MappingEngine { try { return Integer.parseInt(v.toString()); } catch (Exception e) { return fallback; } } + /** 변환 실패 1건을 parse_error_log 에 기록(상태 OPEN). 운영자가 화면에서 보고 보정한다. */ private void saveError(RawCommissionDataVO raw, String type, String field, String value, String msg) { log.warn("MappingEngine error rawId={} type={} field={} value={}: {}", raw.getRawId(), type, field, value, msg); diff --git a/ga-batch/src/test/java/com/ga/batch/settlement/step/PayInstallmentStepTest.java b/ga-batch/src/test/java/com/ga/batch/settlement/step/PayInstallmentStepTest.java new file mode 100644 index 0000000..f9b04e1 --- /dev/null +++ b/ga-batch/src/test/java/com/ga/batch/settlement/step/PayInstallmentStepTest.java @@ -0,0 +1,133 @@ +package com.ga.batch.settlement.step; + +import com.ga.batch.settlement.SettlementContext; +import com.ga.core.mapper.installment.InstallmentPlanMapper; +import com.ga.core.mapper.ledger.RecruitLedgerMapper; +import com.ga.core.mapper.product.ContractMapper; +import com.ga.core.vo.installment.InstallmentPlanVO; +import com.ga.core.vo.ledger.LedgerVO; +import com.ga.core.vo.product.ContractVO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 1200%룰 이연 차액(분급)의 도래월 지급(PayInstallmentStep) 검증. + * - 정상 지급: SCHEDULED 분급 → recruit_ledger 편입 + plan PAID 마킹 + * - 멱등 재실행: 진입 시 PAID→SCHEDULED 역산 호출 + * - 계약 미존재: 원장 미생성 + PAID 마킹 안 함 + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class PayInstallmentStepTest { + + @Mock SettlementContext ctx; + @Mock InstallmentPlanMapper planMapper; + @Mock ContractMapper contractMapper; + @Mock RecruitLedgerMapper recruitMapper; + + PayInstallmentStep step; + + static final String MONTH = "202501"; + static final Long CONTRACT_ID = 100L; + static final Long AGENT_ID = 10L; + + @BeforeEach + void setUp() { + step = new PayInstallmentStep(ctx, planMapper, contractMapper, recruitMapper); + when(ctx.getSettleMonth()).thenReturn(MONTH); + } + + private InstallmentPlanVO plan(BigDecimal expected, int planYear) { + InstallmentPlanVO p = new InstallmentPlanVO(); + p.setPlanId(1L); + p.setContractId(CONTRACT_ID); + p.setPlanYear(planYear); + p.setPlanMonth(1); + p.setSettleMonth(MONTH); + p.setExpectedAmount(expected); + p.setStatus("SCHEDULED"); + return p; + } + + private ContractVO contract() { + ContractVO c = new ContractVO(); + c.setContractId(CONTRACT_ID); + c.setAgentId(AGENT_ID); + c.setPolicyNo("P-100"); + c.setPremium(new BigDecimal("1000000")); + return c; + } + + @Test + @DisplayName("도래월 SCHEDULED 분급 100,000 → recruit_ledger 1행(tax 3,300) 편입 + plan PAID") + void payScheduled_insertsLedgerAndMarksPaid() { + when(planMapper.selectScheduledByMonth(MONTH)) + .thenReturn(List.of(plan(new BigDecimal("100000"), 2))); + when(contractMapper.selectById(CONTRACT_ID)).thenReturn(contract()); + + @SuppressWarnings("unchecked") + ArgumentCaptor> ledgerCaptor = ArgumentCaptor.forClass(List.class); + when(recruitMapper.batchInsert(ledgerCaptor.capture())).thenReturn(1); + + step.execute(null, null); + + LedgerVO led = ledgerCaptor.getValue().get(0); + assertThat(led.getAgentId()).isEqualTo(AGENT_ID); + assertThat(led.getContractId()).isEqualTo(CONTRACT_ID); + assertThat(led.getSettleMonth()).isEqualTo(MONTH); + assertThat(led.getAgentAmount()).isEqualByComparingTo("100000"); + assertThat(led.getTaxAmount()).isEqualByComparingTo("3300.00"); + assertThat(led.getCommissionYear()).isEqualTo(2); + assertThat(led.getStatus()).isEqualTo("CALCULATED"); + + // plan PAID 마킹 (expected=paidAmount) + ArgumentCaptor planCaptor = ArgumentCaptor.forClass(InstallmentPlanVO.class); + verify(planMapper).update(planCaptor.capture()); + assertThat(planCaptor.getValue().getStatus()).isEqualTo("PAID"); + assertThat(planCaptor.getValue().getPaidAmount()).isEqualByComparingTo("100000"); + assertThat(planCaptor.getValue().getPaidAt()).isNotNull(); + + verify(ctx).setInstallmentPaidCount(1); + } + + @Test + @DisplayName("멱등 재실행: 진입 시 PAID→SCHEDULED 역산을 먼저 호출") + void idempotent_resetsPaidFirst() { + when(planMapper.selectScheduledByMonth(MONTH)).thenReturn(List.of()); + + step.execute(null, null); + + verify(planMapper).resetPaidToScheduledByMonth(MONTH); + } + + @Test + @DisplayName("계약 미존재 분급 → 원장 미생성 + PAID 마킹 안 함") + void missingContract_skip() { + when(planMapper.selectScheduledByMonth(MONTH)) + .thenReturn(List.of(plan(new BigDecimal("100000"), 2))); + when(contractMapper.selectById(CONTRACT_ID)).thenReturn(null); + + step.execute(null, null); + + verify(recruitMapper, never()).batchInsert(any()); + verify(planMapper, never()).update(any()); + verify(ctx).setInstallmentPaidCount(0); + } +} diff --git a/ga-common/src/main/java/com/ga/common/adapter/bank/BankTransferRequest.java b/ga-common/src/main/java/com/ga/common/adapter/bank/BankTransferRequest.java index 654b309..60650c2 100644 --- a/ga-common/src/main/java/com/ga/common/adapter/bank/BankTransferRequest.java +++ b/ga-common/src/main/java/com/ga/common/adapter/bank/BankTransferRequest.java @@ -5,6 +5,19 @@ import lombok.Data; import java.math.BigDecimal; +/** + * 펌뱅킹 이체 요청 DTO (외부 은행/KFTC 시스템으로 보낼 입력값). + *

+ * 데이터 흐름: 설계사가 출금 신청 → 출금 도메인이 이 객체를 만들어 + * {@link BankTransferAdapter#requestTransfer}에 넘김 → 어댑터가 외부 펌뱅킹 API 호출. + *

+ * 신입 메모: + * - withdrawRequestId는 같은 출금이 두 번 이체되지 않도록 하는 "멱등키"다. + * 네트워크 재시도가 발생해도 외부 시스템이 중복 이체를 막을 수 있게 함께 보낸다. + * - accountNo는 평문(마스킹 안 된 실제 계좌번호)이다. 이체에는 실제 번호가 필요하므로 + * 여기서는 평문을 담되, 로그로 남길 때는 어댑터 내부에서 마스킹한다. + * - amount는 돈이므로 double이 아닌 {@link BigDecimal}을 쓴다(부동소수 오차 방지). + */ @Data @Builder public class BankTransferRequest { diff --git a/ga-common/src/main/java/com/ga/common/adapter/bank/BankTransferResponse.java b/ga-common/src/main/java/com/ga/common/adapter/bank/BankTransferResponse.java index 34e7114..1924a72 100644 --- a/ga-common/src/main/java/com/ga/common/adapter/bank/BankTransferResponse.java +++ b/ga-common/src/main/java/com/ga/common/adapter/bank/BankTransferResponse.java @@ -5,6 +5,17 @@ import lombok.Data; import java.time.LocalDateTime; +/** + * 펌뱅킹 이체 응답 DTO (외부 은행/KFTC 시스템이 돌려준 결과값). + *

+ * {@link BankTransferAdapter#requestTransfer} 또는 {@link BankTransferAdapter#queryStatus}의 + * 반환값으로, 호출한 출금 도메인이 이 결과를 보고 출금 상태를 갱신/대사(對査)한다. + *

+ * 신입 메모: + * - txId는 외부 시스템이 부여한 거래 식별자다. 나중에 상태를 다시 조회(queryStatus)할 때 쓴다. + * - status는 {@link BankTransferStatus}(접수/완료/실패)로, 우리 DB 출금 상태와 매핑된다. + * - resultCode/resultMessage는 외부 시스템의 원본 응답으로, 실패 원인 추적용 로그에 남긴다. + */ @Data @Builder public class BankTransferResponse { diff --git a/ga-common/src/main/java/com/ga/common/adapter/bank/BankTransferStatus.java b/ga-common/src/main/java/com/ga/common/adapter/bank/BankTransferStatus.java index 9cf06f3..96777b5 100644 --- a/ga-common/src/main/java/com/ga/common/adapter/bank/BankTransferStatus.java +++ b/ga-common/src/main/java/com/ga/common/adapter/bank/BankTransferStatus.java @@ -1,7 +1,13 @@ package com.ga.common.adapter.bank; +/** + * 펌뱅킹 이체의 진행 상태. + *

+ * 외부 응답 코드를 우리 시스템이 이해하는 3단계로 정규화한 값이다. + * 출금 도메인은 이 값을 보고 "이체 접수됨/입금 완료/실패"를 판단한다. + */ public enum BankTransferStatus { - ACCEPTED, // 접수 - SETTLED, // 입금 완료 - FAILED // 실패 + ACCEPTED, // 접수 — 외부에 요청이 받아들여졌으나 아직 최종 입금 전(추후 queryStatus로 확정) + SETTLED, // 입금 완료 — 수취 계좌에 실제 입금까지 끝난 최종 성공 상태 + FAILED // 실패 — 잔액부족/계좌오류 등으로 이체가 거절됨 } diff --git a/ga-common/src/main/java/com/ga/common/adapter/message/MessageChannel.java b/ga-common/src/main/java/com/ga/common/adapter/message/MessageChannel.java index a48a62c..dd8c846 100644 --- a/ga-common/src/main/java/com/ga/common/adapter/message/MessageChannel.java +++ b/ga-common/src/main/java/com/ga/common/adapter/message/MessageChannel.java @@ -1,6 +1,11 @@ package com.ga.common.adapter.message; +/** + * 메시지 발송 채널 종류. + *

+ * {@link MessageRequest#getChannel()}에 담겨 어댑터가 어느 통로로 보낼지 결정한다. + */ public enum MessageChannel { - SMS, - KAKAO + SMS, // 일반 문자 (Toast SMS) — templateCode 불필요 + KAKAO // 카카오 알림톡 — 사전 승인된 templateCode 필요 } diff --git a/ga-common/src/main/java/com/ga/common/adapter/message/MessageRequest.java b/ga-common/src/main/java/com/ga/common/adapter/message/MessageRequest.java index 0042ae6..2006f95 100644 --- a/ga-common/src/main/java/com/ga/common/adapter/message/MessageRequest.java +++ b/ga-common/src/main/java/com/ga/common/adapter/message/MessageRequest.java @@ -3,6 +3,18 @@ package com.ga.common.adapter.message; import lombok.Builder; import lombok.Data; +/** + * SMS/카카오 알림톡 발송 요청 DTO. + *

+ * 데이터 흐름: 알림 도메인(승인/정산 등 이벤트)이 이 객체를 만들어 + * {@link MessageAdapter#send}에 전달 → 어댑터가 Toast 등 외부 발송사 API를 호출한다. + *

+ * 신입 메모: + * - channel(SMS/KAKAO)에 따라 필요한 필드가 다르다. 알림톡은 templateCode가 필수, + * 일반 SMS는 templateCode를 비워두면 된다. + * - refUserId/refType/refId는 "이 메시지가 어떤 사용자·어떤 업무 때문에 나갔는지"를 + * 추적하기 위한 꼬리표다(user_notification 이력과 연결). + */ @Data @Builder public class MessageRequest { diff --git a/ga-common/src/main/java/com/ga/common/adapter/message/MessageResponse.java b/ga-common/src/main/java/com/ga/common/adapter/message/MessageResponse.java index bd1d8ad..c3d6f2f 100644 --- a/ga-common/src/main/java/com/ga/common/adapter/message/MessageResponse.java +++ b/ga-common/src/main/java/com/ga/common/adapter/message/MessageResponse.java @@ -5,6 +5,13 @@ import lombok.Data; import java.time.LocalDateTime; +/** + * 메시지 발송 결과 DTO ({@link MessageAdapter#send}의 반환값). + *

+ * 신입 메모: + * - sent(true/false)로 성공 여부를 판단하고, 실패 시 resultCode/resultMessage로 원인을 로그에 남긴다. + * - messageId는 외부 발송사가 부여한 식별자로, 추후 발송 결과 조회/문의에 사용한다. + */ @Data @Builder public class MessageResponse { diff --git a/ga-common/src/main/java/com/ga/common/adapter/push/PushRequest.java b/ga-common/src/main/java/com/ga/common/adapter/push/PushRequest.java index 03d426a..32319ef 100644 --- a/ga-common/src/main/java/com/ga/common/adapter/push/PushRequest.java +++ b/ga-common/src/main/java/com/ga/common/adapter/push/PushRequest.java @@ -3,6 +3,17 @@ package com.ga.common.adapter.push; import lombok.Builder; import lombok.Data; +/** + * 모바일 푸시 발송 요청 DTO. + *

+ * 데이터 흐름: 출금/승인/정산 이벤트 발생 → 사용자의 디바이스 토큰을 조회 → + * 이 객체를 만들어 {@link PushAdapter#send}에 전달 → 어댑터가 FCM API 호출. + *

+ * 신입 메모: + * - deviceToken은 앱이 발급받아 서버에 등록해 둔 FCM 토큰이다(단말 하나당 하나). + * - refType/refId는 푸시의 data payload로 함께 실려, 앱이 알림 탭 시 어느 화면으로 + * 이동할지(예: WITHDRAW=출금 상세) 판단하는 데 쓰인다. + */ @Data @Builder public class PushRequest { diff --git a/ga-common/src/main/java/com/ga/common/adapter/push/PushResponse.java b/ga-common/src/main/java/com/ga/common/adapter/push/PushResponse.java index 67294ef..4e08be0 100644 --- a/ga-common/src/main/java/com/ga/common/adapter/push/PushResponse.java +++ b/ga-common/src/main/java/com/ga/common/adapter/push/PushResponse.java @@ -5,6 +5,12 @@ import lombok.Data; import java.time.LocalDateTime; +/** + * 모바일 푸시 발송 결과 DTO ({@link PushAdapter#send}의 반환값). + *

+ * 신입 메모: sent로 성공 여부를 판단하고, messageId(FCM이 부여한 이름/식별자)와 + * resultCode/resultMessage를 발송 이력·실패 추적 로그에 남긴다. + */ @Data @Builder public class PushResponse { diff --git a/ga-common/src/main/java/com/ga/common/annotation/DataChangeLog.java b/ga-common/src/main/java/com/ga/common/annotation/DataChangeLog.java index 768ae21..95ac879 100644 --- a/ga-common/src/main/java/com/ga/common/annotation/DataChangeLog.java +++ b/ga-common/src/main/java/com/ga/common/annotation/DataChangeLog.java @@ -6,20 +6,30 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** - * 데이터 변경 이력 자동 기록. + * 데이터 변경 이력 자동 기록 어노테이션. + * + *

왜 필요한가: 수수료 정산 시스템은 금액·규정을 다루므로 "누가 무엇을 언제 바꿨는지"를 + * 반드시 남겨야 한다(감사 추적). 매 서비스 메서드마다 직접 로그 코드를 넣으면 누락·중복이 + * 생기므로, 메서드 위에 이 어노테이션 한 줄만 붙이면 AOP가 자동으로 이력을 남기도록 했다. + * + *

어떻게 동작하나: 이 어노테이션이 붙은 메서드는 {@code DataChangeLogAspect}(AOP)가 가로채서 + * 실행 전·후의 대상 데이터를 비교 → data_change_log 테이블의 JSONB 컬럼에 저장한다. + * 즉 이 파일은 "표시(marker)"일 뿐, 실제 기록 로직은 aop 패키지의 Aspect가 담당한다. * *

  *   @DataChangeLog(menu = "RULE", table = "payout_rule")
  *   public void updatePayoutRule(PayoutRuleVO vo) { ... }
  * 
- * - * 동작: 메서드 실행 전·후의 대상 데이터를 비교 → data_change_log JSONB 컬럼에 저장. */ +// @Target(METHOD): 메서드에만 붙일 수 있다. +// @Retention(RUNTIME): 실행 중 리플렉션으로 읽을 수 있어야 AOP가 감지 가능하므로 RUNTIME 필수. @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface DataChangeLog { + /** 어떤 메뉴(화면)에서 발생한 변경인지. 이력 조회 시 분류 키로 쓰인다. */ String menu(); + /** 변경 대상 DB 테이블명. 이력에 함께 기록된다. */ String table(); - /** 액션 유형. 자동 추정 안 될 때 명시 */ + /** 액션 유형(INSERT/UPDATE/DELETE). 비워두면 메서드 이름으로 자동 추정, 자동 추정이 틀릴 때만 명시. */ String action() default ""; } diff --git a/ga-common/src/main/java/com/ga/common/annotation/ExcelColumn.java b/ga-common/src/main/java/com/ga/common/annotation/ExcelColumn.java index 5cc1471..3d1bf4c 100644 --- a/ga-common/src/main/java/com/ga/common/annotation/ExcelColumn.java +++ b/ga-common/src/main/java/com/ga/common/annotation/ExcelColumn.java @@ -6,17 +6,34 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** - * 엑셀 컬럼 매핑 어노테이션. ExcelExporter / ExcelImporter 가 사용. + * 엑셀 컬럼 매핑 어노테이션. VO(데이터 클래스)의 필드에 붙여서 엑셀의 어느 열에 어떤 형식으로 + * 출력/입력될지를 선언한다. + * + *

왜 필요한가: 정산 데이터는 엑셀 내려받기/올리기가 잦다. 필드마다 헤더명·순서·서식을 코드로 + * 일일이 다루면 번거롭고 실수가 많으므로, 필드 위에 이 어노테이션만 달면 ExcelService(엑셀 처리기)가 + * 리플렉션으로 읽어 자동으로 엑셀을 생성/파싱한다. + * + *

어떻게 쓰이나: {@code ExcelColumnParser}가 VO의 각 필드에서 이 어노테이션을 읽어 + * {@code ExcelColumnInfo} 목록으로 변환하고, {@code ExcelService}가 그 목록으로 엑셀을 만든다. */ +// @Target(FIELD): VO의 멤버 필드에만 붙인다. @Retention(RUNTIME): 실행 중 리플렉션으로 읽으므로 필수. @Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) public @interface ExcelColumn { + /** 엑셀 첫 행에 표시될 헤더(컬럼 제목). */ String header(); + /** 컬럼 표시 순서(작을수록 왼쪽). */ int order(); + /** 컬럼 너비(엑셀 단위). 기본 15. */ int width() default 15; + /** 셀 표시 서식(예: 날짜/숫자 포맷). 비우면 기본 서식. */ String format() default ""; + /** 공통코드 그룹. 코드값을 사람이 읽는 코드명으로 변환해 표시할 때 사용. */ String codeGroup() default ""; + /** true면 민감정보(주민번호 등)를 마스킹하여 출력. */ boolean masked() default false; + /** 값이 비어있을 때 채울 기본값. */ String defaultValue() default ""; + /** true면 엑셀 업로드(임포트) 시 필수 입력 컬럼으로 검증. */ boolean required() default false; } diff --git a/ga-common/src/main/java/com/ga/common/annotation/Mask.java b/ga-common/src/main/java/com/ga/common/annotation/Mask.java index 8338397..029c68c 100644 --- a/ga-common/src/main/java/com/ga/common/annotation/Mask.java +++ b/ga-common/src/main/java/com/ga/common/annotation/Mask.java @@ -8,10 +8,18 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** - * 응답 시 자동 마스킹 어노테이션 (응답 직렬화 시 적용). + * 응답 시 자동 마스킹 어노테이션. VO 필드에 붙이면 API 응답으로 직렬화될 때 해당 값이 + * 마스킹(예: 홍길동 → 홍*동, 010-1234-5678 → 010-****-5678)되어 나간다. + * + *

왜 필요한가: 개인정보보호(주민번호·전화·계좌 등)를 위해 화면/응답에는 가린 값만 보여야 한다. + * 필드마다 가공 코드를 넣는 대신 어노테이션으로 선언하면 누락을 줄이고 일관되게 마스킹할 수 있다. + * + *

value 로 마스킹 방식({@link MaskType})을 지정한다(기본 NAME=이름 마스킹). */ +// @Target(FIELD): 마스킹할 VO 필드에 붙인다. @Retention(RUNTIME): 직렬화 시점에 리플렉션으로 읽으므로 필수. @Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) public @interface Mask { + /** 적용할 마스킹 유형(이름/전화/주민번호/계좌 등). 기본값은 이름 마스킹. */ MaskType value() default MaskType.NAME; } diff --git a/ga-common/src/main/java/com/ga/common/annotation/RequirePermission.java b/ga-common/src/main/java/com/ga/common/annotation/RequirePermission.java index 6070f14..47d7c26 100644 --- a/ga-common/src/main/java/com/ga/common/annotation/RequirePermission.java +++ b/ga-common/src/main/java/com/ga/common/annotation/RequirePermission.java @@ -6,19 +6,28 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** - * 메뉴+권한 단위 권한 검증. + * 메뉴+권한 단위 권한 검증 어노테이션. 컨트롤러/서비스 메서드 위에 붙이면 호출 전에 + * 현재 로그인 사용자가 그 작업을 할 권한이 있는지 자동으로 검사한다. + * + *

왜 필요한가: "이 화면의 조회/등록/삭제를 누가 할 수 있는가"는 역할(role)마다 다르다. + * 메서드마다 권한 검사 코드를 직접 쓰면 빠뜨리기 쉬우므로, 어노테이션으로 선언하고 + * AOP가 일괄 검사하도록 했다. + * + *

어떻게 동작하나: {@code PermissionAspect}(AOP)가 이 어노테이션을 감지해, 현재 사용자의 역할이 + * role_menu_permission 테이블에 (menu, perm) 권한을 가지는지 {@code PermissionChecker}로 확인한다. + * 없으면 {@code BizException(ErrorCode.FORBIDDEN)} 을 던져 403으로 차단한다. * *

  *   @RequirePermission(menu = "ORG_AGENT", perm = "READ")
  *   public ApiResponse<...> list(...) { ... }
  * 
- * - * 동작: 현재 사용자의 역할 → role_menu_permission 에 (menu, perm) 권한이 있는지 확인. - * 없으면 BizException(ErrorCode.FORBIDDEN). */ +// @Target(METHOD): 메서드에만 붙인다. @Retention(RUNTIME): 실행 중 AOP가 리플렉션으로 읽으므로 필수. @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequirePermission { + /** 권한을 검사할 메뉴 코드(예: "ORG_AGENT"). */ String menu(); + /** 필요한 권한 종류(예: READ/WRITE/DELETE). 기본값은 READ(조회). */ String perm() default "READ"; } diff --git a/ga-common/src/main/java/com/ga/common/aop/ApiLogAspect.java b/ga-common/src/main/java/com/ga/common/aop/ApiLogAspect.java index 0d6324b..e39a448 100644 --- a/ga-common/src/main/java/com/ga/common/aop/ApiLogAspect.java +++ b/ga-common/src/main/java/com/ga/common/aop/ApiLogAspect.java @@ -13,30 +13,38 @@ import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; /** - * 모든 @RestController 의 요청을 비동기 로깅한다. - * - URL, Method, 응답시간, 사용자 - * - 민감 파라미터(비밀번호 등)는 마스킹 + * 모든 @RestController 의 요청을 비동기로 접근 로그(api_access_log)에 남기는 AOP. + * - 기록 항목: URL, HTTP Method, 응답시간(ms), 사용자, 성공/실패, 클라이언트 IP + * - 민감 파라미터(비밀번호 등)는 저장 전에 마스킹 + * + *

왜 필요한가: 어떤 요청이 언제 얼마나 걸렸고 누가 했는지 추적해야 보안/성능 분석이 가능하다. + * 컨트롤러마다 로깅 코드를 넣지 않고, AOP로 모든 REST 컨트롤러를 한 곳에서 가로채 일괄 기록한다. + * + *

핵심: 로그 저장은 {@code saveAsync}(비동기)로 호출해 본 요청 응답이 로깅 때문에 느려지지 않게 한다. + * 또한 로깅 실패가 실제 비즈니스 응답을 망치면 안 되므로 finally 안에서 try-catch로 삼켜 무시한다. */ @Slf4j -@Aspect -@Component +@Aspect // 이 클래스가 AOP 관심사(가로채기 로직)임을 선언 +@Component // 스프링 빈으로 등록되어야 AOP가 활성화됨 @RequiredArgsConstructor public class ApiLogAspect { private final ApiAccessLogService apiAccessLogService; + // within(@...RestController *) : @RestController 가 붙은 클래스의 모든 메서드를 대상으로 둘러싼다(@Around). @Around("within(@org.springframework.web.bind.annotation.RestController *)") public Object log(ProceedingJoinPoint jp) throws Throwable { - long start = System.currentTimeMillis(); + long start = System.currentTimeMillis(); // 응답시간 측정 시작점 Throwable error = null; Object result = null; try { - result = jp.proceed(); + result = jp.proceed(); // 실제 컨트롤러 메서드 실행 return result; } catch (Throwable e) { - error = e; + error = e; // 예외도 일단 보관했다가 로그에 실패로 기록하고 다시 던진다 throw e; } finally { + // 성공/실패와 무관하게 항상 로그를 남긴다. 로깅 자체의 오류는 무시(아래 catch). try { int elapsed = (int) (System.currentTimeMillis() - start); HttpServletRequest req = currentRequest(); @@ -58,11 +66,13 @@ public class ApiLogAspect { } } + /** 현재 스레드에 묶인 HTTP 요청 객체를 꺼낸다. 요청 컨텍스트가 없으면(비웹 호출 등) null. */ private HttpServletRequest currentRequest() { var attr = RequestContextHolder.getRequestAttributes(); return attr instanceof ServletRequestAttributes sra ? sra.getRequest() : null; } + /** 쿼리스트링을 로그용으로 가공: 비밀번호/토큰 같은 민감 값은 ***로 가린다. */ private String queryString(HttpServletRequest req) { String qs = req.getQueryString(); if (qs == null) return null; @@ -70,8 +80,10 @@ public class ApiLogAspect { return qs.replaceAll("(password|pwd|token)=[^&]*", "$1=***"); } + /** 클라이언트 실제 IP를 구한다. 프록시/로드밸런서 뒤일 수 있으므로 X-Forwarded-For 헤더를 우선 사용. */ private String ipOf(HttpServletRequest req) { String xf = req.getHeader("X-Forwarded-For"); + // 여러 IP가 콤마로 나열되면 맨 앞(최초 클라이언트)이 실제 사용자 IP다. if (xf != null && !xf.isBlank()) return xf.split(",")[0].trim(); return req.getRemoteAddr(); } diff --git a/ga-common/src/main/java/com/ga/common/aop/DataChangeLogAspect.java b/ga-common/src/main/java/com/ga/common/aop/DataChangeLogAspect.java index f0494e7..f430d8a 100644 --- a/ga-common/src/main/java/com/ga/common/aop/DataChangeLogAspect.java +++ b/ga-common/src/main/java/com/ga/common/aop/DataChangeLogAspect.java @@ -13,27 +13,32 @@ import org.aspectj.lang.reflect.MethodSignature; import org.springframework.stereotype.Component; /** - * @DataChangeLog 처리. + * {@link com.ga.common.annotation.DataChangeLog} 어노테이션이 붙은 메서드를 가로채 데이터 변경 이력을 남기는 AOP. + * + *

왜 필요한가: 금액·규정 변경의 감사 추적을 위해, 어노테이션 한 줄로 변경 전/후 상태를 자동 기록하기 위함. + * (어노테이션은 "표시"만 하고, 실제 기록 동작은 이 클래스가 담당한다.) * * 정책: - * - 메서드 호출 전 첫 인자(또는 ID로 추정 가능한 인자)를 직렬화 → before - * - 정상 종료 후 동일 인자를 직렬화 → after + * - 메서드 호출 전 첫 인자(또는 ID로 추정 가능한 인자)를 JSON 직렬화 → before(변경 전 상태) + * - 정상 종료 후 동일 인자를 직렬화 → after(변경 후 상태) * - DataChangeLogService 가 before/after 비교 후 changed_keys 산출하여 INSERT * * 단순화를 위해 본 구현은 메서드 인자 전체를 JSON 직렬화 후 저장한다. * 실제 환경에서는 menu/table 별 조회 콜백을 등록하여 정확한 before-state 를 가져오는 것을 권장. */ @Slf4j -@Aspect +@Aspect // AOP 가로채기 로직임을 선언 @Component @RequiredArgsConstructor public class DataChangeLogAspect { private final DataChangeLogService dataChangeLogService; - private final ObjectMapper objectMapper; + private final ObjectMapper objectMapper; // 인자를 JSON 문자열로 직렬화하는 데 사용 + // @annotation(...) : @DataChangeLog 가 붙은 메서드만 둘러싸서(@Around) 전/후 상태를 기록한다. @Around("@annotation(com.ga.common.annotation.DataChangeLog)") public Object log(ProceedingJoinPoint jp) throws Throwable { + // 가로챈 메서드에 달린 @DataChangeLog 어노테이션을 리플렉션으로 꺼내 menu/table/action 값을 읽는다. MethodSignature sig = (MethodSignature) jp.getSignature(); DataChangeLog ann = sig.getMethod().getAnnotation(DataChangeLog.class); @@ -41,12 +46,14 @@ public class DataChangeLogAspect { String afterJson; Object[] args = jp.getArgs(); try { + // 변경 전 상태: 첫 번째 인자(보통 VO)를 직렬화. 인자가 없으면 빈 객체. beforeJson = args.length > 0 ? toJson(args[0]) : "{}"; } catch (Exception ignore) { } - Object result = jp.proceed(); + Object result = jp.proceed(); // 실제 비즈니스 메서드 실행(여기서 데이터가 바뀜) try { + // 변경 후 상태: 인자가 있으면 인자를, 없으면 반환값을 직렬화. afterJson = args.length > 0 ? toJson(args[0]) : toJson(result); dataChangeLogService.save( SecurityUtil.getCurrentUserId(), @@ -58,12 +65,14 @@ public class DataChangeLogAspect { afterJson ); } catch (Exception e) { + // 이력 저장 실패가 본 업무를 막으면 안 되므로 경고만 남기고 정상 진행한다. log.warn("DataChangeLog save failed: {}", e.getMessage()); } return result; } + /** 객체를 JSON 문자열로 직렬화. null이거나 직렬화 실패 시 빈 객체 "{}" 반환(이력 기록이 깨지지 않게). */ private String toJson(Object o) { if (o == null) return "{}"; try { @@ -73,6 +82,7 @@ public class DataChangeLogAspect { } } + /** 인자 중 식별자(Long/Integer/String)를 골라 변경 레코드의 PK로 추정한다. 없으면 null. */ private String extractRecordId(Object[] args) { for (Object arg : args) { if (arg instanceof Long l) return String.valueOf(l); @@ -82,6 +92,7 @@ public class DataChangeLogAspect { return null; } + /** 액션 유형 결정: 어노테이션에 명시돼 있으면 그대로, 없으면 메서드 이름 접두어로 추정(INSERT/DELETE/UPDATE). */ private String inferAction(DataChangeLog ann, String methodName) { if (!ann.action().isBlank()) return ann.action(); String n = methodName.toLowerCase(); diff --git a/ga-common/src/main/java/com/ga/common/aop/PermissionAspect.java b/ga-common/src/main/java/com/ga/common/aop/PermissionAspect.java index 8d2e790..96ff8fd 100644 --- a/ga-common/src/main/java/com/ga/common/aop/PermissionAspect.java +++ b/ga-common/src/main/java/com/ga/common/aop/PermissionAspect.java @@ -13,7 +13,10 @@ import org.aspectj.lang.reflect.MethodSignature; import org.springframework.stereotype.Component; /** - * @RequirePermission 처리. 현재 사용자의 (menu, perm) 권한을 검증한다. + * {@link com.ga.common.annotation.RequirePermission} 어노테이션이 붙은 메서드를 가로채 권한을 검증하는 AOP. + * + *

왜 필요한가: 화면/작업별 권한 검사를 컨트롤러마다 직접 쓰지 않고 한 곳에서 일괄 처리하기 위함. + * 메서드 "실행 전(@Before)"에 검사하므로, 권한이 없으면 본 로직이 아예 실행되지 않는다. */ @Slf4j @Aspect @@ -21,17 +24,21 @@ import org.springframework.stereotype.Component; @RequiredArgsConstructor public class PermissionAspect { - private final PermissionChecker permissionChecker; + private final PermissionChecker permissionChecker; // 실제 권한 보유 여부를 DB에서 조회/판단 + // @Before: @RequirePermission 이 붙은 메서드가 호출되기 "직전"에 이 검사 로직이 먼저 실행된다. @Before("@annotation(com.ga.common.annotation.RequirePermission)") public void check(org.aspectj.lang.JoinPoint jp) { + // 로그인하지 않은 사용자(인증 토큰 없음)는 401로 차단. Long userId = SecurityUtil.getCurrentUserId(); if (userId == null) throw new BizException(ErrorCode.UNAUTHORIZED); + // 가로챈 메서드의 어노테이션에서 요구 권한(menu, perm)을 읽는다. MethodSignature sig = (MethodSignature) jp.getSignature(); RequirePermission ann = sig.getMethod().getAnnotation(RequirePermission.class); if (ann == null) return; + // 사용자가 해당 (menu, perm) 권한을 갖고 있지 않으면 403으로 차단. if (!permissionChecker.hasPermission(userId, ann.menu(), ann.perm())) { log.warn("Permission denied: userId={}, menu={}, perm={}", userId, ann.menu(), ann.perm()); throw new BizException(ErrorCode.FORBIDDEN); diff --git a/ga-common/src/main/java/com/ga/common/auth/JwtAuthFilter.java b/ga-common/src/main/java/com/ga/common/auth/JwtAuthFilter.java index 6a0bdae..e4d219f 100644 --- a/ga-common/src/main/java/com/ga/common/auth/JwtAuthFilter.java +++ b/ga-common/src/main/java/com/ga/common/auth/JwtAuthFilter.java @@ -21,54 +21,70 @@ import java.util.List; import java.util.Map; /** - * Authorization: Bearer xxx 헤더에서 JWT 추출 → SecurityContext 설정. + * 모든 요청에서 "Authorization: Bearer xxx" 헤더의 JWT를 꺼내 검증하고, 통과하면 + * 인증 정보를 SecurityContext에 심는 필터. + * + *

왜 필요한가: 이 시스템은 세션이 아니라 토큰(JWT) 기반 무상태(stateless) 인증을 쓴다. + * 컨트롤러가 "현재 누가 요청했는지" 알려면, 요청이 컨트롤러에 닿기 전에 이 필터가 토큰을 해석해 + * 사용자 정보를 SecurityContext에 올려줘야 한다. SecurityConfig에서 스프링 시큐리티 필터 체인에 등록된다. + * + *

OncePerRequestFilter: 한 요청당 정확히 한 번만 실행되도록 보장하는 스프링 기반 필터. + * 토큰이 없으면 인증을 세팅하지 않고 그냥 통과시키며(이후 보호된 URL이면 시큐리티가 401 처리), + * 토큰이 있는데 잘못됐으면 여기서 즉시 JSON 에러로 응답을 끊는다. */ @Slf4j @Component @RequiredArgsConstructor public class JwtAuthFilter extends OncePerRequestFilter { - private static final String HEADER = "Authorization"; - private static final String PREFIX = "Bearer "; + private static final String HEADER = "Authorization"; // 토큰이 실리는 헤더명 + private static final String PREFIX = "Bearer "; // 토큰 앞에 붙는 표준 접두어 - private final JwtTokenProvider tokenProvider; - private final ObjectMapper objectMapper; + private final JwtTokenProvider tokenProvider; // 토큰 파싱/검증 담당 + private final ObjectMapper objectMapper; // 에러 응답을 JSON으로 직렬화 @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String header = request.getHeader(HEADER); + // "Bearer "로 시작하는 헤더가 있을 때만 토큰 인증을 시도한다(없으면 비로그인 상태로 통과). if (header != null && header.startsWith(PREFIX)) { - String token = header.substring(PREFIX.length()); + String token = header.substring(PREFIX.length()); // "Bearer " 뒤의 실제 토큰만 추출 try { + // 토큰을 검증/해석해 안에 담긴 사용자 정보(claims)를 꺼낸다. Claims claims = tokenProvider.parse(token); - Long userId = claims.get("uid", Long.class); - String loginId = claims.getSubject(); + Long userId = claims.get("uid", Long.class); // 발급 시 넣은 사용자 ID + String loginId = claims.getSubject(); // subject = 로그인 아이디 @SuppressWarnings("unchecked") - List roles = (List) claims.getOrDefault("roles", List.of()); + List roles = (List) claims.getOrDefault("roles", List.of()); // 역할 목록 + // 토큰에서 복원한 정보로 인증 주체(LoginUser) 생성. LoginUser user = LoginUser.builder() .userId(userId) .loginId(loginId) .roleCodes(roles) .build(); + // 스프링 시큐리티에 "이 사용자는 인증됨"으로 등록. 이후 컨트롤러/AOP가 이 정보를 참조한다. UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()); SecurityContextHolder.getContext().setAuthentication(auth); } catch (BizException e) { + // 만료/위조 등 알려진 토큰 오류 → 해당 에러코드로 즉시 응답하고 체인 중단. writeError(response, e.getErrorCode()); return; } catch (Exception e) { + // 예상치 못한 파싱 오류 → 토큰 무효로 처리. log.warn("JWT processing error: {}", e.getMessage()); writeError(response, ErrorCode.TOKEN_INVALID); return; } } - chain.doFilter(request, response); + chain.doFilter(request, response); // 다음 필터/컨트롤러로 요청을 넘긴다 } + /** 인증 실패 시 표준 JSON 에러({success,code,message})를 직접 응답에 써준다. */ private void writeError(HttpServletResponse response, ErrorCode ec) throws IOException { response.setStatus(ec.getHttpStatus()); response.setContentType("application/json;charset=UTF-8"); diff --git a/ga-common/src/main/java/com/ga/common/auth/JwtTokenProvider.java b/ga-common/src/main/java/com/ga/common/auth/JwtTokenProvider.java index c3faed6..b6ced27 100644 --- a/ga-common/src/main/java/com/ga/common/auth/JwtTokenProvider.java +++ b/ga-common/src/main/java/com/ga/common/auth/JwtTokenProvider.java @@ -18,23 +18,30 @@ import java.util.List; import java.util.Map; /** - * JWT 발급/검증. + * JWT(JSON Web Token)를 발급하고 검증하는 핵심 유틸 컴포넌트. + * + *

왜 필요한가: 로그인 성공 시 여기서 토큰을 만들어 클라이언트에 주고, 이후 요청마다 + * {@link JwtAuthFilter}가 여기서 토큰을 검증한다. 즉 인증 토큰의 "생성"과 "해석"을 한곳에 모은 것. + * + *

토큰 종류: access(짧은 수명, 실제 API 호출용)와 refresh(긴 수명, access 재발급용) 두 가지. + * 서명 비밀키(secret)와 만료시간(ms)은 application 설정(ga.jwt.*)에서 주입받는다. */ @Slf4j @Component public class JwtTokenProvider { - private final SecretKey key; - private final long accessExpire; - private final long refreshExpire; + private final SecretKey key; // HMAC 서명에 쓰는 비밀키(이 키로 서명/검증) + private final long accessExpire; // access 토큰 만료시간(ms) + private final long refreshExpire; // refresh 토큰 만료시간(ms) + // 생성자 주입: 설정값(@Value)이 없으면 기본값(access 2시간, refresh 7일) 사용. public JwtTokenProvider( @Value("${ga.jwt.secret}") String secret, @Value("${ga.jwt.access-token-expire:7200000}") long accessExpire, @Value("${ga.jwt.refresh-token-expire:604800000}") long refreshExpire ) { byte[] raw = secret.getBytes(StandardCharsets.UTF_8); - // 256비트 미만이면 안전하게 패딩 + // HMAC-SHA256은 키 길이가 최소 256비트(32바이트)여야 한다. 설정값이 짧으면 0으로 패딩해 길이를 맞춘다. if (raw.length < 32) { byte[] padded = new byte[32]; System.arraycopy(raw, 0, padded, 0, raw.length); @@ -45,14 +52,17 @@ public class JwtTokenProvider { this.refreshExpire = refreshExpire; } + /** 로그인 시 발급하는 access 토큰. 사용자 ID/로그인ID/역할을 담는다. */ public String createAccessToken(Long userId, String loginId, List roles) { return create(userId, loginId, roles, accessExpire, "ACCESS"); } + /** access 만료 시 재발급에 쓰는 refresh 토큰. 역할은 담지 않고 수명이 길다. */ public String createRefreshToken(Long userId, String loginId) { return create(userId, loginId, List.of(), refreshExpire, "REFRESH"); } + // 토큰 생성 공통 로직: subject=로그인ID, claims에 uid/roles/type, 발급/만료 시각을 넣고 비밀키로 서명. private String create(Long userId, String loginId, List roles, long expireMs, String type) { Date now = new Date(); return Jwts.builder() @@ -68,17 +78,22 @@ public class JwtTokenProvider { .compact(); } + /** + * 토큰을 비밀키로 검증하고 안의 claims(uid, roles 등)를 꺼낸다. + * 만료/위조 시 일반 예외 대신 업무 예외(BizException)로 바꿔 던져, 상위에서 표준 에러응답으로 처리하게 한다. + */ public Claims parse(String token) { try { return Jwts.parser().verifyWith(key).build() .parseSignedClaims(token).getPayload(); } catch (ExpiredJwtException e) { - throw new BizException(ErrorCode.TOKEN_EXPIRED); + throw new BizException(ErrorCode.TOKEN_EXPIRED); // 만료된 토큰 } catch (JwtException | IllegalArgumentException e) { - throw new BizException(ErrorCode.TOKEN_INVALID); + throw new BizException(ErrorCode.TOKEN_INVALID); // 서명 불일치/형식 오류 등 } } + /** 예외를 던지지 않고 true/false 로만 유효성을 확인하고 싶을 때 사용하는 편의 메서드. */ public boolean isValid(String token) { try { parse(token); @@ -88,6 +103,7 @@ public class JwtTokenProvider { } } + // 만료시간 조회용 getter. 로그인 응답에 "토큰 만료까지 남은 시간"을 함께 내려줄 때 등에 쓰인다. public long getAccessExpire() { return accessExpire; } public long getRefreshExpire() { return refreshExpire; } } diff --git a/ga-common/src/main/java/com/ga/common/auth/LoginUser.java b/ga-common/src/main/java/com/ga/common/auth/LoginUser.java index 000964e..f1845ca 100644 --- a/ga-common/src/main/java/com/ga/common/auth/LoginUser.java +++ b/ga-common/src/main/java/com/ga/common/auth/LoginUser.java @@ -10,25 +10,32 @@ import java.util.Collection; import java.util.List; /** - * SecurityContext 에 들어갈 인증 주체. + * 로그인한 사용자를 나타내는 인증 주체(principal). 스프링 시큐리티의 SecurityContext에 저장되어, + * 어디서든 "현재 누가 요청 중인지"를 꺼낼 수 있게 한다(예: {@code SecurityUtil.getCurrentUserId()}). + * + *

UserDetails 구현: 스프링 시큐리티가 인증 주체로 인식하려면 이 인터페이스를 구현해야 한다. + * 이 시스템은 토큰(JWT) 기반이라 비밀번호 비교 등은 쓰지 않으므로, 계정 상태 메서드들은 모두 true를 반환한다. */ @Getter @Builder public class LoginUser implements UserDetails { - private final Long userId; - private final String loginId; - private final String userName; - private final Long agentId; - private final List roleCodes; + private final Long userId; // 내부 사용자 PK + private final String loginId; // 로그인 아이디 + private final String userName; // 사용자 이름(표시용) + private final Long agentId; // 소속 설계사/대리점 ID(정산 권한 범위 판단 등에 사용) + private final List roleCodes; // 역할 코드 목록(예: ADMIN, MANAGER) + /** 역할 코드를 스프링 시큐리티 권한(GrantedAuthority)으로 변환. 관례상 "ROLE_" 접두어를 붙인다. */ @Override public Collection getAuthorities() { return roleCodes == null ? List.of() : roleCodes.stream().map(r -> new SimpleGrantedAuthority("ROLE_" + r)).toList(); } + // 비밀번호는 토큰 인증에서 쓰지 않으므로 null. username 은 로그인 아이디를 반환. @Override public String getPassword() { return null; } @Override public String getUsername() { return loginId; } + // 아래 4개 계정 상태 플래그: 잠금/만료 검사는 로그인 단계에서 별도로 처리하므로 여기선 모두 정상(true). @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } diff --git a/ga-common/src/main/java/com/ga/common/code/AccountCodeController.java b/ga-common/src/main/java/com/ga/common/code/AccountCodeController.java index e5e4b59..d514571 100644 --- a/ga-common/src/main/java/com/ga/common/code/AccountCodeController.java +++ b/ga-common/src/main/java/com/ga/common/code/AccountCodeController.java @@ -8,6 +8,12 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +/** + * 회계 계정코드 REST API. + *

+ * 신입 메모: 활성 목록 조회는 로그인 사용자 누구나(드롭다운/배치 참조), 관리(전체조회/등록/수정/삭제)는 + * {@code @RequirePermission(SYSTEM_CODE)}로 보호한다. 로직은 {@link AccountCodeService}에 위임. + */ @Tag(name = "계정코드") @RestController @RequestMapping("/api/common/account-codes") diff --git a/ga-common/src/main/java/com/ga/common/code/AccountCodeMapper.java b/ga-common/src/main/java/com/ga/common/code/AccountCodeMapper.java index 2915e9b..71de217 100644 --- a/ga-common/src/main/java/com/ga/common/code/AccountCodeMapper.java +++ b/ga-common/src/main/java/com/ga/common/code/AccountCodeMapper.java @@ -5,6 +5,10 @@ import org.apache.ibatis.annotations.Param; import java.util.List; +/** + * 계정코드 DB 매퍼 (MyBatis). SQL은 동명 XML 매퍼에 정의된다. + * selectActive는 활성만, selectAll은 비활성 포함 전체를 반환. + */ @Mapper public interface AccountCodeMapper { diff --git a/ga-common/src/main/java/com/ga/common/code/AccountCodeService.java b/ga-common/src/main/java/com/ga/common/code/AccountCodeService.java index e7b3196..404f405 100644 --- a/ga-common/src/main/java/com/ga/common/code/AccountCodeService.java +++ b/ga-common/src/main/java/com/ga/common/code/AccountCodeService.java @@ -8,26 +8,37 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +/** + * 회계 계정코드(차변/대변 분류) 조회·관리 서비스. + *

+ * 신입 메모: 정산 원장/배치가 금액을 "어떤 계정으로 잡을지" 참조하는 코드 표다. + * accountType은 DEBIT(차변)/CREDIT(대변)/BOTH(양쪽 사용 가능)를 뜻한다. + * 공통코드와 달리 별도 테이블·키(accountCode 자체가 PK)로 관리한다. + */ @Service @RequiredArgsConstructor public class AccountCodeService { private final AccountCodeMapper mapper; + /** 활성 계정코드 목록 (드롭다운/배치 참조용). accountType으로 필터 가능. */ public List getActiveCodes(String accountType) { return mapper.selectActive(accountType); } + /** 단일 계정코드 조회. 없으면 NOT_FOUND 예외. */ public AccountCodeVO getCode(String accountCode) { AccountCodeVO vo = mapper.selectOne(accountCode); if (vo == null) throw new BizException(ErrorCode.NOT_FOUND); return vo; } + /** 전체 계정코드 목록 (비활성 포함, 관리 화면용). */ public List listAll(String accountType) { return mapper.selectAll(accountType); } + /** 계정코드 신규 등록. 같은 코드가 있으면 중복 예외. */ @Transactional public void create(AccountCodeVO vo) { if (mapper.existsByCode(vo.getAccountCode()) > 0) { @@ -36,12 +47,14 @@ public class AccountCodeService { mapper.insert(vo); } + /** 계정코드 수정. 존재 확인(getCode) 후 갱신. */ @Transactional public void update(AccountCodeVO vo) { getCode(vo.getAccountCode()); mapper.update(vo); } + /** 계정코드 삭제. 존재 확인 후 삭제. */ @Transactional public void delete(String accountCode) { getCode(accountCode); diff --git a/ga-common/src/main/java/com/ga/common/code/AccountCodeVO.java b/ga-common/src/main/java/com/ga/common/code/AccountCodeVO.java index 3bcb071..9526f9b 100644 --- a/ga-common/src/main/java/com/ga/common/code/AccountCodeVO.java +++ b/ga-common/src/main/java/com/ga/common/code/AccountCodeVO.java @@ -4,14 +4,17 @@ import lombok.Data; import java.time.LocalDateTime; +/** + * 회계 계정코드 한 건. accountCode가 PK다. + */ @Data public class AccountCodeVO { - private String accountCode; - private String accountName; - private String accountType; // DEBIT / CREDIT / BOTH - private String isActive; - private Integer sortOrder; - private String description; + private String accountCode; // 계정코드 (PK) + private String accountName; // 계정명 + private String accountType; // DEBIT(차변) / CREDIT(대변) / BOTH(양쪽) + private String isActive; // 활성 여부 'Y'/'N' + private Integer sortOrder; // 정렬 순서 + private String description; // 설명 private LocalDateTime createdAt; private Long createdBy; private LocalDateTime updatedAt; diff --git a/ga-common/src/main/java/com/ga/common/code/CommonCodeController.java b/ga-common/src/main/java/com/ga/common/code/CommonCodeController.java index 5c5bc8f..a48ec96 100644 --- a/ga-common/src/main/java/com/ga/common/code/CommonCodeController.java +++ b/ga-common/src/main/java/com/ga/common/code/CommonCodeController.java @@ -8,6 +8,13 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +/** + * 공통코드 REST API. + *

+ * 신입 메모: 단순 조회({@code GET /{groupCode}})는 로그인만 하면 누구나 드롭다운용으로 호출하지만, + * 그룹/코드의 등록·수정·삭제는 시스템관리 화면 전용이라 {@code @RequirePermission(SYSTEM_CODE)}로 보호한다. + * 실제 로직은 모두 {@link CommonCodeService}에 위임하고, 여기서는 경로/권한/요청-응답 매핑만 한다. + */ @Tag(name = "공통코드") @RestController @RequestMapping("/api/common/codes") diff --git a/ga-common/src/main/java/com/ga/common/code/CommonCodeGroupVO.java b/ga-common/src/main/java/com/ga/common/code/CommonCodeGroupVO.java index e4ff764..309a9c7 100644 --- a/ga-common/src/main/java/com/ga/common/code/CommonCodeGroupVO.java +++ b/ga-common/src/main/java/com/ga/common/code/CommonCodeGroupVO.java @@ -4,14 +4,19 @@ import lombok.Data; import java.time.LocalDateTime; +/** + * 공통코드 그룹 한 건 (코드들을 묶는 분류 단위). + *

+ * 신입 메모: isSystem='Y'이면 시스템 동작에 묶인 보호 그룹이라 수정/삭제가 제한된다. + */ @Data public class CommonCodeGroupVO { - private String groupCode; - private String groupName; - private String description; - private String isSystem; - private String isActive; - private Integer sortOrder; + private String groupCode; // 그룹 코드 (PK) + private String groupName; // 그룹명 + private String description; // 설명 + private String isSystem; // 시스템 보호 그룹 여부 'Y'/'N' (Y면 수정·삭제 잠금) + private String isActive; // 활성 여부 'Y'/'N' + private Integer sortOrder; // 정렬 순서 private LocalDateTime createdAt; private Long createdBy; private LocalDateTime updatedAt; diff --git a/ga-common/src/main/java/com/ga/common/code/CommonCodeMapper.java b/ga-common/src/main/java/com/ga/common/code/CommonCodeMapper.java index e288ffd..ff36b41 100644 --- a/ga-common/src/main/java/com/ga/common/code/CommonCodeMapper.java +++ b/ga-common/src/main/java/com/ga/common/code/CommonCodeMapper.java @@ -5,6 +5,12 @@ import org.apache.ibatis.annotations.Param; import java.util.List; +/** + * 공통코드 그룹/상세코드 DB 매퍼 (MyBatis). SQL은 같은 이름의 XML 매퍼에 정의된다. + *

+ * 신입 메모: selectActiveCodes는 활성(isActive='Y') 코드만 — 드롭다운/캐시용이고, + * selectCodes는 비활성 포함 전체 — 관리 화면용이다. + */ @Mapper public interface CommonCodeMapper { diff --git a/ga-common/src/main/java/com/ga/common/code/CommonCodeService.java b/ga-common/src/main/java/com/ga/common/code/CommonCodeService.java index 90e4fb8..be1fd25 100644 --- a/ga-common/src/main/java/com/ga/common/code/CommonCodeService.java +++ b/ga-common/src/main/java/com/ga/common/code/CommonCodeService.java @@ -17,6 +17,16 @@ import java.util.Optional; * * 캐시 키: commonCode::{groupCode} * 무효화: refreshCache(groupCode) 또는 코드 변경 시 자동. + *

+ * 신입 메모 - 공통코드란: + * "상태=정상/해지", "성별=남/여"처럼 시스템 전반에서 쓰는 분류값을 DB 한 군데에 모아 둔 것이다. + * 코드 그룹(groupCode) 아래에 코드(code)와 코드명(codeName)이 1:N으로 들어간다. + * 화면 드롭다운은 {@link #getCodesByGroup}로 목록을 받고, 엑셀 출력 등은 {@link #getCodeName}으로 + * 저장된 코드값을 사람이 읽는 코드명으로 바꾼다. + *

+ * 거의 안 바뀌는 데이터라 매번 DB를 치지 않도록 {@code @Cacheable}로 Redis에 캐싱하고, + * 코드/그룹을 수정하면 {@code @CacheEvict}나 {@link #refreshCache}로 해당 캐시를 비운다. + * isSystem='Y'인 그룹은 시스템 동작에 묶여 있어 삭제·키 변경이 막혀 있다(코드명/설명만 수정 가능). */ @Slf4j @Service @@ -60,18 +70,21 @@ public class CommonCodeService { log.info("Common code cache evicted: ALL"); } - // ========== 그룹 관리 ========== + // ========== 그룹 관리 (시스템관리 화면용) ========== + /** 코드 그룹 목록 (keyword로 그룹코드/명 부분검색). */ public List listGroups(String keyword) { return mapper.selectGroupList(keyword); } + /** 단일 그룹 조회. 없으면 NOT_FOUND 예외. */ public CommonCodeGroupVO getGroup(String groupCode) { CommonCodeGroupVO g = mapper.selectGroup(groupCode); if (g == null) throw new BizException(ErrorCode.NOT_FOUND); return g; } + /** 그룹 신규 등록. 같은 groupCode가 이미 있으면 중복 예외. */ @Transactional public void createGroup(CommonCodeGroupVO vo) { if (mapper.selectGroup(vo.getGroupCode()) != null) { @@ -80,6 +93,7 @@ public class CommonCodeService { mapper.insertGroup(vo); } + /** 그룹 수정. 시스템 그룹(isSystem='Y')은 잠겨 있어 수정 불가. 수정 후 캐시 무효화. */ @Transactional public void updateGroup(CommonCodeGroupVO vo) { CommonCodeGroupVO g = getGroup(vo.getGroupCode()); @@ -90,6 +104,7 @@ public class CommonCodeService { refreshCache(vo.getGroupCode()); } + /** 그룹 삭제. 시스템 그룹은 잠겨 있어 삭제 불가. 캐시도 함께 비운다. */ @Transactional @CacheEvict(value = "commonCode", key = "#groupCode") public void deleteGroup(String groupCode) { @@ -102,16 +117,19 @@ public class CommonCodeService { // ========== 상세 코드 관리 ========== + /** 그룹에 속한 코드 전체 목록 (비활성 포함, 관리 화면용). */ public List listCodes(String groupCode) { return mapper.selectCodes(groupCode); } + /** 단일 코드 조회. 없으면 NOT_FOUND 예외. */ public CommonCodeVO getCode(Long codeId) { CommonCodeVO c = mapper.selectCode(codeId); if (c == null) throw new BizException(ErrorCode.NOT_FOUND); return c; } + /** 코드 신규 등록. 같은 그룹에 같은 code가 있으면 중복 예외. 등록 후 캐시 무효화. */ @Transactional public void createCode(CommonCodeVO vo) { if (mapper.existsByGroupAndCode(vo.getGroupCode(), vo.getCode()) > 0) { @@ -121,6 +139,7 @@ public class CommonCodeService { refreshCache(vo.getGroupCode()); } + /** 코드 수정. 시스템 그룹이면 식별자(code)·소속 그룹은 못 바꾸고 코드명/설명만 허용. 후 캐시 무효화. */ @Transactional public void updateCode(CommonCodeVO vo) { CommonCodeVO old = getCode(vo.getCodeId()); @@ -135,6 +154,7 @@ public class CommonCodeService { refreshCache(old.getGroupCode()); } + /** 코드 삭제. 시스템 그룹의 코드는 잠겨 있어 삭제 불가. 후 캐시 무효화. */ @Transactional public void deleteCode(Long codeId) { CommonCodeVO c = getCode(codeId); diff --git a/ga-common/src/main/java/com/ga/common/code/CommonCodeVO.java b/ga-common/src/main/java/com/ga/common/code/CommonCodeVO.java index 527fddc..b78c36c 100644 --- a/ga-common/src/main/java/com/ga/common/code/CommonCodeVO.java +++ b/ga-common/src/main/java/com/ga/common/code/CommonCodeVO.java @@ -4,20 +4,26 @@ import lombok.Data; import java.time.LocalDateTime; +/** + * 공통코드 상세 한 건 (그룹 안의 개별 코드 값). + *

+ * 신입 메모: code는 DB/연동에 쓰는 식별값("01"), codeName은 사람이 보는 이름("정상"). + * attr1~3/refCode는 코드별로 추가 부가정보를 담는 자유 칸이다(예: 세율, 연결 코드 등). + */ @Data public class CommonCodeVO { - private Long codeId; - private String groupCode; - private String code; - private String codeName; - private String codeNameEn; - private String codeDesc; - private String attr1; - private String attr2; - private String attr3; - private String refCode; - private Integer sortOrder; - private String isActive; + private Long codeId; // 코드 PK + private String groupCode; // 소속 그룹 코드 + private String code; // 코드값 (저장/연동용 식별자) + private String codeName; // 코드명 (화면 표시용) + private String codeNameEn; // 영문 코드명 + private String codeDesc; // 코드 설명 + private String attr1; // 부가속성1 (코드별 자유 용도) + private String attr2; // 부가속성2 + private String attr3; // 부가속성3 + private String refCode; // 연관 코드 (계층/참조용) + private Integer sortOrder; // 정렬 순서 + private String isActive; // 활성 여부 'Y'/'N' private LocalDateTime createdAt; private Long createdBy; private LocalDateTime updatedAt; diff --git a/ga-common/src/main/java/com/ga/common/config/AsyncConfig.java b/ga-common/src/main/java/com/ga/common/config/AsyncConfig.java index 4ada2e6..a5dc206 100644 --- a/ga-common/src/main/java/com/ga/common/config/AsyncConfig.java +++ b/ga-common/src/main/java/com/ga/common/config/AsyncConfig.java @@ -7,17 +7,24 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.util.concurrent.Executor; +/** + * 비동기 실행 설정. @Async 가 붙은 메서드를 별도 스레드 풀에서 돌리기 위한 구성. + * + *

왜 필요한가: API 접근 로그 저장({@code saveAsync}) 같은 부가 작업은 본 요청 응답을 느리게 하면 안 된다. + * @EnableAsync 로 비동기 기능을 켜고, 전용 스레드 풀(asyncExecutor)을 정의해 부가 작업을 백그라운드로 넘긴다. + */ @Configuration @EnableAsync public class AsyncConfig { + // @Async("asyncExecutor") 로 지정해 사용하는 전용 스레드 풀. @Bean(name = "asyncExecutor") public Executor asyncExecutor() { ThreadPoolTaskExecutor ex = new ThreadPoolTaskExecutor(); - ex.setCorePoolSize(4); - ex.setMaxPoolSize(16); - ex.setQueueCapacity(500); - ex.setThreadNamePrefix("ga-async-"); + ex.setCorePoolSize(4); // 기본 유지 스레드 수 + ex.setMaxPoolSize(16); // 큐가 가득 찰 때 늘릴 수 있는 최대 스레드 수 + ex.setQueueCapacity(500); // 대기 작업 큐 크기(초과분이 생기면 maxPool까지 확장) + ex.setThreadNamePrefix("ga-async-"); // 로그에서 비동기 스레드를 알아보기 쉽게 이름 접두어 지정 ex.initialize(); return ex; } diff --git a/ga-common/src/main/java/com/ga/common/config/MyBatisConfig.java b/ga-common/src/main/java/com/ga/common/config/MyBatisConfig.java index 2bddcfd..42f1a25 100644 --- a/ga-common/src/main/java/com/ga/common/config/MyBatisConfig.java +++ b/ga-common/src/main/java/com/ga/common/config/MyBatisConfig.java @@ -10,10 +10,18 @@ import org.springframework.context.annotation.Configuration; * 모든 com.ga 패키지 하위에서 @Mapper 가 붙은 인터페이스만 스캔. * (common.code / common.menu / common.system / common.notification / common.file / core.mapper.*) */ +/** + * MyBatis 관련 공통 설정. + * + *

@MapperScan: com.ga 하위에서 {@code @Mapper}가 붙은 인터페이스만 찾아 자동으로 빈으로 등록한다. + * 덕분에 각 Mapper 인터페이스에 별도 등록 코드 없이 @Mapper 한 줄만 붙이면 주입받아 쓸 수 있다. + * 또한 감사 필드 자동 채움 인터셉터({@link AuditInterceptor})를 빈으로 등록해 MyBatis 실행에 끼운다. + */ @Configuration @MapperScan(basePackages = "com.ga", annotationClass = Mapper.class) public class MyBatisConfig { + // 빈으로 등록되면 mybatis-spring-boot-starter가 자동으로 SqlSessionFactory의 인터셉터 체인에 추가한다. @Bean public AuditInterceptor auditInterceptor() { return new AuditInterceptor(); diff --git a/ga-common/src/main/java/com/ga/common/config/RedisConfig.java b/ga-common/src/main/java/com/ga/common/config/RedisConfig.java index ddb4b06..a527981 100644 --- a/ga-common/src/main/java/com/ga/common/config/RedisConfig.java +++ b/ga-common/src/main/java/com/ga/common/config/RedisConfig.java @@ -25,6 +25,7 @@ import java.time.Duration; @ConditionalOnBean(RedisConnectionFactory.class) public class RedisConfig { + // 코드에서 Redis에 직접 값을 읽고 쓸 때 사용하는 도구. 키는 문자열, 값은 JSON으로 직렬화한다. @Bean public RedisTemplate redisTemplate(RedisConnectionFactory cf, ObjectMapper objectMapper) { RedisTemplate tpl = new RedisTemplate<>(); @@ -37,11 +38,12 @@ public class RedisConfig { return tpl; } + // @Cacheable 등 스프링 캐시 추상화가 사용할 캐시 매니저. 저장소로 Redis를 쓴다. @Bean public CacheManager cacheManager(RedisConnectionFactory cf, ObjectMapper objectMapper) { RedisCacheConfiguration cfg = RedisCacheConfiguration.defaultCacheConfig() - .entryTtl(Duration.ofHours(1)) - .disableCachingNullValues() + .entryTtl(Duration.ofHours(1)) // 캐시 항목 유효시간 1시간 + .disableCachingNullValues() // null은 캐싱하지 않음(빈 결과 캐싱으로 인한 혼선 방지) .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer( new GenericJackson2JsonRedisSerializer(objectMapper))); diff --git a/ga-common/src/main/java/com/ga/common/config/SimpleCacheConfig.java b/ga-common/src/main/java/com/ga/common/config/SimpleCacheConfig.java index 7d78120..fb5c529 100644 --- a/ga-common/src/main/java/com/ga/common/config/SimpleCacheConfig.java +++ b/ga-common/src/main/java/com/ga/common/config/SimpleCacheConfig.java @@ -17,6 +17,8 @@ import org.springframework.data.redis.connection.RedisConnectionFactory; @ConditionalOnMissingBean(RedisConnectionFactory.class) public class SimpleCacheConfig { + // 자바 메모리(ConcurrentMap) 기반 캐시. "commonCode"(공통코드), "userPerm"(사용자 권한) 두 캐시를 미리 만든다. + // 주의: 인메모리라서 서버 재시작 시 사라지고 여러 서버 간 공유도 안 된다(운영은 Redis 권장). @Bean public CacheManager cacheManager() { return new ConcurrentMapCacheManager("commonCode", "userPerm"); diff --git a/ga-common/src/main/java/com/ga/common/config/SwaggerConfig.java b/ga-common/src/main/java/com/ga/common/config/SwaggerConfig.java index d9104f8..208166f 100644 --- a/ga-common/src/main/java/com/ga/common/config/SwaggerConfig.java +++ b/ga-common/src/main/java/com/ga/common/config/SwaggerConfig.java @@ -8,10 +8,17 @@ import io.swagger.v3.oas.models.security.SecurityScheme; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +/** + * Swagger(OpenAPI) 문서 설정. 실행 중인 API 목록과 모델을 자동 문서화하고 브라우저에서 테스트하게 해준다. + * + *

왜 필요한가: 프런트엔드/협업자가 API 명세를 쉽게 확인하고 호출 테스트하려면 문서 UI가 필요하다. + * 또한 이 시스템은 JWT 인증을 쓰므로, 문서 UI에서 "Authorize" 버튼으로 Bearer 토큰을 넣고 테스트할 수 있도록 + * 보안 스킴(bearerAuth)을 함께 등록한다. (접근 URL은 SecurityConfig에서 공개로 허용됨) + */ @Configuration public class SwaggerConfig { - private static final String SCHEME = "bearerAuth"; + private static final String SCHEME = "bearerAuth"; // 보안 스킴 이름(아래에서 등록/참조) @Bean public OpenAPI openAPI() { diff --git a/ga-common/src/main/java/com/ga/common/config/WebMvcConfig.java b/ga-common/src/main/java/com/ga/common/config/WebMvcConfig.java index d858a4d..32d3a93 100644 --- a/ga-common/src/main/java/com/ga/common/config/WebMvcConfig.java +++ b/ga-common/src/main/java/com/ga/common/config/WebMvcConfig.java @@ -5,13 +5,19 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +/** + * 웹 계층 공통 설정. 현재는 JSON 직렬화 도구인 ObjectMapper 빈을 정의한다. + * + *

왜 필요한가: 응답/요청 JSON 변환에 쓰는 ObjectMapper를 명시적으로 빈 등록해 두면, + * 컨트롤러 응답뿐 아니라 JWT 필터·예외 핸들러·Redis 직렬화 등 여러 곳에서 같은 설정의 매퍼를 주입받아 일관되게 쓸 수 있다. + */ @Configuration public class WebMvcConfig { @Bean public ObjectMapper objectMapper() { return new ObjectMapper() - .registerModule(new JavaTimeModule()) - .findAndRegisterModules(); + .registerModule(new JavaTimeModule()) // LocalDateTime 등 자바8 날짜/시간 타입을 JSON으로 직렬화 가능하게 함 + .findAndRegisterModules(); // 클래스패스에 있는 추가 Jackson 모듈을 자동 등록 } } diff --git a/ga-common/src/main/java/com/ga/common/excel/ExcelColumnInfo.java b/ga-common/src/main/java/com/ga/common/excel/ExcelColumnInfo.java index 3f78072..d551899 100644 --- a/ga-common/src/main/java/com/ga/common/excel/ExcelColumnInfo.java +++ b/ga-common/src/main/java/com/ga/common/excel/ExcelColumnInfo.java @@ -6,6 +6,13 @@ import lombok.Getter; import java.lang.reflect.Field; +/** + * VO의 {@code @ExcelColumn} 필드 하나에서 뽑아낸 엑셀 컬럼 메타정보. + *

+ * 신입 메모: 애너테이션 값(헤더/순서/너비/코드그룹/마스킹 여부 등)을 매번 읽는 대신, + * 한 번 추출해 이 불변 객체에 담아 두고 {@link ExcelService}가 출력/입력에 재사용한다. + * {@code field}는 리플렉션으로 값을 읽고 쓰기 위해 setAccessible(true) 처리된 상태다. + */ @Getter @Builder public class ExcelColumnInfo { @@ -19,6 +26,7 @@ public class ExcelColumnInfo { private final String defaultValue; private final boolean required; + /** 필드와 그 위의 {@code @ExcelColumn} 애너테이션을 합쳐 메타정보 객체를 만든다. */ public static ExcelColumnInfo of(Field field, ExcelColumn ann) { field.setAccessible(true); return ExcelColumnInfo.builder() diff --git a/ga-common/src/main/java/com/ga/common/excel/ExcelColumnParser.java b/ga-common/src/main/java/com/ga/common/excel/ExcelColumnParser.java index 0cee3ec..ecf5659 100644 --- a/ga-common/src/main/java/com/ga/common/excel/ExcelColumnParser.java +++ b/ga-common/src/main/java/com/ga/common/excel/ExcelColumnParser.java @@ -9,11 +9,16 @@ import java.util.List; /** * VO 클래스 → @ExcelColumn 메타정보 추출. + *

+ * 신입 메모: VO에 선언된 필드들을 훑어 {@code @ExcelColumn}이 붙은 것만 골라 + * {@link ExcelColumnInfo} 목록으로 만들고, order 값 오름차순으로 정렬해 엑셀 컬럼 순서를 확정한다. */ public final class ExcelColumnParser { + /** 인스턴스화 금지용 private 생성자 (정적 메서드 모음 클래스). */ private ExcelColumnParser() {} + /** 주어진 VO 타입에서 엑셀 컬럼 메타정보를 order 순으로 추출한다. */ public static List parse(Class clazz) { List list = new ArrayList<>(); for (Field f : clazz.getDeclaredFields()) { diff --git a/ga-common/src/main/java/com/ga/common/excel/ExcelErrorRow.java b/ga-common/src/main/java/com/ga/common/excel/ExcelErrorRow.java index 94fe783..de4a9a1 100644 --- a/ga-common/src/main/java/com/ga/common/excel/ExcelErrorRow.java +++ b/ga-common/src/main/java/com/ga/common/excel/ExcelErrorRow.java @@ -4,15 +4,22 @@ import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +/** + * 엑셀 업로드 중 변환/검증에 실패한 한 행의 정보. + *

+ * 신입 메모: 업로드는 실패한 행이 있어도 멈추지 않으므로, 어느 행(rowNumber)에서 + * 무엇(fieldName/value)이 왜(errorMessage) 틀렸는지 모아 두었다가 사용자에게 돌려준다. + */ @Data @AllArgsConstructor @NoArgsConstructor public class ExcelErrorRow { - private int rowNumber; - private String fieldName; - private String value; + private int rowNumber; // 엑셀 기준 행 번호(1=헤더) + private String fieldName; // 문제가 된 컬럼명 (없을 수 있음) + private String value; // 문제가 된 입력값 (없을 수 있음) private String errorMessage; + /** 컬럼/값 특정 없이 행 번호와 오류 메시지만으로 만드는 간이 생성자. */ public ExcelErrorRow(int rowNumber, String errorMessage) { this.rowNumber = rowNumber; this.errorMessage = errorMessage; diff --git a/ga-common/src/main/java/com/ga/common/excel/ExcelImportResult.java b/ga-common/src/main/java/com/ga/common/excel/ExcelImportResult.java index 6a67d3b..a16cf50 100644 --- a/ga-common/src/main/java/com/ga/common/excel/ExcelImportResult.java +++ b/ga-common/src/main/java/com/ga/common/excel/ExcelImportResult.java @@ -5,6 +5,12 @@ import lombok.Data; import java.util.ArrayList; import java.util.List; +/** + * 엑셀 업로드 결과 집계. + *

+ * 신입 메모: 처리한 총건수/성공/실패/스킵 카운트와 실패 행 목록을 담아 화면에 돌려준다. + * errorFileId는 실패 행만 모아 다시 내려받게 만든 오류 엑셀 파일의 식별자다(있을 때). + */ @Data public class ExcelImportResult { private int totalCount; @@ -14,7 +20,9 @@ public class ExcelImportResult { private String errorFileId; private List errors = new ArrayList<>(); + /** 성공 n건을 누적(성공/총건수 동시 증가). */ public void addSuccess(int n) { successCount += n; totalCount += n; } + /** 실패 행을 목록에 추가하고 오류/총건수를 증가. */ public void addError(ExcelErrorRow row) { errors.add(row); errorCount++; diff --git a/ga-common/src/main/java/com/ga/common/excel/ExcelService.java b/ga-common/src/main/java/com/ga/common/excel/ExcelService.java index 0ce84a0..9c46a32 100644 --- a/ga-common/src/main/java/com/ga/common/excel/ExcelService.java +++ b/ga-common/src/main/java/com/ga/common/excel/ExcelService.java @@ -32,6 +32,16 @@ import java.util.function.Supplier; * - exportLargeExcel: SXSSFWorkbook + MyBatis Cursor로 100만건 다운로드 * - importLargeExcel: xlsx-streamer(SAX) + 배치 INSERT로 70만건 업로드 * - exportTemplate: 업로드 양식 (헤더 + 코드그룹 드롭다운) + *

+ * 신입 메모 - 왜 이렇게 복잡한가: + * 수십만 건을 한 번에 메모리에 올리면 OutOfMemory가 난다. 그래서 + * (1) 다운로드는 SXSSF(작성한 행을 디스크로 흘려보내는 POI 스트리밍)와 MyBatis Cursor + * (DB 결과를 한 행씩 끌어오는 커서)를 짝지어 메모리를 일정하게 유지하고, + * (2) 업로드는 xlsx-streamer(SAX 방식, 한 행씩 읽기)로 읽어 batchSize만큼 모이면 + * batchConsumer로 한꺼번에 INSERT한다. + *

+ * VO의 어떤 필드를 어느 컬럼으로 내보낼지는 {@code @ExcelColumn} 애너테이션으로 선언하고, + * {@link ExcelColumnParser}가 그 메타정보를 읽어 순서/헤더/마스킹/코드변환을 처리한다. */ @Slf4j @Service @@ -43,6 +53,16 @@ public class ExcelService { /* ========================================================= 다운로드 (Export) — SXSSF + Cursor 스트리밍 ========================================================= */ + /** + * 대용량 데이터를 xlsx로 스트리밍 다운로드한다. + * + * @param clazz 행으로 내보낼 VO 타입 ({@code @ExcelColumn}이 붙은 필드만 출력) + * @param cursorSupplier 호출 시점에 DB 커서를 열어 주는 람다 (한 행씩 끌어옴 → 메모리 절약) + * @param response HTTP 응답 (여기에 직접 엑셀 바이트를 흘려보낸다) + *

+ * 신입 메모: 트랜잭션 안에서 커서를 열어야(=@Transactional) 끝까지 한 행씩 읽을 수 있다. + * 이 어노테이션이 빠지면 export 도중 커넥션이 닫혀 500 오류가 났던 전례가 있다. + */ @Transactional(readOnly = true) public void exportLargeExcel(String fileName, Class clazz, Supplier> cursorSupplier, @@ -96,6 +116,14 @@ public class ExcelService { /* ========================================================= 업로드 (Import) — SAX 스트리밍 + 배치 INSERT ========================================================= */ + /** + * 업로드된 xlsx를 한 행씩 읽어 VO로 변환하고, batchSize만큼 모이면 batchConsumer로 넘긴다. + * + * @param batchSize 이 개수만큼 모이면 한 번에 INSERT (메모리/성능 균형점) + * @param batchConsumer 모인 VO 묶음을 실제로 DB에 저장하는 람다 (도메인마다 다름) + * @return 총건수/성공건수/오류행 목록을 담은 결과. 한 행이 깨져도 전체를 멈추지 않고 + * 해당 행만 {@link ExcelImportResult#addError} 로 기록하고 계속 진행한다. + */ public ExcelImportResult importLargeExcel(MultipartFile file, Class clazz, int batchSize, Consumer> batchConsumer) { ExcelImportResult result = new ExcelImportResult(); @@ -172,6 +200,11 @@ public class ExcelService { } } + /** + * 셀 하나에 값을 쓴다. 컬럼 설정(코드그룹/마스킹)과 값 타입에 따라 표현을 바꾼다. + * 신입 메모: DB에는 코드값("01")이 저장돼 있어도 엑셀에는 코드명("정상")으로 보여주고, + * 주민번호 같은 민감정보는 마스킹해서 내보낸다. + */ private void writeCell(Cell cell, Object value, ExcelColumnInfo info) { if (value == null) { cell.setBlank(); return; } @@ -195,6 +228,10 @@ public class ExcelService { else cell.setCellValue(value.toString()); } + /** + * 엑셀 한 행을 VO 한 개로 변환한다. 빈 셀이면 기본값을 채우고, 기본값도 없는데 + * 필수(required) 컬럼이면 예외를 던져 해당 행을 오류로 처리하게 한다. + */ private T parseRow(Row row, Class clazz, List columns) throws Exception { T item = clazz.getDeclaredConstructor().newInstance(); for (int i = 0; i < columns.size(); i++) { @@ -211,6 +248,11 @@ public class ExcelService { return item; } + /** + * 셀 하나를 VO 필드 타입에 맞는 값으로 읽어 변환한다. + * 신입 메모: 엑셀 셀에는 "타입"(문자/숫자/날짜/불리언/수식)이 있어 분기 처리하며, + * 변환 실패 시 예외 대신 null을 돌려준다(필수 검증은 호출부 parseRow가 담당). + */ private Object readCell(Cell cell, ExcelColumnInfo info) { if (cell == null) return null; Class type = info.getField().getType(); @@ -237,6 +279,7 @@ public class ExcelService { } } + /** 문자열을 대상 필드 타입으로 변환(숫자는 콤마 제거, 날짜는 ISO 파싱, enum은 이름 매칭). 빈 값은 null. */ @SuppressWarnings({"unchecked", "rawtypes"}) private Object convertString(String s, Class type) { if (s == null) return null; @@ -251,6 +294,7 @@ public class ExcelService { return s; } + /** 다운로드 응답 헤더 설정. 한글 파일명이 깨지지 않도록 UTF-8로 URL 인코딩한다. */ private void setExcelResponseHeader(HttpServletResponse response, String fileName) { response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); String encoded = URLEncoder.encode(fileName + ".xlsx", StandardCharsets.UTF_8).replace("+", "%20"); diff --git a/ga-common/src/main/java/com/ga/common/exception/BizException.java b/ga-common/src/main/java/com/ga/common/exception/BizException.java index 4c406fb..0759f36 100644 --- a/ga-common/src/main/java/com/ga/common/exception/BizException.java +++ b/ga-common/src/main/java/com/ga/common/exception/BizException.java @@ -3,32 +3,43 @@ package com.ga.common.exception; import lombok.Getter; /** - * 업무 예외. 서비스에서 throw new BizException(ErrorCode.XXX) 형태로 사용. + * 업무(비즈니스) 예외. "정상적으로 처리 가능한 실패"(예: 이미 확정된 정산, 권한 없음, 잔액 부족)를 표현한다. + * + *

왜 필요한가: 서비스 로직에서 {@code throw new BizException(ErrorCode.XXX)} 한 줄로 실패를 알리면, + * {@link GlobalExceptionHandler}가 이를 잡아 정해진 HTTP 상태/코드/메시지의 표준 JSON 응답으로 변환한다. + * 즉 "어디서 어떻게 실패했는가"와 "사용자에게 어떻게 보여줄까"를 분리하는 장치다. + * + *

RuntimeException 상속: 체크 예외가 아니라서 throws 선언 없이 어디서든 던질 수 있고, + * 스프링 트랜잭션은 기본적으로 RuntimeException 발생 시 자동 롤백한다. */ @Getter public class BizException extends RuntimeException { - private final ErrorCode errorCode; - private final String customMessage; + private final ErrorCode errorCode; // 어떤 종류의 실패인지(코드/HTTP상태/기본메시지 보유) + private final String customMessage; // 기본 메시지 대신 보여줄 상황별 메시지(없으면 null) + /** 에러코드만으로 던질 때. 메시지는 ErrorCode의 기본 메시지를 사용. */ public BizException(ErrorCode errorCode) { super(errorCode.getMessage()); this.errorCode = errorCode; this.customMessage = null; } + /** 상황에 맞는 구체 메시지를 직접 지정해 던질 때(기본 메시지 대신 사용). */ public BizException(ErrorCode errorCode, String customMessage) { super(customMessage); this.errorCode = errorCode; this.customMessage = customMessage; } + /** 하위 원인 예외(cause)를 함께 보존해 던질 때. 로그에 원인 스택을 남기고 싶을 때 사용. */ public BizException(ErrorCode errorCode, Throwable cause) { super(errorCode.getMessage(), cause); this.errorCode = errorCode; this.customMessage = null; } + /** 사용자에게 보여줄 최종 메시지: customMessage가 있으면 그것을, 없으면 에러코드 기본 메시지를 반환. */ public String getDisplayMessage() { return customMessage != null ? customMessage : errorCode.getMessage(); } diff --git a/ga-common/src/main/java/com/ga/common/exception/ErrorCode.java b/ga-common/src/main/java/com/ga/common/exception/ErrorCode.java index 2f07e22..e2fcc7d 100644 --- a/ga-common/src/main/java/com/ga/common/exception/ErrorCode.java +++ b/ga-common/src/main/java/com/ga/common/exception/ErrorCode.java @@ -4,15 +4,23 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; /** - * 시스템 표준 에러 코드. + * 시스템 전체에서 공통으로 쓰는 표준 에러 코드 모음(enum). + * + *

왜 필요한가: 실패 상황마다 "코드 / HTTP 상태 / 사용자 메시지"를 한 곳에 모아두면, + * 서비스는 {@code throw new BizException(ErrorCode.XXX)} 만 하면 되고 응답 형식은 자동으로 일관된다. + * 프런트엔드도 code 값으로 분기 처리할 수 있다. + * + *

각 항목은 (코드 문자열, HTTP 상태, 사용자 메시지) 세 값을 가진다. + * 코드 분류 규칙: * - 0xxx: 성공 * - Exxx: 공통 에러 * - Axxx: 인증/권한 * - Bxxx: 업무 에러 * - Cxxx: 외부 연동 + * (Fxxx: 파일/엑셀) */ @Getter -@RequiredArgsConstructor +@RequiredArgsConstructor // 아래 final 필드(code/httpStatus/message)를 받는 생성자를 롬복이 자동 생성 public enum ErrorCode { SUCCESS ("0000", 200, "성공"), @@ -60,7 +68,7 @@ public enum ErrorCode { // 외부 연동 EXTERNAL_API_FAIL ("C001", 502, "외부 시스템 호출에 실패했습니다"); - private final String code; - private final int httpStatus; - private final String message; + private final String code; // 클라이언트가 식별에 쓰는 코드 문자열(예: "B001") + private final int httpStatus; // 응답으로 내려줄 HTTP 상태코드(예: 400, 403, 500) + private final String message; // 사용자에게 보여줄 기본 한국어 메시지 } diff --git a/ga-common/src/main/java/com/ga/common/exception/GlobalExceptionHandler.java b/ga-common/src/main/java/com/ga/common/exception/GlobalExceptionHandler.java index c2c3b38..b269c3e 100644 --- a/ga-common/src/main/java/com/ga/common/exception/GlobalExceptionHandler.java +++ b/ga-common/src/main/java/com/ga/common/exception/GlobalExceptionHandler.java @@ -19,18 +19,27 @@ import java.util.LinkedHashMap; import java.util.Map; /** - * 전역 예외 처리. 모든 예외를 ApiResponse 표준 응답으로 변환. + * 전역 예외 처리기. 컨트롤러 어디서든 예외가 터지면 여기로 모여서 표준 {@link ApiResponse} JSON으로 변환된다. + * + *

왜 필요한가: 각 컨트롤러가 try-catch로 직접 에러를 다루면 응답 형식이 제각각이 된다. + * @RestControllerAdvice 로 한 곳에 모아두면, 모든 API가 동일한 {success,code,message} 형식과 + * 올바른 HTTP 상태코드로 응답하게 되어 프런트엔드 처리가 단순해진다. + * + *

각 @ExceptionHandler 메서드는 특정 예외 타입을 받아 적절한 ErrorCode로 매핑한다. + * 마지막 handleAll(Exception)은 위에서 잡지 못한 모든 예외의 안전망(500)이다. */ @Slf4j @RestControllerAdvice public class GlobalExceptionHandler { + /** 우리가 의도적으로 던진 업무 예외 → 그 안의 에러코드/메시지를 그대로 응답으로 변환. */ @ExceptionHandler(BizException.class) public ResponseEntity> handleBiz(BizException e, HttpServletRequest req) { log.warn("BizException at {} : {} - {}", req.getRequestURI(), e.getErrorCode(), e.getDisplayMessage()); return build(e.getErrorCode(), e.getDisplayMessage()); } + /** @Valid 검증 실패(요청 본문 필드 오류) → 400. 필드별 메시지를 모은다. */ @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity>> handleValidation(MethodArgumentNotValidException e) { Map fieldErrors = new LinkedHashMap<>(); @@ -42,50 +51,59 @@ public class GlobalExceptionHandler { .body(ApiResponse.>fail(ec.getCode(), ec.getMessage())); } + /** 파라미터 타입 불일치(예: 숫자 자리에 문자) → 400. */ @ExceptionHandler(MethodArgumentTypeMismatchException.class) public ResponseEntity> handleTypeMismatch(MethodArgumentTypeMismatchException e) { return build(ErrorCode.INVALID_PARAMETER, "파라미터 [" + e.getName() + "] 타입이 올바르지 않습니다"); } + /** 필수 요청 파라미터 누락 → 400. */ @ExceptionHandler(MissingServletRequestParameterException.class) public ResponseEntity> handleMissingParam(MissingServletRequestParameterException e) { return build(ErrorCode.INVALID_PARAMETER, "필수 파라미터 [" + e.getParameterName() + "] 가 누락되었습니다"); } + /** 허용되지 않는 HTTP 메서드(예: GET 전용 URL에 POST) → 405. */ @ExceptionHandler(HttpRequestMethodNotSupportedException.class) public ResponseEntity> handleMethod(HttpRequestMethodNotSupportedException e) { return build(ErrorCode.METHOD_NOT_ALLOWED, ErrorCode.METHOD_NOT_ALLOWED.getMessage()); } + /** DB 유니크 제약 위반(중복 키) → 409. 서비스에서 일일이 검사 안 해도 여기서 안전하게 잡힌다. */ @ExceptionHandler(DuplicateKeyException.class) public ResponseEntity> handleDup(DuplicateKeyException e) { log.warn("DuplicateKeyException: {}", e.getMessage()); return build(ErrorCode.DUPLICATE_DATA, ErrorCode.DUPLICATE_DATA.getMessage()); } + /** 스프링 시큐리티 인가 실패(권한 없음) → 403. */ @ExceptionHandler(AccessDeniedException.class) public ResponseEntity> handleForbidden(AccessDeniedException e) { return build(ErrorCode.FORBIDDEN, ErrorCode.FORBIDDEN.getMessage()); } + /** 스프링 시큐리티 인증 실패(로그인 안 됨) → 401. */ @ExceptionHandler(AuthenticationException.class) public ResponseEntity> handleAuth(AuthenticationException e) { return build(ErrorCode.UNAUTHORIZED, ErrorCode.UNAUTHORIZED.getMessage()); } + /** 잘못된 인자 → 400. 예외 메시지를 그대로 사용자에게 전달. */ @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity> handleIllegal(IllegalArgumentException e) { return build(ErrorCode.BAD_REQUEST, e.getMessage()); } + /** 위에서 잡지 못한 모든 예외의 최종 안전망 → 500. 원인은 서버 로그에 error 레벨로 남긴다. */ @ExceptionHandler(Exception.class) public ResponseEntity> handleAll(Exception e, HttpServletRequest req) { log.error("Unhandled exception at {}", req.getRequestURI(), e); return build(ErrorCode.INTERNAL_ERROR, ErrorCode.INTERNAL_ERROR.getMessage()); } + /** 공통 응답 빌더: 에러코드의 HTTP 상태 + 표준 실패 JSON(ApiResponse.fail)을 만들어 반환. */ private ResponseEntity> build(ErrorCode ec, String msg) { return ResponseEntity.status(ec.getHttpStatus()) .body(ApiResponse.fail(ec.getCode(), msg)); diff --git a/ga-common/src/main/java/com/ga/common/file/FileController.java b/ga-common/src/main/java/com/ga/common/file/FileController.java index e2ca3ad..2a72188 100644 --- a/ga-common/src/main/java/com/ga/common/file/FileController.java +++ b/ga-common/src/main/java/com/ga/common/file/FileController.java @@ -18,6 +18,12 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.List; +/** + * 파일 업로드/다운로드 REST API. + *

+ * 신입 메모: download는 ApiResponse(JSON)가 아니라 응답 스트림에 파일 바이트를 직접 흘려보낸다. + * 한글 파일명이 깨지지 않도록 Content-Disposition을 UTF-8로 인코딩한다. + */ @Tag(name = "파일") @Slf4j @RestController diff --git a/ga-common/src/main/java/com/ga/common/file/FileMapper.java b/ga-common/src/main/java/com/ga/common/file/FileMapper.java index 259a6b9..13a13e8 100644 --- a/ga-common/src/main/java/com/ga/common/file/FileMapper.java +++ b/ga-common/src/main/java/com/ga/common/file/FileMapper.java @@ -5,6 +5,9 @@ import org.apache.ibatis.annotations.Param; import java.util.List; +/** + * 파일 메타데이터 DB 매퍼 (MyBatis). 실제 파일 바이트는 디스크에, 여기는 메타만 저장한다. + */ @Mapper public interface FileMapper { int insert(FileVO vo); diff --git a/ga-common/src/main/java/com/ga/common/file/FileService.java b/ga-common/src/main/java/com/ga/common/file/FileService.java index c8551c0..36a6267 100644 --- a/ga-common/src/main/java/com/ga/common/file/FileService.java +++ b/ga-common/src/main/java/com/ga/common/file/FileService.java @@ -20,6 +20,12 @@ import java.util.UUID; /** * 파일 업로드/다운로드. 로컬 파일시스템 저장 (S3 등으로 교체 가능한 구조). + *

+ * 신입 메모 - 저장 구조: + * 업로드한 파일은 원본 이름 대신 UUID로 된 "저장명"으로 저장한다(이름 충돌·한글/특수문자 문제 방지). + * 실제 경로는 {@code 업로드루트/yyyy/MM/dd/UUID.ext}처럼 날짜 폴더로 나눠 한 폴더에 파일이 몰리지 않게 한다. + * DB({@link FileVO})에는 원본명·저장명·경로·크기 등 메타데이터만 적재하고, 다운로드 시 이 메타로 실제 파일을 찾아 내려준다. + * 삭제는 DB에 삭제 표시만 하고(soft delete) 물리 파일 삭제는 배치가 따로 처리한다. */ @Slf4j @Service @@ -31,6 +37,10 @@ public class FileService { @Value("${ga.file.upload-dir:./uploads}") private String uploadDir; + /** + * 업로드된 MultipartFile을 저장하고 메타데이터(FileVO)를 DB에 적재한다. + * @param fileGroup 첨부 묶음 분류값 (어느 업무의 첨부인지 구분) + */ @Transactional public FileVO upload(MultipartFile file, String fileGroup) { if (file == null || file.isEmpty()) { @@ -98,6 +108,7 @@ public class FileService { } } + /** 파일 메타 조회 + 다운로드 횟수 증가. 실제 바이트 전송은 컨트롤러가 경로로 수행. 없으면 FILE_NOT_FOUND. */ public FileVO download(Long fileId) { FileVO vo = mapper.selectById(fileId); if (vo == null) throw new BizException(ErrorCode.FILE_NOT_FOUND); @@ -105,10 +116,12 @@ public class FileService { return vo; } + /** 특정 그룹(업무 묶음)에 속한 파일 목록 조회. */ public List listByGroup(String fileGroup) { return mapper.selectByGroup(fileGroup); } + /** 파일 삭제 표시(soft delete). 실제 디스크 파일 삭제는 배치가 처리. */ @Transactional public void delete(Long fileId) { FileVO vo = mapper.selectById(fileId); @@ -117,6 +130,7 @@ public class FileService { // 물리 삭제는 배치로 처리 } + /** 파일명에서 확장자를 소문자로 추출 (없으면 빈 문자열). */ private String extension(String name) { int idx = name.lastIndexOf('.'); return idx >= 0 ? name.substring(idx + 1).toLowerCase() : ""; diff --git a/ga-common/src/main/java/com/ga/common/file/FileVO.java b/ga-common/src/main/java/com/ga/common/file/FileVO.java index 84d6bd0..e640b22 100644 --- a/ga-common/src/main/java/com/ga/common/file/FileVO.java +++ b/ga-common/src/main/java/com/ga/common/file/FileVO.java @@ -4,18 +4,21 @@ import lombok.Data; import java.time.LocalDateTime; +/** + * 파일 메타데이터 한 건. 실제 파일은 filePath의 디스크 위치에 저장된다. + */ @Data public class FileVO { - private Long fileId; - private String fileGroup; - private String originalName; - private String storedName; - private String filePath; - private Long fileSize; - private String contentType; - private String extension; - private Integer downloadCount; - private String isDeleted; + private Long fileId; // 파일 PK + private String fileGroup; // 첨부 묶음 분류 (어느 업무의 첨부인지) + private String originalName; // 사용자가 올린 원본 파일명 (다운로드 시 보여줄 이름) + private String storedName; // 실제 저장명 (UUID 기반, 충돌 방지) + private String filePath; // 디스크 절대 경로 + private Long fileSize; // 바이트 크기 + private String contentType; // MIME 타입 + private String extension; // 확장자(소문자) + private Integer downloadCount; // 다운로드 횟수 + private String isDeleted; // 삭제 표시 'Y'/'N' (soft delete) private LocalDateTime createdAt; - private Long createdBy; + private Long createdBy; // 업로더 user_id } diff --git a/ga-common/src/main/java/com/ga/common/menu/MenuController.java b/ga-common/src/main/java/com/ga/common/menu/MenuController.java index b0d9684..2e459bb 100644 --- a/ga-common/src/main/java/com/ga/common/menu/MenuController.java +++ b/ga-common/src/main/java/com/ga/common/menu/MenuController.java @@ -8,6 +8,12 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +/** + * 메뉴 REST API. + *

+ * 신입 메모: {@code GET /my}는 로그인 사용자의 사이드바 메뉴(권한 포함)라 누구나 호출하고, + * 나머지 트리 조회·CRUD는 시스템관리 화면 전용이라 {@code @RequirePermission(SYSTEM_MENU)}로 보호한다. + */ @Tag(name = "메뉴") @RestController @RequestMapping("/api/menus") diff --git a/ga-common/src/main/java/com/ga/common/menu/MenuMapper.java b/ga-common/src/main/java/com/ga/common/menu/MenuMapper.java index 6b70fe1..6f198f8 100644 --- a/ga-common/src/main/java/com/ga/common/menu/MenuMapper.java +++ b/ga-common/src/main/java/com/ga/common/menu/MenuMapper.java @@ -6,6 +6,10 @@ import org.apache.ibatis.annotations.Param; import java.util.List; import java.util.Map; +/** + * 메뉴/권한 DB 매퍼 (MyBatis). SQL은 동명 XML 매퍼에 정의된다. + * selectUserPermissions는 (menuId, permCode) 행들을 돌려주며, 서비스가 메뉴별로 묶는다. + */ @Mapper public interface MenuMapper { diff --git a/ga-common/src/main/java/com/ga/common/menu/MenuService.java b/ga-common/src/main/java/com/ga/common/menu/MenuService.java index b0e0159..ee7ca1d 100644 --- a/ga-common/src/main/java/com/ga/common/menu/MenuService.java +++ b/ga-common/src/main/java/com/ga/common/menu/MenuService.java @@ -11,13 +11,23 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +/** + * 메뉴 조회·관리 서비스. + *

+ * 신입 메모 - 메뉴와 권한의 관계: + * 메뉴는 부모-자식이 있는 트리(parentMenuId로 연결)다. 사용자가 로그인하면 그 사람이 접근 가능한 + * 메뉴만 골라({@link #getMyMenus}) 좌측 사이드바를 그린다. 이때 메뉴마다 그 사용자가 가진 + * 권한 코드(READ/CREATE/UPDATE/DELETE 등)를 함께 실어 보내, 화면에서 버튼 노출 여부를 정한다. + * DB는 평면(flat) 목록으로 주므로 {@link MenuTreeBuilder}가 트리로 조립한다. + * 관리자용 전체 트리({@link #getMenuTree})는 권한 표시 없이 모든 활성 메뉴를 보여준다. + */ @Service @RequiredArgsConstructor public class MenuService { private final MenuMapper mapper; - /** 현재 로그인 사용자의 메뉴 트리 (권한 정보 포함) */ + /** 현재 로그인 사용자의 메뉴 트리 (권한 정보 포함). 미로그인 시 UNAUTHORIZED. */ public List getMyMenus() { Long userId = SecurityUtil.getCurrentUserId(); if (userId == null) throw new BizException(ErrorCode.UNAUTHORIZED); @@ -27,17 +37,19 @@ public class MenuService { return MenuTreeBuilder.build(flat, permissions); } - /** 전체 메뉴 트리 (관리자) */ + /** 전체 메뉴 트리 (관리자용, 권한 표시 없음). */ public List getMenuTree() { return MenuTreeBuilder.build(mapper.selectAllActive(), Map.of()); } + /** 단일 메뉴 조회. 없으면 NOT_FOUND. */ public MenuVO getById(Long menuId) { MenuVO m = mapper.selectById(menuId); if (m == null) throw new BizException(ErrorCode.NOT_FOUND); return m; } + /** 메뉴 신규 등록. menuCode 중복이면 예외. 생성된 menuId 반환. */ @Transactional public Long create(MenuVO vo) { if (mapper.selectByCode(vo.getMenuCode()) != null) { @@ -47,6 +59,7 @@ public class MenuService { return vo.getMenuId(); } + /** 메뉴 수정. 존재 확인 후 갱신. */ @Transactional public void update(Long menuId, MenuVO vo) { getById(menuId); @@ -54,6 +67,7 @@ public class MenuService { mapper.update(vo); } + /** 메뉴 삭제. 하위 메뉴가 있으면 먼저 지워야 하므로 DEPENDENT_DATA 예외. */ @Transactional public void delete(Long menuId) { if (mapper.countChildren(menuId) > 0) { @@ -62,6 +76,10 @@ public class MenuService { mapper.deleteById(menuId); } + /** + * 사용자의 권한 행들을 (menuId → permCode 목록) 맵으로 묶는다. + * DB는 한 행에 (menuId, permCode) 하나씩 주므로, 같은 메뉴의 여러 권한을 한 리스트로 모은다. + */ private Map> loadPermissionsByMenuId(Long userId) { Map> result = new HashMap<>(); for (Map row : mapper.selectUserPermissions(userId)) { diff --git a/ga-common/src/main/java/com/ga/common/menu/MenuTreeBuilder.java b/ga-common/src/main/java/com/ga/common/menu/MenuTreeBuilder.java index a9fb8d9..5e402ad 100644 --- a/ga-common/src/main/java/com/ga/common/menu/MenuTreeBuilder.java +++ b/ga-common/src/main/java/com/ga/common/menu/MenuTreeBuilder.java @@ -4,11 +4,21 @@ import java.util.*; /** * 평면 메뉴 목록 → 트리 구조 변환. + *

+ * 신입 메모: DB는 메뉴를 부모 정보(parentMenuId)만 가진 일렬 목록으로 준다. + * 화면 사이드바는 계층 트리가 필요하므로, 여기서 (1) 각 메뉴를 응답 객체로 바꾸고 + * (2) parentMenuId를 따라 자식을 부모의 children에 끼워 넣은 뒤 (3) sortOrder로 정렬한다. + * 부모를 못 찾은 메뉴(고아)는 루트로 올려 누락되지 않게 한다. */ public final class MenuTreeBuilder { + /** 인스턴스화 금지용 private 생성자 (정적 메서드 모음 클래스). */ private MenuTreeBuilder() {} + /** + * 평면 목록을 트리로 조립한다. + * @param permissionsByMenuId 메뉴별 사용자 권한 코드 맵 (관리자 전체트리는 빈 맵을 넘김) + */ public static List build(List flat, Map> permissionsByMenuId) { Map map = new LinkedHashMap<>(); // VO → Resp 변환 @@ -47,6 +57,7 @@ public final class MenuTreeBuilder { return roots; } + /** 같은 레벨 형제들을 sortOrder 오름차순(null은 뒤)으로 정렬하고, 자식들도 재귀로 정렬. */ private static void sortRecursive(List nodes) { nodes.sort(Comparator.comparing(MenuResp::getSortOrder, Comparator.nullsLast(Integer::compareTo))); for (MenuResp n : nodes) sortRecursive(n.getChildren()); diff --git a/ga-common/src/main/java/com/ga/common/menu/MenuVO.java b/ga-common/src/main/java/com/ga/common/menu/MenuVO.java index 27aabd6..144acd4 100644 --- a/ga-common/src/main/java/com/ga/common/menu/MenuVO.java +++ b/ga-common/src/main/java/com/ga/common/menu/MenuVO.java @@ -2,19 +2,22 @@ package com.ga.common.menu; import lombok.Data; +/** + * 메뉴 한 건 (DB 테이블 매핑용). 트리 응답은 {@link MenuResp}를 따로 쓴다. + */ @Data public class MenuVO { - private Long menuId; - private Long parentMenuId; - private String menuCode; - private String menuName; - private String menuType; // DIRECTORY / PAGE / LINK - private String menuPath; - private String menuIcon; - private String componentPath; - private Integer menuLevel; - private Integer sortOrder; - private String isVisible; - private String isActive; - private String description; + private Long menuId; // 메뉴 PK + private Long parentMenuId; // 부모 메뉴 PK (null이면 최상위) + private String menuCode; // 메뉴 코드 (권한 매핑 키, 예: SYSTEM_MENU) + private String menuName; // 메뉴명 + private String menuType; // DIRECTORY(폴더) / PAGE(화면) / LINK(외부링크) + private String menuPath; // 라우팅 경로 (프론트 URL) + private String menuIcon; // 아이콘 + private String componentPath; // 매핑되는 프론트 컴포넌트 경로 + private Integer menuLevel; // 트리 깊이 + private Integer sortOrder; // 같은 레벨 내 정렬 순서 + private String isVisible; // 사이드바 노출 여부 'Y'/'N' + private String isActive; // 활성 여부 'Y'/'N' + private String description; // 설명 } diff --git a/ga-common/src/main/java/com/ga/common/menu/PermissionChecker.java b/ga-common/src/main/java/com/ga/common/menu/PermissionChecker.java index e04ac57..604efc1 100644 --- a/ga-common/src/main/java/com/ga/common/menu/PermissionChecker.java +++ b/ga-common/src/main/java/com/ga/common/menu/PermissionChecker.java @@ -4,6 +4,13 @@ import lombok.RequiredArgsConstructor; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Component; +/** + * "이 사용자가 이 메뉴에서 이 동작을 할 권한이 있는가?"를 판정하는 컴포넌트. + *

+ * 신입 메모: {@code @RequirePermission} 애너테이션이 붙은 API가 호출될 때 이 클래스가 불려 + * 권한을 확인한다. 매 요청마다 DB를 치지 않도록 (userId, menuCode, permCode) 조합 결과를 + * Redis에 캐싱하며, 결과가 false면 캐싱하지 않아(unless) 권한 부여 즉시 반영되게 한다. + */ @Component @RequiredArgsConstructor public class PermissionChecker { diff --git a/ga-common/src/main/java/com/ga/common/model/ApiResponse.java b/ga-common/src/main/java/com/ga/common/model/ApiResponse.java index 96f4c79..e64b95a 100644 --- a/ga-common/src/main/java/com/ga/common/model/ApiResponse.java +++ b/ga-common/src/main/java/com/ga/common/model/ApiResponse.java @@ -6,18 +6,25 @@ import lombok.Getter; import java.time.LocalDateTime; /** - * 모든 REST API의 표준 응답. + * 모든 REST API의 표준 응답 포장지(envelope). + * + *

왜 필요한가: 성공이든 실패든 응답 모양을 {success, code, message, data, timestamp}로 통일하면 + * 프런트엔드가 한 가지 형태만 처리하면 된다. 컨트롤러는 데이터만 넣어 감싸 반환한다. + * + *

제네릭 {@code }: data에 어떤 타입(단건 VO, 리스트, PageResponse 등)이든 담을 수 있게 한다. * 신입 개발자는 ApiResponse.ok(...) / ApiResponse.fail(...) 만 사용하면 된다. + * (실패는 보통 GlobalExceptionHandler가 자동 생성하므로 직접 fail을 쓸 일은 드물다.) */ @Getter public class ApiResponse { - private final boolean success; - private final String code; - private final String message; - private final T data; - private final LocalDateTime timestamp = LocalDateTime.now(); + private final boolean success; // 성공 여부(true/false). 프런트가 가장 먼저 보는 값 + private final String code; // 결과 코드("0000"=성공, 그 외는 ErrorCode 코드) + private final String message; // 사람이 읽는 메시지 + private final T data; // 실제 응답 데이터(실패 시 null) + private final LocalDateTime timestamp = LocalDateTime.now(); // 응답 생성 시각 + // 외부에서 직접 new 하지 못하게 private. 아래 정적 팩토리 메서드(ok/fail)로만 만든다. private ApiResponse(boolean success, String code, String message, T data) { this.success = success; this.code = code; @@ -25,26 +32,32 @@ public class ApiResponse { this.data = data; } + /** 성공 + 데이터 반환(가장 많이 쓰는 형태). */ public static ApiResponse ok(T data) { return new ApiResponse<>(true, ErrorCode.SUCCESS.getCode(), ErrorCode.SUCCESS.getMessage(), data); } + /** 성공 + 데이터 + 커스텀 메시지(예: "저장되었습니다"). */ public static ApiResponse ok(T data, String message) { return new ApiResponse<>(true, ErrorCode.SUCCESS.getCode(), message, data); } + /** 반환할 데이터가 없는 성공(예: 삭제/처리 완료). */ public static ApiResponse ok() { return new ApiResponse<>(true, ErrorCode.SUCCESS.getCode(), ErrorCode.SUCCESS.getMessage(), null); } + /** 실패 응답: 코드/메시지를 직접 지정. */ public static ApiResponse fail(String code, String message) { return new ApiResponse<>(false, code, message, null); } + /** 실패 응답: ErrorCode의 코드/기본 메시지를 사용. */ public static ApiResponse fail(ErrorCode errorCode) { return new ApiResponse<>(false, errorCode.getCode(), errorCode.getMessage(), null); } + /** 실패 응답: ErrorCode + 커스텀 메시지 인자를 받는 오버로드(현재는 ErrorCode 기본 메시지를 사용). */ public static ApiResponse fail(ErrorCode errorCode, String message) { return new ApiResponse<>(false, errorCode.getCode(), message, null); } diff --git a/ga-common/src/main/java/com/ga/common/model/PageResponse.java b/ga-common/src/main/java/com/ga/common/model/PageResponse.java index be56f21..6c02f13 100644 --- a/ga-common/src/main/java/com/ga/common/model/PageResponse.java +++ b/ga-common/src/main/java/com/ga/common/model/PageResponse.java @@ -10,8 +10,13 @@ import java.util.Collections; import java.util.List; /** - * 페이징 응답 (PageHelper 연동). - * 사용 예: return PageResponse.of(mapper.selectList(param)); + * 목록 조회의 페이징 응답 모델. 데이터 목록과 함께 "현재 페이지/페이지 크기/전체 건수/전체 페이지 수"를 담는다. + * + *

왜 필요한가: 목록 화면은 페이지네이션(이전/다음, 총 N건)이 필요하다. MyBatis 페이징 라이브러리인 + * PageHelper가 채워준 통계를 이 모델로 옮겨 담아 프런트엔드에 일관된 형태로 내려준다. + * + *

사용 흐름: 서비스에서 {@code param.startPage()}로 페이징을 켜고 조회한 뒤 + * {@code return PageResponse.of(mapper.selectList(param));} 한 줄로 변환한다(of가 통계까지 자동 추출). */ @Getter @Builder @@ -19,18 +24,20 @@ import java.util.List; @NoArgsConstructor public class PageResponse { - private List list; - private int pageNum; - private int pageSize; - private long total; - private int pages; + private List list; // 현재 페이지의 데이터 목록 + private int pageNum; // 현재 페이지 번호(1부터) + private int pageSize; // 페이지당 건수 + private long total; // 조건에 맞는 전체 건수 + private int pages; // 전체 페이지 수 + /** 조회 결과 목록을 받아 PageHelper가 채운 페이징 통계와 함께 응답 객체로 변환. null이면 빈 결과. */ public static PageResponse of(List list) { if (list == null) { return PageResponse.builder() .list(Collections.emptyList()) .pageNum(1).pageSize(0).total(0).pages(0).build(); } + // PageInfo: PageHelper가 조회 시 함께 계산해 둔 페이징 메타정보(전체 건수/페이지 수 등)를 꺼내는 래퍼. PageInfo info = new PageInfo<>(list); return PageResponse.builder() .list(info.getList()) @@ -41,6 +48,7 @@ public class PageResponse { .build(); } + /** 조회 자체를 하지 않고 빈 페이징 응답을 내려줄 때 사용(예: 권한상 결과가 없는 경우). */ public static PageResponse empty() { return PageResponse.builder() .list(Collections.emptyList()) diff --git a/ga-common/src/main/java/com/ga/common/model/SearchParam.java b/ga-common/src/main/java/com/ga/common/model/SearchParam.java index 7fb5701..71ecbf6 100644 --- a/ga-common/src/main/java/com/ga/common/model/SearchParam.java +++ b/ga-common/src/main/java/com/ga/common/model/SearchParam.java @@ -4,40 +4,47 @@ import com.github.pagehelper.PageHelper; import lombok.Data; /** - * 공통 검색 파라미터. 모든 *SearchParam 의 부모. + * 목록 조회용 공통 검색 파라미터. 모든 화면별 *SearchParam 클래스가 이 클래스를 상속한다. + * + *

왜 필요한가: 거의 모든 목록 화면이 "페이지 번호/크기, 검색어, 기간, 상태, 정렬" 같은 공통 입력을 받는다. + * 이를 한 곳에 모아두면 각 화면은 자기만의 필드만 추가하면 되고, 페이징/정렬 처리도 공통화된다. * * 사용: * public PageResponse list(XSearchParam p) { - * p.startPage(); + * p.startPage(); // 페이징/정렬 적용 * return PageResponse.of(mapper.selectList(p)); * } */ @Data public class SearchParam { - private int pageNum = 1; - private int pageSize = 20; + private int pageNum = 1; // 현재 페이지(1부터) + private int pageSize = 20; // 페이지당 건수(기본 20) - private String searchType; - private String searchKeyword; + private String searchType; // 검색 대상 항목(예: name, code) + private String searchKeyword; // 검색어 - private String startDate; - private String endDate; + private String startDate; // 조회 기간 시작 + private String endDate; // 조회 기간 종료 - private String status; + private String status; // 상태 필터 - private String sortField; - private String sortOrder = "DESC"; + private String sortField; // 정렬 기준 컬럼명 + private String sortOrder = "DESC"; // 정렬 방향(ASC/DESC, 기본 내림차순) /** MyBatis XML에서 #{offset} 참조 시 사용되는 계산값 */ public int getOffset() { return Math.max(0, pageNum - 1) * pageSize; } - /** PageHelper 시작 호출. Service 첫 줄에서 호출한다. */ + /** + * 페이징/정렬 활성화. 서비스에서 목록 조회 SQL을 부르기 "직전"에 호출해야 한다. + * PageHelper.startPage를 호출해 두면, 바로 다음 MyBatis 쿼리에 자동으로 LIMIT/OFFSET과 정렬이 적용된다. + */ public void startPage() { PageHelper.startPage(pageNum, pageSize); if (sortField != null && !sortField.isBlank()) { + // 정렬 방향은 ASC/DESC 두 값만 허용(그 외 입력은 DESC로 강제) — 임의 SQL 주입 방지. String order = "ASC".equalsIgnoreCase(sortOrder) ? "ASC" : "DESC"; PageHelper.orderBy(sanitizeSortField(sortField) + " " + order); } diff --git a/ga-common/src/main/java/com/ga/common/model/TreeNode.java b/ga-common/src/main/java/com/ga/common/model/TreeNode.java index 7986731..7f79580 100644 --- a/ga-common/src/main/java/com/ga/common/model/TreeNode.java +++ b/ga-common/src/main/java/com/ga/common/model/TreeNode.java @@ -6,16 +6,22 @@ import java.util.ArrayList; import java.util.List; /** - * 트리 응답 공통 모델 (메뉴, 조직 등). + * 계층 구조(트리) 데이터를 표현하는 공통 응답 모델. 메뉴 트리, 조직도 등에 쓰인다. + * + *

왜 필요한가: DB는 부모-자식을 parentId로 가진 평면(flat) 목록으로 저장하지만, 화면 트리 컴포넌트는 + * 자식을 children에 중첩한 형태를 원한다. 평면 목록을 이 중첩 구조로 조립해 내려주기 위한 공통 그릇이다. + * + *

제네릭 {@code }: data에 메뉴/조직 등 어떤 원본 객체든 실어 보낼 수 있다. */ @Data public class TreeNode { - private Long id; - private Long parentId; - private String label; - private T data; - private List> children = new ArrayList<>(); + private Long id; // 이 노드의 ID + private Long parentId; // 부모 노드 ID(최상위면 null) + private String label; // 화면에 표시할 이름 + private T data; // 원본 데이터(메뉴/조직 VO 등) + private List> children = new ArrayList<>(); // 자식 노드 목록 + /** 자식 노드를 추가한다. 트리 조립 시 부모 노드에 자식을 붙일 때 사용. */ public void addChild(TreeNode child) { children.add(child); } diff --git a/ga-common/src/main/java/com/ga/common/mybatis/AuditInterceptor.java b/ga-common/src/main/java/com/ga/common/mybatis/AuditInterceptor.java index 766ba77..0a1dc5a 100644 --- a/ga-common/src/main/java/com/ga/common/mybatis/AuditInterceptor.java +++ b/ga-common/src/main/java/com/ga/common/mybatis/AuditInterceptor.java @@ -16,13 +16,17 @@ import java.util.Collection; import java.util.Map; /** - * INSERT / UPDATE 시 감사 필드(created_at, created_by, updated_at, updated_by)를 자동 채운다. + * MyBatis 인터셉터: INSERT/UPDATE 실행 직전에 감사 필드(created_at, created_by, updated_at, updated_by)를 자동으로 채운다. * - * VO 에 다음 필드가 있을 때만 작동: - * - createdAt, createdBy, updatedAt, updatedBy + *

왜 필요한가: "누가 언제 만들고/고쳤는가"는 거의 모든 테이블에 필요한데, 매 INSERT/UPDATE마다 손으로 + * 채우면 누락되기 쉽다. DB 접근 공통 지점(MyBatis Executor)을 가로채 자동으로 채워주면 실수가 사라진다. * - * Map 또는 Collection 파라미터의 경우 내부 객체 각각에 대해 적용한다. + *

동작 대상: VO에 createdAt/createdBy/updatedAt/updatedBy 세터가 있을 때만 채운다(없으면 무시). + * Map 또는 Collection 파라미터(다건 처리)의 경우 내부 객체 각각에 대해 적용한다. + * 사용자 ID는 {@code SecurityUtil}로 현재 로그인 사용자에서 가져온다. */ +// @Intercepts/@Signature: "Executor.update(MappedStatement, Object)" 호출을 가로채겠다는 선언. +// MyBatis의 INSERT/UPDATE/DELETE는 내부적으로 모두 Executor.update 를 거치므로 이 한 지점에서 잡을 수 있다. @Intercepts({ @Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}) @@ -32,16 +36,18 @@ public class AuditInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { Object[] args = invocation.getArgs(); - MappedStatement ms = (MappedStatement) args[0]; - Object parameter = args[1]; + MappedStatement ms = (MappedStatement) args[0]; // 실행될 SQL 메타정보 + Object parameter = args[1]; // SQL에 넘긴 파라미터(보통 VO) + // INSERT/UPDATE일 때만 감사 필드를 채운다(DELETE는 대상 아님). SqlCommandType type = ms.getSqlCommandType(); if (type == SqlCommandType.INSERT || type == SqlCommandType.UPDATE) { apply(parameter, type); } - return invocation.proceed(); + return invocation.proceed(); // 원래 SQL 실행을 그대로 진행 } + // 파라미터 형태(단건/컬렉션/맵)에 따라 내부 객체를 풀어서 각각 감사 필드를 채운다. private void apply(Object parameter, SqlCommandType type) { if (parameter == null) return; @@ -62,13 +68,16 @@ public class AuditInterceptor implements Interceptor { applyOne(parameter, type); } + // 단일 객체 하나에 대해 감사 필드를 채운다. 해당 세터가 없거나 VO가 아니면 조용히 건너뛴다. private void applyOne(Object obj, SqlCommandType type) { if (obj == null) return; try { + // MetaObject: 리플렉션 없이 객체의 프로퍼티 존재 여부/값을 다루게 해주는 MyBatis 유틸. MetaObject meta = SystemMetaObject.forObject(obj); LocalDateTime now = LocalDateTime.now(); Long userId = SecurityUtil.getCurrentUserId(); + // 등록 시: 생성 시각/생성자는 아직 비어 있을 때만 채운다(이미 지정돼 있으면 존중). if (type == SqlCommandType.INSERT) { if (meta.hasSetter("createdAt") && meta.getValue("createdAt") == null) { meta.setValue("createdAt", now); @@ -77,6 +86,7 @@ public class AuditInterceptor implements Interceptor { meta.setValue("createdBy", userId); } } + // 수정 시: 수정 시각/수정자는 항상 현재 값으로 갱신. if (type == SqlCommandType.UPDATE) { if (meta.hasSetter("updatedAt")) { meta.setValue("updatedAt", now); diff --git a/ga-common/src/main/java/com/ga/common/mybatis/BaseMapper.java b/ga-common/src/main/java/com/ga/common/mybatis/BaseMapper.java index ab9b34f..5f9305c 100644 --- a/ga-common/src/main/java/com/ga/common/mybatis/BaseMapper.java +++ b/ga-common/src/main/java/com/ga/common/mybatis/BaseMapper.java @@ -5,14 +5,21 @@ import com.ga.common.model.SearchParam; import java.util.List; /** - * 공통 CRUD Mapper. 단순한 단일 테이블에 대해 선택적으로 상속하여 사용. - * 복잡한 조인이 필요하면 일반 Mapper Interface 만 정의해도 된다. + * 단일 테이블에 대한 기본 CRUD 메서드 시그니처를 모아둔 공통 Mapper 인터페이스. + * + *

왜 필요한가: 대부분의 테이블은 단건 조회/목록/등록/수정/삭제/카운트라는 비슷한 메서드를 갖는다. + * 이 인터페이스를 상속하면 메서드 선언을 반복하지 않아도 되고, 팀 전체가 같은 메서드 이름을 쓰게 되어 일관성이 생긴다. + * (실제 SQL은 각 Mapper의 XML에 메서드 이름과 동일한 id로 작성한다.) + * + *

주의: 복잡한 조인/통계 쿼리가 필요하면 이걸 상속하지 않고 일반 Mapper 인터페이스로 필요한 메서드만 정의해도 된다. + * + * @param 매핑 대상 VO 타입 */ public interface BaseMapper { - T selectById(Long id); - List selectList(SearchParam param); - int insert(T entity); - int update(T entity); - int deleteById(Long id); - int countByCondition(SearchParam param); + T selectById(Long id); // PK로 단건 조회 + List selectList(SearchParam param); // 검색조건으로 목록 조회(페이징은 PageHelper가 처리) + int insert(T entity); // 등록(반환값=영향 행 수) + int update(T entity); // 수정(반환값=영향 행 수) + int deleteById(Long id); // PK로 삭제(반환값=영향 행 수) + int countByCondition(SearchParam param); // 검색조건에 맞는 전체 건수 } diff --git a/ga-common/src/main/java/com/ga/common/mybatis/EncryptTypeHandler.java b/ga-common/src/main/java/com/ga/common/mybatis/EncryptTypeHandler.java index 2b7b92f..7cd2470 100644 --- a/ga-common/src/main/java/com/ga/common/mybatis/EncryptTypeHandler.java +++ b/ga-common/src/main/java/com/ga/common/mybatis/EncryptTypeHandler.java @@ -31,11 +31,13 @@ public class EncryptTypeHandler extends BaseTypeHandler { EncryptTypeHandler.encryptUtil = util; } + // DB에 쓸 때(파라미터 바인딩): 평문을 암호화해서 저장한다. @Override public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, encryptUtil != null ? encryptUtil.encrypt(parameter) : parameter); } + // DB에서 읽을 때(결과 매핑): 저장된 암호문을 복호화해서 돌려준다. (아래 인덱스 기반 오버로드들도 동일) @Override public String getNullableResult(ResultSet rs, String columnName) throws SQLException { return decrypt(rs.getString(columnName)); @@ -51,6 +53,7 @@ public class EncryptTypeHandler extends BaseTypeHandler { return decrypt(cs.getString(columnIndex)); } + // 복호화 공통 처리. 빈 값은 그대로, 복호화 실패는 평문(미암호화 기존 데이터)으로 간주해 원본을 돌려준다. private String decrypt(String v) { if (v == null || v.isBlank()) return v; try { diff --git a/ga-common/src/main/java/com/ga/common/mybatis/EncryptTypeHandlerInitializer.java b/ga-common/src/main/java/com/ga/common/mybatis/EncryptTypeHandlerInitializer.java index 6a19760..8fb432c 100644 --- a/ga-common/src/main/java/com/ga/common/mybatis/EncryptTypeHandlerInitializer.java +++ b/ga-common/src/main/java/com/ga/common/mybatis/EncryptTypeHandlerInitializer.java @@ -4,12 +4,16 @@ import com.ga.common.util.EncryptUtil; import org.springframework.context.annotation.Configuration; /** - * EncryptTypeHandler 가 Spring 빈이 아닌 일반 클래스이므로, - * 부팅 시 EncryptUtil 인스턴스를 정적으로 주입해주는 초기화 빈. + * {@link EncryptTypeHandler}에 암호화 도구를 연결해주는 초기화 전용 설정 빈. + * + *

왜 필요한가: EncryptTypeHandler는 (MyBatis가 모든 String 컬럼에 자동 적용하는 부작용을 막으려고) + * 일부러 스프링 빈이 아닌 일반 클래스로 두었다. 그래서 일반적인 의존성 주입을 받을 수 없으므로, + * 애플리케이션 부팅 시 이 설정 빈이 생성되면서 생성자에서 EncryptUtil 인스턴스를 핸들러의 정적 필드에 1회 주입한다. */ @Configuration public class EncryptTypeHandlerInitializer { + // 스프링이 EncryptUtil 빈을 주입해주면, 그 즉시 TypeHandler의 정적 필드로 전달한다. public EncryptTypeHandlerInitializer(EncryptUtil util) { EncryptTypeHandler.setEncryptUtil(util); } diff --git a/ga-common/src/main/java/com/ga/common/mybatis/JsonTypeHandler.java b/ga-common/src/main/java/com/ga/common/mybatis/JsonTypeHandler.java index d96293e..b8b743b 100644 --- a/ga-common/src/main/java/com/ga/common/mybatis/JsonTypeHandler.java +++ b/ga-common/src/main/java/com/ga/common/mybatis/JsonTypeHandler.java @@ -13,13 +13,20 @@ import java.sql.ResultSet; import java.sql.SQLException; /** - * PostgreSQL JSONB ↔ Jackson JsonNode 변환 TypeHandler. + * PostgreSQL의 JSONB 컬럼과 Jackson의 {@link JsonNode}(자바 JSON 트리)를 서로 변환하는 MyBatis TypeHandler. + * + *

왜 필요한가: 변경 이력(data_change_log) 등에서 구조가 유동적인 데이터를 JSONB 컬럼에 저장한다. + * JDBC는 JSONB를 그냥 String으로 다루므로, 이 핸들러가 저장 시 JsonNode→JSON문자열(PGobject), + * 조회 시 JSON문자열→JsonNode 로 자동 변환해 개발자가 JSON 트리로 편하게 다루게 해준다. + * + *

@MappedTypes(JsonNode.class): "JsonNode 타입 필드는 이 핸들러로 처리하라"고 MyBatis에 등록. */ @MappedTypes(JsonNode.class) public class JsonTypeHandler extends BaseTypeHandler { - private static final ObjectMapper M = new ObjectMapper(); + private static final ObjectMapper M = new ObjectMapper(); // JSON 직렬화/역직렬화 도구(공용 1개) + // DB에 쓸 때: JsonNode를 JSON 문자열로 만들고, PostgreSQL의 jsonb 타입 객체(PGobject)로 바인딩한다. @Override public void setNonNullParameter(PreparedStatement ps, int i, JsonNode parameter, JdbcType jdbcType) throws SQLException { PGobject pg = new PGobject(); @@ -36,6 +43,7 @@ public class JsonTypeHandler extends BaseTypeHandler { @Override public JsonNode getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return parse(rs.getString(columnIndex)); } @Override public JsonNode getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return parse(cs.getString(columnIndex)); } + // DB에서 읽은 JSON 문자열을 JsonNode 트리로 파싱. 빈 값이면 null, 형식 오류면 SQLException. private JsonNode parse(String json) throws SQLException { if (json == null || json.isBlank()) return null; try { diff --git a/ga-common/src/main/java/com/ga/common/notification/NotificationController.java b/ga-common/src/main/java/com/ga/common/notification/NotificationController.java index e8cf3de..cdbad78 100644 --- a/ga-common/src/main/java/com/ga/common/notification/NotificationController.java +++ b/ga-common/src/main/java/com/ga/common/notification/NotificationController.java @@ -7,6 +7,12 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +/** + * 앱 내 알림 REST API (벨 아이콘 영역). + *

+ * 신입 메모: 모두 "내 알림" 기준이라 권한 애너테이션 없이 로그인 사용자면 호출 가능하다. + * 발송(send)은 다른 도메인이 서비스로 직접 호출하므로 여기 API에는 없다. + */ @Tag(name = "알림") @RestController @RequestMapping("/api/notifications") diff --git a/ga-common/src/main/java/com/ga/common/notification/NotificationMapper.java b/ga-common/src/main/java/com/ga/common/notification/NotificationMapper.java index eba0f42..92e8569 100644 --- a/ga-common/src/main/java/com/ga/common/notification/NotificationMapper.java +++ b/ga-common/src/main/java/com/ga/common/notification/NotificationMapper.java @@ -5,6 +5,9 @@ import org.apache.ibatis.annotations.Param; import java.util.List; +/** + * 앱 내 알림 DB 매퍼 (MyBatis). deleteOld는 보존기간 지난 알림을 배치로 정리할 때 쓴다. + */ @Mapper public interface NotificationMapper { int insert(NotificationVO vo); diff --git a/ga-common/src/main/java/com/ga/common/notification/NotificationService.java b/ga-common/src/main/java/com/ga/common/notification/NotificationService.java index 397e8cf..5d48140 100644 --- a/ga-common/src/main/java/com/ga/common/notification/NotificationService.java +++ b/ga-common/src/main/java/com/ga/common/notification/NotificationService.java @@ -9,24 +9,35 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +/** + * 앱 내(in-app) 알림 서비스. + *

+ * 신입 메모: 이건 화면 종(벨) 아이콘에 쌓이는 "사내 알림"을 DB에 적재/조회하는 기능이다. + * 외부로 나가는 SMS/알림톡/푸시(adapter 패키지)와는 별개이며, 보통 업무 이벤트가 나면 + * {@link #send}로 알림을 한 건 적재하고, 사용자는 목록/안읽음 개수를 조회하고 읽음 처리한다. + * 대부분의 메서드는 {@link SecurityUtil}로 현재 로그인 사용자를 기준으로 동작한다. + */ @Service @RequiredArgsConstructor public class NotificationService { private final NotificationMapper mapper; + /** 현재 사용자의 알림 목록. 미로그인 시 UNAUTHORIZED. */ public List myNotifications() { Long userId = SecurityUtil.getCurrentUserId(); if (userId == null) throw new BizException(ErrorCode.UNAUTHORIZED); return mapper.selectByUser(userId); } + /** 현재 사용자의 안읽음 알림 개수 (벨 배지용). 미로그인 시 0. */ public int unreadCount() { Long userId = SecurityUtil.getCurrentUserId(); if (userId == null) return 0; return mapper.countUnread(userId); } + /** 특정 알림 1건을 읽음 처리 (본인 알림만). */ @Transactional public void markRead(Long notiId) { Long userId = SecurityUtil.getCurrentUserId(); @@ -34,6 +45,7 @@ public class NotificationService { mapper.markRead(notiId, userId); } + /** 현재 사용자의 모든 알림을 읽음 처리. */ @Transactional public void markAllRead() { Long userId = SecurityUtil.getCurrentUserId(); @@ -41,6 +53,7 @@ public class NotificationService { mapper.markAllRead(userId); } + /** 알림 1건 적재(발송). 다른 도메인이 업무 이벤트 발생 시 호출한다. linkUrl은 클릭 시 이동 경로. */ @Transactional public void send(Long userId, String type, String title, String content, String linkUrl) { NotificationVO vo = new NotificationVO(); @@ -52,6 +65,7 @@ public class NotificationService { mapper.insert(vo); } + /** 여러 사용자에게 한 번에 알림 적재 (정산 완료 등 대량 발송용). 빈 목록은 무시. */ @Transactional public void sendBatch(List list) { if (list == null || list.isEmpty()) return; diff --git a/ga-common/src/main/java/com/ga/common/notification/NotificationVO.java b/ga-common/src/main/java/com/ga/common/notification/NotificationVO.java index 660cbd9..efcf68e 100644 --- a/ga-common/src/main/java/com/ga/common/notification/NotificationVO.java +++ b/ga-common/src/main/java/com/ga/common/notification/NotificationVO.java @@ -4,15 +4,18 @@ import lombok.Data; import java.time.LocalDateTime; +/** + * 앱 내 알림 한 건. + */ @Data public class NotificationVO { - private Long notiId; - private Long userId; - private String notiType; // INFO / WARN / ERROR - private String title; - private String content; - private String linkUrl; - private String isRead; - private LocalDateTime readAt; - private LocalDateTime createdAt; + private Long notiId; // 알림 PK + private Long userId; // 수신자 user_id + private String notiType; // INFO / WARN / ERROR (표시 색/아이콘 구분) + private String title; // 제목 + private String content; // 본문 + private String linkUrl; // 클릭 시 이동할 화면 경로 (선택) + private String isRead; // 읽음 여부 'Y'/'N' + private LocalDateTime readAt; // 읽은 시각 + private LocalDateTime createdAt;// 생성 시각 } diff --git a/ga-common/src/main/java/com/ga/common/security/AccessDeniedHandlerImpl.java b/ga-common/src/main/java/com/ga/common/security/AccessDeniedHandlerImpl.java index 68d0668..2d4c9b0 100644 --- a/ga-common/src/main/java/com/ga/common/security/AccessDeniedHandlerImpl.java +++ b/ga-common/src/main/java/com/ga/common/security/AccessDeniedHandlerImpl.java @@ -12,16 +12,23 @@ import org.springframework.stereotype.Component; import java.io.IOException; import java.util.Map; +/** + * 인가 실패(로그인은 했지만 권한이 없는 경우, HTTP 403) 처리기. + * + *

왜 필요한가: 스프링 시큐리티는 기본적으로 권한 없음 시 HTML 에러 페이지를 내보내는데, 이 시스템은 + * 프런트엔드(SPA/PWA)와 JSON으로 통신한다. 그래서 일관된 표준 JSON({success,code,message})으로 + * 응답하도록 이 핸들러를 SecurityConfig에 등록한다. + */ @Component @RequiredArgsConstructor public class AccessDeniedHandlerImpl implements AccessDeniedHandler { - private final ObjectMapper objectMapper; + private final ObjectMapper objectMapper; // 에러 응답을 JSON으로 직렬화 @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { - ErrorCode ec = ErrorCode.FORBIDDEN; + ErrorCode ec = ErrorCode.FORBIDDEN; // 권한 없음 = 403 response.setStatus(ec.getHttpStatus()); response.setContentType("application/json;charset=UTF-8"); objectMapper.writeValue(response.getWriter(), diff --git a/ga-common/src/main/java/com/ga/common/security/JwtAuthEntryPoint.java b/ga-common/src/main/java/com/ga/common/security/JwtAuthEntryPoint.java index 82fc0d6..7a831df 100644 --- a/ga-common/src/main/java/com/ga/common/security/JwtAuthEntryPoint.java +++ b/ga-common/src/main/java/com/ga/common/security/JwtAuthEntryPoint.java @@ -12,16 +12,23 @@ import org.springframework.stereotype.Component; import java.io.IOException; import java.util.Map; +/** + * 인증 실패(로그인 안 됨/토큰 없음, HTTP 401) 진입점. + * + *

왜 필요한가: 인증되지 않은 요청이 보호된 URL에 들어오면 스프링 시큐리티가 이 핸들러를 호출한다. + * 기본 동작(로그인 페이지 리다이렉트/HTML) 대신, 프런트엔드가 처리하기 쉬운 표준 JSON 401을 응답하기 위함. + * (권한 없음 403은 {@link AccessDeniedHandlerImpl} 이 담당하며, 둘 다 SecurityConfig에 등록된다.) + */ @Component @RequiredArgsConstructor public class JwtAuthEntryPoint implements AuthenticationEntryPoint { - private final ObjectMapper objectMapper; + private final ObjectMapper objectMapper; // 에러 응답을 JSON으로 직렬화 @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { - ErrorCode ec = ErrorCode.UNAUTHORIZED; + ErrorCode ec = ErrorCode.UNAUTHORIZED; // 인증 필요 = 401 response.setStatus(ec.getHttpStatus()); response.setContentType("application/json;charset=UTF-8"); objectMapper.writeValue(response.getWriter(), diff --git a/ga-common/src/main/java/com/ga/common/security/SecurityConfig.java b/ga-common/src/main/java/com/ga/common/security/SecurityConfig.java index 4953ab3..878e485 100644 --- a/ga-common/src/main/java/com/ga/common/security/SecurityConfig.java +++ b/ga-common/src/main/java/com/ga/common/security/SecurityConfig.java @@ -21,46 +21,69 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import java.util.List; +/** + * 스프링 시큐리티 전체 설정. "이 시스템이 인증/인가를 어떻게 처리하는가"의 중심. + * + *

핵심 정책: + *

    + *
  • JWT 토큰 기반 무상태(STATELESS) 인증 → 서버 세션을 만들지 않는다.
  • + *
  • SPA/PWA + REST API 구조라 CSRF·폼로그인·HTTP Basic은 끈다.
  • + *
  • 로그인/토큰갱신/Swagger/헬스체크 같은 공개 URL만 허용하고 나머지는 모두 인증 요구.
  • + *
  • {@link JwtAuthFilter}를 시큐리티 필터 체인 앞쪽에 끼워 토큰을 먼저 해석하게 한다.
  • + *
+ * + *

@EnableMethodSecurity: 메서드 단위 보안(@PreAuthorize 등)을 켠다. + * (참고: 이 프로젝트는 메뉴 권한을 {@code @RequirePermission} + AOP로 별도 처리한다.) + */ @Configuration @EnableMethodSecurity @RequiredArgsConstructor public class SecurityConfig { - private final JwtAuthFilter jwtAuthFilter; - private final JwtAuthEntryPoint jwtAuthEntryPoint; - private final AccessDeniedHandlerImpl accessDeniedHandler; + private final JwtAuthFilter jwtAuthFilter; // 요청마다 JWT를 검증하는 필터 + private final JwtAuthEntryPoint jwtAuthEntryPoint; // 인증 실패(401) 시 JSON 응답 + private final AccessDeniedHandlerImpl accessDeniedHandler; // 권한 없음(403) 시 JSON 응답 + // 시큐리티 필터 체인 정의. 요청이 어떤 보안 규칙을 거치는지를 한 번에 구성한다. @Bean public SecurityFilterChain filter(HttpSecurity http) throws Exception { http - .csrf(AbstractHttpConfigurer::disable) - .cors(c -> c.configurationSource(corsSource())) - .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .formLogin(AbstractHttpConfigurer::disable) - .httpBasic(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) // 토큰 기반이라 CSRF 불필요 → 비활성화 + .cors(c -> c.configurationSource(corsSource())) // 프런트엔드 도메인에서의 호출 허용(아래 corsSource) + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 미사용 + .formLogin(AbstractHttpConfigurer::disable) // 기본 로그인 폼 사용 안 함(직접 /api/auth/login 처리) + .httpBasic(AbstractHttpConfigurer::disable) // HTTP Basic 인증 사용 안 함 .exceptionHandling(eh -> eh - .authenticationEntryPoint(jwtAuthEntryPoint) - .accessDeniedHandler(accessDeniedHandler)) + .authenticationEntryPoint(jwtAuthEntryPoint) // 401 처리기 등록 + .accessDeniedHandler(accessDeniedHandler)) // 403 처리기 등록 .authorizeHttpRequests(a -> a + // CORS 사전요청(OPTIONS)은 인증 없이 허용해야 브라우저 호출이 가능. .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + // 로그인/토큰 갱신은 아직 토큰이 없으므로 공개. .requestMatchers("/api/auth/login", "/api/auth/refresh").permitAll() + // API 문서(Swagger)와 헬스체크는 공개. .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() .requestMatchers("/actuator/health").permitAll() + // 그 외 모든 요청은 인증 필요. .anyRequest().authenticated()) + // 표준 로그인 필터(UsernamePasswordAuthenticationFilter)보다 앞에 JWT 필터를 둔다. .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } + /** 비밀번호 단방향 해시 인코더. 회원 비밀번호 저장/검증에 BCrypt를 사용한다. */ @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + /** 스프링 시큐리티의 인증 매니저 빈. 로그인 처리에서 자격증명 검증에 쓰인다. */ @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration cfg) throws Exception { return cfg.getAuthenticationManager(); } + /** CORS 설정: 프런트엔드(다른 출처)에서의 API 호출을 허용하고, 토큰/파일다운로드 헤더를 노출한다. */ @Bean public CorsConfigurationSource corsSource() { CorsConfiguration c = new CorsConfiguration(); diff --git a/ga-common/src/main/java/com/ga/common/system/ApiAccessLogService.java b/ga-common/src/main/java/com/ga/common/system/ApiAccessLogService.java index e9750ed..855b800 100644 --- a/ga-common/src/main/java/com/ga/common/system/ApiAccessLogService.java +++ b/ga-common/src/main/java/com/ga/common/system/ApiAccessLogService.java @@ -5,6 +5,13 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; +/** + * API 호출 접근 로그 적재 서비스. + *

+ * 신입 메모: 누가 어떤 API를 언제 호출했고 응답코드/소요시간이 얼마였는지를 감사 로그로 남긴다. + * 본 업무 응답을 지연시키지 않도록 {@code @Async}로 별도 스레드에서 저장하며, 로그 저장이 + * 실패해도 본 업무에 영향이 없게 예외를 삼키고 경고만 남긴다(로그 때문에 요청이 깨지면 안 됨). + */ @Slf4j @Service @RequiredArgsConstructor @@ -12,6 +19,7 @@ public class ApiAccessLogService { private final SystemLogMapper logMapper; + /** API 접근 로그 1건을 비동기로 적재. 저장 실패는 무시(경고만). */ @Async("asyncExecutor") public void saveAsync(Long userId, String method, String url, String requestParam, String responseCode, int responseTime, String ipAddress, String errorMessage) { diff --git a/ga-common/src/main/java/com/ga/common/system/DataChangeLogService.java b/ga-common/src/main/java/com/ga/common/system/DataChangeLogService.java index 06d033d..312bb4c 100644 --- a/ga-common/src/main/java/com/ga/common/system/DataChangeLogService.java +++ b/ga-common/src/main/java/com/ga/common/system/DataChangeLogService.java @@ -14,6 +14,11 @@ import java.util.List; /** * 데이터 변경 이력 저장 (비동기). * before/after JSON 비교 → changed_keys 산출 → JSONB INSERT. + *

+ * 신입 메모: "이 레코드의 어떤 값이 누구에 의해 어떻게 바뀌었나"를 추적하는 감사 기능이다. + * 변경 전(beforeJson)/후(afterJson) 스냅샷을 받아 실제로 달라진 필드명만 골라(changedKeys) + * 함께 저장해 두면, 나중에 "무엇이 바뀌었는지"를 빠르게 확인할 수 있다. + * API 로그와 마찬가지로 {@code @Async}로 본 업무와 분리하고 실패는 삼킨다. */ @Slf4j @Service @@ -23,6 +28,7 @@ public class DataChangeLogService { private final SystemLogMapper logMapper; private final ObjectMapper objectMapper; + /** 변경 이력 1건을 비동기 적재. before/after를 비교해 바뀐 필드 목록도 함께 저장. */ @Async("asyncExecutor") public void save(Long userId, String menuCode, String tableName, String recordId, String actionType, String beforeJson, String afterJson) { @@ -35,6 +41,7 @@ public class DataChangeLogService { } } + /** after JSON의 각 필드를 before와 비교해, 값이 다르거나 새로 생긴 필드명을 콤마로 이어 반환. */ private String diffKeys(String beforeJson, String afterJson) { try { JsonNode b = objectMapper.readTree(beforeJson == null ? "{}" : beforeJson); diff --git a/ga-common/src/main/java/com/ga/common/system/SystemConfigController.java b/ga-common/src/main/java/com/ga/common/system/SystemConfigController.java index fe2c097..8f37aa4 100644 --- a/ga-common/src/main/java/com/ga/common/system/SystemConfigController.java +++ b/ga-common/src/main/java/com/ga/common/system/SystemConfigController.java @@ -8,6 +8,9 @@ import org.springframework.web.bind.annotation.*; import java.util.List; +/** + * 시스템 설정 REST API (시스템관리 화면 전용, SYSTEM_CONFIG 권한 필요). + */ @Tag(name = "시스템설정") @RestController @RequestMapping("/api/system/config") diff --git a/ga-common/src/main/java/com/ga/common/system/SystemConfigMapper.java b/ga-common/src/main/java/com/ga/common/system/SystemConfigMapper.java index f16283f..7e3f7c6 100644 --- a/ga-common/src/main/java/com/ga/common/system/SystemConfigMapper.java +++ b/ga-common/src/main/java/com/ga/common/system/SystemConfigMapper.java @@ -5,6 +5,9 @@ import org.apache.ibatis.annotations.Param; import java.util.List; +/** + * 시스템 설정 DB 매퍼 (MyBatis). SQL은 동명 XML 매퍼에 정의된다. + */ @Mapper public interface SystemConfigMapper { List selectAll(); diff --git a/ga-common/src/main/java/com/ga/common/system/SystemConfigService.java b/ga-common/src/main/java/com/ga/common/system/SystemConfigService.java index 320812a..8508995 100644 --- a/ga-common/src/main/java/com/ga/common/system/SystemConfigService.java +++ b/ga-common/src/main/java/com/ga/common/system/SystemConfigService.java @@ -14,42 +14,59 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +/** + * 시스템 설정값 조회/수정 서비스. + *

+ * 신입 메모: 코드 배포 없이 운영 중 바꿀 수 있는 설정값(키-값)을 DB에서 읽어 온다. + * 값은 DB에 전부 문자열로 저장되므로, 쓰는 쪽 편의를 위해 getInt/getDecimal/getBoolean 등으로 + * 타입 변환해 꺼내며, 값이 없으면 호출자가 준 기본값을 돌려준다. + * 자주 조회되는 값은 {@code @Cacheable}로 캐싱하고 수정 시 해당 키 캐시를 비운다. + * isEditable='N'인 설정은 보호 항목이라 수정이 막혀 있다. + */ @Service @RequiredArgsConstructor public class SystemConfigService { private final SystemConfigMapper mapper; + /** 전체 설정 목록 (관리 화면용). */ public List listAll() { return mapper.selectAll(); } + /** 특정 그룹의 설정 목록. */ public List listByGroup(String group) { return mapper.selectByGroup(group); } + /** 키로 설정값(문자열)을 조회. 없으면 빈 Optional. 결과는 캐시된다. */ @Cacheable(value = "systemConfig", key = "#key") public Optional getValue(String key) { SystemConfigVO vo = mapper.selectByKey(key); return Optional.ofNullable(vo).map(SystemConfigVO::getConfigValue); } + /** 문자열 설정값 (없으면 기본값). */ public String getString(String key, String defaultValue) { return getValue(key).orElse(defaultValue); } + /** 정수 설정값 (없으면 기본값). */ public int getInt(String key, int defaultValue) { return getValue(key).map(Integer::parseInt).orElse(defaultValue); } + /** 금액/비율용 BigDecimal 설정값 (없으면 기본값). */ public BigDecimal getDecimal(String key, BigDecimal defaultValue) { return getValue(key).map(BigDecimal::new).orElse(defaultValue); } + /** 불리언 설정값 ("true"/"Y"를 참으로 인정, 없으면 기본값). */ public boolean getBoolean(String key, boolean defaultValue) { return getValue(key).map(s -> "true".equalsIgnoreCase(s) || "Y".equalsIgnoreCase(s)).orElse(defaultValue); } + /** 설정값 수정. 미존재 시 NOT_FOUND, isEditable='N'이면 수정 거부. 후 해당 키 캐시 무효화. */ @Transactional @CacheEvict(value = "systemConfig", key = "#vo.configKey") public void update(SystemConfigVO vo) { diff --git a/ga-common/src/main/java/com/ga/common/system/SystemConfigVO.java b/ga-common/src/main/java/com/ga/common/system/SystemConfigVO.java index 43354e2..4eb9bce 100644 --- a/ga-common/src/main/java/com/ga/common/system/SystemConfigVO.java +++ b/ga-common/src/main/java/com/ga/common/system/SystemConfigVO.java @@ -4,15 +4,18 @@ import lombok.Data; import java.time.LocalDateTime; +/** + * 시스템 설정 한 건 (키-값). configKey가 PK다. + */ @Data public class SystemConfigVO { - private String configKey; - private String configValue; - private String configGroup; - private String valueType; - private String isEncrypted; - private String isEditable; - private String description; + private String configKey; // 설정 키 (PK) + private String configValue; // 설정 값 (DB에는 항상 문자열로 저장) + private String configGroup; // 설정 그룹 (화면 분류) + private String valueType; // 값 타입 힌트 (STRING/INT/DECIMAL/BOOLEAN 등) + private String isEncrypted; // 암호화 저장 여부 'Y'/'N' + private String isEditable; // 수정 허용 여부 'Y'/'N' (N이면 화면에서 변경 불가) + private String description; // 설명 private LocalDateTime updatedAt; private Long updatedBy; } diff --git a/ga-common/src/main/java/com/ga/common/system/SystemLogController.java b/ga-common/src/main/java/com/ga/common/system/SystemLogController.java index 7618388..208ccfd 100644 --- a/ga-common/src/main/java/com/ga/common/system/SystemLogController.java +++ b/ga-common/src/main/java/com/ga/common/system/SystemLogController.java @@ -12,6 +12,13 @@ import org.springframework.web.bind.annotation.*; import java.util.HashMap; import java.util.Map; +/** + * 시스템 로그 조회 REST API (시스템관리 화면 전용, SYSTEM_LOG 권한 필요). + *

+ * 신입 메모: 로그인 로그/API 접근 로그/데이터 변경 로그 세 종류를 페이지네이션으로 조회한다. + * 로그는 컬럼이 종류마다 달라 VO 대신 Map으로 받아 그대로 화면에 넘긴다. + * 목록 조회 전 PageHelper로 페이징을 건다(이후 첫 쿼리에 LIMIT이 자동 적용). + */ @Tag(name = "시스템로그") @RestController @RequestMapping("/api/system/logs") @@ -56,6 +63,7 @@ public class SystemLogController { return ApiResponse.ok(PageResponse.of(mapper.selectDataChangeLogs(p))); } + /** 공통 검색 파라미터(기간)를 매퍼에 넘길 Map으로 변환. 추가 필터는 호출부에서 put. */ private Map toMap(SearchParam p) { Map m = new HashMap<>(); m.put("startDate", p.getStartDate()); diff --git a/ga-common/src/main/java/com/ga/common/system/SystemLogMapper.java b/ga-common/src/main/java/com/ga/common/system/SystemLogMapper.java index 7fed2a6..19e30ba 100644 --- a/ga-common/src/main/java/com/ga/common/system/SystemLogMapper.java +++ b/ga-common/src/main/java/com/ga/common/system/SystemLogMapper.java @@ -6,6 +6,10 @@ import org.apache.ibatis.annotations.Param; import java.util.List; import java.util.Map; +/** + * 시스템 로그 DB 매퍼 (MyBatis) — 로그인/API접근/데이터변경 3종. + * insert*는 로그 적재용(주로 비동기 서비스가 호출), select*는 관리 화면 조회용이다. + */ @Mapper public interface SystemLogMapper { diff --git a/ga-common/src/main/java/com/ga/common/util/DateUtil.java b/ga-common/src/main/java/com/ga/common/util/DateUtil.java index 6185aa6..8e89a48 100644 --- a/ga-common/src/main/java/com/ga/common/util/DateUtil.java +++ b/ga-common/src/main/java/com/ga/common/util/DateUtil.java @@ -8,9 +8,14 @@ import java.time.temporal.ChronoUnit; /** * 날짜/정산월 유틸. 정산월 포맷은 "yyyyMM" 6자리. + *

+ * 신입 메모: 수수료 정산은 "월" 단위로 돈다. 그래서 화면/DB에서 정산월을 "202605"처럼 + * 6자리 문자열로 주고받는데, 이 유틸이 그 문자열과 실제 날짜 범위(월초~월말) 사이를 + * 변환해 준다. {@link java.time.YearMonth}를 써서 윤년/월별 일수 계산을 안전하게 처리한다. */ public final class DateUtil { + /** 인스턴스화 금지용 private 생성자 (정적 메서드 모음 클래스). */ private DateUtil() {} public static final DateTimeFormatter FMT_DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd"); @@ -18,19 +23,25 @@ public final class DateUtil { public static final DateTimeFormatter FMT_MONTH = DateTimeFormatter.ofPattern("yyyyMM"); public static final DateTimeFormatter FMT_DATE_KOR = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일"); + /** 오늘 날짜 "yyyy-MM-dd". */ public static String today() { return LocalDate.now().format(FMT_DATE); } + /** 이번 달 정산월 "yyyyMM" (인자 없으면 현재 기준). */ public static String getSettleMonth() { return YearMonth.now().format(FMT_MONTH); } + /** 주어진 날짜가 속한 정산월 "yyyyMM". */ public static String getSettleMonth(LocalDate date) { return YearMonth.from(date).format(FMT_MONTH); } - /** "202605" → ["2026-05-01", "2026-05-31"] */ + /** + * 정산월을 [월초, 월말] 날짜 배열로 변환. 예) "202605" → ["2026-05-01", "2026-05-31"]. + * 정산 대상 기간을 SQL between 조건 등에 넘길 때 사용한다. + */ public static String[] getMonthRange(String settleMonth) { YearMonth ym = YearMonth.parse(settleMonth, FMT_MONTH); return new String[] { @@ -39,16 +50,19 @@ public final class DateUtil { }; } + /** 두 정산월 사이의 개월 수 차이 (to - from). 경과월 계산(차수 산정 등)에 사용. */ public static int calcMonthDiff(String fromYyyymm, String toYyyymm) { YearMonth from = YearMonth.parse(fromYyyymm, FMT_MONTH); YearMonth to = YearMonth.parse(toYyyymm, FMT_MONTH); return (int) ChronoUnit.MONTHS.between(from, to); } + /** 두 날짜가 속한 월 사이의 개월 수 차이 (to - from). */ public static int calcMonthDiff(LocalDate from, LocalDate to) { return (int) ChronoUnit.MONTHS.between(YearMonth.from(from), YearMonth.from(to)); } + /** 문자열을 주어진 패턴으로 LocalDate 파싱. 비거나 형식이 틀리면 null(예외 던지지 않음). */ public static LocalDate parseDate(String s, String pattern) { if (s == null || s.isBlank()) return null; try { @@ -58,10 +72,12 @@ public final class DateUtil { } } + /** LocalDate를 주어진 패턴 문자열로 변환. null이면 null. */ public static String format(LocalDate date, String pattern) { return date == null ? null : date.format(DateTimeFormatter.ofPattern(pattern)); } + /** 정산월에 개월 수를 더한 정산월 반환. 예) addMonth("202605", 3) → "202608". 음수면 과거로 이동. */ public static String addMonth(String yyyymm, int months) { return YearMonth.parse(yyyymm, FMT_MONTH).plusMonths(months).format(FMT_MONTH); } diff --git a/ga-common/src/main/java/com/ga/common/util/MaskUtil.java b/ga-common/src/main/java/com/ga/common/util/MaskUtil.java index 74dce1a..5dec964 100644 --- a/ga-common/src/main/java/com/ga/common/util/MaskUtil.java +++ b/ga-common/src/main/java/com/ga/common/util/MaskUtil.java @@ -7,13 +7,20 @@ package com.ga.common.util; * - RRN : 880101-1234567 → 880101-******* * - ACCT : 110-123-456789 → 110-***-****** * - EMAIL: aaa@bbb.com → a**@bbb.com + *

+ * 신입 메모: 주민번호/계좌번호 등은 DB에는 암호화({@link EncryptUtil})해 저장하고, + * 화면·로그에 보여줄 때는 이 유틸로 일부만 가린다. "저장=암호화, 표시=마스킹" 두 가지가 다른 역할임에 주의. + * 형식이 예상과 다르면(자리수 불일치 등) 원본을 그대로 돌려준다. */ public final class MaskUtil { + /** 인스턴스화 금지용 private 생성자 (정적 메서드 모음 클래스). */ private MaskUtil() {} + /** 마스킹 대상 종류 (이름/전화/주민번호/계좌/이메일). */ public enum MaskType { NAME, PHONE, RRN, ACCOUNT, EMAIL } + /** 종류에 맞는 마스킹을 골라 적용하는 진입점. 값이 비어 있으면 그대로 반환. */ public static String mask(String value, MaskType type) { if (value == null || value.isBlank()) return value; return switch (type) { @@ -25,6 +32,7 @@ public final class MaskUtil { }; } + /** 이름 마스킹: 가운데 글자를 *로. 2글자면 끝 글자만 가림(홍길동→홍*동, 홍길→홍*). */ public static String maskName(String name) { int len = name.length(); if (len <= 1) return name; @@ -32,6 +40,7 @@ public final class MaskUtil { return name.charAt(0) + "*".repeat(len - 2) + name.charAt(len - 1); } + /** 전화번호 마스킹: 가운데 국번을 *로 (숫자만 추출 후 10/11자리만 처리). */ public static String maskPhone(String phone) { String digits = phone.replaceAll("[^0-9]", ""); if (digits.length() == 11) { @@ -43,12 +52,14 @@ public final class MaskUtil { return phone; } + /** 주민등록번호 마스킹: 뒷 7자리 전부 *로 (앞 생년월일 6자리만 노출). */ public static String maskRrn(String rrn) { String s = rrn.replaceAll("[^0-9]", ""); if (s.length() != 13) return rrn; return s.substring(0, 6) + "-*******"; } + /** 계좌번호 마스킹: 첫 구간만 남기고 이후 구간을 *로. 구분자(-)가 없으면 앞 4자리만 노출. */ public static String maskAccount(String account) { if (account == null) return null; String[] parts = account.split("-"); @@ -63,6 +74,7 @@ public final class MaskUtil { return account.substring(0, 4) + "*".repeat(account.length() - 4); } + /** 이메일 마스킹: 아이디 첫 글자만 남기고 나머지를 *로 (도메인은 그대로). */ public static String maskEmail(String email) { int at = email.indexOf('@'); if (at <= 1) return email; diff --git a/ga-common/src/main/java/com/ga/common/util/MoneyUtil.java b/ga-common/src/main/java/com/ga/common/util/MoneyUtil.java index 2bec56b..41c0ba5 100644 --- a/ga-common/src/main/java/com/ga/common/util/MoneyUtil.java +++ b/ga-common/src/main/java/com/ga/common/util/MoneyUtil.java @@ -6,35 +6,49 @@ import java.text.DecimalFormat; /** * 금액 계산 유틸. 모든 금액은 BigDecimal, 반올림은 HALF_UP, 소수점 2자리. + *

+ * 신입 메모: 돈 계산에 double/float를 쓰면 0.1+0.2 같은 데서 미세 오차가 생긴다. + * 그래서 수수료 시스템의 모든 금액은 {@link BigDecimal}로 다루고, 더하기/빼기/곱하기/나누기/퍼센트를 + * 이 유틸의 정적 메서드로만 처리해 반올림 규칙(HALF_UP, 2자리)을 한 곳에서 통일한다. + * 또한 모든 입력은 null이 들어와도 0으로 간주({@link #zero})하여 NPE를 막는다. */ public final class MoneyUtil { + /** 인스턴스화 금지용 private 생성자 (정적 메서드 모음 클래스). */ private MoneyUtil() {} + /** 금액 소수점 자리수 (원 단위 + 소수 2자리). */ public static final int SCALE = 2; + /** 퍼센트 계산용 상수 100. */ public static final BigDecimal HUNDRED = BigDecimal.valueOf(100); + /** 사업소득 원천징수 기본 세율 3.3% (소득세 3% + 지방소득세 0.3%). */ public static final BigDecimal DEFAULT_TAX_RATE = new BigDecimal("3.3"); private static final DecimalFormat FMT = new DecimalFormat("#,##0"); private static final DecimalFormat FMT_DEC = new DecimalFormat("#,##0.00"); private static final DecimalFormat FMT_SIGN = new DecimalFormat("+#,##0;-#,##0"); + /** null이면 0으로 바꿔 돌려준다(모든 계산의 NPE 방지 기본기). */ public static BigDecimal zero(BigDecimal v) { return v == null ? BigDecimal.ZERO : v; } + /** 더하기 (a+b, null은 0). */ public static BigDecimal add(BigDecimal a, BigDecimal b) { return zero(a).add(zero(b)); } + /** 빼기 (a-b, null은 0). */ public static BigDecimal sub(BigDecimal a, BigDecimal b) { return zero(a).subtract(zero(b)); } + /** 곱하기 (a×b) 후 2자리 반올림. */ public static BigDecimal mul(BigDecimal a, BigDecimal b) { return zero(a).multiply(zero(b)).setScale(SCALE, RoundingMode.HALF_UP); } + /** 나누기 (a÷b) 후 2자리 반올림. 0으로 나누면 예외 대신 0을 반환한다. */ public static BigDecimal div(BigDecimal a, BigDecimal b) { if (b == null || b.signum() == 0) return BigDecimal.ZERO; return zero(a).divide(b, SCALE, RoundingMode.HALF_UP); @@ -46,28 +60,35 @@ public final class MoneyUtil { return amount.multiply(ratePct).divide(HUNDRED, SCALE, RoundingMode.HALF_UP); } - /** 세액 계산 (기본 3.3%) */ + /** 세액 계산 (기본 3.3% 원천징수). 설계사 수수료 지급 시 떼는 세금 계산에 사용. */ public static BigDecimal tax(BigDecimal amount) { return pct(amount, DEFAULT_TAX_RATE); } + /** 세율을 직접 지정하는 세액 계산. */ public static BigDecimal tax(BigDecimal amount, BigDecimal ratePct) { return pct(amount, ratePct); } + /** 천단위 콤마 정수 문자열 (예: 1234567 → "1,234,567"). null은 빈 문자열. */ public static String fmt(BigDecimal v) { return v == null ? "" : FMT.format(v); } + /** 천단위 콤마 + 소수 2자리 문자열 (예: "1,234,567.00"). */ public static String fmtDec(BigDecimal v) { return v == null ? "" : FMT_DEC.format(v); } + /** 부호를 항상 표시하는 문자열 (예: +1,000 / -1,000). 환수/차감 표기에 유용. */ public static String fmtSign(BigDecimal v) { return v == null ? "" : FMT_SIGN.format(v); } + /** 0(또는 null)인가? */ public static boolean isZero(BigDecimal v) { return v == null || v.signum() == 0; } + /** 양수인가? (지급 대상 판단 등) */ public static boolean isPositive(BigDecimal v) { return v != null && v.signum() > 0; } + /** 음수인가? (환수/마이너스 정산 판단 등) */ public static boolean isNegative(BigDecimal v) { return v != null && v.signum() < 0; } } diff --git a/ga-common/src/main/java/com/ga/common/util/SecurityUtil.java b/ga-common/src/main/java/com/ga/common/util/SecurityUtil.java index e69c783..527d6e8 100644 --- a/ga-common/src/main/java/com/ga/common/util/SecurityUtil.java +++ b/ga-common/src/main/java/com/ga/common/util/SecurityUtil.java @@ -8,11 +8,19 @@ import java.util.Optional; /** * 현재 로그인 사용자 조회. + *

+ * 신입 메모: Spring Security는 요청을 처리하는 스레드마다 "지금 누가 로그인했는지"를 + * {@link SecurityContextHolder}에 담아 둔다. 이 유틸은 거기서 우리 앱의 사용자 객체 + * {@link LoginUser}를 꺼내 주는 단축 도구다. 컨트롤러/서비스 어디서든 파라미터로 + * 사용자 정보를 넘기지 않고도 현재 사용자 id/loginId를 얻을 수 있다. + * (배치 등 인증 컨텍스트가 없으면 비어 있음/null이 나온다.) */ public final class SecurityUtil { + /** 인스턴스화 금지용 private 생성자 (정적 메서드 모음 클래스). */ private SecurityUtil() {} + /** 현재 로그인 사용자({@link LoginUser})를 Optional로 반환. 미인증이면 빈 Optional. */ public static Optional getLoginUser() { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); if (auth == null || !auth.isAuthenticated()) return Optional.empty(); @@ -21,10 +29,12 @@ public final class SecurityUtil { return Optional.empty(); } + /** 현재 로그인 사용자의 내부 PK(user_id). 미인증이면 null. 등록자/수정자 기록에 자주 쓴다. */ public static Long getCurrentUserId() { return getLoginUser().map(LoginUser::getUserId).orElse(null); } + /** 현재 로그인 사용자의 로그인 아이디. 미인증이면 null. */ public static String getCurrentLoginId() { return getLoginUser().map(LoginUser::getLoginId).orElse(null); } diff --git a/ga-core/src/main/java/com/ga/core/mapper/installment/InstallmentPlanMapper.java b/ga-core/src/main/java/com/ga/core/mapper/installment/InstallmentPlanMapper.java index 9891354..198844c 100644 --- a/ga-core/src/main/java/com/ga/core/mapper/installment/InstallmentPlanMapper.java +++ b/ga-core/src/main/java/com/ga/core/mapper/installment/InstallmentPlanMapper.java @@ -23,6 +23,9 @@ public interface InstallmentPlanMapper { /** 정산월의 SCHEDULED 분급 계획 조회 (배치 지급 처리용) */ List selectScheduledByMonth(@Param("settleMonth") String settleMonth); + /** 정산월의 PAID 분급 계획을 SCHEDULED로 되돌림 (배치 재실행 멱등용) */ + int resetPaidToScheduledByMonth(@Param("settleMonth") String settleMonth); + int insert(InstallmentPlanVO vo); /** 7년치 분급 계획 일괄 생성 */ diff --git a/ga-core/src/main/resources/mapper/installment/InstallmentPlanMapper.xml b/ga-core/src/main/resources/mapper/installment/InstallmentPlanMapper.xml index e92dd7d..f069522 100644 --- a/ga-core/src/main/resources/mapper/installment/InstallmentPlanMapper.xml +++ b/ga-core/src/main/resources/mapper/installment/InstallmentPlanMapper.xml @@ -185,6 +185,13 @@ WHERE plan_id = #{planId} + + + UPDATE installment_plan + SET status = 'SCHEDULED', paid_amount = 0, paid_at = NULL, payment_id = NULL, updated_at = NOW() + WHERE settle_month = #{settleMonth} AND status = 'PAID' + + DELETE FROM installment_plan WHERE contract_id = #{contractId} diff --git a/ga-core/src/main/resources/mapper/ledger/RecruitLedgerMapper.xml b/ga-core/src/main/resources/mapper/ledger/RecruitLedgerMapper.xml index 746e67a..df47165 100644 --- a/ga-core/src/main/resources/mapper/ledger/RecruitLedgerMapper.xml +++ b/ga-core/src/main/resources/mapper/ledger/RecruitLedgerMapper.xml @@ -88,13 +88,13 @@ INSERT INTO recruit_ledger (contract_id, agent_id, policy_no, company_code, product_code, insurance_type, commission_year, premium, pay_method, company_rate, company_amount, - payout_rate, agent_amount, tax_amount, settle_month, status, version, created_at) + payout_rate, agent_amount, tax_amount, reconcile_status, settle_month, status, version, created_at) VALUES (#{r.contractId}, #{r.agentId}, #{r.policyNo}, #{r.companyCode}, #{r.productCode}, #{r.insuranceType}, #{r.commissionYear}, #{r.premium}, #{r.payMethod}, #{r.companyRate}, #{r.companyAmount}, #{r.payoutRate}, #{r.agentAmount}, #{r.taxAmount}, - #{r.settleMonth}, COALESCE(#{r.status},'CALCULATED'), 1, NOW()) + COALESCE(#{r.reconcileStatus},'PENDING'), #{r.settleMonth}, COALESCE(#{r.status},'CALCULATED'), 1, NOW()) diff --git a/ga-frontend/src/App.tsx b/ga-frontend/src/App.tsx index 256c3c3..c0c91d1 100644 --- a/ga-frontend/src/App.tsx +++ b/ga-frontend/src/App.tsx @@ -4,6 +4,13 @@ import { Spin } from 'antd'; import LoginPage from '@/pages/LoginPage'; import MainLayout from '@/layouts/MainLayout'; +// ────────────────────────────────────────────────────── +// 앱의 라우팅(주소 → 화면) 정의 파일. +// 모든 페이지는 React.lazy 로 "필요할 때만" 불러오도록(코드 스플리팅) 처리한다. +// → 첫 로딩 속도를 빠르게 하고, 안 쓰는 화면 코드는 나중에 받는다. +// 로그인('/login')만 공개이고, 나머지는 MainLayout 아래 RequireAuth 로 보호된다. +// ────────────────────────────────────────────────────── + // ── 페이지 레이지 로딩 ────────────────────────────────── const Dashboard = React.lazy(() => import('@/pages/Dashboard')); @@ -154,6 +161,7 @@ const PageFallback = ( ); +// 인증 가드 — accessToken 이 없으면 로그인 페이지로 보낸다(보호 라우트 진입 차단) function RequireAuth({ children }: { children: React.ReactNode }) { const token = localStorage.getItem('accessToken'); return token ? <>{children} : ; diff --git a/ga-frontend/src/api/agent.ts b/ga-frontend/src/api/agent.ts index d3f84af..02524e5 100644 --- a/ga-frontend/src/api/agent.ts +++ b/ga-frontend/src/api/agent.ts @@ -1,5 +1,10 @@ +// ============================================================================ +// 설계사 API — 백엔드 /api/agents (AgentController) +// 설계사(agent): 보험을 모집·관리하는 영업 인력. 조직(org)/등급(grade)에 소속. +// ============================================================================ import api, { PageResponse, unwrap } from './request'; +// 설계사 조회 응답. 조직/등급/입사일 + 계약수/누적수수료 등 요약 포함. export interface AgentResp { agentId: number; agentName: string; @@ -26,6 +31,7 @@ export interface AgentSearchParam { gradeId?: number; } +// 설계사 등록/수정 요청. residentNo(주민번호)·계좌정보 등 개인정보 포함. export interface AgentSaveReq { agentName: string; orgId: number; @@ -40,11 +46,16 @@ export interface AgentSaveReq { } export const agentApi = { + // 설계사 목록 — GET /api/agents list: (p: AgentSearchParam) => unwrap>(api.get('/api/agents', { params: p })), + // 설계사 상세 — GET /api/agents/{id} detail: (id: number) => unwrap(api.get(`/api/agents/${id}`)), + // 설계사 등록(생성된 id 반환) — POST /api/agents create: (req: AgentSaveReq) => unwrap(api.post('/api/agents', req)), + // 설계사 수정 — PUT /api/agents/{id} update: (id: number, req: AgentSaveReq) => unwrap(api.put(`/api/agents/${id}`, req)), + // 상태 변경(재직/휴직/해촉 등) — PUT /api/agents/{id}/status changeStatus: (id: number, status: string) => unwrap(api.put(`/api/agents/${id}/status`, { status })), }; diff --git a/ga-frontend/src/api/auth.ts b/ga-frontend/src/api/auth.ts index c49c4db..e1df7a8 100644 --- a/ga-frontend/src/api/auth.ts +++ b/ga-frontend/src/api/auth.ts @@ -1,10 +1,17 @@ +// ============================================================================ +// 인증/로그인 API — 백엔드 /api/auth/* (AuthController) +// 로그인 후 받은 accessToken 을 localStorage 에 저장하면 request.ts 인터셉터가 +// 모든 요청에 자동으로 붙여준다. +// ============================================================================ import api, { unwrap } from './request'; +// 로그인 요청 본문. loginId(사번/아이디) + password. export interface LoginReq { loginId: string; password: string; } +// 로그인 응답. accessToken(JWT) + 사용자 기본정보 + 권한(roles). export interface LoginResp { accessToken: string; refreshToken?: string; @@ -15,6 +22,7 @@ export interface LoginResp { roles: string[]; } +// 내 정보(me) 응답. 로그인 사용자와 연결된 설계사(agent)/조직 정보 포함. export interface UserResp { userId: number; loginId: string; @@ -29,11 +37,16 @@ export interface UserResp { } export const authApi = { + // 로그인: 아이디/비밀번호로 토큰 발급 — POST /api/auth/login login: (req: LoginReq) => unwrap(api.post('/api/auth/login', req)), + // 토큰 갱신: refreshToken 으로 새 accessToken 발급 — POST /api/auth/refresh refresh: (refreshToken: string) => unwrap(api.post('/api/auth/refresh', { refreshToken })), + // 내 정보 조회 — GET /api/auth/me me: () => unwrap(api.get('/api/auth/me')), + // 로그아웃 — POST /api/auth/logout logout: () => unwrap(api.post('/api/auth/logout')), + // 비밀번호 변경 — POST /api/auth/password changePassword: (oldPassword: string, newPassword: string) => unwrap(api.post('/api/auth/password', { oldPassword, newPassword })), }; diff --git a/ga-frontend/src/api/commission.ts b/ga-frontend/src/api/commission.ts index 45bf4ce..cab4e43 100644 --- a/ga-frontend/src/api/commission.ts +++ b/ga-frontend/src/api/commission.ts @@ -1,6 +1,26 @@ +// ============================================================================ +// 수수료 종합 API — 백엔드 /api/commissions 외 다수 수수료 컨트롤러 +// ---------------------------------------------------------------------------- +// 이 파일 하나가 "수수료 계산"과 관련된 여러 도메인을 한데 모아 다룬다. +// 큰 흐름: 각 수수료 종류마다 (1) 규칙(Rule) = 요율/금액 정의, (2) 원장(Ledger) = +// 설계사별 실제 산출 결과. 화면은 보통 규칙 CRUD + 원장 목록조회로 구성된다. +// +// [보험 수수료 용어 풀이 — 신입 필독] +// - 수수료(commission): 보험계약 체결/유지로 설계사가 받는 보수. +// - 수금수수료(collection): 보험료가 실제로 수금될 때 지급되는 수수료. +// - 갱신수수료(renewal): 계약 갱신 시 발생하는 수수료. +// - 부활수수료(reinstatement): 실효된 계약이 부활될 때의 수수료. +// - 유지보너스(persistency): 계약이 일정 개월(13/25/37/49개월) 유지되면 주는 보너스. +// - 오버라이드(override): 하위 설계사 실적의 일부를 상위 관리자/조직이 받는 수수료. +// - 환수(clawback): 이미 지급한 수수료를 사후에 회수하는 것(조기 해지 등). +// - 정산(settle): 한 달 치 여러 수수료를 합산해 실지급액을 확정하는 절차. +// - 분급/선지급(advance): 미래 수수료를 미리 당겨 지급하는 것. +// - 마진(margin): 회사가 보험사에서 받은 수입 - 설계사에게 준 지급액. +// ============================================================================ import api, { PageResponse, unwrap } from './request'; // ── 공통 ────────────────────────────────────────── +// 수수료 화면 공통 검색 파라미터(설계사/정산월/수수료종류/페이지). export interface CommissionSearchParam { agentId?: number; agentName?: string; @@ -11,6 +31,9 @@ export interface CommissionSearchParam { } // ── CommissionMaster ────────────────────────────── +// 수수료 마스터: 설계사 1명의 한 달치 수수료 집계 행. +// - grossAmount: 세전 총액, netAmount: 공제/세금 반영 후 실지급액 +// - status: 처리상태(산출/확정 등) export interface CommissionMasterRow extends Record { masterId: number; agentId: number; @@ -23,7 +46,8 @@ export interface CommissionMasterRow extends Record { status: string; } -// ── CollectionCommission ────────────────────────── +// ── CollectionCommission(수금수수료) ────────────── +// Rule: 보험사/상품유형/납입주기별 수금수수료 요율(ratePercent)과 적용기간. export interface CollectionRuleRow extends Record { ruleId: number; carrierId: number; @@ -36,19 +60,21 @@ export interface CollectionRuleRow extends Record { status: string; } +// Ledger: 계약별 실제 수금액(collectionAmount)에 요율을 적용한 수수료 산출 결과. export interface CollectionLedgerRow extends Record { ledgerId: number; agentId: number; agentName: string; contractNo: string; settleMonth: string; - collectionAmount: number; - commissionAmount: number; + collectionAmount: number; // 실제 수금된 보험료 + commissionAmount: number; // 산출된 수수료액 ratePercent: number; status: string; } -// ── RenewalCommission ───────────────────────────── +// ── RenewalCommission(갱신수수료) ────────────────── +// 계약 갱신 시 갱신보험료(renewalPremium)에 요율을 곱해 산출. export interface RenewalRuleRow extends Record { ruleId: number; carrierId: number; @@ -71,7 +97,8 @@ export interface RenewalLedgerRow extends Record { status: string; } -// ── ReinstatementCommission ─────────────────────── +// ── ReinstatementCommission(부활수수료) ──────────── +// 실효된 계약이 부활될 때 부활보험료(reinstatementPremium) 기준으로 산출. export interface ReinstatementRuleRow extends Record { ruleId: number; carrierId: number; @@ -94,7 +121,8 @@ export interface ReinstatementLedgerRow extends Record { status: string; } -// ── PersistencyBonus ────────────────────────────── +// ── PersistencyBonus(유지보너스) ─────────────────── +// 계약 유지율(retentionRate)이 기준(minRate) 이상이면 13/25/37/49개월 시점에 지급. export interface PersistencyRuleRow extends Record { ruleId: number; bonusType: string; // 13M/25M/37M/49M @@ -118,7 +146,9 @@ export interface PersistencyBonusRow extends Record { status: string; } -// ── OverrideLedger ──────────────────────────────── +// ── OverrideLedger(오버라이드 원장) ──────────────── +// 하위 설계사(fromAgent)의 기준수수료(baseCommission)에 오버라이드 요율을 곱해 +// 상위 관리자/조직(toAgent)에게 귀속되는 금액(overrideAmount)을 기록. export interface OverrideLedgerRow extends Record { ledgerId: number; fromAgentId: number; @@ -144,7 +174,8 @@ export interface OverrideLedgerSearchParam { pageSize?: number; } -// ── P7: IncomeCommission ────────────────────────── +// ── P7: IncomeCommission(수입수수료) ─────────────── +// 회사가 보험사로부터 받는 수입수수료. premiumAmount×companyRate=incomeAmount. export interface IncomeLedgerRow extends Record { incomeId: number; contractId: number; @@ -159,6 +190,7 @@ export interface IncomeLedgerRow extends Record { status: string; } +// 마진 요약: 수입(incomeTotal) - 지급(payoutTotal) = 마진(marginAmount). export interface MarginSummaryRow extends Record { marginId: number; settleMonth: string; @@ -170,7 +202,8 @@ export interface MarginSummaryRow extends Record { marginRate?: number; } -// ── P7: ReferralCommission ──────────────────────── +// ── P7: ReferralCommission(소개수수료) ───────────── +// 계약을 소개한 설계사(referrer)에게 지급. calcType: RATE(요율) 또는 FIXED(정액). export interface ReferralRuleRow extends Record { ruleId: number; referralType: string; @@ -195,7 +228,9 @@ export interface ReferralLedgerRow extends Record { status: string; } -// ── P7: SettlingSupport ─────────────────────────── +// ── P7: SettlingSupport(정착지원금) ──────────────── +// 신입/도입 설계사에게 일정기간(durationMonths) 지급하는 정착 지원금. +// 조기 이탈 시 clawbackMonths 기준으로 환수(clawbackAmount)될 수 있다. export interface SettlingSupportRuleRow extends Record { ruleId: number; agentGrade: string; @@ -217,7 +252,8 @@ export interface SettlingSupportLedgerRow extends Record { status: string; } -// ── P7: RecognizedPerformance ───────────────────── +// ── P7: RecognizedPerformance(인정실적) ──────────── +// 상품별 환산율(conversionRate)로 보험료를 환산하고, 인정율을 적용해 인정실적 산출. export interface ConversionRateRow extends Record { rateId: number; productId: number; @@ -242,7 +278,8 @@ export interface RecognizedLedgerRow extends Record { status: string; } -// ── P7: Award ───────────────────────────────────── +// ── P7: Award(시상/포상금) ───────────────────────── +// 실적이 기준(thresholdAmount)을 넘으면 지급하는 시상금. 요율(awardRate) 또는 정액(awardFixed). export interface AwardRuleRow extends Record { ruleId: number; awardType: string; @@ -267,12 +304,14 @@ export interface AwardLedgerRow extends Record { status: string; } +// 시상금 환수 요청: 지급된 시상금 일부/전부를 회수(환수)할 때 금액과 사유. export interface AwardClawbackReq { clawbackAmount: number; reason: string; } -// ── P7: LifeSupport ─────────────────────────────── +// ── P7: LifeSupport(생활지원금) ──────────────────── +// 설계사 등급(agentGrade)별 요율로 지급하는 생활지원금. 환수 발생 가능. export interface LifeSupportRuleRow extends Record { ruleId: number; agentGrade: string; @@ -294,7 +333,8 @@ export interface LifeSupportLedgerRow extends Record { status: string; } -// ── P7: BranchAllowance ─────────────────────────── +// ── P7: BranchAllowance(지점장수당) ──────────────── +// 직책(positionCode)별 수당. 요율 적용하되 상한(capAmount)이 있으면 그 이하로 제한. export interface BranchAllowanceRuleRow extends Record { ruleId: number; positionCode: string; @@ -318,7 +358,8 @@ export interface BranchAllowanceLedgerRow extends Record { status: string; } -// ── MOD-1: RecruiterCommission ──────────────────── +// ── MOD-1: RecruiterCommission(리크루팅 수수료) ──── +// 다른 설계사를 도입(리크루팅)한 설계사(recruiter)에게 지급하는 수수료. export interface RecruiterRuleRow extends Record { ruleId: number; recruiterType: string; @@ -342,7 +383,8 @@ export interface RecruiterLedgerRow extends Record { status: string; } -// ── MOD-3: ClawbackInstallment ──────────────────── +// ── MOD-3: ClawbackInstallment(환수 분할상환) ────── +// 환수금(receivable)을 한 번에 회수하지 않고 여러 회차(installment)로 나눠 상환하는 계획. export interface ClawbackInstallmentRow extends Record { planId: number; receivableId: number; @@ -359,7 +401,8 @@ export interface ClawbackInstallmentGenerateReq { startMonth: string; } -// ── MOD-4: LapsedReinstatement ──────────────────── +// ── MOD-4: LapsedReinstatement(실효-부활 수수료 회복) ── +// 계약이 실효(lapsed)되면 수수료를 보류(withheld)했다가, 부활(reinstated)되면 다시 지급(repaid). export interface LapsedReinstatementRow extends Record { recoveryId: number; contractId: number; @@ -381,7 +424,8 @@ export interface LapsedReinstatementSaveReq { withheldAmount: number; } -// ── MOD-6: StatuteConfig ────────────────────────── +// ── MOD-6: StatuteConfig(소멸시효 설정) ──────────── +// 채권(환수금 등)의 소멸 기준 개월수(expirationMonths) 설정. 기간 경과 시 소멸 처리. export interface StatuteConfigRow extends Record { configId: number; entityType: string; @@ -389,7 +433,8 @@ export interface StatuteConfigRow extends Record { isActive: string; } -// ── MOD-7: ChargebackWaiver ─────────────────────── +// ── MOD-7: ChargebackWaiver(환수 면제) ───────────── +// 원래 환수해야 할 금액(originalChargeback) 중 일부/전부를 면제(waiverAmount)하는 결재 건. export interface ChargebackWaiverRow extends Record { requestId: number; contractId: number; @@ -415,8 +460,11 @@ export interface ChargebackWaiverSaveReq { } // ── API ─────────────────────────────────────────── +// 수수료 도메인 호출 모음. 각 수수료 종류별로 규칙(rules) CRUD + 원장(ledgers) 조회. +// 패턴: xxxRules(목록) / xxxRuleCreate(등록) / xxxRuleUpdate(수정) / +// xxxRuleDelete(삭제) / xxxLedgers(원장 목록조회). export const commissionApi = { - // CommissionMaster + // CommissionMaster: 수수료 마스터 목록 — GET /api/commissions masterList: (p: CommissionSearchParam) => unwrap>( api.get('/api/commissions', { params: p }), diff --git a/ga-frontend/src/api/contract.ts b/ga-frontend/src/api/contract.ts index e595d31..70e625a 100644 --- a/ga-frontend/src/api/contract.ts +++ b/ga-frontend/src/api/contract.ts @@ -1,5 +1,12 @@ +// ============================================================================ +// 보험계약 API — 백엔드 /api/contracts (ContractController) +// 계약(contract): 고객이 가입한 보험증권 1건. 모든 수수료 계산의 출발점. +// - premium(보험료): 계약자가 내는 보험료. payCycle(납입주기): 월납/연납 등. +// - policyNo(증권번호): 계약 식별 번호. contractorName(계약자)/insuredName(피보험자). +// ============================================================================ import api, { PageResponse, unwrap } from './request'; +// 계약 조회 응답. 설계사/상품/보험사/보험료/상태 등 계약 요약. export interface ContractResp { contractId: number; agentId: number; @@ -35,6 +42,7 @@ export interface ContractSearchParam { endDate?: string; } +// 계약 등록/수정 요청. payPeriod(납입기간)/coveragePeriod(보장기간) 등 포함. export interface ContractSaveReq { agentId: number; productId: number; @@ -51,9 +59,13 @@ export interface ContractSaveReq { } export const contractApi = { + // 계약 목록 — GET /api/contracts list: (p: ContractSearchParam) => unwrap>(api.get('/api/contracts', { params: p })), + // 계약 상세 — GET /api/contracts/{id} detail: (id: number) => unwrap(api.get(`/api/contracts/${id}`)), + // 계약 등록(생성된 id 반환) — POST /api/contracts create: (req: ContractSaveReq) => unwrap(api.post('/api/contracts', req)), + // 계약 수정 — PUT /api/contracts/{id} update: (id: number, req: ContractSaveReq) => unwrap(api.put(`/api/contracts/${id}`, req)), }; diff --git a/ga-frontend/src/api/request.ts b/ga-frontend/src/api/request.ts index 8f0102b..2d74224 100644 --- a/ga-frontend/src/api/request.ts +++ b/ga-frontend/src/api/request.ts @@ -1,6 +1,19 @@ +// ============================================================================ +// [공통] HTTP 통신 기반 모듈 — axios 인스턴스 + 응답 래퍼 +// ---------------------------------------------------------------------------- +// 이 파일은 모든 API 모듈(commission.ts, settle.ts 등)이 공유하는 공통 토대다. +// - 백엔드는 항상 { success, code, message, data } 형태의 JSON(ApiResponse)으로 응답한다. +// - 신입 가이드: 다른 *.ts 의 함수들은 여기서 만든 api 인스턴스와 unwrap() 을 import 해 사용한다. +// ============================================================================ import axios, { AxiosError } from 'axios'; import { message } from 'antd'; +// 백엔드 표준 응답 봉투(envelope). 실제 데이터는 data 필드 안에 들어 있다. +// - success: 성공 여부(false 면 비즈니스 오류) +// - code : 응답/오류 코드 문자열 +// - message: 사용자에게 보여줄 메시지 +// - data : 실제 페이로드(제네릭 T) +// - timestamp: 서버 응답 시각 export interface ApiResponse { success: boolean; code: string; @@ -9,6 +22,9 @@ export interface ApiResponse { timestamp: string; } +// 목록 조회용 페이지네이션 응답. list(현재 페이지 데이터) + 페이지 메타. +// - pageNum: 현재 페이지 번호, pageSize: 페이지당 건수 +// - total : 전체 건수, pages: 전체 페이지 수 export interface PageResponse { list: T[]; pageNum: number; @@ -17,17 +33,20 @@ export interface PageResponse { pages: number; } +// axios 인스턴스 생성. baseURL 은 환경변수(VITE_API_URL), 타임아웃 60초. const api = axios.create({ baseURL: import.meta.env.VITE_API_URL ?? '', timeout: 60_000, }); +// [요청 인터셉터] 모든 요청에 로그인 토큰(JWT)을 Authorization 헤더로 자동 첨부. api.interceptors.request.use((config) => { const token = localStorage.getItem('accessToken'); if (token) config.headers.Authorization = `Bearer ${token}`; return config; }); +// [응답 인터셉터] 성공 응답이라도 success=false 면 비즈니스 오류로 간주해 토스트 표시. api.interceptors.response.use( (res) => { const body = res.data; @@ -37,6 +56,7 @@ api.interceptors.response.use( } return res; }, + // [HTTP 오류 처리] 401: 토큰 만료/미인증 → 로그인 화면으로. 403: 권한 없음. 그 외: 서버 오류. (err: AxiosError>) => { const status = err.response?.status; const body = err.response?.data; diff --git a/ga-frontend/src/api/settle.ts b/ga-frontend/src/api/settle.ts index 4c17aff..455d0c1 100644 --- a/ga-frontend/src/api/settle.ts +++ b/ga-frontend/src/api/settle.ts @@ -1,5 +1,18 @@ +// ============================================================================ +// 정산 API — 백엔드 /api/settle (SettleController) 외 분급/대사/배치 +// ---------------------------------------------------------------------------- +// 정산(settle): 한 달치 모든 수수료(모집/유지/오버라이드 - 환수 등)를 설계사별로 +// 합산해 실지급액(netAmount)을 확정하는 절차. 흐름: 산출 → 확정(confirm) / +// 보류(hold) / 보류해제(release). 이 파일엔 정산 외에 선지급(분급), 보험사 대사, +// 정산 배치 실행 API 도 함께 들어 있다. +// ============================================================================ import api, { PageResponse, unwrap } from './request'; +// 정산 결과 행: 설계사 1명의 한 달치 정산 명세. +// - recruitTotal: 모집수수료 합, maintainTotal: 유지수수료 합 +// - exceptionPlus/Minus: 예외 가감액(+/-), overrideTotal: 오버라이드 합 +// - chargebackTotal: 환수 합, grossAmount: 세전 총액 +// - taxAmount: 세금, netAmount: 실지급액, status: 정산상태, holdReason: 보류사유 export interface SettleResp { settleId: number; agentId: number; @@ -31,21 +44,29 @@ export interface SettleSearchParam { } export const settleApi = { + // 정산 목록 조회 — GET /api/settle list: (p: SettleSearchParam) => unwrap>(api.get('/api/settle', { params: p })), + // 월 정산 요약(항목별 합계) — GET /api/settle/summary summary: (settleMonth: string) => unwrap>(api.get('/api/settle/summary', { params: { settleMonth } })), + // 조직별 정산 요약 — GET /api/settle/org-summary orgSummary: (settleMonth: string) => unwrap>>(api.get('/api/settle/org-summary', { params: { settleMonth } })), + // 정산 확정(지급 확정) — PUT /api/settle/{id}/confirm confirm: (settleId: number) => unwrap(api.put(`/api/settle/${settleId}/confirm`)), + // 정산 보류(지급 보류, 사유 필수) — PUT /api/settle/{id}/hold hold: (settleId: number, reason: string) => unwrap(api.put(`/api/settle/${settleId}/hold`, { reason })), + // 보류 해제 — PUT /api/settle/{id}/release release: (settleId: number) => unwrap(api.put(`/api/settle/${settleId}/release`)), }; -// ── MOD-2: AdvancePayment ───────────────────────── +// ── MOD-2: AdvancePayment(선지급/분급) ───────────── +// 미래 수수료를 미리 당겨 지급(principalAmount)하고, 이후 정산에서 조금씩 회수 +// (recoveredAmount)한다. balance = 미회수 잔액. export interface AdvancePaymentRow extends Record { advanceId: number; agentId: number; @@ -81,7 +102,9 @@ export interface AdvancePaymentSearchParam { advanceMonth?: string; } -// ── MOD-5: CarrierReconIn ───────────────────────── +// ── MOD-5: CarrierReconIn(보험사 수수료 대사) ────── +// 보험사가 보고한 수수료총액(reportedTotal)과 우리 시스템 산출총액(systemTotal)을 +// 대조(reconcile)해 차이(varianceAmount)를 찾아내는 대사 작업. export interface CarrierReconInRow extends Record { reconId: number; companyId: number; @@ -120,30 +143,42 @@ export interface CarrierReconSearchParam { status?: string; } +// 선지급(분급) API — /api/advance-payments export const advancePaymentApi = { + // 선지급 목록 — GET /api/advance-payments list: (p: AdvancePaymentSearchParam) => unwrap>(api.get('/api/advance-payments', { params: p })), + // 선지급 등록 — POST /api/advance-payments create: (body: AdvancePaymentSaveReq) => unwrap(api.post('/api/advance-payments', body)), + // 잔액 면제(회수 포기) — PUT /api/advance-payments/{id}/waive waive: (advanceId: number) => unwrap(api.put(`/api/advance-payments/${advanceId}/waive`)), + // 회수 이력 조회 — GET /api/advance-payments/{id}/recoveries recoveries: (advanceId: number) => unwrap(api.get(`/api/advance-payments/${advanceId}/recoveries`)), }; +// 보험사 수수료 대사 API — /api/carrier-recon-in export const carrierReconInApi = { + // 대사 목록 — GET /api/carrier-recon-in list: (p: CarrierReconSearchParam) => unwrap>(api.get('/api/carrier-recon-in', { params: p })), + // 대사 건 등록(보고 데이터 업로드) — POST /api/carrier-recon-in create: (body: CarrierReconInSaveReq) => unwrap(api.post('/api/carrier-recon-in', body)), + // 대사 실행(차이 산출) — POST /api/carrier-recon-in/{id}/reconcile reconcile: (reconId: number) => unwrap(api.post(`/api/carrier-recon-in/${reconId}/reconcile`)), + // 대사 결과 승인 — PUT /api/carrier-recon-in/{id}/approve approve: (reconId: number) => unwrap(api.put(`/api/carrier-recon-in/${reconId}/approve`)), + // 차이 내역 조회 — GET /api/carrier-recon-in/{id}/variances variances: (reconId: number) => unwrap(api.get(`/api/carrier-recon-in/${reconId}/variances`)), }; +// 정산 배치 작업 로그: 진행률(progressPct)/현재단계(currentStep)/소요시간 등. export interface BatchJobLog { jobId: number; jobName: string; @@ -157,9 +192,12 @@ export interface BatchJobLog { durationSec?: number; } +// 정산 배치 API — /api/batch export const batchApi = { + // 정산 배치 실행(해당 월 전체 재계산) — POST /api/batch/settlement/run run: (settleMonth: string) => unwrap(api.post('/api/batch/settlement/run', { settleMonth })), + // 배치 진행 상태 조회 — GET /api/batch/status status: () => unwrap(api.get('/api/batch/status')), }; diff --git a/ga-frontend/src/components/biz/ApprovalProgress.tsx b/ga-frontend/src/components/biz/ApprovalProgress.tsx index e4108d5..5443040 100644 --- a/ga-frontend/src/components/biz/ApprovalProgress.tsx +++ b/ga-frontend/src/components/biz/ApprovalProgress.tsx @@ -5,17 +5,22 @@ import { COLOR_SUCCESS, COLOR_ERROR, COLOR_PRIMARY, GRAY } from '@/theme/tokens' const { Text } = Typography; +// Props +// - steps: 결재선 단계 목록(각 단계의 결재자/상태/처리시각/의견) +// - currentStep: 현재 진행 중인 단계 번호(1부터 시작) interface Props { steps: ApprovalRequestStep[]; currentStep: number; } +// 단계 상태값을 단계 아이콘(승인=체크, 반려=X, 그 외=시계)으로 변환 function stepIcon(status: string) { if (status === 'APPROVED') return ; if (status === 'REJECTED') return ; return ; } +// 단계 상태값을 antd Steps 가 이해하는 상태(finish/error/process/wait)로 변환 function stepStatus(status: string): 'finish' | 'error' | 'process' | 'wait' { if (status === 'APPROVED') return 'finish'; if (status === 'REJECTED') return 'error'; diff --git a/ga-frontend/src/components/biz/AttachmentUpload.tsx b/ga-frontend/src/components/biz/AttachmentUpload.tsx index d344ce8..2bde140 100644 --- a/ga-frontend/src/components/biz/AttachmentUpload.tsx +++ b/ga-frontend/src/components/biz/AttachmentUpload.tsx @@ -8,6 +8,9 @@ import { GRAY, RADIUS, SHADOW, COLOR_ERROR } from '@/theme/tokens'; const { Text } = Typography; const { Dragger } = Upload; +// Props: 첨부파일이 어떤 대상에 묶이는지 식별 +// - refType: 대상 종류(예: 'CONTRACT', 'WITHDRAW' 등 업무 구분) +// - refId: 그 대상의 PK interface Props { refType: string; refId: number; @@ -22,12 +25,14 @@ export default function AttachmentUpload({ refType, refId }: Props) { const [uploading, setUploading] = useState(false); const [progress, setProgress] = useState(0); + // 현재 대상(refType+refId)에 달린 첨부파일 목록 조회 (refId 가 있어야 실행) const { data: files = [], isLoading } = useQuery({ queryKey: ['attachments', refType, refId], queryFn: () => attachmentApi.list(refType, refId), enabled: !!refId, }); + // 파일 업로드 — 진행률 콜백으로 Progress 갱신, 완료 후 목록 캐시 무효화로 재조회 const handleUpload = async (file: File) => { setUploading(true); setProgress(0); @@ -44,6 +49,7 @@ export default function AttachmentUpload({ refType, refId }: Props) { return false; // prevent antd auto-upload }; + // 첨부파일 삭제 후 목록 재조회 const handleDelete = async (id: number, name: string) => { try { await attachmentApi.delete(id); @@ -54,6 +60,7 @@ export default function AttachmentUpload({ refType, refId }: Props) { } }; + // 바이트 크기를 사람이 읽기 쉬운 단위(B/KB/MB)로 변환 const fmtSize = (bytes: number) => { if (bytes < 1024) return `${bytes}B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; diff --git a/ga-frontend/src/components/biz/NotificationBell.tsx b/ga-frontend/src/components/biz/NotificationBell.tsx index 1553947..15c4e03 100644 --- a/ga-frontend/src/components/biz/NotificationBell.tsx +++ b/ga-frontend/src/components/biz/NotificationBell.tsx @@ -13,6 +13,7 @@ const { Text } = Typography; export default function NotificationBell() { const qc = useQueryClient(); + // 미읽음 개수 — 30초마다 자동 재조회(refetchInterval)하여 뱃지 숫자를 갱신 const { data: count = 0 } = useQuery({ queryKey: ['notifications', 'unread-count'], queryFn: notificationApi.unreadCount, @@ -26,11 +27,13 @@ export default function NotificationBell() { const notifications = page?.list ?? []; + // 알림 1건 읽음 처리 후, 캐시 무효화로 카운트/목록을 다시 불러온다 const handleRead = async (id: number) => { await notificationApi.markRead(id); qc.invalidateQueries({ queryKey: ['notifications'] }); }; + // 전체 읽음 처리 const handleReadAll = async () => { await notificationApi.markAllRead(); qc.invalidateQueries({ queryKey: ['notifications'] }); diff --git a/ga-frontend/src/components/common/CodeSelect.tsx b/ga-frontend/src/components/common/CodeSelect.tsx index 941f03e..91df4c9 100644 --- a/ga-frontend/src/components/common/CodeSelect.tsx +++ b/ga-frontend/src/components/common/CodeSelect.tsx @@ -1,12 +1,21 @@ import { Select, SelectProps } from 'antd'; import { useCommonCodes } from '@/hooks/useCommonCodes'; +// Props: antd Select props(options 제외 — 내부에서 자동 생성) + 공통코드 관련 옵션 +// - groupCode: 불러올 공통코드 그룹(예: 'INSURANCE_TYPE') +// - includeAll: 맨 앞에 '전체' 선택지를 넣을지 여부(주로 검색 필터에서 사용) +// - allLabel: '전체' 선택지의 라벨 텍스트 interface Props extends Omit { groupCode: string; includeAll?: boolean; allLabel?: string; } +/** + * 공통코드 드롭다운 — 보험종류/계약상태 등 코드성 값을 고르는 곳마다 재사용된다. + * useCommonCodes 로 그룹별 코드 목록을 받아 Select 의 options 로 자동 변환한다. + * 화면마다 코드 목록을 직접 fetch/가공하지 않도록 공통화했다. + */ export default function CodeSelect({ groupCode, includeAll, allLabel = '전체', ...rest }: Props) { const { data = [], isLoading } = useCommonCodes(groupCode); const options = [ diff --git a/ga-frontend/src/components/common/ErrorBoundary.tsx b/ga-frontend/src/components/common/ErrorBoundary.tsx index 1569b32..1e72c9d 100644 --- a/ga-frontend/src/components/common/ErrorBoundary.tsx +++ b/ga-frontend/src/components/common/ErrorBoundary.tsx @@ -5,11 +5,17 @@ interface Props { children: ReactNode; } +// State: 하위 렌더링 중 오류가 났는지(hasError)와 그 오류 객체(error)를 보관 interface State { hasError: boolean; error: Error | null; } +/** + * 에러 경계(Error Boundary) — MainLayout 의 을 감싸 화면 렌더링 중 예외를 잡는다. + * 한 페이지에서 에러가 나도 앱 전체가 하얗게 죽지 않고 "다시 시도" 화면을 보여준다. + * React 의 에러 경계는 함수 컴포넌트로 만들 수 없어 클래스 컴포넌트로 작성한다. + */ export default class ErrorBoundary extends Component { constructor(props: Props) { super(props); diff --git a/ga-frontend/src/components/common/ExcelExportButton.tsx b/ga-frontend/src/components/common/ExcelExportButton.tsx index e8a131f..7c15589 100644 --- a/ga-frontend/src/components/common/ExcelExportButton.tsx +++ b/ga-frontend/src/components/common/ExcelExportButton.tsx @@ -2,6 +2,10 @@ import { Button, ButtonProps, message } from 'antd'; import { DownloadOutlined } from '@ant-design/icons'; import api from '@/api/request'; +// Props: antd Button props(onClick 제외) + 다운로드에 필요한 값 +// - url: 엑셀을 내려주는 백엔드 API 경로 +// - params: 현재 검색조건(어떤 SearchParam VO 든 받을 수 있도록 unknown record) +// - fileName: 저장될 파일 이름(확장자 .xlsx 는 자동으로 붙음) interface Props extends Omit { url: string; // 어떤 SearchParam VO 든 받을 수 있도록 unknown record @@ -10,9 +14,12 @@ interface Props extends Omit { } /** - * 대용량 엑셀 다운로드. axios responseType=blob, content-disposition 무시하고 fileName 강제. + * 엑셀 내보내기 공통 버튼 — 목록 화면들의 "엑셀" 다운로드 버튼에서 재사용된다. + * 응답을 blob 으로 받아(브라우저 메모리상 임시 파일) 클릭을 흉내내 저장한다. + * 서버가 내려주는 content-disposition 파일명을 무시하고 props 의 fileName 으로 강제 통일한다. */ export default function ExcelExportButton({ url, params, fileName, ...rest }: Props) { + // 버튼 클릭 시: 검색조건을 그대로 쿼리스트링으로 보내 엑셀 blob 을 받아 내려받는다 const handleClick = async () => { try { const res = await api.get(url, { params, responseType: 'blob' }); diff --git a/ga-frontend/src/components/common/PermissionButton.tsx b/ga-frontend/src/components/common/PermissionButton.tsx index f841850..fef99a9 100644 --- a/ga-frontend/src/components/common/PermissionButton.tsx +++ b/ga-frontend/src/components/common/PermissionButton.tsx @@ -1,12 +1,20 @@ import { Button, ButtonProps } from 'antd'; import { usePermission } from '@/hooks/usePermission'; +// Props: antd Button 의 모든 props 를 그대로 받으면서(menuCode/permCode 추가) +// - menuCode: 권한을 확인할 메뉴 식별자 (예: 'PRODUCT_COMPANY') +// - permCode: 이 버튼이 요구하는 권한 종류 interface Props extends ButtonProps { menuCode: string; permCode: 'READ' | 'CREATE' | 'UPDATE' | 'DELETE' | 'APPROVE' | 'EXPORT' | 'EXECUTE'; } -/** 권한 없으면 렌더링 자체를 안 함 */ +/** + * 권한 기반 버튼 — 거의 모든 화면의 등록/수정/삭제/실행 버튼에서 재사용된다. + * usePermission 으로 현재 사용자가 해당 menuCode 에 permCode 권한을 가졌는지 확인하고, + * 권한이 없으면 null 을 반환해 버튼 자체를 화면에 그리지 않는다(숨김 처리). + * 권한 체크를 화면마다 if 문으로 흩뿌리지 않으려고 공통화했다. + */ export default function PermissionButton({ menuCode, permCode, children, ...rest }: Props) { const perms = usePermission(menuCode); const allowed = diff --git a/ga-frontend/src/components/common/TemplateUpload.tsx b/ga-frontend/src/components/common/TemplateUpload.tsx index ca1f458..8993655 100644 --- a/ga-frontend/src/components/common/TemplateUpload.tsx +++ b/ga-frontend/src/components/common/TemplateUpload.tsx @@ -15,7 +15,14 @@ interface Props { onComplete?: (result: UploadResultResp) => void; } +/** + * 템플릿 기반 엑셀/CSV 업로드 공통 컴포넌트 — 대량 데이터 적재 화면에서 재사용된다. + * 흐름: 업로드 템플릿 선택 → 파일 드래그/선택 → 서버 업로드 → 결과(성공/오류 건수) 모달 표시. + * 업로드 UI/진행률/결과 표시 로직을 매 화면에 중복 작성하지 않으려고 공통화했다. + */ export default function TemplateUpload({ category, onComplete }: Props) { + // templateCode: 선택한 업로드 템플릿 / uploading: 업로드 진행 중 여부 + // progress: 진행률(0~100) / result: 서버가 준 업로드 결과 / resultVisible: 결과 모달 표시 여부 const [templateCode, setTemplateCode] = useState(); const [uploading, setUploading] = useState(false); const [progress, setProgress] = useState(0); @@ -41,6 +48,7 @@ export default function TemplateUpload({ category, onComplete }: Props) { label: `[${t.category}] ${t.templateName}`, })); + // 파일을 골랐을 때 실행 — 템플릿 미선택이면 막고, 선택됐으면 서버로 업로드한다 const handleUpload = async (file: RcFile): Promise => { if (!templateCode) { message.warning('템플릿을 먼저 선택하세요'); diff --git a/ga-frontend/src/layouts/MainLayout.tsx b/ga-frontend/src/layouts/MainLayout.tsx index a01ffee..e020fd5 100644 --- a/ga-frontend/src/layouts/MainLayout.tsx +++ b/ga-frontend/src/layouts/MainLayout.tsx @@ -17,6 +17,7 @@ import { const { Text } = Typography; +// 좌측 메뉴 접힘 상태를 저장할 localStorage 키(새로고침 후에도 유지) const COLLAPSE_KEY = 'ga-sider-collapsed'; /** API 메뉴 트리 → ProLayout MenuDataItem */ @@ -38,6 +39,11 @@ function readCollapsed(): boolean { try { return localStorage.getItem(COLLAPSE_KEY) === 'true'; } catch { return false; } } +/** + * 메인 레이아웃 — 로그인 후 모든 화면을 감싸는 공통 틀. + * 좌측 사이드 메뉴 + 상단 헤더(정산월 선택/알림벨/사용자 메뉴) + 본문() 구성. + * 메뉴 트리는 /menu/my API 로 받아오므로 사용자 권한에 따라 보이는 메뉴가 달라진다. + */ export default function MainLayout() { const navigate = useNavigate(); const location = useLocation(); @@ -50,11 +56,13 @@ export default function MainLayout() { try { localStorage.setItem(COLLAPSE_KEY, String(collapsed)); } catch { /* ignore */ } }, [collapsed]); + // 현재 사용자가 접근 가능한 메뉴 트리 조회 const { data: menus = [] } = useQuery({ queryKey: ['menu', 'my'], queryFn: menuApi.myMenus, }); + // 로그아웃 — 서버 세션 종료 시도 후 토큰/스토어 정리하고 로그인 페이지로 이동 const handleLogout = async () => { try { await authApi.logout(); } catch { /* ignore */ } localStorage.removeItem('accessToken'); diff --git a/ga-frontend/src/main.tsx b/ga-frontend/src/main.tsx index 8251199..92397b8 100644 --- a/ga-frontend/src/main.tsx +++ b/ga-frontend/src/main.tsx @@ -13,13 +13,24 @@ import { import App from './App'; import './index.css'; +// ────────────────────────────────────────────────────── +// 앱의 진입점(entry point). 여기서 React 트리를 실제 DOM 에 붙인다. +// 전역 Provider 들을 한 곳에 모아 감싼다: +// ConfigProvider(antd 한국어 locale + 디자인 테마) +// → QueryClientProvider(React Query 서버상태 캐시) +// → BrowserRouter(주소 기반 라우팅) +// → App(실제 라우트 정의) +// ────────────────────────────────────────────────────── + // 토스트 메시지 전역 설정 (top-right, 최대 5개, 3초) message.config({ top: 16, maxCount: 5, duration: 3 }); +// React Query 전역 설정 — 실패 시 1회 재시도, 30초간은 캐시를 신선한 것으로 간주 const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 30_000 } }, }); +// antd 전역 테마 — theme/tokens.ts 의 색상/폰트/반경 값을 antd 디자인 토큰에 주입한다 const theme: ThemeConfig = { token: { // ── 브랜드 — 토스 블루 ── diff --git a/ga-frontend/src/pages/accounting/AccountingCloseList.tsx b/ga-frontend/src/pages/accounting/AccountingCloseList.tsx index 041d6ea..eb161b4 100644 --- a/ga-frontend/src/pages/accounting/AccountingCloseList.tsx +++ b/ga-frontend/src/pages/accounting/AccountingCloseList.tsx @@ -15,8 +15,10 @@ import { } from '@/api/accounting-close'; import { GRAY, RADIUS, SHADOW } from '@/theme/tokens'; +// 숫자 → 천단위 콤마 문자열(값 없으면 '-'). AG-Grid valueFormatter/Table render 에 공용 const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-'; +// AG-Grid 메인 그리드(결산 목록) 컬럼 정의 const COLUMNS: ColDef[] = [ { field: 'closePeriod', headerName: '결산기간', width: 120, pinned: 'left' }, { field: 'closeTypeName', headerName: '결산구분', width: 100 }, @@ -40,8 +42,13 @@ const SNAPSHOT_COLUMNS = [ const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }; +/** + * 회계 결산 화면 — 월/분기/연 결산 생성·마감·재오픈, 계정별 잔액 스냅샷 조회. + * API: accountingCloseApi.list/createDraft/close/reopen/snapshots + * params(검색조건)가 바뀌면 useQuery 가 자동으로 목록을 다시 불러온다. + */ export default function AccountingCloseList() { - const qc = useQueryClient(); + const qc = useQueryClient(); // 액션 성공 후 invalidateQueries 로 목록 갱신용 const [params, setParams] = useState({ pageSize: 200 }); const [selectedRows, setSelectedRows] = useState([]); const [draftOpen, setDraftOpen] = useState(false); diff --git a/ga-frontend/src/pages/commission/Award.tsx b/ga-frontend/src/pages/commission/Award.tsx index 5de34b0..d51e4dd 100644 --- a/ga-frontend/src/pages/commission/Award.tsx +++ b/ga-frontend/src/pages/commission/Award.tsx @@ -1,3 +1,15 @@ +/** + * [시상] 화면 + * - 업무: 설계사에게 주는 시상(인센티브)의 "룰"을 관리하고, 실제 지급된 "원장"을 조회/환수하는 화면. + * 상단 탭 2개: (1) 시상 룰(등록/수정/삭제) (2) 원장(월별 지급 내역 조회 + 환수처리). + * - 호출 API: commissionApi.awardRules / awardRuleCreate·Update·Delete (룰 CRUD), + * awardLedgers (원장 조회), awardLedgerClawback (원장 환수처리). + * - 주요 상태: ledgerParams(원장 검색조건), ruleModalOpen/editingRule(룰 모달), clawbackTarget(환수 대상 원장). + * - 도메인 용어: + * - 시상: 일정 실적/유지율을 달성하면 추가로 주는 포상금. + * - 환수(clawback): 이미 지급한 시상금을 사후 사유(계약 해지 등)로 일부/전부 회수하는 것. + * - 정률(RATE)/정액(FIXED): 시상금을 실적의 비율로 줄지, 고정 금액으로 줄지 계산 방식. + */ import { useState } from 'react'; import { Alert, Form, Input, InputNumber, Modal, Select, Space, Tabs, message } from 'antd'; import { ProCard } from '@ant-design/pro-components'; @@ -17,8 +29,10 @@ import { } from '@/api/commission'; import { GRAY, RADIUS, SHADOW } from '@/theme/tokens'; +// 숫자 천단위 콤마, 없으면 '-' const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-'; +// 시상유형 선택 옵션(룰 등록 모달의 드롭다운) const AWARD_TYPE_OPTIONS = [ { value: 'NEW_RECRUIT', label: '신인도입' }, { value: 'CORPORATE', label: '법인' }, @@ -26,6 +40,7 @@ const AWARD_TYPE_OPTIONS = [ { value: 'PERFORMANCE', label: '실적' }, ]; +// "시상 룰" 탭 그리드 컬럼: 어떤 조건(기준실적)일 때 얼마(시상율/시상정액)를 주는지의 규칙 목록 const RULE_COLS: ColDef[] = [ { field: 'awardType', headerName: '시상유형', width: 130 }, { field: 'calcType', headerName: '계산방식', width: 110 }, @@ -36,6 +51,7 @@ const RULE_COLS: ColDef[] = [ { field: 'effectiveTo', headerName: '적용종료', width: 120 }, ]; +// "원장" 탭 그리드 컬럼: 설계사별·월별 실제 시상 지급 내역(시상금 - 환수액 = 실지급) const LEDGER_COLS: ColDef[] = [ { field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' }, { field: 'agentName', headerName: '설계사', width: 130 }, @@ -59,6 +75,7 @@ export default function Award() { const [clawbackTarget, setClawbackTarget] = useState(null); const [clawbackForm] = Form.useForm(); + // 원장 환수처리 mutation: 성공 시 토스트 + 원장 목록 새로고침 + 모달 닫기 const clawbackMutation = useMutation({ mutationFn: ({ ledgerId, body }: { ledgerId: number; body: AwardClawbackReq }) => commissionApi.awardLedgerClawback(ledgerId, body), @@ -70,17 +87,20 @@ export default function Award() { }, }); + // 환수 모달의 "처리" 버튼: 폼 검증 통과 후 환수 mutation 실행 const handleClawbackOk = async () => { const values = await clawbackForm.validateFields(); clawbackMutation.mutate({ ledgerId: clawbackTarget!.ledgerId, body: values }); }; + // 시상 룰 목록 조회(읽기) const { data: rulesData, isLoading: rulesLoading, isError: rulesError } = useQuery({ queryKey: ['award', 'rules'], queryFn: () => commissionApi.awardRules(), retry: false, }); + // 원장(지급 내역) 목록 조회. 검색조건(ledgerParams) 변경 시 자동 재조회 const { data: ledgersData, isLoading: ledgersLoading } = useQuery({ queryKey: ['award', 'ledgers', ledgerParams], queryFn: () => commissionApi.awardLedgers(ledgerParams), @@ -90,9 +110,11 @@ export default function Award() { const rules = rulesData?.list ?? []; const ledgers = ledgersData?.list ?? []; + // 룰 등록 모달 열기(빈 폼) / 룰 수정 모달 열기(선택 행 값 채움) const openCreate = () => { setEditingRule(null); form.resetFields(); setRuleModalOpen(true); }; const openEdit = (row: AwardRuleRow) => { setEditingRule(row); form.setFieldsValue(row); setRuleModalOpen(true); }; + // 룰 저장: editingRule 유무로 수정/등록 분기. 완료 후 룰 목록 새로고침 const handleSave = async () => { const values = await form.validateFields(); try { @@ -108,6 +130,7 @@ export default function Award() { } catch { /* request.ts 처리 */ } }; + // 룰 삭제: 확인창 후 삭제 API 호출, 목록 새로고침 const handleDelete = (id: number) => Modal.confirm({ title: '룰 삭제', content: '삭제하시겠습니까?', onOk: async () => { @@ -117,6 +140,7 @@ export default function Award() { }, }); + // 원장 그리드 하단 고정 합계 행(기준실적/시상금/환수액/실지급 각 합계) const pinnedLedger = ledgers.length > 0 ? { agentName: '합계', baseAmount: ledgers.reduce((s, r) => s + (r.baseAmount ?? 0), 0), diff --git a/ga-frontend/src/pages/commission/BranchAllowance.tsx b/ga-frontend/src/pages/commission/BranchAllowance.tsx index 8c30cd3..c5bb591 100644 --- a/ga-frontend/src/pages/commission/BranchAllowance.tsx +++ b/ga-frontend/src/pages/commission/BranchAllowance.tsx @@ -1,3 +1,15 @@ +/** + * [지점장수당] 화면 + * - 업무: 지점장(조직장)에게 조직 실적에 비례해 주는 수당의 "룰"을 관리하고, 월별 지급 "원장"을 조회하는 화면. + * 상단 탭 2개: (1) 수당 룰(등록/수정/삭제) (2) 원장(조회 전용). + * - 호출 API: commissionApi.branchAllowanceRules / ...RuleCreate·Update·Delete (룰 CRUD), + * branchAllowanceLedgers (원장 조회). + * - 주요 상태: ledgerParams(원장 검색조건), ruleModalOpen/editingRule(룰 모달). + * - 도메인 용어: + * - 지점장수당: 본인 계약이 아니라 자기 조직(지점)의 전체 실적에 대해 직책자가 받는 수당. + * - 수당율(allowanceRate): 조직실적에 곱하는 비율(0~1, 화면엔 %로 표시). + * - 상한(capAmount): 수당의 최대 한도. 미입력이면 무제한. + */ import { useState } from 'react'; import { Alert, Form, Input, InputNumber, Modal, Space, Tabs, message } from 'antd'; import { ProCard } from '@ant-design/pro-components'; @@ -16,9 +28,12 @@ import { } from '@/api/commission'; import { GRAY, RADIUS, SHADOW } from '@/theme/tokens'; +// 숫자 천단위 콤마, 없으면 '-' const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-'; +// 비율(0~1)을 퍼센트 문자열로(예: 0.035 -> "3.50%") const fmtRate = (v: unknown) => v != null ? `${((v as number) * 100).toFixed(2)}%` : '-'; +// "수당 룰" 탭 그리드 컬럼: 직책별 수당율/상한/적용기간 규칙 const RULE_COLS: ColDef[] = [ { field: 'positionCode', headerName: '직책코드', width: 130 }, { field: 'allowanceRate', headerName: '수당율', width: 110, type: 'numericColumn', valueFormatter: (p) => fmtRate(p.value) }, @@ -27,6 +42,7 @@ const RULE_COLS: ColDef[] = [ { field: 'effectiveTo', headerName: '적용종료', width: 120 }, ]; +// "원장" 탭 그리드 컬럼: 지점장별·월별 실제 수당 지급 내역(조직실적 x 수당율 = 수당금액, 환수 반영) const LEDGER_COLS: ColDef[] = [ { field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' }, { field: 'managerAgentName',headerName: '지점장', width: 130 }, @@ -47,12 +63,14 @@ export default function BranchAllowance() { const [editingRule, setEditingRule] = useState(null); const [form] = Form.useForm(); + // 수당 룰 목록 조회(읽기) const { data: rulesData, isLoading: rulesLoading, isError: rulesError } = useQuery({ queryKey: ['branchAllowance', 'rules'], queryFn: () => commissionApi.branchAllowanceRules(), retry: false, }); + // 원장(지급 내역) 목록 조회. 검색조건(ledgerParams) 변경 시 자동 재조회 const { data: ledgersData, isLoading: ledgersLoading } = useQuery({ queryKey: ['branchAllowance', 'ledgers', ledgerParams], queryFn: () => commissionApi.branchAllowanceLedgers(ledgerParams), @@ -62,9 +80,11 @@ export default function BranchAllowance() { const rules = rulesData?.list ?? []; const ledgers = ledgersData?.list ?? []; + // 룰 등록 모달 열기(빈 폼) / 룰 수정 모달 열기(선택 행 값 채움) const openCreate = () => { setEditingRule(null); form.resetFields(); setRuleModalOpen(true); }; const openEdit = (row: BranchAllowanceRuleRow) => { setEditingRule(row); form.setFieldsValue(row); setRuleModalOpen(true); }; + // 룰 저장: editingRule 유무로 수정/등록 분기. 완료 후 룰 목록 새로고침 const handleSave = async () => { const values = await form.validateFields(); try { @@ -80,6 +100,7 @@ export default function BranchAllowance() { } catch { /* request.ts 처리 */ } }; + // 룰 삭제: 확인창 후 삭제 API 호출, 목록 새로고침 const handleDelete = (id: number) => Modal.confirm({ title: '룰 삭제', content: '삭제하시겠습니까?', onOk: async () => { @@ -89,6 +110,7 @@ export default function BranchAllowance() { }, }); + // 원장 그리드 하단 고정 합계 행(조직실적/수당금액/환수액 각 합계) const pinnedLedger = ledgers.length > 0 ? { managerAgentName: '합계', baseAmount: ledgers.reduce((s, r) => s + (r.baseAmount ?? 0), 0), diff --git a/ga-frontend/src/pages/commission/ChargebackWaiver.tsx b/ga-frontend/src/pages/commission/ChargebackWaiver.tsx index 41c22d7..b131083 100644 --- a/ga-frontend/src/pages/commission/ChargebackWaiver.tsx +++ b/ga-frontend/src/pages/commission/ChargebackWaiver.tsx @@ -1,3 +1,14 @@ +/** + * [환수감면신청] 화면 + * - 업무: 설계사에게 부과된 "환수액"의 일부/전부를 감면해 달라는 신청을 등록하고, 관리자가 승인/반려하는 워크플로 화면. + * - 호출 API: commissionApi.chargebackWaiverList(조회), chargebackWaiverCreate(신청), + * chargebackWaiverApprove(승인), chargebackWaiverReject(반려). + * - 주요 상태: searchParams(검색조건), createOpen(신청 모달), rejectTarget/rejectReason(반려 모달). + * - 도메인 용어: + * - 환수: 계약 해지 등으로 이미 지급한 수수료를 회수하는 것. + * - 감면(waiver): 환수액 중 일부를 사정 참작해 면제해 주는 것. + * - 승인상태: 신청(REQUESTED) -> 승인(APPROVED) 또는 반려(REJECTED). + */ import { useState } from 'react'; import { Alert, Form, Input, InputNumber, Modal, Select, Space, message } from 'antd'; import { ProCard } from '@ant-design/pro-components'; @@ -24,6 +35,7 @@ const STATUS_OPTIONS = [ { value: 'REJECTED', label: '반려' }, ]; +// 그리드 컬럼: 감면 신청 목록(원환수액 대비 감면액, 사유, 승인상태) const COLS: ColDef[] = [ { field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' }, { field: 'agentName', headerName: '설계사', width: 130 }, @@ -45,6 +57,7 @@ export default function ChargebackWaiver() { const [rejectReason, setRejectReason] = useState(''); const [form] = Form.useForm(); + // 감면 신청 목록 조회(읽기). 검색조건 변경 시 자동 재조회 const { data: listData, isLoading, isError } = useQuery({ queryKey: ['chargebackWaiver', 'list', searchParams], queryFn: () => commissionApi.chargebackWaiverList(searchParams), @@ -53,6 +66,7 @@ export default function ChargebackWaiver() { const rows = listData?.list ?? []; + // 감면 신청 등록 mutation: 성공 시 목록 새로고침 + 모달 닫기 const createMutation = useMutation({ mutationFn: (body: ChargebackWaiverSaveReq) => commissionApi.chargebackWaiverCreate(body), onSuccess: () => { @@ -63,6 +77,7 @@ export default function ChargebackWaiver() { }, }); + // 신청 승인 mutation const approveMutation = useMutation({ mutationFn: (requestId: number) => commissionApi.chargebackWaiverApprove(requestId), onSuccess: () => { @@ -71,6 +86,7 @@ export default function ChargebackWaiver() { }, }); + // 신청 반려 mutation(사유 필요) const rejectMutation = useMutation({ mutationFn: ({ requestId, reason }: { requestId: number; reason: string }) => commissionApi.chargebackWaiverReject(requestId, reason), @@ -82,22 +98,26 @@ export default function ChargebackWaiver() { }, }); + // 신청 모달 "신청" 버튼: 폼 검증 후 등록 const handleCreate = async () => { const values = await form.validateFields(); createMutation.mutate(values); }; + // 승인 버튼: 확인창 후 승인 처리 const handleApprove = (row: ChargebackWaiverRow) => Modal.confirm({ title: '승인', content: `${row.agentName} 감면 신청(감면액: ${fmt(row.waiverAmount)})을 승인하시겠습니까?`, onOk: () => approveMutation.mutateAsync(row.requestId), }); + // 반려 모달 열기(대상 행 지정) const openReject = (row: ChargebackWaiverRow) => { setRejectTarget(row); setRejectReason(''); }; + // 반려 확정: 사유 필수 검증 후 반려 처리 const handleReject = () => { if (!rejectReason.trim()) { message.warning('반려 사유를 입력하세요'); return; } rejectMutation.mutate({ requestId: rejectTarget!.requestId, reason: rejectReason }); diff --git a/ga-frontend/src/pages/commission/ClawbackInstallment.tsx b/ga-frontend/src/pages/commission/ClawbackInstallment.tsx index 7a2bd81..009bf61 100644 --- a/ga-frontend/src/pages/commission/ClawbackInstallment.tsx +++ b/ga-frontend/src/pages/commission/ClawbackInstallment.tsx @@ -1,3 +1,14 @@ +/** + * [환수분할상환] 화면 + * - 업무: 금액이 큰 환수채권을 한 번에 갚기 어려울 때 여러 회차로 나눠 납부하는 "분할 계획"을 생성/조회/취소하는 화면. + * - 호출 API: commissionApi.clawbackInstallmentList(조회), clawbackInstallmentGenerate(분할계획 생성), + * clawbackInstallmentCancel(회차 취소). + * - 주요 상태: searchParams(검색조건), generateOpen(분할계획 생성 모달). + * - 도메인 용어: + * - 환수채권(receivable): 설계사가 GA에 갚아야 할 환수 잔액. + * - 분할상환: 채권을 N회차로 나눠 매월 일정액(분할금액)을 회수. + * - 회차 상태: SCHEDULED(예정) -> 회수 완료. SCHEDULED 상태만 취소 가능. + */ import { useState } from 'react'; import { Alert, Form, Input, InputNumber, Modal, Space, message } from 'antd'; import { ProCard } from '@ant-design/pro-components'; @@ -22,6 +33,7 @@ interface SearchParam { pageSize?: number; } +// 그리드 컬럼: 채권별 분할 회차 목록(회차/분할금액/납부월/상태) const COLS: ColDef[] = [ { field: 'receivableId', headerName: '채권ID', width: 110, pinned: 'left' }, { field: 'installmentNo', headerName: '회차', width: 80, type: 'numericColumn' }, @@ -37,6 +49,7 @@ export default function ClawbackInstallment() { const [generateOpen, setGenerateOpen] = useState(false); const [form] = Form.useForm(); + // 분할 회차 목록 조회(읽기) const { data: listData, isLoading, isError } = useQuery({ queryKey: ['clawbackInstallment', 'list', searchParams], queryFn: () => commissionApi.clawbackInstallmentList(searchParams), @@ -45,6 +58,7 @@ export default function ClawbackInstallment() { const rows = listData?.list ?? []; + // 분할 계획 생성 mutation: 채권을 N회차로 쪼개 일괄 생성. 성공 시 생성 건수 안내 + 목록 새로고침 const generateMutation = useMutation({ mutationFn: (body: ClawbackInstallmentGenerateReq) => commissionApi.clawbackInstallmentGenerate(body), @@ -56,6 +70,7 @@ export default function ClawbackInstallment() { }, }); + // 개별 회차 취소 mutation const cancelMutation = useMutation({ mutationFn: (planId: number) => commissionApi.clawbackInstallmentCancel(planId), onSuccess: () => { diff --git a/ga-frontend/src/pages/commission/ClawbackReceivable.tsx b/ga-frontend/src/pages/commission/ClawbackReceivable.tsx index 3a03d0d..40d16c9 100644 --- a/ga-frontend/src/pages/commission/ClawbackReceivable.tsx +++ b/ga-frontend/src/pages/commission/ClawbackReceivable.tsx @@ -1,3 +1,14 @@ +/** + * [환수채권관리] 화면 + * - 업무: 회수하지 못한 환수금(미수금) 채권을 목록/상세로 보고, 직접 상환 완료나 대손(회수 포기) 처리를 하는 화면. + * 행 클릭 시 오른쪽 Drawer로 상세(회수 이력 타임라인 포함)를 본다. + * - 호출 API: clawbackReceivableApi.list(목록), detail(상세), writeOff(대손/상환 처리), export(엑셀 다운로드). + * - 주요 상태: params(검색조건), selectedRow/drawerOpen(상세 Drawer), writeOffOpen(처리 모달). + * - 도메인 용어: + * - 환수채권: 설계사에게 받아야 할 환수 잔액. 상태: 미회수/회수중/상환완료/대손. + * - 연체이자: 미회수 기간에 대해 가산되는 이자. 잔액 = 원금 - 회수액 + 연체이자. + * - 대손(write-off): 회수 불가능으로 판단해 채권을 손실로 처리하는 것. + */ import { useState } from 'react'; import { Drawer, Form, Input, Modal, Select, Space, Tag, Timeline, Typography, message, @@ -37,6 +48,7 @@ const STATUS_LABEL: Record = { WRITTEN_OFF: '대손', }; +// 상태 값을 색상 칩(Tag)으로 표시. 색상은 STATUS_COLOR, 라벨은 서버값(statusName) 우선 function StatusChip({ status, statusName }: { status: string; statusName?: string }) { const color = STATUS_COLOR[status] ?? GRAY[400]; const label = statusName ?? STATUS_LABEL[status] ?? status; @@ -153,6 +165,7 @@ export default function ClawbackReceivable() { setDrawerOpen(true); }; + // 엑셀 다운로드: 현재 검색조건으로 받은 Blob을 a 태그 클릭으로 파일 저장 const handleExport = async () => { try { const res = await clawbackReceivableApi.export(params); diff --git a/ga-frontend/src/pages/commission/CollectionCommission.tsx b/ga-frontend/src/pages/commission/CollectionCommission.tsx index bbda506..ce61dac 100644 --- a/ga-frontend/src/pages/commission/CollectionCommission.tsx +++ b/ga-frontend/src/pages/commission/CollectionCommission.tsx @@ -1,3 +1,14 @@ +/** + * [수금수수료] 화면 + * - 업무: 보험료 수납(수금) 실적에 대해 주는 수수료의 "룰"을 관리하고, 월별 지급 "원장"을 조회하는 화면. + * 상단 탭 2개: (1) 수수료 룰(등록/수정/삭제) (2) 수금 원장(조회 전용). + * - 호출 API: commissionApi.collectionRules / ...RuleCreate·Update·Delete (룰 CRUD), + * collectionLedgers (원장 조회). + * - 주요 상태: ledgerParams(원장 검색조건), ruleModalOpen/editingRule(룰 모달). + * - 도메인 용어: + * - 수금수수료: 계약 체결이 아니라 보험료를 실제로 거둬들인(수납) 것에 대해 지급하는 수수료. + * - 룰은 보험사/상품유형/납입주기별로 수수료율을 정의한다. + */ import { useState } from 'react'; import { Alert, Button, Form, Input, InputNumber, Modal, Space, Tabs, message } from 'antd'; import { ProCard } from '@ant-design/pro-components'; diff --git a/ga-frontend/src/pages/commission/CommissionMaster.tsx b/ga-frontend/src/pages/commission/CommissionMaster.tsx index 008c4bf..9b98af2 100644 --- a/ga-frontend/src/pages/commission/CommissionMaster.tsx +++ b/ga-frontend/src/pages/commission/CommissionMaster.tsx @@ -1,3 +1,13 @@ +/** + * [수수료 통합 마스터] 화면 + * - 업무: 한 설계사가 한 정산월에 받은 9종(신계약/유지/소개 등) 수수료를 한 줄로 합쳐 보여주는 통합 조회 화면. + * - 호출 API: GET /api/commissions (commissionApi.masterList) — 조건(설계사명/정산월/수수료종류)으로 조회만 한다(읽기 전용). + * - 주요 상태: params(검색조건). 화면 진입 시 이번 달 정산월로 200건 조회. + * - 도메인 용어: + * - 정산월(settleMonth): 수수료를 정산·지급하는 기준 월(YYYYMM). + * - 총수수료(grossAmount): 세금/공제 전 받을 수수료 합. + * - 실지급(netAmount): 환수·세금·공제를 뺀 실제 지급액. + */ import { useState } from 'react'; import { Alert } from 'antd'; import { ProCard } from '@ant-design/pro-components'; @@ -10,8 +20,10 @@ import DataGrid from '@/components/common/DataGrid'; import { commissionApi, CommissionMasterRow, CommissionSearchParam } from '@/api/commission'; import { GRAY, RADIUS, SHADOW } from '@/theme/tokens'; +// 숫자에 천단위 콤마(예: 1234567 -> "1,234,567"), 값이 없으면 '-' 표시 const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-'; +// AG Grid 컬럼 정의: pinned: 'left'는 가로 스크롤해도 왼쪽에 고정. type:'numericColumn'은 우측 정렬 숫자 컬럼. const COLUMNS: ColDef[] = [ { field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' }, { field: 'agentName', headerName: '설계사', width: 120, pinned: 'left' }, @@ -27,6 +39,7 @@ export default function CommissionMaster() { pageSize: 200, }); + // 서버에서 통합 마스터 목록 조회. params가 바뀌면 react-query가 자동 재조회. retry:false = 실패 시 재시도 안 함(에러 즉시 노출). const { data, isLoading, isError } = useQuery({ queryKey: ['commission', 'master', params], queryFn: () => commissionApi.masterList(params), @@ -35,6 +48,7 @@ export default function CommissionMaster() { const rows = data?.list ?? []; + // 그리드 맨 아래에 고정으로 붙는 합계 행(총수수료/실지급의 전체 합). 데이터가 있을 때만 만든다. const pinnedRow = rows.length > 0 ? { agentName: '합계', commissionTypeName: '', diff --git a/ga-frontend/src/pages/commission/CommissionSimulator.tsx b/ga-frontend/src/pages/commission/CommissionSimulator.tsx index 9abf10c..90d8df3 100644 --- a/ga-frontend/src/pages/commission/CommissionSimulator.tsx +++ b/ga-frontend/src/pages/commission/CommissionSimulator.tsx @@ -1,3 +1,16 @@ +/** + * [수수료 시뮬레이터] 화면 + * - 업무: 보험료/보험사요율/지급률/회차를 입력하면 설계사가 받을 수수료·세금·실지급·예상환수를 미리 계산해보는 화면. + * 계산 결과는 상단 통계 카드로, 과거 계산 이력은 하단 그리드로 보여준다. + * - 호출 API: operationApi.simulate (계산 요청), operationApi.simulationList (저장된 시뮬 목록 조회). + * - 주요 상태: form(입력 폼), result(가장 최근 계산 결과 1건). + * - 도메인 용어: + * - 보험사요율(companyRate): 보험사가 GA에 주는 수수료 비율. + * - 지급률(payoutRate): GA가 받은 금액 중 설계사에게 지급하는 비율(분급률). + * - 원천세 3.3%: 사업소득 원천징수 세율. 실지급 = 설계사수수료 - 원천세. + * - 예상환수: 향후 계약 해지 시 되돌려받을 수 있는 추정 금액. + * - 주의: 화면 입력은 % 단위지만 백엔드에는 0~1로 환산해 보낸다(handleSimulate 참고). + */ import { useState } from 'react'; import { Alert, Card, Col, Form, Input, InputNumber, Row, Statistic, message } from 'antd'; import { ProCard } from '@ant-design/pro-components'; @@ -12,6 +25,7 @@ import { GRAY, RADIUS, SHADOW, COLOR_PRIMARY, COLOR_ERROR, COLOR_SUCCESS } from const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-'; const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }; +// 하단 "저장된 시뮬레이션 목록" 그리드 컬럼: 과거 계산 시나리오들의 입력값과 결과 const HIST_COLS: ColDef[] = [ { field: 'simName', headerName: '시나리오명', width: 150 }, { field: 'premiumAmount', headerName: '보험료', width: 130, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, @@ -41,6 +55,7 @@ export default function CommissionSimulator() { const rows = listData?.list ?? []; + // "계산" 버튼: 폼 검증 후 % 입력을 0~1로 환산해 백엔드 계산 요청 → 결과 카드 갱신 + 이력 새로고침 const handleSimulate = async () => { const values = await form.validateFields(); // UI: 율은 % 단위 → 백엔드는 0~1 diff --git a/ga-frontend/src/pages/kpi/ForecastKpiList.tsx b/ga-frontend/src/pages/kpi/ForecastKpiList.tsx index ef141c5..e5d5fed 100644 --- a/ga-frontend/src/pages/kpi/ForecastKpiList.tsx +++ b/ga-frontend/src/pages/kpi/ForecastKpiList.tsx @@ -15,6 +15,23 @@ import { } from '@/api/forecast-kpi'; import { GRAY, RADIUS, SHADOW } from '@/theme/tokens'; +/** + * [화면] 차월 KPI 예측 (ForecastKpiList) + * + * 용도: 과거 실적을 바탕으로 다음 달(차월)의 보험료/지급/유지율 같은 KPI를 예측한 결과를 보여준다. + * 이동평균 등 통계 기법으로 산출하며, 예측값과 함께 신뢰구간(상·하한)을 제공한다. + * + * 도메인 용어: + * - KPI: 핵심성과지표(매출/지급/유지율 등 경영지표). + * - 예측 기법(method): SMA3/SMA6(3·6개월 이동평균), NAIVE(직전월 그대로). + * - 상·하한(±1σ): 예측의 불확실성 범위(표준편차 1배). 표본수(sampleCount)가 많을수록 신뢰도 상승. + * - 대상조직: 비어있으면 전사(全社) 기준. + * + * 호출 API (api/forecast-kpi.ts): forecastKpiApi.list(검색) / regenerate(재산출). + * 주요 상태: 검색 파라미터, 재산출 모달. + */ + +// 숫자 표시 포맷터: null 이면 '-', 아니면 천단위 콤마. const fmt = (v: unknown) => (v == null ? '-' : (v as number).toLocaleString()); const COLUMNS: ColDef[] = [ diff --git a/ga-frontend/src/pages/product/CompanyList.tsx b/ga-frontend/src/pages/product/CompanyList.tsx index 824df52..a859709 100644 --- a/ga-frontend/src/pages/product/CompanyList.tsx +++ b/ga-frontend/src/pages/product/CompanyList.tsx @@ -9,13 +9,21 @@ import PageContainer from '@/components/common/PageContainer'; import PermissionButton from '@/components/common/PermissionButton'; import { companyApi, CompanyResp, CompanySaveReq, CompanySearchParam } from '@/api/company'; +// 보험사 구분 드롭다운 선택지(고정값이라 공통코드 대신 상수로 둠) const COMPANY_TYPE_OPTIONS = [ { value: 'LIFE', label: '생명보험' }, { value: 'NON_LIFE', label: '손해보험' }, { value: 'OTHER', label: '기타' }, ]; +/** + * 보험사 관리 화면. + * API: companyApi.list(목록) / create(등록) / update(수정) + * ProTable 로 검색+목록을 그리고, ModalForm 으로 등록/수정 모달을 띄운다. + */ export default function CompanyList() { + // actionRef: ProTable 을 코드에서 제어(reload 등)하기 위한 참조 + // modal: 등록/수정 모달 열림 여부와 수정 대상 id(editId 있으면 수정, 없으면 신규) const actionRef = useRef(); const [modal, setModal] = useState<{ open: boolean; editId?: number }>({ open: false }); @@ -30,16 +38,19 @@ export default function CompanyList() { }); const [editRecord, setEditRecord] = useState(null); + // 등록 모달 열기(수정 대상 없음) const openCreate = () => { setEditRecord(null); setModal({ open: true }); }; + // 수정 모달 열기(선택한 행을 초기값으로) const openEdit = (record: CompanyResp) => { setEditRecord(record); setModal({ open: true, editId: record.companyId }); }; + // 모달 제출 — editId 유무로 수정/등록을 분기, 성공 시 목록 새로고침 const onSubmit = async (v: any) => { const req: CompanySaveReq = { ...v, @@ -57,6 +68,7 @@ export default function CompanyList() { return true; }; + // ProTable 컬럼 정의(search:false 면 검색폼에서 제외, 표에만 표시) const columns: ProColumns[] = [ { title: '검색어', dataIndex: 'searchKeyword', hideInTable: true, fieldProps: { placeholder: '회사명 / 사업자번호' } }, diff --git a/ga-frontend/src/pages/product/ContractList.tsx b/ga-frontend/src/pages/product/ContractList.tsx index db6dec6..bff5682 100644 --- a/ga-frontend/src/pages/product/ContractList.tsx +++ b/ga-frontend/src/pages/product/ContractList.tsx @@ -15,9 +15,14 @@ import { contractApi, ContractResp, ContractSaveReq, ContractSearchParam } from import { productApi } from '@/api/product'; import { agentApi } from '@/api/agent'; +/** + * 계약 관리 화면. + * API: contractApi.list/create, 등록 모달용 productApi.list / agentApi.list + * 보험종류/계약상태/납입주기/판매채널은 공통코드로 불러와 CodeBadge·CodeSelect 에 사용한다. + */ export default function ContractList() { const actionRef = useRef(); - const [open, setOpen] = useState(false); + const [open, setOpen] = useState(false); // 계약 등록 모달 열림 여부 const { data: insuranceCodes = [] } = useCommonCodes('INSURANCE_TYPE'); const { data: statusCodes = [] } = useCommonCodes('CONTRACT_STATUS'); const { data: payCycleCodes = [] } = useCommonCodes('PAY_CYCLE'); @@ -35,6 +40,7 @@ export default function ContractList() { enabled: open, }); + // 계약 등록 제출 — 날짜를 문자열로 변환 후 생성 API 호출, 성공 시 목록 새로고침 const onSubmit = async (v: any) => { const req: ContractSaveReq = { ...v, diff --git a/ga-frontend/src/pages/product/ProductList.tsx b/ga-frontend/src/pages/product/ProductList.tsx index 1750c7b..1684d1a 100644 --- a/ga-frontend/src/pages/product/ProductList.tsx +++ b/ga-frontend/src/pages/product/ProductList.tsx @@ -13,6 +13,7 @@ import { useCommonCodes } from '@/hooks/useCommonCodes'; import { productApi, ProductResp, ProductSaveReq, ProductSearchParam } from '@/api/product'; import { companyApi } from '@/api/company'; +// 납입주기 드롭다운 선택지(고정값) const PAY_PERIOD_OPTIONS = [ { value: 'MONTHLY', label: '월납' }, { value: 'QUARTERLY', label: '분기납' }, @@ -20,12 +21,18 @@ const PAY_PERIOD_OPTIONS = [ { value: 'SINGLE', label: '일시납' }, ]; +/** + * 상품 관리 화면. + * API: productApi.list/create/update/remove, companyApi.list(보험사 선택용) + * 보험종류는 공통코드(INSURANCE_TYPE)로 불러온다. + */ export default function ProductList() { const actionRef = useRef(); const { data: insuranceCodes = [] } = useCommonCodes('INSURANCE_TYPE'); const [modal, setModal] = useState<{ open: boolean; editId?: number }>({ open: false }); const [editRecord, setEditRecord] = useState(null); + // 등록/수정 모달의 보험사 선택지 — 모달이 열렸을 때만(enabled) 조회 const { data: companies } = useQuery({ queryKey: ['companies', 'all'], queryFn: () => companyApi.list({ pageSize: 999 }), @@ -42,6 +49,7 @@ export default function ProductList() { setModal({ open: true, editId: record.productId }); }; + // 삭제 — 확인창 후 삭제 API 호출, 성공 시 목록 새로고침 const onDelete = (record: ProductResp) => { Modal.confirm({ title: '상품 삭제', @@ -57,6 +65,7 @@ export default function ProductList() { }); }; + // 모달 제출 — 날짜는 문자열로, 활성여부는 Y/N 으로 변환 후 editId 유무로 수정/등록 분기 const onSubmit = async (v: any) => { const req: ProductSaveReq = { ...v, diff --git a/ga-frontend/src/pages/system/Approvals.tsx b/ga-frontend/src/pages/system/Approvals.tsx index ac42348..8d63769 100644 --- a/ga-frontend/src/pages/system/Approvals.tsx +++ b/ga-frontend/src/pages/system/Approvals.tsx @@ -18,6 +18,29 @@ import { GRAY, RADIUS, SHADOW, COLOR_SUCCESS, COLOR_ERROR, COLOR_PRIMARY } from const { Text } = Typography; const { TextArea } = Input; +/** + * [화면] 결재 관리 (Approvals) + * + * 용도: 사내 전자결재 처리 화면. 결재선(승인 단계의 묶음) 조회, 내가 올린 결재 요청 확인, + * 나에게 온 결재 대기 건의 승인/반려를 한 곳에서 처리한다. + * + * 도메인 용어: + * - 결재선(Approval Line): "팀장 → 본부장 → 대표" 처럼 순서대로 승인하는 단계 정의. + * - 결재 요청(Approval Request): 어떤 안건을 특정 결재선에 태워 올린 1건. + * - 단계(Step): 결재선 안의 개별 승인자. currentStep/totalSteps 로 진행률 표시. + * + * 호출 API (api/approval.ts): + * - approvalApi.lines() 결재선 목록 + * - approvalApi.myRequests() 내가 요청한 결재 목록 + * - approvalApi.pendingRequests() 내 결재 대기 목록 + * - approvalApi.requestDetail() 결재 상세(단계 진행 현황 포함) + * - approvalApi.advance() 승인/반려 처리(다음 단계로 진행) + * - approvalApi.requestCreate() 신규 결재 요청 등록 + * + * 주요 상태: 상세 Drawer 열림 여부, 선택된 결재 건, 코멘트 입력값, 결재요청 모달 열림 여부. + */ + +// AG Grid 컬럼 정의: 결재선 목록 테이블의 표시 열(헤더명/너비/정렬). const LINE_COLS: ColDef[] = [ { field: 'lineName', headerName: '결재선명', flex: 1 }, { field: 'description', headerName: '설명', flex: 1 }, @@ -26,6 +49,7 @@ const LINE_COLS: ColDef[] = [ { field: 'createdAt', headerName: '생성일', width: 120 }, ]; +// AG Grid 컬럼 정의: 결재 요청(내 요청/대기 공용) 테이블의 표시 열. 진행/상태는 cellRenderer 로 커스텀. const REQ_COLS: ColDef[] = [ { field: 'title', headerName: '제목', flex: 1 }, { field: 'lineName', headerName: '결재선', width: 140 }, @@ -70,10 +94,12 @@ export default function Approvals() { retry: false, }); + // 서버 응답이 없을 때를 대비해 빈 배열로 기본값 처리(undefined 방지). const lines = linesData?.list ?? []; const myRequests = myReqData?.list ?? []; const pendingRequests = pendingData?.list ?? []; + // 결재 대기 건을 클릭 → 상세를 다시 조회해 단계 진행현황까지 채운 뒤 Drawer 를 연다. const openDetail = async (req: ApprovalRequestRow) => { try { const detail = await approvalApi.requestDetail(req.requestId); @@ -82,6 +108,7 @@ export default function Approvals() { } catch { /* handled */ } }; + // 승인/반려 버튼 처리: approved=true 승인, false 반려. 처리 후 결재 관련 캐시를 갱신한다. const handleAdvance = async (approved: boolean) => { if (!selectedRequest) return; try { @@ -93,6 +120,7 @@ export default function Approvals() { } catch { /* handled */ } }; + // 신규 결재 요청 등록: 모달 폼 검증 통과 시 결재선 ID/제목/내용을 서버에 보낸다. const handleCreateReq = async () => { const values = await form.validateFields() as ApprovalRequestSaveReq; try { diff --git a/ga-frontend/src/pages/system/CodeList.tsx b/ga-frontend/src/pages/system/CodeList.tsx index 32c434e..a484c70 100644 --- a/ga-frontend/src/pages/system/CodeList.tsx +++ b/ga-frontend/src/pages/system/CodeList.tsx @@ -12,8 +12,29 @@ import PermissionButton from '@/components/common/PermissionButton'; import { commonCodeApi, CommonCodeGroup, CommonCode } from '@/api/user'; const { Text } = Typography; +// 메뉴 코드: 권한(RBAC) 체크 기준. PermissionButton 이 이 코드+CREATE/UPDATE/DELETE 권한 보유 여부로 노출/비활성 결정. const MENU = 'SYSTEM_CODE'; +/** + * [화면] 공통코드 관리 (CodeList) + * + * 용도: 시스템 전반에서 쓰는 "공통코드"를 관리한다. 좌측에 코드 그룹, 우측에 그 그룹에 속한 코드 목록. + * 예) 그룹 AGENT_STATUS(설계사 상태) → 코드 ACTIVE(재직)/RESIGN(해촉) 등. + * 드롭다운·상태 뱃지 등 화면 곳곳의 라벨이 모두 이 공통코드에서 나온다. + * + * 도메인 용어: + * - 코드 그룹(Group): 코드의 분류 단위. 시스템(isSystem=Y) 그룹은 식별자 변경/삭제가 제한된다. + * - 코드(Code): 그룹 안의 개별 값. code(영문키)+codeName(한글 표시명)+isActive(사용여부). + * - 캐시 갱신(refreshCache): 코드 변경 시 서버 캐시를 비워 즉시 반영되게 한다. + * + * 호출 API (api/user.ts → commonCodeApi): + * - groups / codes 그룹·코드 목록 조회 + * - createGroup/updateGroup/deleteGroup, createCode/updateCode/deleteCode CRUD + * - refreshCache 변경 후 서버 코드 캐시 무효화 + * + * 주요 상태: 선택된 그룹, 검색어, 그룹/코드 모달(생성·수정 구분 union 타입), 저장 로딩. + */ + /* ------------------------------------------------------------------ */ /* 내부 상태 타입 */ /* ------------------------------------------------------------------ */ @@ -57,6 +78,7 @@ export default function CodeList() { queryFn: () => commonCodeApi.groups(keyword || undefined), }); + // 우측 코드 목록: 그룹이 선택됐을 때만(enabled) 해당 그룹의 코드를 조회한다. const { data: codes = [], isFetching: codeLoading } = useQuery({ queryKey: ['code', 'codes', selected?.groupCode], queryFn: () => commonCodeApi.codes(selected!.groupCode), diff --git a/ga-frontend/src/pages/system/DataDictionary.tsx b/ga-frontend/src/pages/system/DataDictionary.tsx index 41bafc5..857a4a1 100644 --- a/ga-frontend/src/pages/system/DataDictionary.tsx +++ b/ga-frontend/src/pages/system/DataDictionary.tsx @@ -17,7 +17,27 @@ import { const { Text } = Typography; +/** + * [화면] 데이터 사전 (DataDictionary) + * + * 용도: DB 테이블/컬럼의 "뜻"을 정리하는 메타데이터 화면. 좌측에서 테이블을 고르면 우측에 + * 그 테이블 컬럼들의 한글명·도메인·DB타입·마스킹/암호화 정책·샘플값을 보여주고 편집한다. + * 개발자·기획자가 "이 컬럼이 무슨 데이터인가"를 빠르게 파악하기 위한 사전. + * + * 도메인 용어: + * - 도메인(Domain): 컬럼의 의미 유형(IDENTIFIER/PII/MONEY/RATE 등). PII는 개인정보로 마스킹/암호화 대상. + * - 마스킹(maskingType): 화면 표시 시 가림 규칙(NAME/PHONE/RRN 등). 암호화(encrypted): 저장 시 암호화 여부. + * - 미등록 행: 한글명/도메인/업무규칙이 비어있는 컬럼. 회색 배경으로 구분(dict-row-unregistered). + * + * 호출 API (api/dict.ts): + * - dataDictApi.tables / full(table) / create / remove 테이블·컬럼사전 조회 및 CRUD + * - dataDomainApi.options 도메인 선택 옵션 + * + * 구조: TableList(좌측 목록·카테고리 그룹핑) + ColumnDictTable(우측 컬럼표) + DictFormModal(등록/수정). + */ + // ─── 테이블명 → 카테고리 자동 분류 ────────────────────────────────────────── +// 테이블 접두사로 좌측 목록의 카테고리(조직/계약/원장/정산/시스템)를 자동 분류한다. const TABLE_CATEGORY_PREFIX: Array<{ prefix: string; label: string }> = [ { prefix: 'org_', label: '조직' }, @@ -158,6 +178,7 @@ function DictFormModal({ } // ─── 우측: 컬럼 사전 테이블 ────────────────────────────────────────────────── +// 선택된 테이블의 컬럼 목록을 표로 보여주고, 행별로 사전 정보 추가/수정/삭제를 제공한다. function ColumnDictTable({ tableName }: { tableName: string }) { const qc = useQueryClient(); @@ -331,6 +352,7 @@ function ColumnDictTable({ tableName }: { tableName: string }) { } // ─── 좌측: 테이블 목록 ──────────────────────────────────────────────────────── +// 검색어로 필터링 후 카테고리별로 그룹핑해 목록을 그린다. 클릭 시 onSelect 로 우측 표를 전환. function TableList({ selected, diff --git a/ga-frontend/src/pages/system/DataDomainList.tsx b/ga-frontend/src/pages/system/DataDomainList.tsx index 4d4b03c..a4d4e3e 100644 --- a/ga-frontend/src/pages/system/DataDomainList.tsx +++ b/ga-frontend/src/pages/system/DataDomainList.tsx @@ -11,7 +11,27 @@ import PageContainer from '@/components/common/PageContainer'; import PermissionButton from '@/components/common/PermissionButton'; import { dataDomainApi, DataDomainResp, DataDomainSaveReq } from '@/api/dict'; +/** + * [화면] 데이터 도메인 사전 (DataDomainList) + * + * 용도: 컬럼의 "의미 유형(도메인)"을 정의·관리하는 마스터 화면. 데이터 사전(DataDictionary)에서 + * 각 컬럼에 연결하는 도메인의 원본 목록이다. 여기서 정의한 마스킹/암호화 정책이 보안 처리의 기준. + * + * 도메인 용어: + * - 카테고리(domainCategory): IDENTIFIER(식별자)/PII(개인정보)/MONEY(금액)/RATE(요율) 등 분류. + * - 마스킹 유형(maskingType): 화면 노출 시 가림 규칙(NAME/PHONE/RRN=주민번호/ACCOUNT=계좌 등). + * - 암호화(encrypted): 저장 시 암호화 여부. PII/계좌 등 민감 항목에 적용. + * - 길이/정밀도: precision(전체 자릿수)·scale(소수 자릿수) 또는 length(문자 길이). + * + * 호출 API (api/dict.ts → dataDomainApi): + * - list(검색 파라미터) 목록 조회(ProTable request 로 페이징/검색) + * - create / update / remove 도메인 CRUD (사용 중 컬럼 있으면 삭제 시 오류) + * + * 구조: DomainFormModal(등록/수정 모달) + ProTable(목록·검색·페이징). + */ + // ─── 카테고리 색상 ──────────────────────────────────────────────────────────── +// 카테고리별 태그 색상 매핑. PII/계좌(RRN/ACCOUNT)는 민감도가 높아 red 로 강조. const CATEGORY_COLOR: Record = { IDENTIFIER: 'blue', @@ -142,8 +162,10 @@ function DomainFormModal({ export default function DataDomainList() { const qc = useQueryClient(); + // ProTable 제어용 ref: reload() 로 목록을 강제 재요청한다. const actionRef = useRef(); + // 캐시 무효화 + 테이블 새로고침을 함께 수행(저장/삭제 직후 호출). const refresh = () => { qc.invalidateQueries({ queryKey: ['dataDomain'] }); actionRef.current?.reload(); diff --git a/ga-frontend/src/pages/system/ExternalCallLogList.tsx b/ga-frontend/src/pages/system/ExternalCallLogList.tsx index d3ec9f7..458f2d1 100644 --- a/ga-frontend/src/pages/system/ExternalCallLogList.tsx +++ b/ga-frontend/src/pages/system/ExternalCallLogList.tsx @@ -4,6 +4,21 @@ import { ProTable, type ProColumns } from '@ant-design/pro-components'; import PageContainer from '@/components/common/PageContainer'; import { externalCallLogApi, ExternalCallLogResp } from '@/api/externalCallLog'; +/** + * [화면] 외부연동 호출이력 (ExternalCallLogList) + * + * 용도: 펌뱅킹(은행 이체)·SMS/카카오 등 외부 시스템 어댑터 호출의 감사 로그를 조회한다. + * 서버 AOP가 모든 외부 호출을 자동으로 기록(영속화)하므로, 성공/실패·소요시간·요청/응답 + * 원문을 추적해 장애 원인 분석에 사용한다. (읽기 전용 화면) + * + * 도메인 용어: + * - 어댑터(adapter): 외부 시스템과 통신하는 구현 클래스(adapterClass)와 메서드(methodName). + * - 대상(targetRef): 이 호출이 연관된 업무 객체(예: 정산건 ID). "타입#ID" 로 표시. + * - 결과: success(true/false), resultCode, durationMs(소요 밀리초), 예외 발생 시 errorClass/Message. + * + * 호출 API: externalCallLogApi.list (검색 파라미터 + 페이징). + * 주요 상태: 클릭한 행의 상세를 Drawer 로 보여주기 위한 detail. + */ export default function ExternalCallLogList() { const [detail, setDetail] = useState(null); diff --git a/ga-frontend/src/pages/system/MenuManage.tsx b/ga-frontend/src/pages/system/MenuManage.tsx index 3e721cc..664a804 100644 --- a/ga-frontend/src/pages/system/MenuManage.tsx +++ b/ga-frontend/src/pages/system/MenuManage.tsx @@ -5,8 +5,22 @@ import { IconCategory } from '@tabler/icons-react'; import PageContainer from '@/components/common/PageContainer'; import { menuApi, MenuResp } from '@/api/menu'; +/** + * [화면] 메뉴 관리 (MenuManage) + * + * 용도: 시스템 좌측 네비게이션을 구성하는 메뉴 계층을 트리로 보여준다(읽기 전용 표시). + * 메뉴는 권한(RBAC)의 단위이기도 해서, 역할/권한 화면에서 메뉴별로 권한을 부여한다. + * + * 도메인 용어: + * - 메뉴 유형(menuType): DIRECTORY(폴더·하위 묶음) / PAGE(실제 화면) / LINK(외부 링크). + * - menuCode: 권한 체크 키. menuPath: 라우팅 경로. + * + * 호출 API: menuApi.tree (메뉴 트리 조회). + */ + interface TreeNode { key: string; title: any; children?: TreeNode[]; } +// 서버 메뉴 응답(MenuResp 계층)을 Ant Design Tree 가 쓰는 노드 구조로 재귀 변환한다. function toTree(menus: MenuResp[]): TreeNode[] { return menus.map((m) => ({ key: String(m.menuId), @@ -50,6 +64,7 @@ export default function MenuManage() { ); } +// 트리 전체 메뉴 개수를 재귀로 합산(상단 태그의 "전체 N개" 표시용). function countMenus(menus: MenuResp[]): number { return menus.reduce((sum, m) => sum + 1 + (m.children ? countMenus(m.children) : 0), 0); } diff --git a/ga-frontend/src/pages/system/Notices.tsx b/ga-frontend/src/pages/system/Notices.tsx index f4cc6c7..491e033 100644 --- a/ga-frontend/src/pages/system/Notices.tsx +++ b/ga-frontend/src/pages/system/Notices.tsx @@ -14,6 +14,21 @@ import { GRAY, RADIUS, SHADOW, COLOR_ERROR } from '@/theme/tokens'; const { Text } = Typography; const { TextArea } = Input; +/** + * [화면] 공지사항 관리 (Notices) + * + * 용도: 시스템 공지사항을 등록/검색/수정/삭제한다. 상단 고정(isPinned), 유형/상태별 분류, + * 게시일·만료일 기간 관리를 지원한다. + * + * 도메인 용어: + * - 유형(NOTICE_TYPE)·상태(NOTICE_STATUS): 공통코드. CodeBadge 로 한글 라벨/색상 표시. + * - 고정(isPinned): 목록 상단에 우선 노출되는 공지. + * + * 호출 API (api/notice.ts): noticeApi.list / create / update / delete. + * 주요 상태: 검색 파라미터(params), 등록/수정 모달, 수정 대상(editingNotice·null이면 신규). + */ + +// AG Grid 컬럼 정의: 공지 목록 표시 열. 고정/유형/상태는 cellRenderer 로 뱃지 렌더링. const COLUMNS: ColDef[] = [ { field: 'isPinned', headerName: '고정', width: 70, @@ -50,9 +65,11 @@ export default function Notices() { const rows = data?.list ?? []; + // 신규 등록 모달 열기: 수정 대상 비우고 폼 초기화. / 수정 모달 열기: 선택 행 값으로 폼 채움. const openCreate = () => { setEditingNotice(null); form.resetFields(); setModalOpen(true); }; const openEdit = (row: NoticeRow) => { setEditingNotice(row); form.setFieldsValue(row); setModalOpen(true); }; + // 저장: editingNotice 유무로 수정/등록 분기. 성공 시 목록 캐시 갱신. const handleSave = async () => { const values = await form.validateFields() as NoticeSaveReq; try { diff --git a/ga-frontend/src/pages/system/RoleList.tsx b/ga-frontend/src/pages/system/RoleList.tsx index 04ff371..829c9c4 100644 --- a/ga-frontend/src/pages/system/RoleList.tsx +++ b/ga-frontend/src/pages/system/RoleList.tsx @@ -17,6 +17,26 @@ import PageContainer from '@/components/common/PageContainer'; import { roleApi, RoleVO } from '@/api/user'; import { menuApi, MenuResp } from '@/api/menu'; +/** + * [화면] 역할 / 권한 (RoleList) + * + * 용도: RBAC(역할 기반 접근제어)의 핵심 화면. 좌측에서 역할을 고르면, 우측에 "메뉴 x 권한" 매트릭스가 + * 체크박스로 펼쳐진다. 체크된 조합만 그 역할에게 허용된다(예: 정산 메뉴의 조회/엑셀만 허용). + * + * 도메인 용어: + * - 역할(Role): 권한 묶음. 사용자에게 역할을 부여해 화면/기능 접근을 통제한다. + * - 권한(Perm): READ(조회)/CREATE(등록)/UPDATE(수정)/DELETE(삭제)/APPROVE(승인)/EXPORT(엑셀). + * - 매트릭스: 각 메뉴별로 6개 권한 체크. DIRECTORY(폴더) 행은 권한 없이 분류만. + * - 권한 키 표현: "menuId:permCode" 문자열로 Set 에 담아 부여 여부를 관리한다. + * + * 호출 API: + * - roleApi.list / permissions(roleId) / savePermissions(roleId, list) + * - menuApi.tree (권한을 매길 메뉴 트리) + * + * 주요 상태: 선택 역할, granted(부여된 권한 키 Set), 저장 로딩. + */ + +// 매트릭스 열로 표시할 권한 코드와 한글 라벨. const PERMS = ['READ', 'CREATE', 'UPDATE', 'DELETE', 'APPROVE', 'EXPORT'] as const; const PERM_LABELS: Record = { READ: '조회', @@ -37,6 +57,7 @@ interface FlatMenuItem { indent: number; // 0 = top-level directory, 1 = child page } +// 계층형 메뉴 트리를 들여쓰기(indent) 정보가 있는 평면 배열로 펼친다(매트릭스 행으로 쓰기 위함). function flattenMenu(nodes: MenuResp[], indent = 0): FlatMenuItem[] { const out: FlatMenuItem[] = []; for (const n of nodes) { @@ -72,6 +93,7 @@ export default function RoleList() { const flatMenu = flattenMenu(tree); + // 역할 클릭 시: 그 역할의 부여 권한을 조회해 "menuId:permCode" 키 Set 으로 매트릭스에 반영. const selectRole = async (r: RoleVO) => { setSelected(r); const perms = await roleApi.permissions(r.roleId); @@ -82,6 +104,7 @@ export default function RoleList() { setGranted(set); }; + // 단일 체크박스 토글: 해당 메뉴-권한 키를 Set 에 넣거나 뺀다. const toggle = (menuId: number, permCode: string) => { const key = `${menuId}:${permCode}`; const next = new Set(granted); @@ -89,6 +112,7 @@ export default function RoleList() { setGranted(next); }; + // 전체 선택/해제: 폴더가 아닌 모든 페이지의 모든 권한을 한꺼번에 켜거나 끈다. const toggleAll = () => { const pages = flatMenu.filter((m) => m.menuType !== 'DIRECTORY'); const allKeys = pages.flatMap((m) => PERMS.map((p) => `${m.menuId}:${p}`)); @@ -100,6 +124,7 @@ export default function RoleList() { } }; + // 저장: 부여 키 Set 을 {menuId, permCode, isGranted:'Y'} 목록으로 변환해 서버에 전송. const save = () => { if (!selected) return; Modal.confirm({ diff --git a/ga-frontend/src/pages/system/SystemConfig.tsx b/ga-frontend/src/pages/system/SystemConfig.tsx index c7d5cd6..0f2752c 100644 --- a/ga-frontend/src/pages/system/SystemConfig.tsx +++ b/ga-frontend/src/pages/system/SystemConfig.tsx @@ -6,8 +6,24 @@ import PageContainer from '@/components/common/PageContainer'; import PermissionButton from '@/components/common/PermissionButton'; import { configApi, SystemConfigResp } from '@/api/config'; +// 권한(RBAC) 체크용 메뉴 코드. const MENU = 'SYSTEM_CONFIG'; +/** + * [화면] 시스템 설정 (SystemConfig) + * + * 용도: 시스템 동작을 좌우하는 키-값(key-value) 설정을 조회/수정한다. 그룹별(일반/배치/보안/메일/세금) + * 로 분류되며, 일부는 잠금(isEditable=N)·암호화(isEncrypted=Y) 항목이라 다루는 방식이 다르다. + * + * 도메인 용어: + * - 설정 키(configKey): 코드에서 참조하는 식별자(예: tax.rate). configValue: 그 값. + * - 암호화 항목: 화면에는 *** 로만 표시. 수정 시 새 값을 입력하면 서버에서 암호화해 저장. + * - 잠금 항목: isEditable=N. 화면에서 수정 불가(작업 버튼 미노출). + * + * 호출 API (api/config.ts): configApi.list(group) / update(key, value). + * 주요 상태: 수정 모달(editState union — 닫힘/열림+행), 저장 로딩. + */ + const GROUP_COLOR: Record = { GENERAL: 'blue', BATCH: 'cyan', diff --git a/ga-frontend/src/pages/system/SystemLog.tsx b/ga-frontend/src/pages/system/SystemLog.tsx index dec0aa7..0289365 100644 --- a/ga-frontend/src/pages/system/SystemLog.tsx +++ b/ga-frontend/src/pages/system/SystemLog.tsx @@ -4,6 +4,17 @@ import { ProTable, type ProColumns } from '@ant-design/pro-components'; import PageContainer from '@/components/common/PageContainer'; import { logApi, ApiAccessLogResp, DataChangeLogResp, LoginLogResp } from '@/api/log'; +/** + * [화면] 시스템 로그 (SystemLog) + * + * 용도: 감사(audit) 목적의 3종 로그를 탭으로 보여준다(모두 읽기 전용). + * - 로그인 이력(LoginTable): 로그인/로그아웃/실패. 실패 사유·IP·User-Agent 추적. + * - API 호출(ApiTable): 요청 메서드/URL/응답상태/소요시간. 상태코드별 색상(4xx 경고, 5xx 오류). + * - 변경 이력(ChangeTable): 어떤 테이블의 어떤 레코드가 CREATE/UPDATE/DELETE 되었는지. + * + * 호출 API (api/log.ts): logApi.login / api / change (각각 페이징+검색). + * 주요 상태: 활성 탭(tab). + */ export default function SystemLog() { const [tab, setTab] = useState('login'); diff --git a/ga-frontend/src/pages/system/UploadTemplateManager.tsx b/ga-frontend/src/pages/system/UploadTemplateManager.tsx index f4bc447..28be909 100644 --- a/ga-frontend/src/pages/system/UploadTemplateManager.tsx +++ b/ga-frontend/src/pages/system/UploadTemplateManager.tsx @@ -25,7 +25,26 @@ import { const { Text } = Typography; +/** + * [화면] 업로드 템플릿 관리 (UploadTemplateManager) + * + * 용도: 보험사에서 받은 엑셀/CSV 파일을 우리 DB 테이블로 적재할 때, "엑셀의 어느 열을 DB의 어느 컬럼에 + * 넣을지"를 정의하는 매핑 템플릿을 만든다. 좌측에 템플릿 목록, 우측에 선택 템플릿의 컬럼 매핑 편집기. + * + * 도메인 용어: + * - 카테고리: 적재 대상 원장 구분(모집/유지/예외/지급/기타). 대상 테이블(targetTable)로 흐른다. + * - 소스(source): 엑셀 쪽 출처 — 열 인덱스(A,B,C)/헤더명/고정값. + * - 대상 필드(targetField): DB 컬럼. 변환 규칙(transformRule)으로 값 가공(TRIM/DATE/DIVIDE/CODE_MAP 등). + * - 샘플 엑셀 자동 채우기: 견본 파일 헤더를 읽어 매핑 행을 자동 생성(extractHeaders). + * + * 호출 API (api/uploadTemplate.ts): list/create/update/remove, getColumns/saveColumns, + * targetTables/targetFields, extractHeaders, preview, history. + * + * 구조: TemplateFormModal(템플릿 CRUD) + ColumnEditor(컬럼 매핑 편집) + PreviewModal(미리보기) + HistoryDrawer(업로드 이력). + */ + // transformRule → color tag +// 변환 규칙별 태그 색상/라벨. 단순(TRIM 등)/숫자(DATE·DIVIDE)/조회(LOOKUP·CODE_MAP)/문자(REPLACE 등)로 색 구분. const RULE_TAG: Record = { TRIM: { color: 'default', label: 'TRIM' }, UPPER: { color: 'default', label: 'UPPER' }, @@ -94,6 +113,7 @@ function toRows(cols: UploadTemplateColumnVO[]): ColumnRow[] { } // ─── 변환 규칙에 따라 동적 파라미터 입력 렌더 ────────────────────────────── +// 선택된 변환 규칙(DATE/DIVIDE/LOOKUP 등)에 맞는 파라미터 입력 위젯을 그려준다. function RuleParamCell({ row, onChange }: { row: ColumnRow; onChange: (key: string, field: keyof UploadTemplateColumnVO, value: unknown) => void; @@ -295,6 +315,7 @@ function HistoryDrawer({ templateId, onClose }: { templateId: number; onClose: ( } // ─── 컬럼 매핑 편집기 ───────────────────────────────────────────────────── +// 선택 템플릿의 매핑 행을 로컬 상태(rows)로 편집한다. 행 추가/삭제/순서이동·샘플 자동채우기·저장 제공. function ColumnEditor({ template, onSaved }: { template: UploadTemplateResp; onSaved: () => void; @@ -364,6 +385,7 @@ function ColumnEditor({ template, onSaved }: { } }; + // 특정 매핑 행(_key)의 한 필드 값을 갱신. const updateCell = (key: string, field: keyof UploadTemplateColumnVO, value: unknown) => { setRows((prev) => prev.map((r) => (r._key === key ? { ...r, [field]: value } : r)), @@ -387,6 +409,7 @@ function ColumnEditor({ template, onSaved }: { setRows((prev) => prev.filter((r) => r._key !== key).map((r, i) => ({ ...r, sortOrder: i + 1 }))); }; + // 행 순서 위/아래 이동 후 sortOrder 재계산. const moveRow = (key: string, dir: 'up' | 'down') => { setRows((prev) => { const idx = prev.findIndex((r) => r._key === key); @@ -400,6 +423,7 @@ function ColumnEditor({ template, onSaved }: { }); }; + // 저장: 로컬 편집용 _key 를 제거한 매핑 목록을 서버에 일괄 저장. const save = async () => { setSaving(true); try { @@ -732,6 +756,7 @@ function TemplateFormModal({ editTarget, onSuccess, trigger }: { } // ─── 메인 페이지 ────────────────────────────────────────────────────────── +// 좌측 템플릿 목록(선택) + 우측 컬럼 매핑 편집기를 조합. 이력 Drawer 는 선택 시 표시. export default function UploadTemplateManager() { const qc = useQueryClient(); const actionRef = useRef(); diff --git a/ga-frontend/src/pages/system/UserList.tsx b/ga-frontend/src/pages/system/UserList.tsx index 7f8a766..e1fb167 100644 --- a/ga-frontend/src/pages/system/UserList.tsx +++ b/ga-frontend/src/pages/system/UserList.tsx @@ -8,6 +8,21 @@ import PermissionButton from '@/components/common/PermissionButton'; import { useCommonCodes } from '@/hooks/useCommonCodes'; import { userApi, UserResp } from '@/api/user'; +/** + * [화면] 사용자 관리 (UserList) + * + * 용도: 시스템 로그인 계정을 관리한다. 검색/조회 후 계정별로 전화번호 등록, 비밀번호 초기화, + * 잠긴 계정(로그인 실패 누적 등) 잠금 해제를 수행한다. + * + * 도메인 용어: + * - 연결 설계사(agentName): 이 계정이 매핑된 설계사. GA에서 사용자 계정과 설계사 인사정보는 별개라 연결 관계로 표현. + * - 상태(status): 공통코드 AGENT_STATUS. LOCKED 면 잠금해제 버튼 노출. + * - 실패횟수(failCount): 로그인 실패 누적. 한도 초과 시 계정 잠김. + * - 전화번호: SMS 결재 알림 발송에 사용. 비우면 SMS 스킵. + * + * 호출 API (api/user.ts): userApi.list / resetPassword / unlock / updatePhone. + * 주요 상태: ProTable 제어 ref, 전화번호 수정 모달(phoneEdit). + */ export default function UserList() { const qc = useQueryClient(); const actionRef = useRef(); @@ -18,6 +33,7 @@ export default function UserList() { actionRef.current?.reload(); }; + // 비밀번호 초기화: 임시 비밀번호로 재설정(확인 모달 후 실행). const onReset = (id: number) => Modal.confirm({ title: '비밀번호 초기화', @@ -25,6 +41,7 @@ export default function UserList() { onOk: async () => { await userApi.resetPassword(id); message.success('초기화 완료'); refresh(); }, }); + // 계정 잠금 해제: 잠긴 계정의 실패횟수/잠금상태를 풀어 다시 로그인 가능하게 한다. const onUnlock = (id: number) => Modal.confirm({ title: '계정 잠금 해제', diff --git a/ga-frontend/src/stores/auth.ts b/ga-frontend/src/stores/auth.ts index b6057ab..13e4e1e 100644 --- a/ga-frontend/src/stores/auth.ts +++ b/ga-frontend/src/stores/auth.ts @@ -1,6 +1,9 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +// 로그인한 사용자 정보 + 조작 함수 +// - userId/loginId/userName/roles: 현재 사용자 정보 +// - setAuth: 로그인 성공 시 사용자 정보 저장 / clear: 로그아웃 시 초기화 interface AuthState { userId?: number; loginId?: string; @@ -10,6 +13,10 @@ interface AuthState { clear: () => void; } +/** + * 인증 전역 스토어(zustand) — 어느 컴포넌트에서나 useAuthStore() 로 사용자 정보를 읽는다. + * persist 미들웨어로 localStorage('ga-auth')에 저장되어 새로고침해도 로그인 상태가 유지된다. + */ export const useAuthStore = create()( persist( (set) => ({ diff --git a/progress.txt b/progress.txt index d70b42c..a962d85 100644 --- a/progress.txt +++ b/progress.txt @@ -50,3 +50,36 @@ US-D 사업소득 간이지급명세서(월별): commission_statement(MONTHLY) 검증: ./gradlew build 전모듈 SUCCESS, ga-batch/ga-api/ga-common test PASS, ga-frontend tsc -b PASS, Flyway v126 적용, 엔드포인트 smoke(D 200, 권한게이트 403 정상). 주의: 무관한 기존 변경(InstallmentRatioMapper.xml) 미커밋 상태 그대로 보존(내 변경 아님). + +================================================================ +[남은작업 — 미커밋 변경 완성·검증·커밋] 2026-06-12 (ralph) +================================================================ +미커밋 working tree(342파일 M + 2 신규)를 인수받아 완성·검증·커밋. + +구성 판별: git diff를 EOL/공백 무시(--ignore-space-at-eol --ignore-all-space)+양측 주석제거로 +정밀 분석 → 진짜 신규 코드는 PayInstallmentStep 기능뿐, 나머지 340파일은 전부 신입교육 주석 +추가(+EOL 정규화 LF→CRLF로 diff가 전 라인 변경처럼 보였음). + +신규 기능 — PayInstallmentStep(Step 4.5): 1200%룰 이연 분급(installment_plan SCHEDULED)의 +도래월(settle_month=정산월) 실지급. recruit_ledger 편입(status=CALCULATED, reconcileStatus=MATCHED, +commissionYear=planYear) → Step8 aggregate로 settle_master gross 반영. 멱등=진입 시 +resetPaidToScheduledByMonth(PAID→SCHEDULED) 후 재지급, Step4 deleteBySettleMonth(CALCULATED)가 +원장 정리. BatchConfig job flow step4b(calcRecruit→payInstallment→calcMaintain) 등록. +신규: InstallmentPlanMapper.resetPaidToScheduledByMonth(+XML), RecruitLedgerMapper insert에 +reconcile_status 컬럼 추가, SettlementContext.installmentPaidCount. + +검증: 전체 ./gradlew build SUCCESSFUL(전 모듈 test 포함), PayInstallmentStepTest 3/3 GREEN +(정상지급 100000→tax3300 / 멱등 reset먼저 / 계약미존재 skip), frontend tsc -b 0오류. + +적대적 리뷰(general-purpose THOROUGH) 4건 → PL 증거기반 판정: +- #1 sumByContract 미필터로 분급 재clip(HIGH주장): 비차단. PayInstallmentStep은 검사기 미호출 + → 이연분급은 7년 일정대로 완납, 재clip 없음. 지급분이 누적한도에 포함되는 건 계약이 실제 + 받은 금액 반영=정상. 신규 모집수수료가 수년 후 들어올 때만 (정상)clip되는 좁은 엣지. 코드 + 주석으로 의도 명시(검사기 미호출·분급분 누적포함=정상) 추가. +- #2 @Transactional 부재 원자성(HIGH주장): 반증. .tasklet(tasklet, tx) 단일 트랜잭션, + reset+update+insert 원자, 재실행 self-healing. +- #3 status 문자열 결합(MED): 현재 양측 'CALCULATED' 정상, 변경 시에만 잠재. 현 버그 아님. +- #4 원장 서술필드 NULL(LOW): 집계/정산 무영향(agent_amount만 SUM). ContractVO에 해당 필드 + 없어 깨끗한 수정은 조인쿼리 필요 → 비외과적, 미수정(알려진 경미 한계). +유일 코드변경=PayInstallmentStep 클래스 Javadoc에 한도-상호작용 의도 명시(comment-only). +deslop: 피처파일 dead code/debug/TODO 0, 주석은 의도된 교육산출물(유지). post-deslop build GREEN.