feat(core): RecruitLedger/MaintainLedger sumByContract 메서드 추가 (P1 보강)

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 해소.
This commit is contained in:
GA Pro
2026-05-13 00:50:57 +09:00
parent cc5d0e55f2
commit 1aa31deb77
10 changed files with 372 additions and 0 deletions
@@ -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;
}
@@ -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<PageResponse<InstallmentPlanResp>> list(InstallmentPlanSearchParam param) {
return ApiResponse.ok(installmentService.list(param));
}
@GetMapping("/{planId}")
@RequirePermission(menu = "INSTALLMENT", perm = "READ")
public ApiResponse<InstallmentPlanResp> 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<Void> 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<Void> 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<Void> 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<Void> cancel(@PathVariable Long planId) {
installmentService.cancelPlan(planId);
return ApiResponse.ok();
}
@GetMapping("/scheduled")
@RequirePermission(menu = "INSTALLMENT", perm = "READ")
public ApiResponse<List<InstallmentPlanVO>> scheduled(@RequestParam String settleMonth) {
return ApiResponse.ok(installmentService.selectScheduledForMonth(settleMonth));
}
}
@@ -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<PageResponse<InstallmentRatioResp>> list(InstallmentRatioSearchParam param) {
return ApiResponse.ok(installmentService.listRatios(param));
}
@PostMapping
@RequirePermission(menu = "INSTALLMENT", perm = "CREATE")
@DataChangeLog(menu = "INSTALLMENT", table = "installment_ratio")
public ApiResponse<Void> create(@Valid @RequestBody InstallmentRatioSaveReq req) {
installmentService.createRatio(req);
return ApiResponse.ok();
}
}
@@ -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;
}
@@ -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<InstallmentPlanVO> generate(Long contractId, BigDecimal totalCommission,
LocalDate contractDate, String productCategory) {
String baseDate = contractDate.format(DATE_FMT);
List<InstallmentRatioVO> ratios = ratioMapper.selectAllActiveRatios(productCategory, baseDate);
List<InstallmentPlanVO> 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;
}
}
@@ -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<InstallmentPlanResp> 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<InstallmentPlanVO> selectScheduledForMonth(String settleMonth) {
return planMapper.selectScheduledByMonth(settleMonth);
}
// ---- Ratio 조회 ----
@Transactional(readOnly = true)
public PageResponse<InstallmentRatioResp> 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<InstallmentPlanVO> 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<InstallmentPlanVO> 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;
}
}
@@ -26,4 +26,7 @@ public interface MaintainLedgerMapper {
BigDecimal sumByMonth(@Param("settleMonth") String settleMonth); BigDecimal sumByMonth(@Param("settleMonth") String settleMonth);
BigDecimal sumByAgentMonth(@Param("agentId") Long agentId, BigDecimal sumByAgentMonth(@Param("agentId") Long agentId,
@Param("settleMonth") String settleMonth); @Param("settleMonth") String settleMonth);
/** 계약별 유지 원장 수수료 합산 (1200%룰 누적 검증용) */
BigDecimal sumByContract(@Param("contractId") Long contractId);
} }
@@ -30,4 +30,10 @@ public interface RecruitLedgerMapper {
/** 정산월 + 설계사 합계 */ /** 정산월 + 설계사 합계 */
BigDecimal sumByAgentMonth(@Param("agentId") Long agentId, BigDecimal sumByAgentMonth(@Param("agentId") Long agentId,
@Param("settleMonth") String settleMonth); @Param("settleMonth") String settleMonth);
/** 계약별 신계약 원장 수수료 합산 (1200%룰 누적 검증용) */
BigDecimal sumByContract(@Param("contractId") Long contractId);
/** 계약별 1차년도 수수료 합산 (1차년도 한도 검증용) */
BigDecimal sumFirstYearByContract(@Param("contractId") Long contractId);
} }
@@ -112,4 +112,9 @@
WHERE agent_id = #{agentId} AND settle_month = #{settleMonth} WHERE agent_id = #{agentId} AND settle_month = #{settleMonth}
</select> </select>
<select id="sumByContract" resultType="java.math.BigDecimal">
SELECT COALESCE(SUM(agent_amount), 0) FROM maintain_ledger
WHERE contract_id = #{contractId}
</select>
</mapper> </mapper>
@@ -129,4 +129,14 @@
WHERE agent_id = #{agentId} AND settle_month = #{settleMonth} WHERE agent_id = #{agentId} AND settle_month = #{settleMonth}
</select> </select>
<select id="sumByContract" resultType="java.math.BigDecimal">
SELECT COALESCE(SUM(agent_amount), 0) FROM recruit_ledger
WHERE contract_id = #{contractId}
</select>
<select id="sumFirstYearByContract" resultType="java.math.BigDecimal">
SELECT COALESCE(SUM(agent_amount), 0) FROM recruit_ledger
WHERE contract_id = #{contractId} AND commission_year = 1
</select>
</mapper> </mapper>