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