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:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user