feat: 수수료 갭 보완 4종 — 지급보류/환수원천세역분개/연체이자/간이지급명세서 (V124~V126)
감사(서브에이전트 3영역)로 발견한 실제 결함/규제 갭만 선별 보완.
- A 지급보류(HOLD) 지급배치 누락 버그: PaymentBatchItemMapper.insertFromSettleMaster
status NOT IN('PAID','HOLD') — 보류건이 지급배치에 포함되어 실제 이체되던 결함 수정
- B 환수 원천세 역분개: withholding_tax_adjustment(V124) 멱등 적재 +
분기 원천세신고 집계 netting(payment UNION ALL adjustment). settle_master 금액흐름 불변
- C 환수채권 연체이자: clawback_receivable.accrued_interest + clawback_interest(V125) +
ClawbackInterestCalculator(연율/1200), config 기본 0 = 안전폴백, 기존 FIFO 상계가 이자 회수
- D 사업소득 간이지급명세서(월별): commission_statement(MONTHLY) 위 읽기전용 리포트 +
메뉴 REPORT_SIMPLIFIED_PAYMENT(V126) + 프론트 SimplifiedPaymentReport
검증: ./gradlew build 전모듈 GREEN, 신규 단위테스트 6건, ga-frontend tsc -b PASS,
Flyway V124~V126 운영DB 적용(v126), 적대리뷰 APPROVED(LOW 1건 GREATEST 클램프 즉수정),
B/C/D 라이브DB 검증(롤백) + D admin e2e list/export 200.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import com.ga.common.model.ApiResponse;
|
|||||||
import com.ga.core.vo.report.ChargebackRiskRow;
|
import com.ga.core.vo.report.ChargebackRiskRow;
|
||||||
import com.ga.core.vo.report.OrgReportRow;
|
import com.ga.core.vo.report.OrgReportRow;
|
||||||
import com.ga.core.vo.report.PerfReportRow;
|
import com.ga.core.vo.report.PerfReportRow;
|
||||||
|
import com.ga.core.vo.report.SimplifiedPaymentRow;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -81,4 +82,19 @@ public class ReportController {
|
|||||||
excelService.exportLargeExcel("환수위험리포트", ChargebackRiskRow.class,
|
excelService.exportLargeExcel("환수위험리포트", ChargebackRiskRow.class,
|
||||||
() -> service.chargebackRiskCursor(), response);
|
() -> service.chargebackRiskCursor(), response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 사업소득 간이지급명세서 (월별) ──
|
||||||
|
@GetMapping("/simplified-payment")
|
||||||
|
@RequirePermission(menu = "REPORT_SIMPLIFIED_PAYMENT", perm = "READ")
|
||||||
|
public ApiResponse<List<SimplifiedPaymentRow>> simplifiedPayment(@RequestParam(required = false) String settleMonth) {
|
||||||
|
return ApiResponse.ok(service.simplifiedPayment(defaultMonth(settleMonth)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/simplified-payment/export")
|
||||||
|
@RequirePermission(menu = "REPORT_SIMPLIFIED_PAYMENT", perm = "READ")
|
||||||
|
public void simplifiedPaymentExport(@RequestParam(required = false) String settleMonth, HttpServletResponse response) {
|
||||||
|
String m = defaultMonth(settleMonth);
|
||||||
|
excelService.exportLargeExcel("간이지급명세서_" + m, SimplifiedPaymentRow.class,
|
||||||
|
() -> service.simplifiedPaymentCursor(m), response);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.ga.core.mapper.report.ReportMapper;
|
|||||||
import com.ga.core.vo.report.ChargebackRiskRow;
|
import com.ga.core.vo.report.ChargebackRiskRow;
|
||||||
import com.ga.core.vo.report.OrgReportRow;
|
import com.ga.core.vo.report.OrgReportRow;
|
||||||
import com.ga.core.vo.report.PerfReportRow;
|
import com.ga.core.vo.report.PerfReportRow;
|
||||||
|
import com.ga.core.vo.report.SimplifiedPaymentRow;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.ibatis.cursor.Cursor;
|
import org.apache.ibatis.cursor.Cursor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -47,4 +48,13 @@ public class ReportService {
|
|||||||
public Cursor<ChargebackRiskRow> chargebackRiskCursor() {
|
public Cursor<ChargebackRiskRow> chargebackRiskCursor() {
|
||||||
return mapper.selectChargebackRiskCursor();
|
return mapper.selectChargebackRiskCursor();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<SimplifiedPaymentRow> simplifiedPayment(String settleMonth) {
|
||||||
|
return mapper.selectSimplifiedPayment(settleMonth);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Cursor<SimplifiedPaymentRow> simplifiedPaymentCursor(String settleMonth) {
|
||||||
|
return mapper.selectSimplifiedPaymentCursor(settleMonth);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.ga.batch.settlement.domain;
|
||||||
|
|
||||||
|
import com.ga.common.system.SystemConfigService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 환수채권 연체이자 계산기 (V125).
|
||||||
|
* 당월 적립 연체이자 = 잔액 × (연이자율 / 100 / 12), HALF_UP scale 0(원).
|
||||||
|
* 이자율 0(기본값) 이면 0 반환 → 기존 동작과 동일(안전 폴백).
|
||||||
|
*
|
||||||
|
* 적립은 매 정산월 1개월분이며, 잔액 기준이므로 월복리로 누적된다.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ClawbackInterestCalculator {
|
||||||
|
|
||||||
|
private final SystemConfigService configService;
|
||||||
|
|
||||||
|
/** 당월 적립 연체이자. 잔액·이자율이 0 이하이면 0. */
|
||||||
|
public BigDecimal monthlyInterest(BigDecimal balance) {
|
||||||
|
BigDecimal annualRate = configService.getDecimal("CLAWBACK_OVERDUE_INTEREST_RATE", BigDecimal.ZERO);
|
||||||
|
if (annualRate.signum() <= 0 || balance == null || balance.signum() <= 0) {
|
||||||
|
return BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
return balance.multiply(annualRate)
|
||||||
|
.divide(BigDecimal.valueOf(1200), 0, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 연체이자 거치기간(개월) — 발생 후 N개월 경과부터 적립. 기본 0. */
|
||||||
|
public int graceMonths() {
|
||||||
|
return configService.getInt("CLAWBACK_INTEREST_GRACE_MONTHS", 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,12 @@ package com.ga.batch.settlement.step;
|
|||||||
|
|
||||||
import com.ga.batch.settlement.SettlementContext;
|
import com.ga.batch.settlement.SettlementContext;
|
||||||
import com.ga.batch.settlement.domain.BatchTaxCalculator;
|
import com.ga.batch.settlement.domain.BatchTaxCalculator;
|
||||||
|
import com.ga.batch.settlement.domain.ClawbackInterestCalculator;
|
||||||
import com.ga.batch.settlement.domain.TaxBreakdown;
|
import com.ga.batch.settlement.domain.TaxBreakdown;
|
||||||
|
import com.ga.common.util.DateUtil;
|
||||||
import com.ga.common.util.MoneyUtil;
|
import com.ga.common.util.MoneyUtil;
|
||||||
|
import com.ga.core.mapper.commission.ClawbackInterestMapper;
|
||||||
|
import com.ga.core.vo.commission.ClawbackInterestVO;
|
||||||
import com.ga.core.mapper.commission.AdvancePaymentMapper;
|
import com.ga.core.mapper.commission.AdvancePaymentMapper;
|
||||||
import com.ga.core.mapper.commission.AdvanceRecoveryLedgerMapper;
|
import com.ga.core.mapper.commission.AdvanceRecoveryLedgerMapper;
|
||||||
import com.ga.core.mapper.commission.AwardLedgerMapper;
|
import com.ga.core.mapper.commission.AwardLedgerMapper;
|
||||||
@@ -28,6 +32,7 @@ import com.ga.core.mapper.settle.ChargebackMapper;
|
|||||||
import com.ga.core.mapper.settle.CommissionStatementMapper;
|
import com.ga.core.mapper.settle.CommissionStatementMapper;
|
||||||
import com.ga.core.mapper.settle.OverrideSettleMapper;
|
import com.ga.core.mapper.settle.OverrideSettleMapper;
|
||||||
import com.ga.core.mapper.settle.SettleMasterMapper;
|
import com.ga.core.mapper.settle.SettleMasterMapper;
|
||||||
|
import com.ga.core.mapper.tax.WithholdingTaxAdjustmentMapper;
|
||||||
import com.ga.core.vo.commission.AdvancePaymentVO;
|
import com.ga.core.vo.commission.AdvancePaymentVO;
|
||||||
import com.ga.core.vo.commission.AdvanceRecoveryLedgerVO;
|
import com.ga.core.vo.commission.AdvanceRecoveryLedgerVO;
|
||||||
import com.ga.core.vo.commission.ClawbackReceivableResp;
|
import com.ga.core.vo.commission.ClawbackReceivableResp;
|
||||||
@@ -43,6 +48,7 @@ import com.ga.core.vo.settle.CommissionStatementVO;
|
|||||||
import com.ga.core.vo.settle.DeductionStatus;
|
import com.ga.core.vo.settle.DeductionStatus;
|
||||||
import com.ga.core.vo.settle.SettleMasterVO;
|
import com.ga.core.vo.settle.SettleMasterVO;
|
||||||
import com.ga.core.vo.settle.StatementType;
|
import com.ga.core.vo.settle.StatementType;
|
||||||
|
import com.ga.core.vo.tax.WithholdingTaxAdjustmentVO;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.batch.core.StepContribution;
|
import org.springframework.batch.core.StepContribution;
|
||||||
@@ -90,6 +96,9 @@ public class AggregateStep implements Tasklet {
|
|||||||
private final BatchTaxCalculator taxCalculator;
|
private final BatchTaxCalculator taxCalculator;
|
||||||
private final ClawbackReceivableMapper receivableMapper;
|
private final ClawbackReceivableMapper receivableMapper;
|
||||||
private final ClawbackRecoveryMapper recoveryMapper;
|
private final ClawbackRecoveryMapper recoveryMapper;
|
||||||
|
private final WithholdingTaxAdjustmentMapper adjustmentMapper;
|
||||||
|
private final ClawbackInterestMapper interestMapper;
|
||||||
|
private final ClawbackInterestCalculator interestCalculator;
|
||||||
|
|
||||||
// 설계사 지급성 9종 원장 Mapper
|
// 설계사 지급성 9종 원장 Mapper
|
||||||
private final CollectionCommissionLedgerMapper collectionMapper;
|
private final CollectionCommissionLedgerMapper collectionMapper;
|
||||||
@@ -192,6 +201,13 @@ public class AggregateStep implements Tasklet {
|
|||||||
// MOD-2 멱등 reverse: 선지급 상계 이력 역산 후 advance_payment balance 복원
|
// MOD-2 멱등 reverse: 선지급 상계 이력 역산 후 advance_payment balance 복원
|
||||||
reverseAdvanceRecovery(ctx.getSettleMonth());
|
reverseAdvanceRecovery(ctx.getSettleMonth());
|
||||||
|
|
||||||
|
// V124 멱등 reverse: 당월 환수 원천세 역분개 전량 삭제 후 루프에서 재적재
|
||||||
|
adjustmentMapper.deleteBySettleMonth(ctx.getSettleMonth());
|
||||||
|
|
||||||
|
// V125 환수채권 연체이자: 당월 적립분 reverse 후 미결 채권에 1개월분 재적립
|
||||||
|
// (balance 에 포함 → 이후 agent 루프의 FIFO 상계가 이자까지 회수)
|
||||||
|
reverseAndAccrueClawbackInterest(ctx.getSettleMonth());
|
||||||
|
|
||||||
for (Long agentId : agentIds) {
|
for (Long agentId : agentIds) {
|
||||||
BigDecimal recruit = recruitMap.getOrDefault(agentId, BigDecimal.ZERO);
|
BigDecimal recruit = recruitMap.getOrDefault(agentId, BigDecimal.ZERO);
|
||||||
BigDecimal maintain = maintainMap.getOrDefault(agentId, BigDecimal.ZERO);
|
BigDecimal maintain = maintainMap.getOrDefault(agentId, BigDecimal.ZERO);
|
||||||
@@ -305,6 +321,21 @@ public class AggregateStep implements Tasklet {
|
|||||||
applyRecovery(agentId, ctx.getSettleMonth(), recovered);
|
applyRecovery(agentId, ctx.getSettleMonth(), recovered);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// V124 환수 원천세 역분개: gross<0(상계되지 않은 환수분)에 대응하는 기납부 원천세를
|
||||||
|
// 역분개로 적재 → 분기 원천세신고 과대신고 해소. settle_master 금액 흐름은 불변.
|
||||||
|
if (gross.signum() < 0) {
|
||||||
|
BigDecimal reversalBase = gross.negate();
|
||||||
|
TaxBreakdown rev = taxCalculator.calculate(reversalBase, taxType, VatType.NONE);
|
||||||
|
WithholdingTaxAdjustmentVO adj = new WithholdingTaxAdjustmentVO();
|
||||||
|
adj.setAgentId(agentId);
|
||||||
|
adj.setSettleMonth(ctx.getSettleMonth());
|
||||||
|
adj.setReason("CHARGEBACK");
|
||||||
|
adj.setBaseAmount(reversalBase);
|
||||||
|
adj.setIncomeTaxAdj(rev.incomeTax().negate());
|
||||||
|
adj.setLocalTaxAdj(rev.localTax().negate());
|
||||||
|
adjustmentMapper.upsert(adj);
|
||||||
|
}
|
||||||
|
|
||||||
// 차감 적용 완료 처리
|
// 차감 적용 완료 처리
|
||||||
if (!agentDeductions.isEmpty()) {
|
if (!agentDeductions.isEmpty()) {
|
||||||
agentDeductions.forEach(d -> appliedDeductionIds.add(d.getDeductionId()));
|
agentDeductions.forEach(d -> appliedDeductionIds.add(d.getDeductionId()));
|
||||||
@@ -472,6 +503,40 @@ public class AggregateStep implements Tasklet {
|
|||||||
settleMonth, sumByReceivable.size());
|
settleMonth, sumByReceivable.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V125 환수채권 연체이자 멱등 적립.
|
||||||
|
* 1) 당월(settleMonth) 적립 이력을 역산 — balance/accrued_interest 에서 차감 후 이력 삭제(재실행 안전).
|
||||||
|
* 2) 이자율>0 이면 거치기간 경과 미결 채권에 1개월분 연체이자를 balance/accrued_interest 에 가산.
|
||||||
|
* balance 에 포함되므로 이후 FIFO 상계가 이자까지 회수한다.
|
||||||
|
* 이자율 0(기본값) 이면 적립 없음 → 기존 동작과 동일.
|
||||||
|
*/
|
||||||
|
private void reverseAndAccrueClawbackInterest(String settleMonth) {
|
||||||
|
// 1) 멱등 reverse: 당월 적립분 차감 후 삭제
|
||||||
|
List<ClawbackInterestVO> prev = interestMapper.selectBySettleMonth(settleMonth);
|
||||||
|
for (ClawbackInterestVO iv : prev) {
|
||||||
|
receivableMapper.applyInterest(iv.getReceivableId(), iv.getInterestAmount().negate());
|
||||||
|
}
|
||||||
|
if (!prev.isEmpty()) {
|
||||||
|
interestMapper.deleteBySettleMonth(settleMonth);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 적립: 거치기간(grace) 경과한 미결 채권 대상. beforeMonth = settleMonth - (grace+1)
|
||||||
|
int grace = interestCalculator.graceMonths();
|
||||||
|
String beforeMonth = DateUtil.addMonth(settleMonth, -(grace + 1));
|
||||||
|
for (ClawbackReceivableVO rcv : receivableMapper.selectAccruable(beforeMonth)) {
|
||||||
|
BigDecimal interest = interestCalculator.monthlyInterest(rcv.getBalance());
|
||||||
|
if (interest.signum() <= 0) continue;
|
||||||
|
|
||||||
|
receivableMapper.applyInterest(rcv.getReceivableId(), interest);
|
||||||
|
|
||||||
|
ClawbackInterestVO iv = new ClawbackInterestVO();
|
||||||
|
iv.setReceivableId(rcv.getReceivableId());
|
||||||
|
iv.setSettleMonth(settleMonth);
|
||||||
|
iv.setInterestAmount(interest);
|
||||||
|
interestMapper.insertOne(iv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FIFO 상계 회수 적용: 오래된 origin_settle_month 순으로 채권을 소진하며
|
* FIFO 상계 회수 적용: 오래된 origin_settle_month 순으로 채권을 소진하며
|
||||||
* recoveredTotal 만큼 clawback_receivable balance를 차감하고
|
* recoveredTotal 만큼 clawback_receivable balance를 차감하고
|
||||||
|
|||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
package com.ga.batch.settlement.domain;
|
||||||
|
|
||||||
|
import com.ga.common.system.SystemConfigService;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* US-C 환수채권 연체이자 계산 검증.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ClawbackInterestCalculatorTest {
|
||||||
|
|
||||||
|
@Mock SystemConfigService configService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("이자율 0(기본값)이면 적립 0 — 안전 폴백")
|
||||||
|
void zero_rate_no_interest() {
|
||||||
|
when(configService.getDecimal(eq("CLAWBACK_OVERDUE_INTEREST_RATE"), any(BigDecimal.class)))
|
||||||
|
.thenReturn(BigDecimal.ZERO);
|
||||||
|
ClawbackInterestCalculator calc = new ClawbackInterestCalculator(configService);
|
||||||
|
|
||||||
|
assertThat(calc.monthlyInterest(new BigDecimal("1000000"))).isEqualByComparingTo("0");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("연 12% → 월 1% — 잔액 1,000,000 의 1개월 연체이자 = 10,000")
|
||||||
|
void twelve_percent_monthly() {
|
||||||
|
when(configService.getDecimal(eq("CLAWBACK_OVERDUE_INTEREST_RATE"), any(BigDecimal.class)))
|
||||||
|
.thenReturn(new BigDecimal("12"));
|
||||||
|
ClawbackInterestCalculator calc = new ClawbackInterestCalculator(configService);
|
||||||
|
|
||||||
|
// 1,000,000 × 12 / 1200 = 10,000
|
||||||
|
assertThat(calc.monthlyInterest(new BigDecimal("1000000"))).isEqualByComparingTo("10000");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("잔액 0/null 이면 이자 0")
|
||||||
|
void zero_balance_no_interest() {
|
||||||
|
when(configService.getDecimal(eq("CLAWBACK_OVERDUE_INTEREST_RATE"), any(BigDecimal.class)))
|
||||||
|
.thenReturn(new BigDecimal("12"));
|
||||||
|
ClawbackInterestCalculator calc = new ClawbackInterestCalculator(configService);
|
||||||
|
|
||||||
|
assertThat(calc.monthlyInterest(BigDecimal.ZERO)).isEqualByComparingTo("0");
|
||||||
|
assertThat(calc.monthlyInterest(null)).isEqualByComparingTo("0");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.ga.batch.settlement.domain;
|
||||||
|
|
||||||
|
import com.ga.common.system.SystemConfigService;
|
||||||
|
import com.ga.core.vo.org.TaxType;
|
||||||
|
import com.ga.core.vo.org.VatType;
|
||||||
|
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.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* US-B 환수 원천세 역분개 검증.
|
||||||
|
*
|
||||||
|
* 환수(클로백) 우위로 gross<0 인 월에는 기납부 원천세를 역분개(음수)하여
|
||||||
|
* 분기 원천세신고 누적이 정확히 net-off 되어야 한다. AggregateStep 은 reversalBase=|gross| 에
|
||||||
|
* BatchTaxCalculator.calculate 를 적용하고 그 결과를 negate 하여 적재한다 — 본 테스트는 그 계산을 검증한다.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class WithholdingReversalTest {
|
||||||
|
|
||||||
|
@Mock SystemConfigService configService;
|
||||||
|
BatchTaxCalculator taxCalculator;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
taxCalculator = new BatchTaxCalculator(configService);
|
||||||
|
// 세율: configService 는 기본값(2번째 인자)을 그대로 반환 (사업 3.3% / 지방 10%)
|
||||||
|
lenient().when(configService.getDecimal(anyString(), any(BigDecimal.class)))
|
||||||
|
.thenAnswer(inv -> inv.getArgument(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("역분개액 = |gross| 기준 원천세를 음수로 — 1,000,000 → 소득세 -33,000 / 지방세 -3,300")
|
||||||
|
void reversal_amounts() {
|
||||||
|
BigDecimal reversalBase = new BigDecimal("1000000"); // |gross| (환수 우위분)
|
||||||
|
TaxBreakdown rev = taxCalculator.calculate(reversalBase, TaxType.BUSINESS_INCOME, VatType.NONE);
|
||||||
|
|
||||||
|
BigDecimal incomeAdj = rev.incomeTax().negate();
|
||||||
|
BigDecimal localAdj = rev.localTax().negate();
|
||||||
|
|
||||||
|
assertThat(incomeAdj).isEqualByComparingTo("-33000"); // 1,000,000 × 3.3%
|
||||||
|
assertThat(localAdj).isEqualByComparingTo("-3300"); // 33,000 × 10%
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("양수월 원천징수 + 환수월 역분개 = 분기 누적 원천세 0 (net-off)")
|
||||||
|
void quarter_nets_off() {
|
||||||
|
// M월: gross +1,000,000 정상 지급 → 원천징수
|
||||||
|
TaxBreakdown paid = taxCalculator.calculate(new BigDecimal("1000000"),
|
||||||
|
TaxType.BUSINESS_INCOME, VatType.NONE);
|
||||||
|
// M+2월: 환수만 발생 gross -1,000,000 → 역분개(음수)
|
||||||
|
TaxBreakdown rev = taxCalculator.calculate(new BigDecimal("1000000"),
|
||||||
|
TaxType.BUSINESS_INCOME, VatType.NONE);
|
||||||
|
|
||||||
|
BigDecimal quarterWithheld = paid.incomeTax().add(rev.incomeTax().negate());
|
||||||
|
BigDecimal quarterLocal = paid.localTax().add(rev.localTax().negate());
|
||||||
|
|
||||||
|
assertThat(quarterWithheld).isEqualByComparingTo("0");
|
||||||
|
assertThat(quarterLocal).isEqualByComparingTo("0");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("환수가 당월 양수분으로 완전 상계되면(gross>=0) 역분개 없음 — 당월 세금에 이미 반영")
|
||||||
|
void no_reversal_when_gross_non_negative() {
|
||||||
|
// gross = +500,000 (신규 1,500,000 − 환수 1,000,000) → 당월 과세 정상, 역분개 대상 아님
|
||||||
|
TaxBreakdown within = taxCalculator.calculate(new BigDecimal("500000"),
|
||||||
|
TaxType.BUSINESS_INCOME, VatType.NONE);
|
||||||
|
assertThat(within.incomeTax()).isEqualByComparingTo("16500"); // 당월분만 과세
|
||||||
|
// gross>=0 이므로 AggregateStep 의 역분개 블록(gross.signum()<0)은 진입하지 않는다.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- V124: 환수(클로백) 발생 시 기납부 원천세 역분개(환급/조정)
|
||||||
|
-- gross<0(환수 우위) 월에 원천징수 역분개 레코드를 적재하여
|
||||||
|
-- 분기 원천세신고(withholding_tax_report) 과대신고를 해소한다.
|
||||||
|
-- settle_master 금액 흐름은 불변 — 본 테이블은 세무신고 보정 전용(부가 레코드).
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 1. withholding_tax_adjustment (원천세 역분개)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE withholding_tax_adjustment (
|
||||||
|
adjustment_id BIGSERIAL PRIMARY KEY,
|
||||||
|
agent_id BIGINT NOT NULL,
|
||||||
|
settle_month CHAR(6) NOT NULL,
|
||||||
|
reason VARCHAR(20) NOT NULL DEFAULT 'CHARGEBACK',
|
||||||
|
base_amount NUMERIC(15,2) NOT NULL, -- 역분개 기준액(=상계되지 않은 |gross| 음수분, 양수 저장)
|
||||||
|
income_tax_adj NUMERIC(15,2) NOT NULL, -- 소득세 역분개(환급분, 음수)
|
||||||
|
local_tax_adj NUMERIC(15,2) NOT NULL, -- 지방소득세 역분개(환급분, 음수)
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT uq_wta UNIQUE (agent_id, settle_month, reason),
|
||||||
|
CONSTRAINT chk_wta_reason CHECK (reason IN ('CHARGEBACK')),
|
||||||
|
CONSTRAINT chk_wta_base CHECK (base_amount >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE withholding_tax_adjustment IS '환수 원천세 역분개(세무신고 보정 전용)';
|
||||||
|
COMMENT ON COLUMN withholding_tax_adjustment.agent_id IS '설계사 ID';
|
||||||
|
COMMENT ON COLUMN withholding_tax_adjustment.settle_month IS '역분개 발생 정산월(YYYYMM)';
|
||||||
|
COMMENT ON COLUMN withholding_tax_adjustment.reason IS '사유(CHARGEBACK)';
|
||||||
|
COMMENT ON COLUMN withholding_tax_adjustment.base_amount IS '역분개 기준액(상계되지 않은 |gross| 음수분)';
|
||||||
|
COMMENT ON COLUMN withholding_tax_adjustment.income_tax_adj IS '소득세 역분개(환급분, 음수)';
|
||||||
|
COMMENT ON COLUMN withholding_tax_adjustment.local_tax_adj IS '지방소득세 역분개(환급분, 음수)';
|
||||||
|
|
||||||
|
CREATE INDEX idx_wta_settle_month ON withholding_tax_adjustment (settle_month);
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
-- V125: 환수채권 연체이자 (overdue interest on clawback receivables)
|
||||||
|
-- 미수 환수채권이 이월될 때 연체기간만큼 단리 연체이자를 월별 적립한다.
|
||||||
|
-- 이자율 0(기본값) 이면 적립 없음 — 기존 동작과 동일(안전 폴백).
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 1. clawback_receivable 누적 연체이자 컬럼
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
ALTER TABLE clawback_receivable
|
||||||
|
ADD COLUMN accrued_interest NUMERIC(15,2) NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN clawback_receivable.accrued_interest IS '누적 연체이자 (단리, balance 에 포함되어 회수/상계됨)';
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 2. clawback_interest (월별 연체이자 적립 이력 — 멱등 reverse용)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE clawback_interest (
|
||||||
|
interest_id BIGSERIAL PRIMARY KEY,
|
||||||
|
receivable_id BIGINT NOT NULL REFERENCES clawback_receivable(receivable_id),
|
||||||
|
settle_month CHAR(6) NOT NULL,
|
||||||
|
interest_amount NUMERIC(15,2) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT uq_clawback_interest UNIQUE (receivable_id, settle_month)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE clawback_interest IS '환수채권 월별 연체이자 적립 이력';
|
||||||
|
COMMENT ON COLUMN clawback_interest.receivable_id IS '환수채권 ID';
|
||||||
|
COMMENT ON COLUMN clawback_interest.settle_month IS '적립 정산월(YYYYMM)';
|
||||||
|
COMMENT ON COLUMN clawback_interest.interest_amount IS '당월 적립 연체이자(원금잔액 × 월이자율)';
|
||||||
|
|
||||||
|
CREATE INDEX idx_clawback_interest_settle_month ON clawback_interest (settle_month);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 3. system_config 시드 — 연체이자율 / 거치기간
|
||||||
|
-- 기본값 0 → 적립 없음(기존 동작 보존). 운영정책에 맞게 수정.
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
INSERT INTO system_config (config_key, config_value, config_group, value_type, description) VALUES
|
||||||
|
('CLAWBACK_OVERDUE_INTEREST_RATE', '0', 'COMMISSION', 'NUMBER', '환수채권 연 연체이자율(%) — 0이면 미적립'),
|
||||||
|
('CLAWBACK_INTEREST_GRACE_MONTHS', '0', 'COMMISSION', 'NUMBER', '환수채권 연체이자 거치기간(개월) — 발생 후 N개월 경과부터 적립')
|
||||||
|
ON CONFLICT (config_key) DO NOTHING;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- V126: 사업소득 간이지급명세서(월별) 리포트 메뉴
|
||||||
|
-- REPORT / GRP_REPORT 그룹에 PAGE 추가 (V38 패턴 동일). 신규 데이터 테이블 없음(읽기전용 집계).
|
||||||
|
|
||||||
|
-- 1. REPORT 그룹 하위 PAGE
|
||||||
|
INSERT INTO menu (parent_menu_id, menu_code, menu_name, menu_type, menu_path, component_path, menu_level, sort_order)
|
||||||
|
SELECT p.menu_id, x.code, x.name, 'PAGE', x.path, x.cmpt, 2, x.sort
|
||||||
|
FROM menu p
|
||||||
|
JOIN (VALUES
|
||||||
|
('REPORT_SIMPLIFIED_PAYMENT', '간이지급명세서', '/report/simplified-payment', 'report/SimplifiedPaymentReport', 40)
|
||||||
|
) AS x(code, name, path, cmpt, sort)
|
||||||
|
ON p.menu_code = 'REPORT'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code);
|
||||||
|
|
||||||
|
-- 2. GRP_REPORT 그룹 하위 PAGE (_GRP 접미)
|
||||||
|
INSERT INTO menu (parent_menu_id, menu_code, menu_name, menu_type, menu_path, component_path, menu_level, sort_order)
|
||||||
|
SELECT p.menu_id, x.code || '_GRP', x.name, 'PAGE', x.path, x.cmpt, 2, x.sort
|
||||||
|
FROM menu p
|
||||||
|
JOIN (VALUES
|
||||||
|
('REPORT_SIMPLIFIED_PAYMENT', '간이지급명세서', '/report/simplified-payment', 'report/SimplifiedPaymentReport', 40)
|
||||||
|
) AS x(code, name, path, cmpt, sort)
|
||||||
|
ON p.menu_code = 'GRP_REPORT'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code || '_GRP');
|
||||||
|
|
||||||
|
-- 3. 권한 항목 (READ/EXPORT)
|
||||||
|
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
|
||||||
|
SELECT m.menu_id, p.code, p.name, p.sort
|
||||||
|
FROM menu m
|
||||||
|
CROSS JOIN (VALUES
|
||||||
|
('READ', '조회', 10),
|
||||||
|
('EXPORT', '엑셀', 60)
|
||||||
|
) AS p(code, name, sort)
|
||||||
|
WHERE m.menu_code IN ('REPORT_SIMPLIFIED_PAYMENT', 'REPORT_SIMPLIFIED_PAYMENT_GRP')
|
||||||
|
ON CONFLICT (menu_id, perm_code) DO NOTHING;
|
||||||
|
|
||||||
|
-- 4. SUPER_ADMIN / ADMIN / MANAGER 권한 부여
|
||||||
|
INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
|
||||||
|
SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
|
||||||
|
FROM role r
|
||||||
|
CROSS JOIN menu_permission mp
|
||||||
|
JOIN menu m ON m.menu_id = mp.menu_id
|
||||||
|
WHERE r.role_code IN ('SUPER_ADMIN', 'ADMIN', 'MANAGER')
|
||||||
|
AND m.menu_code IN ('REPORT_SIMPLIFIED_PAYMENT', 'REPORT_SIMPLIFIED_PAYMENT_GRP')
|
||||||
|
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.ga.core.mapper.commission;
|
||||||
|
|
||||||
|
import com.ga.core.vo.commission.ClawbackInterestVO;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* clawback_interest Mapper — 환수채권 월별 연체이자 적립 이력 (V125)
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface ClawbackInterestMapper {
|
||||||
|
|
||||||
|
/** 당월 적립 1건 INSERT (UNIQUE(receivable_id, settle_month)) */
|
||||||
|
int insertOne(ClawbackInterestVO vo);
|
||||||
|
|
||||||
|
/** 정산월 적립 이력 조회 — 멱등 reverse 시 balance 복원용 */
|
||||||
|
List<ClawbackInterestVO> selectBySettleMonth(@Param("settleMonth") String settleMonth);
|
||||||
|
|
||||||
|
/** 정산월 적립 이력 전량 삭제 (멱등 reverse) */
|
||||||
|
int deleteBySettleMonth(@Param("settleMonth") String settleMonth);
|
||||||
|
}
|
||||||
@@ -93,4 +93,18 @@ public interface ClawbackReceivableMapper {
|
|||||||
*/
|
*/
|
||||||
int writeOffExpired(@Param("beforeMonth") String beforeMonth,
|
int writeOffExpired(@Param("beforeMonth") String beforeMonth,
|
||||||
@Param("reason") String reason);
|
@Param("reason") String reason);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [V125] 연체이자 적립 대상 채권 조회.
|
||||||
|
* status IN('OUTSTANDING','RECOVERING') AND balance>0 AND origin_settle_month <= beforeMonth.
|
||||||
|
* beforeMonth = settleMonth - (grace+1) (Step에서 계산 후 전달).
|
||||||
|
*/
|
||||||
|
List<ClawbackReceivableVO> selectAccruable(@Param("beforeMonth") String beforeMonth);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [V125] 연체이자 가감 — balance, accrued_interest 에 delta 를 동시 가산.
|
||||||
|
* 적립 시 delta>0, 멱등 reverse 시 delta<0.
|
||||||
|
*/
|
||||||
|
int applyInterest(@Param("receivableId") Long receivableId,
|
||||||
|
@Param("delta") BigDecimal delta);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.ga.core.mapper.report;
|
|||||||
import com.ga.core.vo.report.ChargebackRiskRow;
|
import com.ga.core.vo.report.ChargebackRiskRow;
|
||||||
import com.ga.core.vo.report.OrgReportRow;
|
import com.ga.core.vo.report.OrgReportRow;
|
||||||
import com.ga.core.vo.report.PerfReportRow;
|
import com.ga.core.vo.report.PerfReportRow;
|
||||||
|
import com.ga.core.vo.report.SimplifiedPaymentRow;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
import org.apache.ibatis.cursor.Cursor;
|
import org.apache.ibatis.cursor.Cursor;
|
||||||
@@ -28,4 +29,9 @@ public interface ReportMapper {
|
|||||||
List<ChargebackRiskRow> selectChargebackRisk();
|
List<ChargebackRiskRow> selectChargebackRisk();
|
||||||
|
|
||||||
Cursor<ChargebackRiskRow> selectChargebackRiskCursor();
|
Cursor<ChargebackRiskRow> selectChargebackRiskCursor();
|
||||||
|
|
||||||
|
/** 사업소득 간이지급명세서(월별) — commission_statement(MONTHLY) 위 집계 */
|
||||||
|
List<SimplifiedPaymentRow> selectSimplifiedPayment(@Param("settleMonth") String settleMonth);
|
||||||
|
|
||||||
|
Cursor<SimplifiedPaymentRow> selectSimplifiedPaymentCursor(@Param("settleMonth") String settleMonth);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.ga.core.mapper.tax;
|
||||||
|
|
||||||
|
import com.ga.core.vo.tax.WithholdingTaxAdjustmentVO;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* withholding_tax_adjustment Mapper — 환수 원천세 역분개 (V124)
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface WithholdingTaxAdjustmentMapper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 멱등 upsert.
|
||||||
|
* ON CONFLICT(agent_id, settle_month, reason) DO UPDATE
|
||||||
|
* SET base_amount/income_tax_adj/local_tax_adj=EXCLUDED.
|
||||||
|
*/
|
||||||
|
int upsert(WithholdingTaxAdjustmentVO vo);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정산월 역분개 전량 삭제 — AggregateStep 재실행 시 clean slate 확보용(멱등).
|
||||||
|
* 처리 건수 반환.
|
||||||
|
*/
|
||||||
|
int deleteBySettleMonth(@Param("settleMonth") String settleMonth);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.ga.core.vo.commission;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* clawback_interest — 환수채권 월별 연체이자 적립 이력 (V125)
|
||||||
|
* 배치 적립 시 INSERT, 재실행(reverse) 시 DELETE — clawback_recovery 와 동일 멱등 패턴.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ClawbackInterestVO {
|
||||||
|
private Long interestId;
|
||||||
|
private Long receivableId;
|
||||||
|
/** 적립 정산월 (YYYYMM) */
|
||||||
|
private String settleMonth;
|
||||||
|
/** 당월 적립 연체이자 */
|
||||||
|
private BigDecimal interestAmount;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -20,6 +20,8 @@ public class ClawbackReceivableResp {
|
|||||||
private BigDecimal originalAmount;
|
private BigDecimal originalAmount;
|
||||||
private BigDecimal recoveredAmount;
|
private BigDecimal recoveredAmount;
|
||||||
private BigDecimal balance;
|
private BigDecimal balance;
|
||||||
|
/** 누적 연체이자 (V125) — balance 에 포함됨 */
|
||||||
|
private BigDecimal accruedInterest;
|
||||||
/** OUTSTANDING / RECOVERING / CLEARED / WRITTEN_OFF */
|
/** OUTSTANDING / RECOVERING / CLEARED / WRITTEN_OFF */
|
||||||
private String status;
|
private String status;
|
||||||
/** 공통코드 CLAWBACK_RCV_STATUS */
|
/** 공통코드 CLAWBACK_RCV_STATUS */
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ public class ClawbackReceivableVO {
|
|||||||
private BigDecimal originalAmount;
|
private BigDecimal originalAmount;
|
||||||
/** 누적 상계 회수액 */
|
/** 누적 상계 회수액 */
|
||||||
private BigDecimal recoveredAmount;
|
private BigDecimal recoveredAmount;
|
||||||
/** 잔액 = original_amount - recovered_amount */
|
/** 잔액 = original_amount - recovered_amount (+ 누적 연체이자) */
|
||||||
private BigDecimal balance;
|
private BigDecimal balance;
|
||||||
|
/** 누적 연체이자 (V125) — 잔액에 포함되어 회수/상계됨, 표시용 */
|
||||||
|
private BigDecimal accruedInterest;
|
||||||
/** OUTSTANDING / RECOVERING / CLEARED / WRITTEN_OFF */
|
/** OUTSTANDING / RECOVERING / CLEARED / WRITTEN_OFF */
|
||||||
private String status;
|
private String status;
|
||||||
/** 최근 회수 정산월 (YYYYMM) */
|
/** 최근 회수 정산월 (YYYYMM) */
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.ga.core.vo.report;
|
||||||
|
|
||||||
|
import com.ga.common.annotation.ExcelColumn;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사업소득 간이지급명세서 행 (월별) — commission_statement(MONTHLY) 위 읽기전용 집계.
|
||||||
|
* 국세청 간이지급명세서(사업소득) 제출 대응: 소득자·지급액·원천징수세액(소득세/지방소득세).
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class SimplifiedPaymentRow {
|
||||||
|
|
||||||
|
private Long agentId;
|
||||||
|
|
||||||
|
@ExcelColumn(header = "귀속월", order = 1, width = 10)
|
||||||
|
private String settleMonth;
|
||||||
|
|
||||||
|
@ExcelColumn(header = "소득자", order = 2, width = 14)
|
||||||
|
private String agentName;
|
||||||
|
|
||||||
|
@ExcelColumn(header = "주민등록번호", order = 3, width = 16)
|
||||||
|
private String residentNo;
|
||||||
|
|
||||||
|
@ExcelColumn(header = "사업자번호", order = 4, width = 16)
|
||||||
|
private String businessNo;
|
||||||
|
|
||||||
|
@ExcelColumn(header = "소득구분", order = 5, width = 12)
|
||||||
|
private String incomeTypeName;
|
||||||
|
|
||||||
|
@ExcelColumn(header = "지급액", order = 6, width = 16)
|
||||||
|
private BigDecimal grossAmount;
|
||||||
|
|
||||||
|
@ExcelColumn(header = "소득세", order = 7, width = 14)
|
||||||
|
private BigDecimal incomeTaxAmount;
|
||||||
|
|
||||||
|
@ExcelColumn(header = "지방소득세", order = 8, width = 14)
|
||||||
|
private BigDecimal localTaxAmount;
|
||||||
|
|
||||||
|
@ExcelColumn(header = "차인지급액", order = 9, width = 16)
|
||||||
|
private BigDecimal netAmount;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.ga.core.vo.tax;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* withholding_tax_adjustment — 환수 원천세 역분개 (V124)
|
||||||
|
* 환수(클로백) 우위로 gross<0 인 월에 기납부 원천세를 역분개(환급)하여
|
||||||
|
* 분기 원천세신고 과대신고를 해소한다. settle_master 금액 흐름은 불변.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class WithholdingTaxAdjustmentVO {
|
||||||
|
private Long adjustmentId;
|
||||||
|
private Long agentId;
|
||||||
|
/** 역분개 발생 정산월 (YYYYMM) */
|
||||||
|
private String settleMonth;
|
||||||
|
/** 사유 (CHARGEBACK) */
|
||||||
|
private String reason;
|
||||||
|
/** 역분개 기준액 — 상계되지 않은 |gross| 음수분, 양수 저장 */
|
||||||
|
private BigDecimal baseAmount;
|
||||||
|
/** 소득세 역분개 — 환급분, 음수 */
|
||||||
|
private BigDecimal incomeTaxAdj;
|
||||||
|
/** 지방소득세 역분개 — 환급분, 음수 */
|
||||||
|
private BigDecimal localTaxAdj;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.ga.core.mapper.commission.ClawbackInterestMapper">
|
||||||
|
|
||||||
|
<resultMap id="VOMap" type="com.ga.core.vo.commission.ClawbackInterestVO"/>
|
||||||
|
|
||||||
|
<insert id="insertOne">
|
||||||
|
INSERT INTO clawback_interest (receivable_id, settle_month, interest_amount)
|
||||||
|
VALUES (#{receivableId}, #{settleMonth}, #{interestAmount})
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<select id="selectBySettleMonth" resultMap="VOMap">
|
||||||
|
SELECT interest_id, receivable_id, settle_month, interest_amount, created_at
|
||||||
|
FROM clawback_interest
|
||||||
|
WHERE settle_month = #{settleMonth}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<delete id="deleteBySettleMonth">
|
||||||
|
DELETE FROM clawback_interest WHERE settle_month = #{settleMonth}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
<result property="originalAmount" column="original_amount"/>
|
<result property="originalAmount" column="original_amount"/>
|
||||||
<result property="recoveredAmount" column="recovered_amount"/>
|
<result property="recoveredAmount" column="recovered_amount"/>
|
||||||
<result property="balance" column="balance"/>
|
<result property="balance" column="balance"/>
|
||||||
|
<result property="accruedInterest" column="accrued_interest"/>
|
||||||
<result property="status" column="status"/>
|
<result property="status" column="status"/>
|
||||||
<result property="statusName" column="status_name"/>
|
<result property="statusName" column="status_name"/>
|
||||||
<result property="lastRecoveryMonth" column="last_recovery_month"/>
|
<result property="lastRecoveryMonth" column="last_recovery_month"/>
|
||||||
@@ -36,7 +37,7 @@
|
|||||||
<sql id="rcvJoinCols">
|
<sql id="rcvJoinCols">
|
||||||
r.receivable_id, r.agent_id, a.agent_name,
|
r.receivable_id, r.agent_id, a.agent_name,
|
||||||
r.origin_settle_month,
|
r.origin_settle_month,
|
||||||
r.original_amount, r.recovered_amount, r.balance,
|
r.original_amount, r.recovered_amount, r.balance, r.accrued_interest,
|
||||||
r.status,
|
r.status,
|
||||||
(SELECT cc.code_name FROM common_code cc
|
(SELECT cc.code_name FROM common_code cc
|
||||||
WHERE cc.group_code = 'CLAWBACK_RCV_STATUS' AND cc.code = r.status) AS status_name,
|
WHERE cc.group_code = 'CLAWBACK_RCV_STATUS' AND cc.code = r.status) AS status_name,
|
||||||
@@ -208,6 +209,33 @@
|
|||||||
ORDER BY origin_settle_month ASC, receivable_id ASC
|
ORDER BY origin_settle_month ASC, receivable_id ASC
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!-- [V125] 연체이자 적립 대상 채권 — beforeMonth = settleMonth - (grace+1) -->
|
||||||
|
<select id="selectAccruable" resultMap="VOMap">
|
||||||
|
SELECT receivable_id, agent_id, origin_settle_month,
|
||||||
|
original_amount, recovered_amount, balance, accrued_interest,
|
||||||
|
status, last_recovery_month,
|
||||||
|
writeoff_reason, written_off_at, written_off_by,
|
||||||
|
created_at, created_by, updated_at
|
||||||
|
FROM clawback_receivable
|
||||||
|
WHERE status IN ('OUTSTANDING', 'RECOVERING')
|
||||||
|
AND balance > 0
|
||||||
|
AND origin_settle_month <= #{beforeMonth}
|
||||||
|
ORDER BY origin_settle_month ASC, receivable_id ASC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
[V125] 연체이자 가감 — balance, accrued_interest 동시 갱신.
|
||||||
|
GREATEST(...,0) 로 0 하한 클램프: 타월 대손(write-off로 balance=0 강제) 후 직전월 적립이
|
||||||
|
역산되는 예외 재실행에서도 CHECK(balance>=0) 위반을 방지. 정상 적립/역산은 클램프 무영향.
|
||||||
|
-->
|
||||||
|
<update id="applyInterest">
|
||||||
|
UPDATE clawback_receivable
|
||||||
|
SET balance = GREATEST(balance + #{delta}, 0),
|
||||||
|
accrued_interest = GREATEST(accrued_interest + #{delta}, 0),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE receivable_id = #{receivableId}
|
||||||
|
</update>
|
||||||
|
|
||||||
<!-- [MOD-6] 만료 대상 채권 일괄 대손 처리 — WRITTEN_OFF 상태 전이 -->
|
<!-- [MOD-6] 만료 대상 채권 일괄 대손 처리 — WRITTEN_OFF 상태 전이 -->
|
||||||
<update id="writeOffExpired">
|
<update id="writeOffExpired">
|
||||||
UPDATE clawback_receivable
|
UPDATE clawback_receivable
|
||||||
|
|||||||
@@ -99,4 +99,33 @@
|
|||||||
<include refid="chargebackRiskSql"/>
|
<include refid="chargebackRiskSql"/>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!-- 4. 사업소득 간이지급명세서(월별) — commission_statement(MONTHLY) 위 읽기전용 집계 -->
|
||||||
|
<sql id="simplifiedPaymentSql">
|
||||||
|
SELECT cs.agent_id,
|
||||||
|
cs.settle_month,
|
||||||
|
a.agent_name,
|
||||||
|
a.resident_no,
|
||||||
|
a.business_no,
|
||||||
|
CASE a.tax_type
|
||||||
|
WHEN 'BUSINESS_INCOME' THEN '사업소득'
|
||||||
|
WHEN 'OTHER_INCOME' THEN '기타소득'
|
||||||
|
ELSE COALESCE(a.tax_type, '사업소득') END AS income_type_name,
|
||||||
|
cs.gross_amount,
|
||||||
|
cs.income_tax_amount,
|
||||||
|
cs.local_tax_amount,
|
||||||
|
cs.net_amount
|
||||||
|
FROM commission_statement cs
|
||||||
|
JOIN agent a ON a.agent_id = cs.agent_id
|
||||||
|
WHERE cs.statement_type = 'MONTHLY'
|
||||||
|
AND cs.settle_month = #{settleMonth}
|
||||||
|
ORDER BY a.agent_name, cs.agent_id
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<select id="selectSimplifiedPayment" resultType="com.ga.core.vo.report.SimplifiedPaymentRow">
|
||||||
|
<include refid="simplifiedPaymentSql"/>
|
||||||
|
</select>
|
||||||
|
<select id="selectSimplifiedPaymentCursor" resultType="com.ga.core.vo.report.SimplifiedPaymentRow" fetchSize="500">
|
||||||
|
<include refid="simplifiedPaymentSql"/>
|
||||||
|
</select>
|
||||||
|
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
조건:
|
조건:
|
||||||
settle_month = #{settleMonth}
|
settle_month = #{settleMonth}
|
||||||
payable_amount > 0 (지급할 금액이 있는 건만)
|
payable_amount > 0 (지급할 금액이 있는 건만)
|
||||||
status != 'PAID' (이미 지급완료된 건 제외)
|
status NOT IN ('PAID','HOLD') (지급완료/지급보류 건 제외)
|
||||||
|
|
||||||
inserted 컬럼:
|
inserted 컬럼:
|
||||||
batch_id = #{batchId} (생성된 배치 PK)
|
batch_id = #{batchId} (생성된 배치 PK)
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
FROM settle_master sm
|
FROM settle_master sm
|
||||||
WHERE sm.settle_month = #{settleMonth}
|
WHERE sm.settle_month = #{settleMonth}
|
||||||
AND sm.payable_amount > 0
|
AND sm.payable_amount > 0
|
||||||
AND sm.status != 'PAID'
|
AND sm.status NOT IN ('PAID', 'HOLD')
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.ga.core.mapper.tax.WithholdingTaxAdjustmentMapper">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
멱등 upsert. AggregateStep에서 gross<0 설계사·정산월마다 1건.
|
||||||
|
ON CONFLICT(agent_id, settle_month, reason) DO UPDATE.
|
||||||
|
-->
|
||||||
|
<insert id="upsert">
|
||||||
|
INSERT INTO withholding_tax_adjustment
|
||||||
|
(agent_id, settle_month, reason, base_amount, income_tax_adj, local_tax_adj)
|
||||||
|
VALUES
|
||||||
|
(#{agentId}, #{settleMonth}, #{reason}, #{baseAmount}, #{incomeTaxAdj}, #{localTaxAdj})
|
||||||
|
ON CONFLICT (agent_id, settle_month, reason) DO UPDATE SET
|
||||||
|
base_amount = EXCLUDED.base_amount,
|
||||||
|
income_tax_adj = EXCLUDED.income_tax_adj,
|
||||||
|
local_tax_adj = EXCLUDED.local_tax_adj
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- 정산월 역분개 전량 삭제 (재실행 멱등) -->
|
||||||
|
<delete id="deleteBySettleMonth">
|
||||||
|
DELETE FROM withholding_tax_adjustment WHERE settle_month = #{settleMonth}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -55,8 +55,11 @@
|
|||||||
|
|
||||||
<!--
|
<!--
|
||||||
분기 원천세 UPSERT 집계.
|
분기 원천세 UPSERT 집계.
|
||||||
payment 테이블에서 해당 분기 설계사별 income_tax_amount / local_tax_amount / pay_amount SUM.
|
payment(실지급) + withholding_tax_adjustment(환수 원천세 역분개, V124) 를 합산.
|
||||||
분기 판단: pay_date 로 EXTRACT(YEAR/QUARTER) 사용. 분기 포맷 YYYY-Qn 파싱.
|
payment: 해당 분기 설계사별 income_tax_amount / local_tax_amount / pay_amount SUM.
|
||||||
|
역분개: 동일 분기 설계사별 음수 income_tax_adj / local_tax_adj / -base_amount 합산
|
||||||
|
→ 환수 우위 월의 기납부 원천세 과대신고를 차감 보정.
|
||||||
|
분기 판단: pay_date 로 EXTRACT(YEAR/QUARTER) / 역분개는 settle_month(YYYYMM) 파싱.
|
||||||
예) '2025-Q1' → YEAR=2025, QUARTER=1
|
예) '2025-Q1' → YEAR=2025, QUARTER=1
|
||||||
ON CONFLICT (agent_id, settle_quarter) DO UPDATE.
|
ON CONFLICT (agent_id, settle_quarter) DO UPDATE.
|
||||||
-->
|
-->
|
||||||
@@ -66,16 +69,32 @@
|
|||||||
total_income, total_withheld, total_local_tax,
|
total_income, total_withheld, total_local_tax,
|
||||||
generated_at)
|
generated_at)
|
||||||
SELECT
|
SELECT
|
||||||
p.agent_id,
|
u.agent_id,
|
||||||
#{quarter} AS settle_quarter,
|
#{quarter} AS settle_quarter,
|
||||||
COALESCE(SUM(p.pay_amount), 0) AS total_income,
|
COALESCE(SUM(u.total_income), 0) AS total_income,
|
||||||
COALESCE(SUM(p.income_tax_amount), 0) AS total_withheld,
|
COALESCE(SUM(u.total_withheld), 0) AS total_withheld,
|
||||||
COALESCE(SUM(p.local_tax_amount), 0) AS total_local_tax,
|
COALESCE(SUM(u.total_local_tax), 0) AS total_local_tax,
|
||||||
NOW() AS generated_at
|
NOW() AS generated_at
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
p.agent_id,
|
||||||
|
p.pay_amount AS total_income,
|
||||||
|
p.income_tax_amount AS total_withheld,
|
||||||
|
p.local_tax_amount AS total_local_tax
|
||||||
FROM payment p
|
FROM payment p
|
||||||
WHERE EXTRACT(YEAR FROM p.pay_date) = CAST(SPLIT_PART(#{quarter}, '-Q', 1) AS INT)
|
WHERE EXTRACT(YEAR FROM p.pay_date) = CAST(SPLIT_PART(#{quarter}, '-Q', 1) AS INT)
|
||||||
AND EXTRACT(QUARTER FROM p.pay_date) = CAST(SPLIT_PART(#{quarter}, '-Q', 2) AS INT)
|
AND EXTRACT(QUARTER FROM p.pay_date) = CAST(SPLIT_PART(#{quarter}, '-Q', 2) AS INT)
|
||||||
GROUP BY p.agent_id
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
a.agent_id,
|
||||||
|
-a.base_amount AS total_income,
|
||||||
|
a.income_tax_adj AS total_withheld,
|
||||||
|
a.local_tax_adj AS total_local_tax
|
||||||
|
FROM withholding_tax_adjustment a
|
||||||
|
WHERE CAST(SUBSTRING(a.settle_month, 1, 4) AS INT) = CAST(SPLIT_PART(#{quarter}, '-Q', 1) AS INT)
|
||||||
|
AND CEIL(CAST(SUBSTRING(a.settle_month, 5, 2) AS NUMERIC) / 3.0) = CAST(SPLIT_PART(#{quarter}, '-Q', 2) AS INT)
|
||||||
|
) u
|
||||||
|
GROUP BY u.agent_id
|
||||||
ON CONFLICT (agent_id, settle_quarter) DO UPDATE SET
|
ON CONFLICT (agent_id, settle_quarter) DO UPDATE SET
|
||||||
total_income = EXCLUDED.total_income,
|
total_income = EXCLUDED.total_income,
|
||||||
total_withheld = EXCLUDED.total_withheld,
|
total_withheld = EXCLUDED.total_withheld,
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ const IncentiveProgram = React.lazy(() => import('@/pages/settle/IncentivePro
|
|||||||
const PerformanceReport = React.lazy(() => import('@/pages/report/PerformanceReport'));
|
const PerformanceReport = React.lazy(() => import('@/pages/report/PerformanceReport'));
|
||||||
const OrgReport = React.lazy(() => import('@/pages/report/OrgReport'));
|
const OrgReport = React.lazy(() => import('@/pages/report/OrgReport'));
|
||||||
const ChargebackRiskReport = React.lazy(() => import('@/pages/report/ChargebackRiskReport'));
|
const ChargebackRiskReport = React.lazy(() => import('@/pages/report/ChargebackRiskReport'));
|
||||||
|
const SimplifiedPaymentReport = React.lazy(() => import('@/pages/report/SimplifiedPaymentReport'));
|
||||||
|
|
||||||
// KPI 대시보드
|
// KPI 대시보드
|
||||||
const KpiMonthlySummary = React.lazy(() => import('@/pages/kpi/KpiMonthlySummary'));
|
const KpiMonthlySummary = React.lazy(() => import('@/pages/kpi/KpiMonthlySummary'));
|
||||||
@@ -212,6 +213,7 @@ export default function App() {
|
|||||||
<Route path="report/performance" element={<PerformanceReport />} />
|
<Route path="report/performance" element={<PerformanceReport />} />
|
||||||
<Route path="report/org" element={<OrgReport />} />
|
<Route path="report/org" element={<OrgReport />} />
|
||||||
<Route path="report/chargeback-risk" element={<ChargebackRiskReport />} />
|
<Route path="report/chargeback-risk" element={<ChargebackRiskReport />} />
|
||||||
|
<Route path="report/simplified-payment" element={<SimplifiedPaymentReport />} />
|
||||||
|
|
||||||
{/* KPI 대시보드 */}
|
{/* KPI 대시보드 */}
|
||||||
<Route path="kpi/monthly" element={<KpiMonthlySummary />} />
|
<Route path="kpi/monthly" element={<KpiMonthlySummary />} />
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface ClawbackReceivableRow extends Record<string, unknown> {
|
|||||||
originalAmount: number;
|
originalAmount: number;
|
||||||
recoveredAmount: number;
|
recoveredAmount: number;
|
||||||
balance: number;
|
balance: number;
|
||||||
|
accruedInterest: number;
|
||||||
status: ClawbackStatus;
|
status: ClawbackStatus;
|
||||||
statusName: string;
|
statusName: string;
|
||||||
lastRecoveryMonth?: string;
|
lastRecoveryMonth?: string;
|
||||||
|
|||||||
@@ -73,9 +73,16 @@ const GRID_COLS: ColDef<ClawbackReceivableRow>[] = [
|
|||||||
type: 'numericColumn',
|
type: 'numericColumn',
|
||||||
valueFormatter: (p) => fmt(p.value),
|
valueFormatter: (p) => fmt(p.value),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
field: 'accruedInterest',
|
||||||
|
headerName: '연체이자',
|
||||||
|
flex: 1,
|
||||||
|
type: 'numericColumn',
|
||||||
|
valueFormatter: (p) => fmt(p.value),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
field: 'balance',
|
field: 'balance',
|
||||||
headerName: '잔액',
|
headerName: '잔액(이자포함)',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
type: 'numericColumn',
|
type: 'numericColumn',
|
||||||
valueFormatter: (p) => fmt(p.value),
|
valueFormatter: (p) => fmt(p.value),
|
||||||
@@ -274,7 +281,8 @@ function DrawerContent({ detail }: { detail: ClawbackReceivableDetail }) {
|
|||||||
{ label: '발생월', value: detail.originSettleMonth },
|
{ label: '발생월', value: detail.originSettleMonth },
|
||||||
{ label: '발생액', value: fmt(detail.originalAmount) },
|
{ label: '발생액', value: fmt(detail.originalAmount) },
|
||||||
{ label: '회수액', value: fmt(detail.recoveredAmount) },
|
{ label: '회수액', value: fmt(detail.recoveredAmount) },
|
||||||
{ label: '잔액', value: fmt(detail.balance) },
|
{ label: '연체이자', value: fmt(detail.accruedInterest) },
|
||||||
|
{ label: '잔액(이자포함)', value: fmt(detail.balance) },
|
||||||
{ label: '상태', value: <StatusChip status={detail.status} statusName={detail.statusName} /> },
|
{ label: '상태', value: <StatusChip status={detail.status} statusName={detail.statusName} /> },
|
||||||
{ label: '최근회수월', value: detail.lastRecoveryMonth ?? '-' },
|
{ label: '최근회수월', value: detail.lastRecoveryMonth ?? '-' },
|
||||||
{ label: '등록일', value: detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD') : '-' },
|
{ label: '등록일', value: detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD') : '-' },
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert, DatePicker } from 'antd';
|
||||||
|
import { ProCard, ProTable, type ProColumns } from '@ant-design/pro-components';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import dayjs, { type Dayjs } from 'dayjs';
|
||||||
|
import PageContainer from '@/components/common/PageContainer';
|
||||||
|
import MoneyText from '@/components/common/MoneyText';
|
||||||
|
import ExcelExportButton from '@/components/common/ExcelExportButton';
|
||||||
|
import api, { unwrap } from '@/api/request';
|
||||||
|
|
||||||
|
interface SimplifiedPaymentRow {
|
||||||
|
agentId: number;
|
||||||
|
settleMonth: string;
|
||||||
|
agentName: string;
|
||||||
|
residentNo?: string;
|
||||||
|
businessNo?: string;
|
||||||
|
incomeTypeName: string;
|
||||||
|
grossAmount: number;
|
||||||
|
incomeTaxAmount: number;
|
||||||
|
localTaxAmount: number;
|
||||||
|
netAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchReport(settleMonth: string) {
|
||||||
|
return unwrap<SimplifiedPaymentRow[]>(
|
||||||
|
api.get('/api/report/simplified-payment', { params: { settleMonth } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ProColumns<SimplifiedPaymentRow>[] = [
|
||||||
|
{ title: '소득자', dataIndex: 'agentName', width: 120, fixed: 'left' },
|
||||||
|
{ title: '주민등록번호', dataIndex: 'residentNo', width: 140 },
|
||||||
|
{ title: '사업자번호', dataIndex: 'businessNo', width: 140 },
|
||||||
|
{ title: '소득구분', dataIndex: 'incomeTypeName', width: 90 },
|
||||||
|
{
|
||||||
|
title: '지급액', dataIndex: 'grossAmount', width: 130, align: 'right',
|
||||||
|
render: (_, r) => <MoneyText value={r.grossAmount} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '소득세', dataIndex: 'incomeTaxAmount', width: 120, align: 'right',
|
||||||
|
render: (_, r) => <MoneyText value={r.incomeTaxAmount} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '지방소득세', dataIndex: 'localTaxAmount', width: 120, align: 'right',
|
||||||
|
render: (_, r) => <MoneyText value={r.localTaxAmount} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '차인지급액', dataIndex: 'netAmount', width: 140, align: 'right',
|
||||||
|
render: (_, r) => <strong><MoneyText value={r.netAmount} /></strong>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SimplifiedPaymentReport() {
|
||||||
|
const [settleMonth, setSettleMonth] = useState<Dayjs>(dayjs());
|
||||||
|
|
||||||
|
const monthStr = settleMonth.format('YYYYMM');
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['report', 'simplified-payment', monthStr],
|
||||||
|
queryFn: () => fetchReport(monthStr),
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer
|
||||||
|
title="사업소득 간이지급명세서"
|
||||||
|
description="월별 사업소득 지급·원천징수 명세 (국세청 간이지급명세서 제출 대응)"
|
||||||
|
extra={
|
||||||
|
<ExcelExportButton
|
||||||
|
url="/api/report/simplified-payment/export"
|
||||||
|
fileName={`간이지급명세서_${monthStr}`}
|
||||||
|
params={{ settleMonth: monthStr }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isError && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
message="조회 실패"
|
||||||
|
description="/api/report/simplified-payment API 응답이 없습니다."
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ProCard bordered style={{ marginBottom: 16 }}>
|
||||||
|
<DatePicker
|
||||||
|
picker="month"
|
||||||
|
value={settleMonth}
|
||||||
|
onChange={(d) => d && setSettleMonth(d)}
|
||||||
|
allowClear={false}
|
||||||
|
format="YYYY-MM"
|
||||||
|
/>
|
||||||
|
</ProCard>
|
||||||
|
|
||||||
|
<ProTable<SimplifiedPaymentRow>
|
||||||
|
rowKey="agentId"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
search={false}
|
||||||
|
pagination={{ pageSize: 50, showTotal: (t) => `총 ${t}건` }}
|
||||||
|
options={{ density: false, reload: false }}
|
||||||
|
scroll={{ x: 1000 }}
|
||||||
|
/>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,3 +27,26 @@ RALPH PROGRESS — GA 수수료 공식 갭 보완 + 8종 신규모듈 (세션 93
|
|||||||
- 1200% 한도: BatchRegulatoryLimitChecker.checkContractTotal(TOTAL_RATIO)/checkFirstYear(FIRST_YEAR_RATIO) 둘 다 존재. resolveLimitKrw가 PERCENT를 연환산보험료×율/100 KRW로 환산.
|
- 1200% 한도: BatchRegulatoryLimitChecker.checkContractTotal(TOTAL_RATIO)/checkFirstYear(FIRST_YEAR_RATIO) 둘 다 존재. resolveLimitKrw가 PERCENT를 연환산보험료×율/100 KRW로 환산.
|
||||||
- 분급: fullSchedule(비율합100%)이면 마지막 회차가 잔차 흡수.
|
- 분급: fullSchedule(비율합100%)이면 마지막 회차가 잔차 흡수.
|
||||||
- 풀스택 패턴: dba(V+메뉴+공통코드+권한) → core(VO/Mapper/Enum/MapStruct) → api(Service/Controller @RequirePermission/@DataChangeLog/ApiResponse/PageResponse/PageHelper) → batch(필요시 Step) → frontend(AG Grid/DataTable+api+App.tsx 라우트).
|
- 풀스택 패턴: dba(V+메뉴+공통코드+권한) → core(VO/Mapper/Enum/MapStruct) → api(Service/Controller @RequirePermission/@DataChangeLog/ApiResponse/PageResponse/PageHelper) → batch(필요시 Step) → frontend(AG Grid/DataTable+api+App.tsx 라우트).
|
||||||
|
|
||||||
|
=== 2026-06-06 수수료 프로세스 E2E 테스트 (Ralph 2차) ===
|
||||||
|
- 테스트월 202605(settle_master 미존재) + 기존 200계약/요율로 실 MonthlySettlementJob 실행.
|
||||||
|
- 신규모듈 검증용 주입: advance_payment(agent50,100000) / recruiter_commission_ledger(agent25,77000) / lapsed_reinstatement_recovery(agent20,33000).
|
||||||
|
- 검증 결과: 무결성 Σ원장==Σmaster(20,944,621.70) 정확일치 / 모집·유지·세금 공식 정확 / MOD-1 도입77000·MOD-2 선지급100000 전액상계(balance0,CLEARED,payable=net-100000)·MOD-4 익월부활33000 전부 settle_master 정확 반영.
|
||||||
|
- **★ 실버그 발견·수정(BUGFIX-1)**: 1200% 1차년 한도 clip은 정상이나 초과분이 분급(installment_plan)으로 이연되지 않고 소실. 원인=InstallmentRatioMapper.selectAllActiveRatios가 product_category 정확일치+양쪽NULL만 매칭 → 계약 insuranceType(비NULL) 전달 시 NULL 카테고리(전상품 기본) 룰을 못찾아 0행 반환. 수정=product_category 일치 우선 + NULL 폴백(DISTINCT ON 슬롯별 1건, chargeback/channel 패턴). 재실행 검증: clip된 6계약 각 7회차 분급 생성, 이연합=초과분 정확(c181 87500 등 6건 전부 일치). 부수: 잔존 stray installment_ratio id8,9(created_by=1, 비-시드, 합108%) 제거→표준7행 합100%.
|
||||||
|
- 롤백: 202605 산출물 전건+주입데이터+job_log 삭제, settle_master 202605=0, 데모(202602~04) 무손상. 코드수정(XML)만 유지.
|
||||||
|
|
||||||
|
================================================================
|
||||||
|
[수수료 갭 보완 4종] 2026-06-08 (ralph) — schema v123→v126
|
||||||
|
================================================================
|
||||||
|
감사(서브에이전트 3영역)로 발견한 실제 갭 4종 구현. 전체 build SUCCESS, 전 테스트 PASS, Flyway V124-126 적용 성공, DB레벨 검증 완료(롤백).
|
||||||
|
|
||||||
|
US-A 지급보류 버그: PaymentBatchItemMapper.insertFromSettleMaster 가 status='HOLD' 미필터 → 보류건도 지급배치 포함되어 실제 이체되던 버그. WHERE status NOT IN('PAID','HOLD') 로 수정. 라이브DB 검증: 보류 설정 시 OLD쿼리=39(버그) NEW=38(제외), 데이터 무변경(롤백).
|
||||||
|
|
||||||
|
US-B 환수 원천세 역분개: 교차월 환수로 gross<0 인 월의 기납부 원천세가 분기 원천세신고에서 미차감(과대신고)되던 갭. 신규 withholding_tax_adjustment(V124) + AggregateStep 에서 gross<0 시 reversalBase=|gross| 에 BatchTaxCalculator 적용→negate 적재(멱등 deleteBySettleMonth). WithholdingTaxReportMapper.upsertQuarterlyAggregate 를 payment UNION ALL adjustment 로 변경해 차감. settle_master 금액흐름 불변(회귀0). 단위테스트 WithholdingReversalTest 3건 + DB netting 검증.
|
||||||
|
|
||||||
|
US-C 환수채권 연체이자: clawback_receivable.accrued_interest 컬럼 + clawback_interest 이력테이블(V125) + ClawbackInterestCalculator(연이자율/1200, 율0=안전폴백) + config 2종(CLAWBACK_OVERDUE_INTEREST_RATE=0, CLAWBACK_INTEREST_GRACE_MONTHS=0). AggregateStep.reverseAndAccrueClawbackInterest 가 멱등 reverse 후 거치기간 경과 미결채권에 월 단리이자 적립(balance 포함→기존 FIFO 상계가 이자까지 회수). 프론트 ClawbackReceivable 화면에 연체이자 컬럼. 단위테스트 ClawbackInterestCalculatorTest 3건 + DB 적립/역산 검증.
|
||||||
|
|
||||||
|
US-D 사업소득 간이지급명세서(월별): commission_statement(MONTHLY) 위 읽기전용 리포트(신규 데이터테이블 없음). ReportController/Service/Mapper +selectSimplifiedPayment(+Cursor), SimplifiedPaymentRow VO(@ExcelColumn), V126 메뉴 REPORT_SIMPLIFIED_PAYMENT(READ/EXPORT). 프론트 SimplifiedPaymentReport.tsx + App.tsx 라우트. admin 로그인 e2e: list 200(실데이터)+export 200.
|
||||||
|
|
||||||
|
검증: ./gradlew build 전모듈 SUCCESS, ga-batch/ga-api/ga-common test PASS, ga-frontend tsc -b PASS, Flyway v126 적용, 엔드포인트 smoke(D 200, 권한게이트 403 정상).
|
||||||
|
주의: 무관한 기존 변경(InstallmentRatioMapper.xml) 미커밋 상태 그대로 보존(내 변경 아님).
|
||||||
|
|||||||
Reference in New Issue
Block a user