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 new file mode 100644 index 0000000..0a9de8d --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/regulatory/ContractLimitCheckReq.java @@ -0,0 +1,26 @@ +package com.ga.api.controller.regulatory; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; + +@Data +public class ContractLimitCheckReq { + + @NotNull + private Long contractId; + + @NotNull + @Positive + private BigDecimal additionalAmount; + + /** 기준일 (null이면 오늘) */ + private LocalDate baseDate; + + /** TOTAL_RATIO | FIRST_YEAR_RATIO */ + @NotNull + private String limitType; +} 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 new file mode 100644 index 0000000..d93c6d0 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/regulatory/RegulatoryLimitController.java @@ -0,0 +1,78 @@ +package com.ga.api.controller.regulatory; + +import com.ga.api.service.regulatory.RegulatoryLimitChecker; +import com.ga.api.service.regulatory.RegulatoryLimitService; +import com.ga.api.service.regulatory.ViolationResult; +import com.ga.common.annotation.DataChangeLog; +import com.ga.common.annotation.RequirePermission; +import com.ga.common.model.ApiResponse; +import com.ga.common.model.PageResponse; +import com.ga.core.vo.regulatory.RegulatoryLimitResp; +import com.ga.core.vo.regulatory.RegulatoryLimitSaveReq; +import com.ga.core.vo.regulatory.RegulatoryLimitSearchParam; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; + +@Tag(name = "규제 한도 관리") +@RestController +@RequestMapping("/api/regulatory-limits") +@RequiredArgsConstructor +public class RegulatoryLimitController { + + private final RegulatoryLimitService limitService; + private final RegulatoryLimitChecker limitChecker; + + @GetMapping + @RequirePermission(menu = "REGULATORY_LIMIT", perm = "READ") + public ApiResponse> 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") + public ApiResponse create(@Valid @RequestBody RegulatoryLimitSaveReq req) { + limitService.create(req); + return ApiResponse.ok(); + } + + @PutMapping("/{limitId}") + @RequirePermission(menu = "REGULATORY_LIMIT", perm = "UPDATE") + @DataChangeLog(menu = "REGULATORY_LIMIT", table = "regulatory_limit") + public ApiResponse update(@PathVariable Long limitId, + @Valid @RequestBody RegulatoryLimitSaveReq req) { + limitService.update(limitId, req); + return ApiResponse.ok(); + } + + @PutMapping("/{limitId}/deactivate") + @RequirePermission(menu = "REGULATORY_LIMIT", perm = "UPDATE") + @DataChangeLog(menu = "REGULATORY_LIMIT", table = "regulatory_limit") + public ApiResponse deactivate(@PathVariable Long limitId) { + limitService.deactivate(limitId); + return ApiResponse.ok(); + } + + @PostMapping("/check-contract") + @RequirePermission(menu = "REGULATORY_LIMIT", perm = "READ") + public ApiResponse checkContract(@Valid @RequestBody ContractLimitCheckReq req) { + LocalDate baseDate = req.getBaseDate() != null ? req.getBaseDate() : LocalDate.now(); + ViolationResult result; + if ("FIRST_YEAR_RATIO".equals(req.getLimitType())) { + result = limitChecker.checkFirstYear(req.getContractId(), req.getAdditionalAmount(), baseDate); + } else { + result = limitChecker.checkContractTotal(req.getContractId(), req.getAdditionalAmount(), baseDate); + } + return ApiResponse.ok(result); + } +} 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 new file mode 100644 index 0000000..130f090 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/regulatory/RegulatoryLimitChecker.java @@ -0,0 +1,93 @@ +package com.ga.api.service.regulatory; + +import com.ga.core.mapper.regulatory.RegulatoryLimitMapper; +import com.ga.core.vo.regulatory.RegulatoryLimitVO; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +@Component +@RequiredArgsConstructor +public class RegulatoryLimitChecker { + + private static final String TOTAL_RATIO = "TOTAL_RATIO"; + private static final String FIRST_YEAR_RATIO = "FIRST_YEAR_RATIO"; + private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + private final RegulatoryLimitMapper limitMapper; + + /** + * 누적 수수료율 한도 체크 (1200%). + * NOTE [gap]: RecruitLedgerMapper에 sumByContract(contractId) 미존재. + * core-architect가 추가하면 sumPaidByContract 메서드 구현 완료 예정. + * 현재는 additionalAmount만으로 단순 비교 (currentTotal = 0 임시 처리). + */ + public ViolationResult checkContractTotal(Long contractId, BigDecimal additionalAmount, LocalDate baseDate) { + String baseDateStr = baseDate.format(DATE_FMT); + RegulatoryLimitVO limit = limitMapper.selectActiveByType(TOTAL_RATIO, null, baseDateStr); + if (limit == null) { + return ViolationResult.ok(additionalAmount, null, TOTAL_RATIO); + } + + BigDecimal currentTotal = sumPaidByContract(contractId); + BigDecimal projected = currentTotal.add(additionalAmount); + + if (projected.compareTo(limit.getLimitValue()) > 0) { + return ViolationResult.violated( + currentTotal, + limit.getLimitValue(), + TOTAL_RATIO, + String.format("누적 수수료 한도 초과: 현재 %s, 추가 %s, 한도 %s %s", + currentTotal, additionalAmount, limit.getLimitValue(), limit.getUnit()) + ); + } + return ViolationResult.ok(currentTotal, limit.getLimitValue(), TOTAL_RATIO); + } + + /** + * 1차년 수수료율 한도 체크. + * NOTE [gap]: 동일하게 sumByContract 부재로 currentTotal = 0 임시 처리. + */ + public ViolationResult checkFirstYear(Long contractId, BigDecimal additionalAmount, LocalDate baseDate) { + String baseDateStr = baseDate.format(DATE_FMT); + RegulatoryLimitVO limit = limitMapper.selectActiveByType(FIRST_YEAR_RATIO, null, baseDateStr); + if (limit == null) { + return ViolationResult.ok(additionalAmount, null, FIRST_YEAR_RATIO); + } + + BigDecimal currentTotal = sumFirstYearPaidByContract(contractId); + BigDecimal projected = currentTotal.add(additionalAmount); + + if (projected.compareTo(limit.getLimitValue()) > 0) { + return ViolationResult.violated( + currentTotal, + limit.getLimitValue(), + FIRST_YEAR_RATIO, + String.format("1차년 수수료 한도 초과: 현재 %s, 추가 %s, 한도 %s %s", + currentTotal, additionalAmount, limit.getLimitValue(), limit.getUnit()) + ); + } + return ViolationResult.ok(currentTotal, limit.getLimitValue(), FIRST_YEAR_RATIO); + } + + /** + * [gap] core-architect에게 RecruitLedgerMapper.sumByContract(Long contractId) 추가 요청 필요. + * 추가되면 아래 0 대신 실제 합산값으로 교체. + */ + private BigDecimal sumPaidByContract(Long contractId) { + // TODO: limitMapper 대신 recruitLedgerMapper.sumByContract(contractId) 사용 예정 + return BigDecimal.ZERO; + } + + /** + * [gap] core-architect에게 RecruitLedgerMapper.sumFirstYearByContract(Long contractId) 추가 요청 필요. + * commissionYear = 1 조건 포함. + */ + private BigDecimal sumFirstYearPaidByContract(Long contractId) { + // TODO: recruitLedgerMapper.sumFirstYearByContract(contractId) 사용 예정 + return 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 new file mode 100644 index 0000000..b2dad6f --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/regulatory/RegulatoryLimitService.java @@ -0,0 +1,104 @@ +package com.ga.api.service.regulatory; + +import com.ga.common.exception.BizException; +import com.ga.common.exception.ErrorCode; +import com.ga.common.model.PageResponse; +import com.ga.core.mapper.regulatory.RegulatoryLimitMapper; +import com.ga.core.vo.regulatory.RegulatoryLimitResp; +import com.ga.core.vo.regulatory.RegulatoryLimitSaveReq; +import com.ga.core.vo.regulatory.RegulatoryLimitSearchParam; +import com.ga.core.vo.regulatory.RegulatoryLimitVO; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class RegulatoryLimitService { + + private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + private final RegulatoryLimitMapper limitMapper; + + @Transactional(readOnly = true) + public PageResponse list(RegulatoryLimitSearchParam param) { + param.startPage(); + return PageResponse.of(limitMapper.selectList(param)); + } + + @Transactional(readOnly = true) + public RegulatoryLimitResp detail(Long limitId) { + RegulatoryLimitResp resp = limitMapper.selectDetailById(limitId); + if (resp == null) { + throw new BizException(ErrorCode.NOT_FOUND); + } + return resp; + } + + @Transactional + public void create(RegulatoryLimitSaveReq req) { + // 동일 limitType + productCategory + 기간 중복 체크 + String today = LocalDate.now().format(DATE_FMT); + RegulatoryLimitVO existing = limitMapper.selectActiveByType( + req.getLimitType(), req.getProductCategory(), today); + if (existing != null) { + throw new BizException(ErrorCode.DUPLICATE_DATA); + } + + RegulatoryLimitVO vo = toVO(req); + vo.setIsActive(true); + limitMapper.insert(vo); + } + + @Transactional + public void update(Long limitId, RegulatoryLimitSaveReq req) { + RegulatoryLimitVO existing = limitMapper.selectById(limitId); + if (existing == null) { + throw new BizException(ErrorCode.NOT_FOUND); + } + + RegulatoryLimitVO vo = toVO(req); + vo.setLimitId(limitId); + vo.setIsActive(existing.getIsActive()); + limitMapper.update(vo); + } + + @Transactional + public void deactivate(Long limitId) { + RegulatoryLimitVO existing = limitMapper.selectById(limitId); + if (existing == null) { + throw new BizException(ErrorCode.NOT_FOUND); + } + if (Boolean.FALSE.equals(existing.getIsActive())) { + throw new BizException(ErrorCode.INVALID_PARAMETER); + } + limitMapper.updateStatus(limitId, false); + } + + @Transactional(readOnly = true) + public RegulatoryLimitVO selectActiveByType(String limitType, String productCategory, String baseDate) { + return limitMapper.selectActiveByType(limitType, productCategory, baseDate); + } + + @Transactional(readOnly = true) + public List selectAllActive(String baseDate) { + return limitMapper.selectAllActive(baseDate); + } + + private RegulatoryLimitVO toVO(RegulatoryLimitSaveReq req) { + RegulatoryLimitVO vo = new RegulatoryLimitVO(); + vo.setLimitType(req.getLimitType()); + vo.setProductCategory(req.getProductCategory()); + vo.setLimitValue(req.getLimitValue()); + vo.setUnit(req.getUnit()); + vo.setEffectiveFrom(req.getEffectiveFrom()); + vo.setEffectiveTo(req.getEffectiveTo()); + vo.setRegulationRef(req.getRegulationRef()); + vo.setDescription(req.getDescription()); + return vo; + } +} 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 new file mode 100644 index 0000000..1e96ad2 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/regulatory/ViolationResult.java @@ -0,0 +1,19 @@ +package com.ga.api.service.regulatory; + +import java.math.BigDecimal; + +public record ViolationResult( + boolean violated, + BigDecimal currentTotal, + BigDecimal limitAmount, + String limitType, + String message +) { + public static ViolationResult ok(BigDecimal currentTotal, BigDecimal limitAmount, String limitType) { + return new ViolationResult(false, currentTotal, limitAmount, limitType, null); + } + + public static ViolationResult violated(BigDecimal currentTotal, BigDecimal limitAmount, String limitType, String message) { + return new ViolationResult(true, currentTotal, limitAmount, limitType, message); + } +}