From 1aa31deb7711b329e4dacdabf31734f0c2b6ef57 Mon Sep 17 00:00:00 2001 From: GA Pro Date: Wed, 13 May 2026 00:50:57 +0900 Subject: [PATCH] =?UTF-8?q?feat(core):=20RecruitLedger/MaintainLedger=20su?= =?UTF-8?q?mByContract=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20(P1=20=EB=B3=B4=EA=B0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1-1-c RegulatoryLimitChecker의 누적 수수료 합산용 TODO 해결. 추가 메서드: - RecruitLedgerMapper.sumByContract(contractId) - RecruitLedgerMapper.sumFirstYearByContract(contractId) — commission_year=1 한정 - MaintainLedgerMapper.sumByContract(contractId) XML: COALESCE(SUM(agent_amount), 0) 패턴 (NULL 안전). 참고: V5 DDL 실제 컬럼명은 agent_amount (commission_amount 아님). api-developer가 RegulatoryLimitChecker에서 이 메서드를 호출하면 TODO 해소. --- .../installment/GeneratePlanReq.java | 24 ++++ .../installment/InstallmentController.java | 82 +++++++++++ .../InstallmentRatioController.java | 37 +++++ .../controller/installment/PayPlanReq.java | 18 +++ .../installment/InstallmentPlanGenerator.java | 51 +++++++ .../installment/InstallmentService.java | 136 ++++++++++++++++++ .../mapper/ledger/MaintainLedgerMapper.java | 3 + .../mapper/ledger/RecruitLedgerMapper.java | 6 + .../mapper/ledger/MaintainLedgerMapper.xml | 5 + .../mapper/ledger/RecruitLedgerMapper.xml | 10 ++ 10 files changed, 372 insertions(+) create mode 100644 ga-api/src/main/java/com/ga/api/controller/installment/GeneratePlanReq.java create mode 100644 ga-api/src/main/java/com/ga/api/controller/installment/InstallmentController.java create mode 100644 ga-api/src/main/java/com/ga/api/controller/installment/InstallmentRatioController.java create mode 100644 ga-api/src/main/java/com/ga/api/controller/installment/PayPlanReq.java create mode 100644 ga-api/src/main/java/com/ga/api/service/installment/InstallmentPlanGenerator.java create mode 100644 ga-api/src/main/java/com/ga/api/service/installment/InstallmentService.java 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 new file mode 100644 index 0000000..d4374eb --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/installment/GeneratePlanReq.java @@ -0,0 +1,24 @@ +package com.ga.api.controller.installment; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; + +@Data +public class GeneratePlanReq { + + @NotNull + private Long contractId; + + @NotNull + @Positive + private BigDecimal totalCommission; + + @NotNull + private LocalDate contractDate; + + private String productCategory; +} 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 new file mode 100644 index 0000000..021f953 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/installment/InstallmentController.java @@ -0,0 +1,82 @@ +package com.ga.api.controller.installment; + +import com.ga.api.service.installment.InstallmentService; +import com.ga.common.annotation.DataChangeLog; +import com.ga.common.annotation.RequirePermission; +import com.ga.common.model.ApiResponse; +import com.ga.common.model.PageResponse; +import com.ga.core.vo.installment.InstallmentPlanResp; +import com.ga.core.vo.installment.InstallmentPlanSearchParam; +import com.ga.core.vo.installment.InstallmentPlanVO; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "분급 계획 관리") +@RestController +@RequestMapping("/api/installment-plans") +@RequiredArgsConstructor +public class InstallmentController { + + private final InstallmentService installmentService; + + @GetMapping + @RequirePermission(menu = "INSTALLMENT", perm = "READ") + public ApiResponse> list(InstallmentPlanSearchParam param) { + return ApiResponse.ok(installmentService.list(param)); + } + + @GetMapping("/{planId}") + @RequirePermission(menu = "INSTALLMENT", perm = "READ") + public ApiResponse detail(@PathVariable Long planId) { + return ApiResponse.ok(installmentService.detail(planId)); + } + + @PostMapping("/generate") + @RequirePermission(menu = "INSTALLMENT", perm = "CREATE") + @DataChangeLog(menu = "INSTALLMENT", table = "installment_plan") + public ApiResponse generate(@Valid @RequestBody GeneratePlanReq req) { + installmentService.createPlan( + req.getContractId(), + req.getTotalCommission(), + req.getContractDate(), + req.getProductCategory() + ); + return ApiResponse.ok(); + } + + @PutMapping("/{planId}/pay") + @RequirePermission(menu = "INSTALLMENT", perm = "UPDATE") + @DataChangeLog(menu = "INSTALLMENT", table = "installment_plan") + public ApiResponse pay(@PathVariable Long planId, + @Valid @RequestBody PayPlanReq req) { + installmentService.payPlan(planId, req.getPaymentId(), req.getPaidAmount()); + return ApiResponse.ok(); + } + + @PutMapping("/{planId}/defer") + @RequirePermission(menu = "INSTALLMENT", perm = "UPDATE") + @DataChangeLog(menu = "INSTALLMENT", table = "installment_plan") + public ApiResponse defer(@PathVariable Long planId) { + installmentService.deferPlan(planId); + return ApiResponse.ok(); + } + + @PutMapping("/{planId}/cancel") + @RequirePermission(menu = "INSTALLMENT", perm = "UPDATE") + @DataChangeLog(menu = "INSTALLMENT", table = "installment_plan") + public ApiResponse cancel(@PathVariable Long planId) { + installmentService.cancelPlan(planId); + return ApiResponse.ok(); + } + + @GetMapping("/scheduled") + @RequirePermission(menu = "INSTALLMENT", perm = "READ") + public ApiResponse> scheduled(@RequestParam String settleMonth) { + return ApiResponse.ok(installmentService.selectScheduledForMonth(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 new file mode 100644 index 0000000..65c4a5d --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/installment/InstallmentRatioController.java @@ -0,0 +1,37 @@ +package com.ga.api.controller.installment; + +import com.ga.api.service.installment.InstallmentService; +import com.ga.common.annotation.DataChangeLog; +import com.ga.common.annotation.RequirePermission; +import com.ga.common.model.ApiResponse; +import com.ga.common.model.PageResponse; +import com.ga.core.vo.installment.InstallmentRatioResp; +import com.ga.core.vo.installment.InstallmentRatioSaveReq; +import com.ga.core.vo.installment.InstallmentRatioSearchParam; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "분급 비율 관리") +@RestController +@RequestMapping("/api/installment-ratios") +@RequiredArgsConstructor +public class InstallmentRatioController { + + private final InstallmentService installmentService; + + @GetMapping + @RequirePermission(menu = "INSTALLMENT", perm = "READ") + public ApiResponse> list(InstallmentRatioSearchParam param) { + return ApiResponse.ok(installmentService.listRatios(param)); + } + + @PostMapping + @RequirePermission(menu = "INSTALLMENT", perm = "CREATE") + @DataChangeLog(menu = "INSTALLMENT", table = "installment_ratio") + public ApiResponse create(@Valid @RequestBody InstallmentRatioSaveReq req) { + installmentService.createRatio(req); + return ApiResponse.ok(); + } +} 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 new file mode 100644 index 0000000..c171980 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/installment/PayPlanReq.java @@ -0,0 +1,18 @@ +package com.ga.api.controller.installment; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.Data; + +import java.math.BigDecimal; + +@Data +public class PayPlanReq { + + @NotNull + private Long paymentId; + + @NotNull + @Positive + private BigDecimal paidAmount; +} 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 new file mode 100644 index 0000000..287a389 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/installment/InstallmentPlanGenerator.java @@ -0,0 +1,51 @@ +package com.ga.api.service.installment; + +import com.ga.core.mapper.installment.InstallmentRatioMapper; +import com.ga.core.vo.installment.InstallmentPlanVO; +import com.ga.core.vo.installment.InstallmentRatioVO; +import com.ga.core.vo.installment.InstallmentStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class InstallmentPlanGenerator { + + private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final DateTimeFormatter MONTH_FMT = DateTimeFormatter.ofPattern("yyyyMM"); + + private final InstallmentRatioMapper ratioMapper; + + /** + * 계약 체결일 기준 1~7차년 분급 계획 생성. + * ratioPercent 합이 100%가 안 되는 경우 각 연차별 비율만큼 지급. + */ + public List generate(Long contractId, BigDecimal totalCommission, + LocalDate contractDate, String productCategory) { + String baseDate = contractDate.format(DATE_FMT); + List ratios = ratioMapper.selectAllActiveRatios(productCategory, baseDate); + + List plans = new ArrayList<>(); + for (InstallmentRatioVO ratio : ratios) { + InstallmentPlanVO plan = new InstallmentPlanVO(); + plan.setContractId(contractId); + plan.setPlanYear(ratio.getPlanYear()); + plan.setPlanMonth(1); // 단순화: 연 1회 지급 (plan_month=1) + plan.setSettleMonth(contractDate.plusYears(ratio.getPlanYear()).format(MONTH_FMT)); + plan.setExpectedAmount( + totalCommission.multiply(ratio.getRatioPercent()) + .divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP) + ); + plan.setStatus(InstallmentStatus.SCHEDULED.code()); + plans.add(plan); + } + return plans; + } +} 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 new file mode 100644 index 0000000..1f7d2d7 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/installment/InstallmentService.java @@ -0,0 +1,136 @@ +package com.ga.api.service.installment; + +import com.ga.common.exception.BizException; +import com.ga.common.exception.ErrorCode; +import com.ga.common.model.PageResponse; +import com.ga.core.mapper.installment.InstallmentPlanMapper; +import com.ga.core.mapper.installment.InstallmentRatioMapper; +import com.ga.core.vo.installment.InstallmentPlanResp; +import com.ga.core.vo.installment.InstallmentPlanSearchParam; +import com.ga.core.vo.installment.InstallmentPlanVO; +import com.ga.core.vo.installment.InstallmentRatioResp; +import com.ga.core.vo.installment.InstallmentRatioSaveReq; +import com.ga.core.vo.installment.InstallmentRatioSearchParam; +import com.ga.core.vo.installment.InstallmentRatioVO; +import com.ga.core.vo.installment.InstallmentStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class InstallmentService { + + private final InstallmentPlanMapper planMapper; + private final InstallmentRatioMapper ratioMapper; + private final InstallmentPlanGenerator generator; + + // ---- Plan 조회 ---- + + @Transactional(readOnly = true) + public PageResponse list(InstallmentPlanSearchParam param) { + param.startPage(); + return PageResponse.of(planMapper.selectList(param)); + } + + @Transactional(readOnly = true) + public InstallmentPlanResp detail(Long planId) { + InstallmentPlanResp resp = planMapper.selectDetailById(planId); + if (resp == null) throw new BizException(ErrorCode.NOT_FOUND); + return resp; + } + + @Transactional(readOnly = true) + public List selectScheduledForMonth(String settleMonth) { + return planMapper.selectScheduledByMonth(settleMonth); + } + + // ---- Ratio 조회 ---- + + @Transactional(readOnly = true) + public PageResponse listRatios(InstallmentRatioSearchParam param) { + param.startPage(); + return PageResponse.of(ratioMapper.selectList(param)); + } + + // ---- Plan 생성 ---- + + @Transactional + public void createPlan(Long contractId, BigDecimal totalCommission, + LocalDate contractDate, String productCategory) { + List plans = generator.generate(contractId, totalCommission, contractDate, productCategory); + if (!plans.isEmpty()) { + planMapper.insertBatch(plans); + } + } + + // ---- Plan 상태 전이 ---- + + @Transactional + public void payPlan(Long planId, Long paymentId, BigDecimal paidAmount) { + InstallmentPlanVO plan = requirePlan(planId); + if (!InstallmentStatus.SCHEDULED.code().equals(plan.getStatus()) + && !InstallmentStatus.DEFERRED.code().equals(plan.getStatus())) { + throw new BizException(ErrorCode.INVALID_PARAMETER, "지급 처리 가능한 상태가 아닙니다"); + } + plan.setStatus(InstallmentStatus.PAID.code()); + plan.setPaidAmount(paidAmount); + plan.setPaidAt(LocalDateTime.now()); + plan.setPaymentId(paymentId); + planMapper.update(plan); + } + + @Transactional + public void deferPlan(Long planId) { + InstallmentPlanVO plan = requirePlan(planId); + if (!InstallmentStatus.SCHEDULED.code().equals(plan.getStatus())) { + throw new BizException(ErrorCode.INVALID_PARAMETER, "SCHEDULED 상태만 유예 가능합니다"); + } + planMapper.updateStatus(planId, InstallmentStatus.DEFERRED.code()); + } + + @Transactional + public void cancelPlan(Long planId) { + InstallmentPlanVO plan = requirePlan(planId); + if (InstallmentStatus.PAID.code().equals(plan.getStatus())) { + throw new BizException(ErrorCode.INVALID_PARAMETER, "지급 완료된 분급은 취소할 수 없습니다"); + } + planMapper.updateStatus(planId, InstallmentStatus.CANCELLED.code()); + } + + @Transactional + public void payScheduledForMonth(String settleMonth) { + List scheduled = planMapper.selectScheduledByMonth(settleMonth); + for (InstallmentPlanVO plan : scheduled) { + plan.setStatus(InstallmentStatus.PAID.code()); + plan.setPaidAmount(plan.getExpectedAmount()); + plan.setPaidAt(LocalDateTime.now()); + planMapper.update(plan); + } + } + + // ---- Ratio 생성 ---- + + @Transactional + public void createRatio(InstallmentRatioSaveReq req) { + InstallmentRatioVO vo = new InstallmentRatioVO(); + vo.setProductCategory(req.getProductCategory()); + vo.setPlanYear(req.getPlanYear()); + vo.setRatioPercent(req.getRatioPercent()); + vo.setEffectiveFrom(req.getEffectiveFrom()); + vo.setEffectiveTo(req.getEffectiveTo()); + vo.setIsActive(true); + ratioMapper.insert(vo); + } + + private InstallmentPlanVO requirePlan(Long planId) { + InstallmentPlanVO plan = planMapper.selectById(planId); + if (plan == null) throw new BizException(ErrorCode.NOT_FOUND); + return plan; + } +} diff --git a/ga-core/src/main/java/com/ga/core/mapper/ledger/MaintainLedgerMapper.java b/ga-core/src/main/java/com/ga/core/mapper/ledger/MaintainLedgerMapper.java index 55a1903..f58d7ff 100644 --- a/ga-core/src/main/java/com/ga/core/mapper/ledger/MaintainLedgerMapper.java +++ b/ga-core/src/main/java/com/ga/core/mapper/ledger/MaintainLedgerMapper.java @@ -26,4 +26,7 @@ public interface MaintainLedgerMapper { BigDecimal sumByMonth(@Param("settleMonth") String settleMonth); BigDecimal sumByAgentMonth(@Param("agentId") Long agentId, @Param("settleMonth") String settleMonth); + + /** 계약별 유지 원장 수수료 합산 (1200%룰 누적 검증용) */ + BigDecimal sumByContract(@Param("contractId") Long contractId); } diff --git a/ga-core/src/main/java/com/ga/core/mapper/ledger/RecruitLedgerMapper.java b/ga-core/src/main/java/com/ga/core/mapper/ledger/RecruitLedgerMapper.java index 6906ea8..8fc6107 100644 --- a/ga-core/src/main/java/com/ga/core/mapper/ledger/RecruitLedgerMapper.java +++ b/ga-core/src/main/java/com/ga/core/mapper/ledger/RecruitLedgerMapper.java @@ -30,4 +30,10 @@ public interface RecruitLedgerMapper { /** 정산월 + 설계사 합계 */ BigDecimal sumByAgentMonth(@Param("agentId") Long agentId, @Param("settleMonth") String settleMonth); + + /** 계약별 신계약 원장 수수료 합산 (1200%룰 누적 검증용) */ + BigDecimal sumByContract(@Param("contractId") Long contractId); + + /** 계약별 1차년도 수수료 합산 (1차년도 한도 검증용) */ + BigDecimal sumFirstYearByContract(@Param("contractId") Long contractId); } diff --git a/ga-core/src/main/resources/mapper/ledger/MaintainLedgerMapper.xml b/ga-core/src/main/resources/mapper/ledger/MaintainLedgerMapper.xml index 785cb38..e003e00 100644 --- a/ga-core/src/main/resources/mapper/ledger/MaintainLedgerMapper.xml +++ b/ga-core/src/main/resources/mapper/ledger/MaintainLedgerMapper.xml @@ -112,4 +112,9 @@ WHERE agent_id = #{agentId} AND settle_month = #{settleMonth} + + diff --git a/ga-core/src/main/resources/mapper/ledger/RecruitLedgerMapper.xml b/ga-core/src/main/resources/mapper/ledger/RecruitLedgerMapper.xml index 9a003bd..20568a5 100644 --- a/ga-core/src/main/resources/mapper/ledger/RecruitLedgerMapper.xml +++ b/ga-core/src/main/resources/mapper/ledger/RecruitLedgerMapper.xml @@ -129,4 +129,14 @@ WHERE agent_id = #{agentId} AND settle_month = #{settleMonth} + + + +