fix: 모집정산 재실행 시 1200% clip 분급 UNIQUE 충돌 — 멱등성 보강

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) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-06-13 00:09:35 +09:00
parent 17ab1098ec
commit 2a27aa31a0
4 changed files with 52 additions and 0 deletions
@@ -175,6 +175,15 @@ public class CalcRecruitStep implements Tasklet {
// 분급 계획 일괄 저장 (한도 초과 계약들에 대해 생성된 7년 분급 예정분을 한 번에 INSERT) // 분급 계획 일괄 저장 (한도 초과 계약들에 대해 생성된 7년 분급 예정분을 한 번에 INSERT)
if (!installmentBatch.isEmpty()) { if (!installmentBatch.isEmpty()) {
// 재실행 멱등: 같은 정산월을 다시 돌리면 동일 계약이 또 clip되어 같은 분급키를
// 만들므로, 먼저 해당 계약들의 미지급(SCHEDULED) 분급을 지워 UNIQUE 충돌을 막는다.
// (PAID 분급 이력은 보존 — selectScheduledByMonth/PayInstallmentStep과 일관)
List<Long> clippedContractIds = installmentBatch.stream()
.map(InstallmentPlanVO::getContractId)
.distinct()
.toList();
installmentPlanMapper.deleteScheduledByContractIds(clippedContractIds);
installmentPlanMapper.insertBatch(installmentBatch); installmentPlanMapper.insertBatch(installmentBatch);
log.info("[Step4] installment plans created: {} (violations: {})", log.info("[Step4] installment plans created: {} (violations: {})",
installmentBatch.size(), violationCount); installmentBatch.size(), violationCount);
@@ -182,4 +182,30 @@ class CalcRecruitStepTest {
LedgerVO saved = ledgerCaptor.getValue().get(0); LedgerVO saved = ledgerCaptor.getValue().get(0);
assertThat(saved.getAgentAmount()).isEqualByComparingTo("300000"); 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<List<Long>> 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);
}
} }
@@ -31,6 +31,13 @@ public interface InstallmentPlanMapper {
/** 7년치 분급 계획 일괄 생성 */ /** 7년치 분급 계획 일괄 생성 */
int insertBatch(@Param("list") List<InstallmentPlanVO> list); int insertBatch(@Param("list") List<InstallmentPlanVO> list);
/**
* 지정 계약들의 SCHEDULED(미지급) 분급 계획을 삭제. 모집정산(Step4) 재실행 시
* clip으로 재생성할 분급 계획이 기존 분급과 UNIQUE(contract_id,plan_year,plan_month)
* 충돌하는 것을 막는 멱등 장치. 이미 지급(PAID)된 분급은 이력 보존을 위해 건드리지 않는다.
*/
int deleteScheduledByContractIds(@Param("contractIds") List<Long> contractIds);
int update(InstallmentPlanVO vo); int update(InstallmentPlanVO vo);
int updateStatus(@Param("planId") Long planId, @Param("status") String status); int updateStatus(@Param("planId") Long planId, @Param("status") String status);
@@ -197,4 +197,14 @@
DELETE FROM installment_plan WHERE contract_id = #{contractId} DELETE FROM installment_plan WHERE contract_id = #{contractId}
</delete> </delete>
<!-- 모집정산 재실행 멱등: 지정 계약들의 미지급(SCHEDULED) 분급만 삭제 (PAID 이력은 보존) -->
<delete id="deleteScheduledByContractIds">
DELETE FROM installment_plan
WHERE status = 'SCHEDULED'
AND contract_id IN
<foreach collection="contractIds" item="cid" open="(" separator="," close=")">
#{cid}
</foreach>
</delete>
</mapper> </mapper>