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:
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user