fix: 잔여 백로그 전건 처리 — 회계 예수금/1200%clip/환수가드/net경보 (PL 자율진행)
- fix(a, MED): 회계 분개에 원천세 예수금 leg 분리. (차)8350 지급수수료 gross / (대)2530 미지급금 net + (대)2540 예수금 원천세. 기존엔 미지급금에 gross 계상 + 원천세 분개 누락. account_code '2540' 예수금 추가(V110), 분개쿼리 2-leg 전환. - fix(b, HIGH): 1200%룰 1차년 한도초과 시 clip+이연. 한도잔여까지만 당월 지급, 초과분만 분급(7년)으로 이연. 전액 이연 시 당월 원장 미생성 + 세액 재계산. (기존: 전액지급 + 전액 분급계획 → 설계 불일치) - fix(e/f, HIGH/MED): 정착지원금/생보운영지원 환수액 ≤ 지원금 검증으로 net 음수 차단. AggregateStep net 음수(공제>지급) 경보 로깅 추가. - (g) 검토결과 무변경: TaxCalculator/BatchTaxCalculator는 의도된 동일로직 쌍둥이, V80 매직팩터는 샘플시드일 뿐 계산경로 아님. 두 쌍둥이 모두 음수가드 적용됨. - (d) 무변경: 승계 "최신 to_agent=현재귀속자"는 다단계체인에 정합, from==agent_id 가드는 체인 회귀 유발하므로 정책확정 전 보류(문서화). - test: CalcRecruitStepTest(clip+이연), SettlingSupportServiceTest(환수>지원 거부). - 전체 build+test GREEN: 155건 0실패. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -85,6 +85,12 @@ public class LifeSupportService {
|
||||
.multiply(req.getSupportRate())
|
||||
.setScale(2, RoundingMode.HALF_UP);
|
||||
BigDecimal clawback = req.getClawbackAmount() != null ? req.getClawbackAmount() : BigDecimal.ZERO;
|
||||
// 환수액은 음수 불가, 지원수수료 초과 불가 (net 음수 방지)
|
||||
if (clawback.signum() < 0 || clawback.compareTo(supportAmount) > 0) {
|
||||
throw new BizException(ErrorCode.BAD_REQUEST,
|
||||
String.format("환수액(%s)은 0 이상이며 지원수수료(%s)를 초과할 수 없습니다",
|
||||
clawback, supportAmount));
|
||||
}
|
||||
// 실지급액 = support - clawback
|
||||
BigDecimal netAmount = supportAmount.subtract(clawback).setScale(2, RoundingMode.HALF_UP);
|
||||
|
||||
|
||||
@@ -85,6 +85,12 @@ public class SettlingSupportService {
|
||||
@Transactional
|
||||
public Long createLedger(SettlingSupportLedgerSaveReq req) {
|
||||
BigDecimal clawback = req.getClawbackAmount() != null ? req.getClawbackAmount() : BigDecimal.ZERO;
|
||||
// 환수액은 음수 불가, 지원금 초과 불가 (net 음수 방지)
|
||||
if (clawback.signum() < 0 || clawback.compareTo(req.getSupportAmount()) > 0) {
|
||||
throw new BizException(ErrorCode.BAD_REQUEST,
|
||||
String.format("환수액(%s)은 0 이상이며 지원금(%s)을 초과할 수 없습니다",
|
||||
clawback, req.getSupportAmount()));
|
||||
}
|
||||
// 실지급액 = support_amount - clawback_amount
|
||||
BigDecimal netAmount = req.getSupportAmount()
|
||||
.subtract(clawback)
|
||||
|
||||
@@ -14,7 +14,13 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import com.ga.common.exception.BizException;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@@ -62,4 +68,18 @@ class SettlingSupportServiceTest {
|
||||
assertThat(saved.getNetAmount()).isEqualByComparingTo("300000.00");
|
||||
assertThat(saved.getClawbackAmount()).isEqualByComparingTo("0");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("createLedger: 환수액이 지원금을 초과하면 거부(net 음수 방지) — 원장 미생성")
|
||||
void createLedger_clawbackExceedsSupport_rejected() {
|
||||
SettlingSupportLedgerSaveReq req = new SettlingSupportLedgerSaveReq();
|
||||
req.setAgentId(1L);
|
||||
req.setSettleMonth("202506");
|
||||
req.setSupportAmount(new BigDecimal("300000"));
|
||||
req.setClawbackAmount(new BigDecimal("400000")); // 지원금 초과
|
||||
|
||||
assertThatThrownBy(() -> service.createLedger(req))
|
||||
.isInstanceOf(BizException.class);
|
||||
verify(ledgerMapper, never()).insert(any());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +194,10 @@ public class AggregateStep implements Tasklet {
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
|
||||
BigDecimal netAmount = tax.netAmount().subtract(deductionTotal);
|
||||
if (netAmount.signum() < 0) {
|
||||
log.warn("[Step8] net 음수(공제>지급): agentId={}, gross={}, deduction={}, net={} — 차월 이월 검토 필요",
|
||||
agentId, gross, deductionTotal, netAmount);
|
||||
}
|
||||
|
||||
// settle_master UPSERT
|
||||
SettleMasterVO vo = new SettleMasterVO();
|
||||
|
||||
@@ -23,6 +23,7 @@ import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||
import org.springframework.batch.repeat.RepeatStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -80,19 +81,31 @@ public class CalcRecruitStep implements Tasklet {
|
||||
c.getContractId(), led.getAgentAmount(), c.getContractDate());
|
||||
|
||||
if (firstYearCheck.violated()) {
|
||||
log.warn("[Step4] 1200%룰 위반: contractId={}, msg={}",
|
||||
c.getContractId(), firstYearCheck.message());
|
||||
violationCount++;
|
||||
|
||||
// 1200%룰 위반 계약: 분급 계획 생성 (7년 35/25/15/10/7/5/3)
|
||||
List<InstallmentPlanVO> plans = installmentGenerator.generate(
|
||||
c.getContractId(), led.getAgentAmount(),
|
||||
c.getContractDate(), c.getInsuranceType());
|
||||
installmentBatch.addAll(plans);
|
||||
// 위반 계약도 원장에는 기록 (감사 목적)
|
||||
// 1차년 한도 잔여분까지만 당월 지급, 초과분은 분급(7년)으로 이연.
|
||||
BigDecimal allowed = MoneyUtil.sub(
|
||||
firstYearCheck.limitAmount(), firstYearCheck.currentTotal());
|
||||
if (allowed.signum() < 0) allowed = BigDecimal.ZERO;
|
||||
BigDecimal excess = MoneyUtil.sub(led.getAgentAmount(), allowed);
|
||||
|
||||
if (excess.signum() > 0) {
|
||||
// 초과분만 분급 계획 생성 (7년 35/25/15/10/7/5/3)
|
||||
List<InstallmentPlanVO> plans = installmentGenerator.generate(
|
||||
c.getContractId(), excess,
|
||||
c.getContractDate(), c.getInsuranceType());
|
||||
installmentBatch.addAll(plans);
|
||||
// 당월 원장은 한도까지만 지급(초과분 차감) + 세액 재계산
|
||||
led.setAgentAmount(allowed);
|
||||
led.setTaxAmount(MoneyUtil.tax(allowed));
|
||||
log.warn("[Step4] 1200%룰 1차년 한도초과 → clip+이연: contractId={}, 지급(한도잔여)={}, 이연(분급)={}",
|
||||
c.getContractId(), allowed, excess);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 한도로 전액 이연되어 당월 지급액이 0이면 원장 미생성
|
||||
if (MoneyUtil.isZero(led.getAgentAmount())) continue;
|
||||
batch.add(led);
|
||||
if (batch.size() >= BATCH_SIZE) {
|
||||
recruitMapper.batchInsert(batch);
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.ga.batch.settlement.step;
|
||||
|
||||
import com.ga.batch.settlement.SettlementContext;
|
||||
import com.ga.batch.settlement.calc.CommissionCalculator;
|
||||
import com.ga.batch.settlement.domain.BatchInstallmentPlanGenerator;
|
||||
import com.ga.batch.settlement.domain.BatchRegulatoryLimitChecker;
|
||||
import com.ga.batch.settlement.domain.ViolationResult;
|
||||
import com.ga.core.mapper.installment.InstallmentPlanMapper;
|
||||
import com.ga.core.mapper.ledger.RecruitLedgerMapper;
|
||||
import com.ga.core.mapper.product.ContractMapper;
|
||||
import com.ga.core.vo.installment.InstallmentPlanVO;
|
||||
import com.ga.core.vo.ledger.LedgerVO;
|
||||
import com.ga.core.vo.product.ContractResp;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 1200%룰 1차년 한도초과 → clip(한도까지만 지급) + 이연(초과분 분급) 검증.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class CalcRecruitStepTest {
|
||||
|
||||
@Mock SettlementContext ctx;
|
||||
@Mock ContractMapper contractMapper;
|
||||
@Mock RecruitLedgerMapper recruitMapper;
|
||||
@Mock CommissionCalculator calculator;
|
||||
@Mock BatchRegulatoryLimitChecker regulatoryChecker;
|
||||
@Mock BatchInstallmentPlanGenerator installmentGenerator;
|
||||
@Mock InstallmentPlanMapper installmentPlanMapper;
|
||||
|
||||
CalcRecruitStep step;
|
||||
|
||||
static final String MONTH = "202401";
|
||||
static final Long CONTRACT_ID = 100L;
|
||||
static final LocalDate CONTRACT_DATE = LocalDate.of(2024, 1, 15);
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
step = new CalcRecruitStep(ctx, contractMapper, recruitMapper,
|
||||
calculator, regulatoryChecker, installmentGenerator, installmentPlanMapper);
|
||||
when(ctx.getSettleMonth()).thenReturn(MONTH);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("1차년 한도 350,000 < 모집수수료 450,000 → 350,000 지급 + 초과 100,000 분급 이연")
|
||||
void firstYearLimit_clipAndDefer() {
|
||||
ContractResp c = new ContractResp();
|
||||
c.setContractId(CONTRACT_ID);
|
||||
c.setAgentId(10L);
|
||||
c.setInsuranceType("WHOLE_LIFE");
|
||||
c.setContractDate(CONTRACT_DATE);
|
||||
when(contractMapper.selectList(any())).thenReturn(List.of(c));
|
||||
|
||||
LedgerVO led = new LedgerVO();
|
||||
led.setContractId(CONTRACT_ID);
|
||||
led.setAgentId(10L);
|
||||
led.setAgentAmount(new BigDecimal("450000"));
|
||||
led.setTaxAmount(new BigDecimal("14850"));
|
||||
when(calculator.calcRecruitLedger(eq(c), eq(MONTH))).thenReturn(led);
|
||||
|
||||
// 한도 350,000(=연납 1,000,000 × 35%), 기지급 0 → 위반
|
||||
when(regulatoryChecker.checkFirstYear(eq(CONTRACT_ID), any(BigDecimal.class), any(LocalDate.class)))
|
||||
.thenReturn(ViolationResult.violated(
|
||||
BigDecimal.ZERO, new BigDecimal("350000"), "FIRST_YEAR_RATIO", "초과"));
|
||||
|
||||
when(installmentGenerator.generate(eq(CONTRACT_ID), any(BigDecimal.class), any(LocalDate.class), anyString()))
|
||||
.thenReturn(List.of(new InstallmentPlanVO()));
|
||||
|
||||
ArgumentCaptor<List<LedgerVO>> ledgerCaptor = ArgumentCaptor.forClass(List.class);
|
||||
when(recruitMapper.batchInsert(ledgerCaptor.capture())).thenReturn(1);
|
||||
|
||||
step.execute(null, null);
|
||||
|
||||
// 초과분 100,000(=450,000−350,000) 이 분급으로 이연 생성
|
||||
ArgumentCaptor<BigDecimal> excessCaptor = ArgumentCaptor.forClass(BigDecimal.class);
|
||||
org.mockito.Mockito.verify(installmentGenerator)
|
||||
.generate(eq(CONTRACT_ID), excessCaptor.capture(), any(LocalDate.class), anyString());
|
||||
assertThat(excessCaptor.getValue()).isEqualByComparingTo("100000");
|
||||
|
||||
// 당월 원장은 한도까지만(350,000) + 세액 재계산(350,000 × 3.3% = 11,550)
|
||||
LedgerVO saved = ledgerCaptor.getValue().get(0);
|
||||
assertThat(saved.getAgentAmount()).isEqualByComparingTo("350000");
|
||||
assertThat(saved.getTaxAmount()).isEqualByComparingTo("11550.00");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
-- V110: 원천세 예수금 계정과목 추가
|
||||
--
|
||||
-- 회계 분개에 원천세 leg 를 분리(8350 지급수수료 / 2530 미지급금 net / 2540 예수금 원천세)하기 위해
|
||||
-- 예수금(withholding payable) 계정을 신설한다.
|
||||
-- 기존: 미지급금에 gross 를 계상하고 원천세 분개가 누락 → 장부가 실지급(net)·세무신고와 불일치.
|
||||
|
||||
INSERT INTO account_code (account_code, account_name, account_type, sort_order, description)
|
||||
VALUES ('2540', '예수금', 'CREDIT', 25, '원천세 예수금 (소득세+지방소득세 원천징수분)')
|
||||
ON CONFLICT (account_code) DO NOTHING;
|
||||
@@ -97,32 +97,48 @@
|
||||
recruit_ledger + maintain_ledger UNION → contract_id 포함.
|
||||
AccountingEntryGenerateStep에서 호출.
|
||||
-->
|
||||
<!--
|
||||
분개 구조 (원천세 반영, 차변합=대변합 균형):
|
||||
(차) 8350 지급수수료 gross / (대) 2530 미지급금 net + (대) 2540 예수금 원천세
|
||||
ledger 1건당 net leg(8350/2530) + tax leg(8350/2540, 원천세>0 일 때만) 로 분리 생성.
|
||||
net = agent_amount − COALESCE(tax_amount, 0)
|
||||
-->
|
||||
<select id="selectBySettleMonth" resultType="com.ga.core.vo.accounting.ContractAccountingEntryVO">
|
||||
SELECT l.contract_id,
|
||||
NULL::BIGINT AS payment_id,
|
||||
CURRENT_DATE AS entry_date,
|
||||
'8350' AS debit_account,
|
||||
'2530' AS credit_account,
|
||||
l.agent_amount AS amount,
|
||||
CONCAT('모집수수료 ', l.settle_month) AS description,
|
||||
l.settle_month AS settle_month,
|
||||
NULL AS journal_no
|
||||
<!-- 모집수수료: 미지급금(net) -->
|
||||
SELECT l.contract_id, NULL::BIGINT AS payment_id, CURRENT_DATE AS entry_date,
|
||||
'8350' AS debit_account, '2530' AS credit_account,
|
||||
(l.agent_amount - COALESCE(l.tax_amount, 0)) AS amount,
|
||||
CONCAT('모집수수료 미지급금 ', l.settle_month) AS description,
|
||||
l.settle_month AS settle_month, NULL AS journal_no
|
||||
FROM recruit_ledger l
|
||||
WHERE l.settle_month = #{settleMonth}
|
||||
AND l.agent_amount > 0
|
||||
WHERE l.settle_month = #{settleMonth} AND l.agent_amount > 0
|
||||
UNION ALL
|
||||
SELECT l.contract_id,
|
||||
NULL::BIGINT AS payment_id,
|
||||
CURRENT_DATE AS entry_date,
|
||||
'8350' AS debit_account,
|
||||
'2530' AS credit_account,
|
||||
l.agent_amount AS amount,
|
||||
CONCAT('유지수수료 ', l.settle_month) AS description,
|
||||
l.settle_month AS settle_month,
|
||||
NULL AS journal_no
|
||||
<!-- 모집수수료: 원천세 예수금 -->
|
||||
SELECT l.contract_id, NULL::BIGINT, CURRENT_DATE,
|
||||
'8350', '2540', l.tax_amount,
|
||||
CONCAT('모집수수료 원천세 ', l.settle_month),
|
||||
l.settle_month, NULL
|
||||
FROM recruit_ledger l
|
||||
WHERE l.settle_month = #{settleMonth} AND l.agent_amount > 0
|
||||
AND COALESCE(l.tax_amount, 0) > 0
|
||||
UNION ALL
|
||||
<!-- 유지수수료: 미지급금(net) -->
|
||||
SELECT l.contract_id, NULL::BIGINT, CURRENT_DATE,
|
||||
'8350', '2530',
|
||||
(l.agent_amount - COALESCE(l.tax_amount, 0)),
|
||||
CONCAT('유지수수료 미지급금 ', l.settle_month),
|
||||
l.settle_month, NULL
|
||||
FROM maintain_ledger l
|
||||
WHERE l.settle_month = #{settleMonth}
|
||||
AND l.agent_amount > 0
|
||||
WHERE l.settle_month = #{settleMonth} AND l.agent_amount > 0
|
||||
UNION ALL
|
||||
<!-- 유지수수료: 원천세 예수금 -->
|
||||
SELECT l.contract_id, NULL::BIGINT, CURRENT_DATE,
|
||||
'8350', '2540', l.tax_amount,
|
||||
CONCAT('유지수수료 원천세 ', l.settle_month),
|
||||
l.settle_month, NULL
|
||||
FROM maintain_ledger l
|
||||
WHERE l.settle_month = #{settleMonth} AND l.agent_amount > 0
|
||||
AND COALESCE(l.tax_amount, 0) > 0
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
|
||||
Reference in New Issue
Block a user