feat(api): RegulatoryLimitChecker + Service + Controller (도메인 P1-1-c, 부분 TODO)

P1-1 마무리. 검증 인프라 + 마스터 CRUD 완성.

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

TODO (P1 마무리 단계에서 정리 예정):
- RegulatoryLimitChecker.sumPaidByContract / sumFirstYearPaidByContract
  현재 BigDecimal.ZERO 반환 (모든 결과가 한도 미만으로 나옴)
- ga-core RecruitLedgerMapper에 sumByContract / sumFirstYearByContract
  메서드 추가 후 실제 합산값으로 교체 필요
- 검증 인프라 자체는 완성, 데이터 연결만 보강 필요
This commit is contained in:
GA Pro
2026-05-13 00:39:24 +09:00
parent 11092537d7
commit bba4a33143
5 changed files with 320 additions and 0 deletions
@@ -0,0 +1,26 @@
package com.ga.api.controller.regulatory;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
@Data
public class ContractLimitCheckReq {
@NotNull
private Long contractId;
@NotNull
@Positive
private BigDecimal additionalAmount;
/** 기준일 (null이면 오늘) */
private LocalDate baseDate;
/** TOTAL_RATIO | FIRST_YEAR_RATIO */
@NotNull
private String limitType;
}
@@ -0,0 +1,78 @@
package com.ga.api.controller.regulatory;
import com.ga.api.service.regulatory.RegulatoryLimitChecker;
import com.ga.api.service.regulatory.RegulatoryLimitService;
import com.ga.api.service.regulatory.ViolationResult;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.regulatory.RegulatoryLimitResp;
import com.ga.core.vo.regulatory.RegulatoryLimitSaveReq;
import com.ga.core.vo.regulatory.RegulatoryLimitSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
@Tag(name = "규제 한도 관리")
@RestController
@RequestMapping("/api/regulatory-limits")
@RequiredArgsConstructor
public class RegulatoryLimitController {
private final RegulatoryLimitService limitService;
private final RegulatoryLimitChecker limitChecker;
@GetMapping
@RequirePermission(menu = "REGULATORY_LIMIT", perm = "READ")
public ApiResponse<PageResponse<RegulatoryLimitResp>> list(RegulatoryLimitSearchParam param) {
return ApiResponse.ok(limitService.list(param));
}
@GetMapping("/{limitId}")
@RequirePermission(menu = "REGULATORY_LIMIT", perm = "READ")
public ApiResponse<RegulatoryLimitResp> detail(@PathVariable Long limitId) {
return ApiResponse.ok(limitService.detail(limitId));
}
@PostMapping
@RequirePermission(menu = "REGULATORY_LIMIT", perm = "CREATE")
@DataChangeLog(menu = "REGULATORY_LIMIT", table = "regulatory_limit")
public ApiResponse<Void> create(@Valid @RequestBody RegulatoryLimitSaveReq req) {
limitService.create(req);
return ApiResponse.ok();
}
@PutMapping("/{limitId}")
@RequirePermission(menu = "REGULATORY_LIMIT", perm = "UPDATE")
@DataChangeLog(menu = "REGULATORY_LIMIT", table = "regulatory_limit")
public ApiResponse<Void> update(@PathVariable Long limitId,
@Valid @RequestBody RegulatoryLimitSaveReq req) {
limitService.update(limitId, req);
return ApiResponse.ok();
}
@PutMapping("/{limitId}/deactivate")
@RequirePermission(menu = "REGULATORY_LIMIT", perm = "UPDATE")
@DataChangeLog(menu = "REGULATORY_LIMIT", table = "regulatory_limit")
public ApiResponse<Void> deactivate(@PathVariable Long limitId) {
limitService.deactivate(limitId);
return ApiResponse.ok();
}
@PostMapping("/check-contract")
@RequirePermission(menu = "REGULATORY_LIMIT", perm = "READ")
public ApiResponse<ViolationResult> checkContract(@Valid @RequestBody ContractLimitCheckReq req) {
LocalDate baseDate = req.getBaseDate() != null ? req.getBaseDate() : LocalDate.now();
ViolationResult result;
if ("FIRST_YEAR_RATIO".equals(req.getLimitType())) {
result = limitChecker.checkFirstYear(req.getContractId(), req.getAdditionalAmount(), baseDate);
} else {
result = limitChecker.checkContractTotal(req.getContractId(), req.getAdditionalAmount(), baseDate);
}
return ApiResponse.ok(result);
}
}
@@ -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;
}
}
@@ -0,0 +1,104 @@
package com.ga.api.service.regulatory;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.regulatory.RegulatoryLimitMapper;
import com.ga.core.vo.regulatory.RegulatoryLimitResp;
import com.ga.core.vo.regulatory.RegulatoryLimitSaveReq;
import com.ga.core.vo.regulatory.RegulatoryLimitSearchParam;
import com.ga.core.vo.regulatory.RegulatoryLimitVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
@Service
@RequiredArgsConstructor
public class RegulatoryLimitService {
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final RegulatoryLimitMapper limitMapper;
@Transactional(readOnly = true)
public PageResponse<RegulatoryLimitResp> list(RegulatoryLimitSearchParam param) {
param.startPage();
return PageResponse.of(limitMapper.selectList(param));
}
@Transactional(readOnly = true)
public RegulatoryLimitResp detail(Long limitId) {
RegulatoryLimitResp resp = limitMapper.selectDetailById(limitId);
if (resp == null) {
throw new BizException(ErrorCode.NOT_FOUND);
}
return resp;
}
@Transactional
public void create(RegulatoryLimitSaveReq req) {
// 동일 limitType + productCategory + 기간 중복 체크
String today = LocalDate.now().format(DATE_FMT);
RegulatoryLimitVO existing = limitMapper.selectActiveByType(
req.getLimitType(), req.getProductCategory(), today);
if (existing != null) {
throw new BizException(ErrorCode.DUPLICATE_DATA);
}
RegulatoryLimitVO vo = toVO(req);
vo.setIsActive(true);
limitMapper.insert(vo);
}
@Transactional
public void update(Long limitId, RegulatoryLimitSaveReq req) {
RegulatoryLimitVO existing = limitMapper.selectById(limitId);
if (existing == null) {
throw new BizException(ErrorCode.NOT_FOUND);
}
RegulatoryLimitVO vo = toVO(req);
vo.setLimitId(limitId);
vo.setIsActive(existing.getIsActive());
limitMapper.update(vo);
}
@Transactional
public void deactivate(Long limitId) {
RegulatoryLimitVO existing = limitMapper.selectById(limitId);
if (existing == null) {
throw new BizException(ErrorCode.NOT_FOUND);
}
if (Boolean.FALSE.equals(existing.getIsActive())) {
throw new BizException(ErrorCode.INVALID_PARAMETER);
}
limitMapper.updateStatus(limitId, false);
}
@Transactional(readOnly = true)
public RegulatoryLimitVO selectActiveByType(String limitType, String productCategory, String baseDate) {
return limitMapper.selectActiveByType(limitType, productCategory, baseDate);
}
@Transactional(readOnly = true)
public List<RegulatoryLimitVO> selectAllActive(String baseDate) {
return limitMapper.selectAllActive(baseDate);
}
private RegulatoryLimitVO toVO(RegulatoryLimitSaveReq req) {
RegulatoryLimitVO vo = new RegulatoryLimitVO();
vo.setLimitType(req.getLimitType());
vo.setProductCategory(req.getProductCategory());
vo.setLimitValue(req.getLimitValue());
vo.setUnit(req.getUnit());
vo.setEffectiveFrom(req.getEffectiveFrom());
vo.setEffectiveTo(req.getEffectiveTo());
vo.setRegulationRef(req.getRegulationRef());
vo.setDescription(req.getDescription());
return vo;
}
}
@@ -0,0 +1,19 @@
package com.ga.api.service.regulatory;
import java.math.BigDecimal;
public record ViolationResult(
boolean violated,
BigDecimal currentTotal,
BigDecimal limitAmount,
String limitType,
String message
) {
public static ViolationResult ok(BigDecimal currentTotal, BigDecimal limitAmount, String limitType) {
return new ViolationResult(false, currentTotal, limitAmount, limitType, null);
}
public static ViolationResult violated(BigDecimal currentTotal, BigDecimal limitAmount, String limitType, String message) {
return new ViolationResult(true, currentTotal, limitAmount, limitType, message);
}
}