diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcRecruitStep.java b/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcRecruitStep.java index daf1cd3..149c749 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcRecruitStep.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/step/CalcRecruitStep.java @@ -175,6 +175,15 @@ public class CalcRecruitStep implements Tasklet { // 분급 계획 일괄 저장 (한도 초과 계약들에 대해 생성된 7년 분급 예정분을 한 번에 INSERT) if (!installmentBatch.isEmpty()) { + // 재실행 멱등: 같은 정산월을 다시 돌리면 동일 계약이 또 clip되어 같은 분급키를 + // 만들므로, 먼저 해당 계약들의 미지급(SCHEDULED) 분급을 지워 UNIQUE 충돌을 막는다. + // (PAID 분급 이력은 보존 — selectScheduledByMonth/PayInstallmentStep과 일관) + List clippedContractIds = installmentBatch.stream() + .map(InstallmentPlanVO::getContractId) + .distinct() + .toList(); + installmentPlanMapper.deleteScheduledByContractIds(clippedContractIds); + installmentPlanMapper.insertBatch(installmentBatch); log.info("[Step4] installment plans created: {} (violations: {})", installmentBatch.size(), violationCount); diff --git a/ga-batch/src/test/java/com/ga/batch/settlement/step/CalcRecruitStepTest.java b/ga-batch/src/test/java/com/ga/batch/settlement/step/CalcRecruitStepTest.java index 42eb761..2ee3cf7 100644 --- a/ga-batch/src/test/java/com/ga/batch/settlement/step/CalcRecruitStepTest.java +++ b/ga-batch/src/test/java/com/ga/batch/settlement/step/CalcRecruitStepTest.java @@ -182,4 +182,30 @@ class CalcRecruitStepTest { LedgerVO saved = ledgerCaptor.getValue().get(0); assertThat(saved.getAgentAmount()).isEqualByComparingTo("300000"); } + + @Test + @DisplayName("재실행 멱등: clip 발생 시 분급 insert 전에 해당 계약 SCHEDULED 분급을 먼저 삭제") + void clip_deletesScheduledBeforeInsert_forIdempotency() { + ContractResp c = contract(); + when(contractMapper.selectList(any())).thenReturn(List.of(c)); + recruitLedger(c, "450000", "14850"); + + when(regulatoryChecker.checkFirstYear(eq(CONTRACT_ID), any(BigDecimal.class), any(LocalDate.class))) + .thenReturn(ViolationResult.violated( + BigDecimal.ZERO, new BigDecimal("350000"), "FIRST_YEAR_RATIO", "초과")); + + InstallmentPlanVO plan = new InstallmentPlanVO(); + plan.setContractId(CONTRACT_ID); + when(installmentGenerator.generate(eq(CONTRACT_ID), any(BigDecimal.class), any(LocalDate.class), anyString())) + .thenReturn(List.of(plan)); + + step.execute(null, null); + + // clip된 계약의 SCHEDULED 분급 삭제가 insertBatch '이전에' 호출되어야 멱등(재실행 시 UNIQUE 충돌 방지) + ArgumentCaptor> idsCaptor = ArgumentCaptor.forClass(List.class); + org.mockito.InOrder inOrder = org.mockito.Mockito.inOrder(installmentPlanMapper); + inOrder.verify(installmentPlanMapper).deleteScheduledByContractIds(idsCaptor.capture()); + inOrder.verify(installmentPlanMapper).insertBatch(any()); + assertThat(idsCaptor.getValue()).containsExactly(CONTRACT_ID); + } } diff --git a/ga-core/src/main/java/com/ga/core/mapper/installment/InstallmentPlanMapper.java b/ga-core/src/main/java/com/ga/core/mapper/installment/InstallmentPlanMapper.java index 198844c..07c08ad 100644 --- a/ga-core/src/main/java/com/ga/core/mapper/installment/InstallmentPlanMapper.java +++ b/ga-core/src/main/java/com/ga/core/mapper/installment/InstallmentPlanMapper.java @@ -31,6 +31,13 @@ public interface InstallmentPlanMapper { /** 7년치 분급 계획 일괄 생성 */ int insertBatch(@Param("list") List list); + /** + * 지정 계약들의 SCHEDULED(미지급) 분급 계획을 삭제. 모집정산(Step4) 재실행 시 + * clip으로 재생성할 분급 계획이 기존 분급과 UNIQUE(contract_id,plan_year,plan_month) + * 충돌하는 것을 막는 멱등 장치. 이미 지급(PAID)된 분급은 이력 보존을 위해 건드리지 않는다. + */ + int deleteScheduledByContractIds(@Param("contractIds") List contractIds); + int update(InstallmentPlanVO vo); int updateStatus(@Param("planId") Long planId, @Param("status") String status); diff --git a/ga-core/src/main/resources/mapper/installment/InstallmentPlanMapper.xml b/ga-core/src/main/resources/mapper/installment/InstallmentPlanMapper.xml index f069522..aa08211 100644 --- a/ga-core/src/main/resources/mapper/installment/InstallmentPlanMapper.xml +++ b/ga-core/src/main/resources/mapper/installment/InstallmentPlanMapper.xml @@ -197,4 +197,14 @@ DELETE FROM installment_plan WHERE contract_id = #{contractId} + + + DELETE FROM installment_plan + WHERE status = 'SCHEDULED' + AND contract_id IN + + #{cid} + + +