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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user