From fbbabf2d049efe400a23122be786966bf8b46895 Mon Sep 17 00:00:00 2001 From: GA Pro Date: Mon, 8 Jun 2026 21:22:13 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=88=98=EC=88=98=EB=A3=8C=20=EA=B0=AD?= =?UTF-8?q?=20=EB=B3=B4=EC=99=84=204=EC=A2=85=20=E2=80=94=20=EC=A7=80?= =?UTF-8?q?=EA=B8=89=EB=B3=B4=EB=A5=98/=ED=99=98=EC=88=98=EC=9B=90?= =?UTF-8?q?=EC=B2=9C=EC=84=B8=EC=97=AD=EB=B6=84=EA=B0=9C/=EC=97=B0?= =?UTF-8?q?=EC=B2=B4=EC=9D=B4=EC=9E=90/=EA=B0=84=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EA=B8=89=EB=AA=85=EC=84=B8=EC=84=9C=20(V124~V126)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 감사(서브에이전트 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) --- .../controller/report/ReportController.java | 16 +++ .../ga/api/service/report/ReportService.java | 10 ++ .../domain/ClawbackInterestCalculator.java | 37 ++++++ .../batch/settlement/step/AggregateStep.java | 65 +++++++++++ .../ClawbackInterestCalculatorTest.java | 56 +++++++++ .../domain/WithholdingReversalTest.java | 81 +++++++++++++ .../db/migration/V124__환수원천세역분개.sql | 32 ++++++ .../db/migration/V125__환수채권연체이자.sql | 42 +++++++ .../migration/V126__간이지급명세서_메뉴.sql | 43 +++++++ .../commission/ClawbackInterestMapper.java | 23 ++++ .../commission/ClawbackReceivableMapper.java | 14 +++ .../ga/core/mapper/report/ReportMapper.java | 6 + .../tax/WithholdingTaxAdjustmentMapper.java | 25 ++++ .../vo/commission/ClawbackInterestVO.java | 21 ++++ .../vo/commission/ClawbackReceivableResp.java | 2 + .../vo/commission/ClawbackReceivableVO.java | 4 +- .../core/vo/report/SimplifiedPaymentRow.java | 43 +++++++ .../vo/tax/WithholdingTaxAdjustmentVO.java | 28 +++++ .../commission/ClawbackInterestMapper.xml | 23 ++++ .../commission/ClawbackReceivableMapper.xml | 30 ++++- .../resources/mapper/report/ReportMapper.xml | 29 +++++ .../mapper/settle/PaymentBatchItemMapper.xml | 6 +- .../tax/WithholdingTaxAdjustmentMapper.xml | 26 +++++ .../mapper/tax/WithholdingTaxReportMapper.xml | 43 +++++-- ga-frontend/src/App.tsx | 2 + ga-frontend/src/api/clawbackReceivable.ts | 1 + .../pages/commission/ClawbackReceivable.tsx | 12 +- .../pages/report/SimplifiedPaymentReport.tsx | 108 ++++++++++++++++++ progress.txt | 23 ++++ 29 files changed, 832 insertions(+), 19 deletions(-) create mode 100644 ga-batch/src/main/java/com/ga/batch/settlement/domain/ClawbackInterestCalculator.java create mode 100644 ga-batch/src/test/java/com/ga/batch/settlement/domain/ClawbackInterestCalculatorTest.java create mode 100644 ga-batch/src/test/java/com/ga/batch/settlement/domain/WithholdingReversalTest.java create mode 100644 ga-common/src/main/resources/db/migration/V124__환수원천세역분개.sql create mode 100644 ga-common/src/main/resources/db/migration/V125__환수채권연체이자.sql create mode 100644 ga-common/src/main/resources/db/migration/V126__간이지급명세서_메뉴.sql create mode 100644 ga-core/src/main/java/com/ga/core/mapper/commission/ClawbackInterestMapper.java create mode 100644 ga-core/src/main/java/com/ga/core/mapper/tax/WithholdingTaxAdjustmentMapper.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/commission/ClawbackInterestVO.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/report/SimplifiedPaymentRow.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/tax/WithholdingTaxAdjustmentVO.java create mode 100644 ga-core/src/main/resources/mapper/commission/ClawbackInterestMapper.xml create mode 100644 ga-core/src/main/resources/mapper/tax/WithholdingTaxAdjustmentMapper.xml create mode 100644 ga-frontend/src/pages/report/SimplifiedPaymentReport.tsx diff --git a/ga-api/src/main/java/com/ga/api/controller/report/ReportController.java b/ga-api/src/main/java/com/ga/api/controller/report/ReportController.java index 5efd76f..7ebe46e 100644 --- a/ga-api/src/main/java/com/ga/api/controller/report/ReportController.java +++ b/ga-api/src/main/java/com/ga/api/controller/report/ReportController.java @@ -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> 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); + } } diff --git a/ga-api/src/main/java/com/ga/api/service/report/ReportService.java b/ga-api/src/main/java/com/ga/api/service/report/ReportService.java index 27b61f7..953d41c 100644 --- a/ga-api/src/main/java/com/ga/api/service/report/ReportService.java +++ b/ga-api/src/main/java/com/ga/api/service/report/ReportService.java @@ -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 chargebackRiskCursor() { return mapper.selectChargebackRiskCursor(); } + + @Transactional(readOnly = true) + public List simplifiedPayment(String settleMonth) { + return mapper.selectSimplifiedPayment(settleMonth); + } + + public Cursor simplifiedPaymentCursor(String settleMonth) { + return mapper.selectSimplifiedPaymentCursor(settleMonth); + } } diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/domain/ClawbackInterestCalculator.java b/ga-batch/src/main/java/com/ga/batch/settlement/domain/ClawbackInterestCalculator.java new file mode 100644 index 0000000..d56c944 --- /dev/null +++ b/ga-batch/src/main/java/com/ga/batch/settlement/domain/ClawbackInterestCalculator.java @@ -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); + } +} diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/step/AggregateStep.java b/ga-batch/src/main/java/com/ga/batch/settlement/step/AggregateStep.java index bd5f49a..7f3d33c 100644 --- a/ga-batch/src/main/java/com/ga/batch/settlement/step/AggregateStep.java +++ b/ga-batch/src/main/java/com/ga/batch/settlement/step/AggregateStep.java @@ -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 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를 차감하고 diff --git a/ga-batch/src/test/java/com/ga/batch/settlement/domain/ClawbackInterestCalculatorTest.java b/ga-batch/src/test/java/com/ga/batch/settlement/domain/ClawbackInterestCalculatorTest.java new file mode 100644 index 0000000..05a2c16 --- /dev/null +++ b/ga-batch/src/test/java/com/ga/batch/settlement/domain/ClawbackInterestCalculatorTest.java @@ -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"); + } +} diff --git a/ga-batch/src/test/java/com/ga/batch/settlement/domain/WithholdingReversalTest.java b/ga-batch/src/test/java/com/ga/batch/settlement/domain/WithholdingReversalTest.java new file mode 100644 index 0000000..640706f --- /dev/null +++ b/ga-batch/src/test/java/com/ga/batch/settlement/domain/WithholdingReversalTest.java @@ -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)은 진입하지 않는다. + } +} diff --git a/ga-common/src/main/resources/db/migration/V124__환수원천세역분개.sql b/ga-common/src/main/resources/db/migration/V124__환수원천세역분개.sql new file mode 100644 index 0000000..457539c --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V124__환수원천세역분개.sql @@ -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); diff --git a/ga-common/src/main/resources/db/migration/V125__환수채권연체이자.sql b/ga-common/src/main/resources/db/migration/V125__환수채권연체이자.sql new file mode 100644 index 0000000..e0fbd47 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V125__환수채권연체이자.sql @@ -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; diff --git a/ga-common/src/main/resources/db/migration/V126__간이지급명세서_메뉴.sql b/ga-common/src/main/resources/db/migration/V126__간이지급명세서_메뉴.sql new file mode 100644 index 0000000..de8030f --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V126__간이지급명세서_메뉴.sql @@ -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; diff --git a/ga-core/src/main/java/com/ga/core/mapper/commission/ClawbackInterestMapper.java b/ga-core/src/main/java/com/ga/core/mapper/commission/ClawbackInterestMapper.java new file mode 100644 index 0000000..fb6c837 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/mapper/commission/ClawbackInterestMapper.java @@ -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 selectBySettleMonth(@Param("settleMonth") String settleMonth); + + /** 정산월 적립 이력 전량 삭제 (멱등 reverse) */ + int deleteBySettleMonth(@Param("settleMonth") String settleMonth); +} diff --git a/ga-core/src/main/java/com/ga/core/mapper/commission/ClawbackReceivableMapper.java b/ga-core/src/main/java/com/ga/core/mapper/commission/ClawbackReceivableMapper.java index 0558c4c..614ed9f 100644 --- a/ga-core/src/main/java/com/ga/core/mapper/commission/ClawbackReceivableMapper.java +++ b/ga-core/src/main/java/com/ga/core/mapper/commission/ClawbackReceivableMapper.java @@ -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 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); } diff --git a/ga-core/src/main/java/com/ga/core/mapper/report/ReportMapper.java b/ga-core/src/main/java/com/ga/core/mapper/report/ReportMapper.java index 295d487..6ca9d6d 100644 --- a/ga-core/src/main/java/com/ga/core/mapper/report/ReportMapper.java +++ b/ga-core/src/main/java/com/ga/core/mapper/report/ReportMapper.java @@ -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 selectChargebackRisk(); Cursor selectChargebackRiskCursor(); + + /** 사업소득 간이지급명세서(월별) — commission_statement(MONTHLY) 위 집계 */ + List selectSimplifiedPayment(@Param("settleMonth") String settleMonth); + + Cursor selectSimplifiedPaymentCursor(@Param("settleMonth") String settleMonth); } diff --git a/ga-core/src/main/java/com/ga/core/mapper/tax/WithholdingTaxAdjustmentMapper.java b/ga-core/src/main/java/com/ga/core/mapper/tax/WithholdingTaxAdjustmentMapper.java new file mode 100644 index 0000000..f3db1c2 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/mapper/tax/WithholdingTaxAdjustmentMapper.java @@ -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); +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackInterestVO.java b/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackInterestVO.java new file mode 100644 index 0000000..534968b --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackInterestVO.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackReceivableResp.java b/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackReceivableResp.java index f655b0f..6f7b7ed 100644 --- a/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackReceivableResp.java +++ b/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackReceivableResp.java @@ -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 */ diff --git a/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackReceivableVO.java b/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackReceivableVO.java index 87a5af0..d3b7b27 100644 --- a/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackReceivableVO.java +++ b/ga-core/src/main/java/com/ga/core/vo/commission/ClawbackReceivableVO.java @@ -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) */ diff --git a/ga-core/src/main/java/com/ga/core/vo/report/SimplifiedPaymentRow.java b/ga-core/src/main/java/com/ga/core/vo/report/SimplifiedPaymentRow.java new file mode 100644 index 0000000..10fc511 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/report/SimplifiedPaymentRow.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/tax/WithholdingTaxAdjustmentVO.java b/ga-core/src/main/java/com/ga/core/vo/tax/WithholdingTaxAdjustmentVO.java new file mode 100644 index 0000000..cba82a8 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/tax/WithholdingTaxAdjustmentVO.java @@ -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; +} diff --git a/ga-core/src/main/resources/mapper/commission/ClawbackInterestMapper.xml b/ga-core/src/main/resources/mapper/commission/ClawbackInterestMapper.xml new file mode 100644 index 0000000..3a95913 --- /dev/null +++ b/ga-core/src/main/resources/mapper/commission/ClawbackInterestMapper.xml @@ -0,0 +1,23 @@ + + + + + + + + INSERT INTO clawback_interest (receivable_id, settle_month, interest_amount) + VALUES (#{receivableId}, #{settleMonth}, #{interestAmount}) + + + + + + DELETE FROM clawback_interest WHERE settle_month = #{settleMonth} + + + diff --git a/ga-core/src/main/resources/mapper/commission/ClawbackReceivableMapper.xml b/ga-core/src/main/resources/mapper/commission/ClawbackReceivableMapper.xml index 10c16ac..f0a9719 100644 --- a/ga-core/src/main/resources/mapper/commission/ClawbackReceivableMapper.xml +++ b/ga-core/src/main/resources/mapper/commission/ClawbackReceivableMapper.xml @@ -18,6 +18,7 @@ + @@ -36,7 +37,7 @@ 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 + + + + + + UPDATE clawback_receivable + SET balance = GREATEST(balance + #{delta}, 0), + accrued_interest = GREATEST(accrued_interest + #{delta}, 0), + updated_at = NOW() + WHERE receivable_id = #{receivableId} + + UPDATE clawback_receivable diff --git a/ga-core/src/main/resources/mapper/report/ReportMapper.xml b/ga-core/src/main/resources/mapper/report/ReportMapper.xml index 54dd1c2..09ac04d 100644 --- a/ga-core/src/main/resources/mapper/report/ReportMapper.xml +++ b/ga-core/src/main/resources/mapper/report/ReportMapper.xml @@ -99,4 +99,33 @@ + + + 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 + + + + + diff --git a/ga-core/src/main/resources/mapper/settle/PaymentBatchItemMapper.xml b/ga-core/src/main/resources/mapper/settle/PaymentBatchItemMapper.xml index 58c78c7..dd89182 100644 --- a/ga-core/src/main/resources/mapper/settle/PaymentBatchItemMapper.xml +++ b/ga-core/src/main/resources/mapper/settle/PaymentBatchItemMapper.xml @@ -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 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 + + + + + DELETE FROM withholding_tax_adjustment WHERE settle_month = #{settleMonth} + + + diff --git a/ga-core/src/main/resources/mapper/tax/WithholdingTaxReportMapper.xml b/ga-core/src/main/resources/mapper/tax/WithholdingTaxReportMapper.xml index 671dd3e..cc92a70 100644 --- a/ga-core/src/main/resources/mapper/tax/WithholdingTaxReportMapper.xml +++ b/ga-core/src/main/resources/mapper/tax/WithholdingTaxReportMapper.xml @@ -55,8 +55,11 @@ @@ -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, diff --git a/ga-frontend/src/App.tsx b/ga-frontend/src/App.tsx index 9863ee0..256c3c3 100644 --- a/ga-frontend/src/App.tsx +++ b/ga-frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> {/* KPI 대시보드 */} } /> diff --git a/ga-frontend/src/api/clawbackReceivable.ts b/ga-frontend/src/api/clawbackReceivable.ts index 496c490..ce3430b 100644 --- a/ga-frontend/src/api/clawbackReceivable.ts +++ b/ga-frontend/src/api/clawbackReceivable.ts @@ -12,6 +12,7 @@ export interface ClawbackReceivableRow extends Record { originalAmount: number; recoveredAmount: number; balance: number; + accruedInterest: number; status: ClawbackStatus; statusName: string; lastRecoveryMonth?: string; diff --git a/ga-frontend/src/pages/commission/ClawbackReceivable.tsx b/ga-frontend/src/pages/commission/ClawbackReceivable.tsx index 5e23808..3a03d0d 100644 --- a/ga-frontend/src/pages/commission/ClawbackReceivable.tsx +++ b/ga-frontend/src/pages/commission/ClawbackReceivable.tsx @@ -73,9 +73,16 @@ const GRID_COLS: ColDef[] = [ 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: }, { label: '최근회수월', value: detail.lastRecoveryMonth ?? '-' }, { label: '등록일', value: detail.createdAt ? dayjs(detail.createdAt).format('YYYY-MM-DD') : '-' }, diff --git a/ga-frontend/src/pages/report/SimplifiedPaymentReport.tsx b/ga-frontend/src/pages/report/SimplifiedPaymentReport.tsx new file mode 100644 index 0000000..4d9a284 --- /dev/null +++ b/ga-frontend/src/pages/report/SimplifiedPaymentReport.tsx @@ -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( + api.get('/api/report/simplified-payment', { params: { settleMonth } }), + ); +} + +const columns: ProColumns[] = [ + { 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) => , + }, + { + title: '소득세', dataIndex: 'incomeTaxAmount', width: 120, align: 'right', + render: (_, r) => , + }, + { + title: '지방소득세', dataIndex: 'localTaxAmount', width: 120, align: 'right', + render: (_, r) => , + }, + { + title: '차인지급액', dataIndex: 'netAmount', width: 140, align: 'right', + render: (_, r) => , + }, +]; + +export default function SimplifiedPaymentReport() { + const [settleMonth, setSettleMonth] = useState(dayjs()); + + const monthStr = settleMonth.format('YYYYMM'); + + const { data, isLoading, isError } = useQuery({ + queryKey: ['report', 'simplified-payment', monthStr], + queryFn: () => fetchReport(monthStr), + retry: false, + }); + + return ( + + } + > + {isError && ( + + )} + + + d && setSettleMonth(d)} + allowClear={false} + format="YYYY-MM" + /> + + + + rowKey="agentId" + columns={columns} + dataSource={data ?? []} + loading={isLoading} + search={false} + pagination={{ pageSize: 50, showTotal: (t) => `총 ${t}건` }} + options={{ density: false, reload: false }} + scroll={{ x: 1000 }} + /> + + ); +} diff --git a/progress.txt b/progress.txt index a354bc9..d70b42c 100644 --- a/progress.txt +++ b/progress.txt @@ -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) 미커밋 상태 그대로 보존(내 변경 아님).