From 2a27aa31a0e76c88dfdd7699d47b262021f05926 Mon Sep 17 00:00:00 2001 From: GA Pro Date: Sat, 13 Jun 2026 00:09:35 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EB=AA=A8=EC=A7=91=EC=A0=95=EC=82=B0=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=A4=ED=96=89=20=EC=8B=9C=201200%=20clip=20?= =?UTF-8?q?=EB=B6=84=EA=B8=89=20UNIQUE=20=EC=B6=A9=EB=8F=8C=20=E2=80=94=20?= =?UTF-8?q?=EB=A9=B1=EB=93=B1=EC=84=B1=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CalcRecruitStep(Step4)이 1200%룰 clip 초과분을 installment_plan(7년)으로 생성할 때, 재계산을 위해 recruit_ledger는 deleteBySettleMonth로 지우면서 분급은 정리하지 않아 같은 정산월을 재실행하면 동일 계약이 또 clip되어 동일 분급키(contract_id,plan_year, plan_month)로 INSERT → UNIQUE 충돌로 Job 실패하던 버그. 수정: insertBatch 직전에 clip된 계약들의 SCHEDULED(미지급) 분급을 먼저 삭제 (InstallmentPlanMapper.deleteScheduledByContractIds). PAID 분급 이력은 보존하여 PayInstallmentStep 지급분과 충돌하지 않음. 검증: CalcRecruitStepTest 멱등 테스트(clip 시 delete→insert 순서/대상계약 검증) 추가, 전체 build SUCCESS. 라이브 E2E: 202605 정산을 연속 2회 실행 → 둘 다 COMPLETED, 분급 196건 불변(중복 0), settle_master gross 동일, 무결성 유지. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../settlement/step/CalcRecruitStep.java | 9 +++++++ .../settlement/step/CalcRecruitStepTest.java | 26 +++++++++++++++++++ .../installment/InstallmentPlanMapper.java | 7 +++++ .../installment/InstallmentPlanMapper.xml | 10 +++++++ 4 files changed, 52 insertions(+) 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} + + +