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:
GA Pro
2026-06-08 21:22:13 +09:00
parent ff6d399dd3
commit fbbabf2d04
29 changed files with 832 additions and 19 deletions
@@ -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를 차감하고
@@ -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 &gt; 0
AND origin_settle_month &lt;= #{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,
+2
View File
@@ -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>
);
}
+23
View File
@@ -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) 미커밋 상태 그대로 보존(내 변경 아님).