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.OrgReportRow;
|
||||
import com.ga.core.vo.report.PerfReportRow;
|
||||
import com.ga.core.vo.report.SimplifiedPaymentRow;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -81,4 +82,19 @@ public class ReportController {
|
||||
excelService.exportLargeExcel("환수위험리포트", ChargebackRiskRow.class,
|
||||
() -> 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.OrgReportRow;
|
||||
import com.ga.core.vo.report.PerfReportRow;
|
||||
import com.ga.core.vo.report.SimplifiedPaymentRow;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.ibatis.cursor.Cursor;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -47,4 +48,13 @@ public class ReportService {
|
||||
public Cursor<ChargebackRiskRow> chargebackRiskCursor() {
|
||||
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.domain.BatchTaxCalculator;
|
||||
import com.ga.batch.settlement.domain.ClawbackInterestCalculator;
|
||||
import com.ga.batch.settlement.domain.TaxBreakdown;
|
||||
import com.ga.common.util.DateUtil;
|
||||
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.AdvanceRecoveryLedgerMapper;
|
||||
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.OverrideSettleMapper;
|
||||
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.AdvanceRecoveryLedgerVO;
|
||||
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.SettleMasterVO;
|
||||
import com.ga.core.vo.settle.StatementType;
|
||||
import com.ga.core.vo.tax.WithholdingTaxAdjustmentVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.batch.core.StepContribution;
|
||||
@@ -90,6 +96,9 @@ public class AggregateStep implements Tasklet {
|
||||
private final BatchTaxCalculator taxCalculator;
|
||||
private final ClawbackReceivableMapper receivableMapper;
|
||||
private final ClawbackRecoveryMapper recoveryMapper;
|
||||
private final WithholdingTaxAdjustmentMapper adjustmentMapper;
|
||||
private final ClawbackInterestMapper interestMapper;
|
||||
private final ClawbackInterestCalculator interestCalculator;
|
||||
|
||||
// 설계사 지급성 9종 원장 Mapper
|
||||
private final CollectionCommissionLedgerMapper collectionMapper;
|
||||
@@ -192,6 +201,13 @@ public class AggregateStep implements Tasklet {
|
||||
// MOD-2 멱등 reverse: 선지급 상계 이력 역산 후 advance_payment balance 복원
|
||||
reverseAdvanceRecovery(ctx.getSettleMonth());
|
||||
|
||||
// V124 멱등 reverse: 당월 환수 원천세 역분개 전량 삭제 후 루프에서 재적재
|
||||
adjustmentMapper.deleteBySettleMonth(ctx.getSettleMonth());
|
||||
|
||||
// V125 환수채권 연체이자: 당월 적립분 reverse 후 미결 채권에 1개월분 재적립
|
||||
// (balance 에 포함 → 이후 agent 루프의 FIFO 상계가 이자까지 회수)
|
||||
reverseAndAccrueClawbackInterest(ctx.getSettleMonth());
|
||||
|
||||
for (Long agentId : agentIds) {
|
||||
BigDecimal recruit = recruitMap.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);
|
||||
}
|
||||
|
||||
// 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()) {
|
||||
agentDeductions.forEach(d -> appliedDeductionIds.add(d.getDeductionId()));
|
||||
@@ -472,6 +503,40 @@ public class AggregateStep implements Tasklet {
|
||||
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 순으로 채권을 소진하며
|
||||
* 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,
|
||||
@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.OrgReportRow;
|
||||
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.Param;
|
||||
import org.apache.ibatis.cursor.Cursor;
|
||||
@@ -28,4 +29,9 @@ public interface ReportMapper {
|
||||
List<ChargebackRiskRow> selectChargebackRisk();
|
||||
|
||||
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 recoveredAmount;
|
||||
private BigDecimal balance;
|
||||
/** 누적 연체이자 (V125) — balance 에 포함됨 */
|
||||
private BigDecimal accruedInterest;
|
||||
/** OUTSTANDING / RECOVERING / CLEARED / WRITTEN_OFF */
|
||||
private String status;
|
||||
/** 공통코드 CLAWBACK_RCV_STATUS */
|
||||
|
||||
@@ -19,8 +19,10 @@ public class ClawbackReceivableVO {
|
||||
private BigDecimal originalAmount;
|
||||
/** 누적 상계 회수액 */
|
||||
private BigDecimal recoveredAmount;
|
||||
/** 잔액 = original_amount - recovered_amount */
|
||||
/** 잔액 = original_amount - recovered_amount (+ 누적 연체이자) */
|
||||
private BigDecimal balance;
|
||||
/** 누적 연체이자 (V125) — 잔액에 포함되어 회수/상계됨, 표시용 */
|
||||
private BigDecimal accruedInterest;
|
||||
/** OUTSTANDING / RECOVERING / CLEARED / WRITTEN_OFF */
|
||||
private String status;
|
||||
/** 최근 회수 정산월 (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="recoveredAmount" column="recovered_amount"/>
|
||||
<result property="balance" column="balance"/>
|
||||
<result property="accruedInterest" column="accrued_interest"/>
|
||||
<result property="status" column="status"/>
|
||||
<result property="statusName" column="status_name"/>
|
||||
<result property="lastRecoveryMonth" column="last_recovery_month"/>
|
||||
@@ -36,7 +37,7 @@
|
||||
<sql id="rcvJoinCols">
|
||||
r.receivable_id, r.agent_id, a.agent_name,
|
||||
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,
|
||||
(SELECT cc.code_name FROM common_code cc
|
||||
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
|
||||
</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 상태 전이 -->
|
||||
<update id="writeOffExpired">
|
||||
UPDATE clawback_receivable
|
||||
|
||||
@@ -99,4 +99,33 @@
|
||||
<include refid="chargebackRiskSql"/>
|
||||
</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>
|
||||
|
||||
@@ -25,8 +25,8 @@
|
||||
|
||||
조건:
|
||||
settle_month = #{settleMonth}
|
||||
payable_amount > 0 (지급할 금액이 있는 건만)
|
||||
status != 'PAID' (이미 지급완료된 건 제외)
|
||||
payable_amount > 0 (지급할 금액이 있는 건만)
|
||||
status NOT IN ('PAID','HOLD') (지급완료/지급보류 건 제외)
|
||||
|
||||
inserted 컬럼:
|
||||
batch_id = #{batchId} (생성된 배치 PK)
|
||||
@@ -49,7 +49,7 @@
|
||||
FROM settle_master sm
|
||||
WHERE sm.settle_month = #{settleMonth}
|
||||
AND sm.payable_amount > 0
|
||||
AND sm.status != 'PAID'
|
||||
AND sm.status NOT IN ('PAID', 'HOLD')
|
||||
</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 집계.
|
||||
payment 테이블에서 해당 분기 설계사별 income_tax_amount / local_tax_amount / pay_amount SUM.
|
||||
분기 판단: pay_date 로 EXTRACT(YEAR/QUARTER) 사용. 분기 포맷 YYYY-Qn 파싱.
|
||||
payment(실지급) + withholding_tax_adjustment(환수 원천세 역분개, V124) 를 합산.
|
||||
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
|
||||
ON CONFLICT (agent_id, settle_quarter) DO UPDATE.
|
||||
-->
|
||||
@@ -66,16 +69,32 @@
|
||||
total_income, total_withheld, total_local_tax,
|
||||
generated_at)
|
||||
SELECT
|
||||
p.agent_id,
|
||||
#{quarter} AS settle_quarter,
|
||||
COALESCE(SUM(p.pay_amount), 0) AS total_income,
|
||||
COALESCE(SUM(p.income_tax_amount), 0) AS total_withheld,
|
||||
COALESCE(SUM(p.local_tax_amount), 0) AS total_local_tax,
|
||||
NOW() AS generated_at
|
||||
FROM payment p
|
||||
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)
|
||||
GROUP BY p.agent_id
|
||||
u.agent_id,
|
||||
#{quarter} AS settle_quarter,
|
||||
COALESCE(SUM(u.total_income), 0) AS total_income,
|
||||
COALESCE(SUM(u.total_withheld), 0) AS total_withheld,
|
||||
COALESCE(SUM(u.total_local_tax), 0) AS total_local_tax,
|
||||
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
|
||||
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)
|
||||
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
|
||||
total_income = EXCLUDED.total_income,
|
||||
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 OrgReport = React.lazy(() => import('@/pages/report/OrgReport'));
|
||||
const ChargebackRiskReport = React.lazy(() => import('@/pages/report/ChargebackRiskReport'));
|
||||
const SimplifiedPaymentReport = React.lazy(() => import('@/pages/report/SimplifiedPaymentReport'));
|
||||
|
||||
// KPI 대시보드
|
||||
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/org" element={<OrgReport />} />
|
||||
<Route path="report/chargeback-risk" element={<ChargebackRiskReport />} />
|
||||
<Route path="report/simplified-payment" element={<SimplifiedPaymentReport />} />
|
||||
|
||||
{/* KPI 대시보드 */}
|
||||
<Route path="kpi/monthly" element={<KpiMonthlySummary />} />
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface ClawbackReceivableRow extends Record<string, unknown> {
|
||||
originalAmount: number;
|
||||
recoveredAmount: number;
|
||||
balance: number;
|
||||
accruedInterest: number;
|
||||
status: ClawbackStatus;
|
||||
statusName: string;
|
||||
lastRecoveryMonth?: string;
|
||||
|
||||
@@ -73,9 +73,16 @@ const GRID_COLS: ColDef<ClawbackReceivableRow>[] = [
|
||||
type: 'numericColumn',
|
||||
valueFormatter: (p) => fmt(p.value),
|
||||
},
|
||||
{
|
||||
field: 'accruedInterest',
|
||||
headerName: '연체이자',
|
||||
flex: 1,
|
||||
type: 'numericColumn',
|
||||
valueFormatter: (p) => fmt(p.value),
|
||||
},
|
||||
{
|
||||
field: 'balance',
|
||||
headerName: '잔액',
|
||||
headerName: '잔액(이자포함)',
|
||||
flex: 1,
|
||||
type: 'numericColumn',
|
||||
valueFormatter: (p) => fmt(p.value),
|
||||
@@ -274,7 +281,8 @@ function DrawerContent({ detail }: { detail: ClawbackReceivableDetail }) {
|
||||
{ label: '발생월', value: detail.originSettleMonth },
|
||||
{ label: '발생액', value: fmt(detail.originalAmount) },
|
||||
{ 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: detail.lastRecoveryMonth ?? '-' },
|
||||
{ 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로 환산.
|
||||
- 분급: 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 라우트).
|
||||
|
||||
=== 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