feat: P12(#2) 지급대상건 구성·수정 — 정산월 지급대상 배치 구성/확정 (PL 자율진행)

incar 지급대상건구성/수정/조회 대응. 기존 settle_master 건별 confirm/hold 위에
정산월 단위 지급대상 배치를 구성→포함/제외 편집→확정하는 워크플로우 레이어 추가
(AccountingClose DRAFT/마감 패턴 준용, settle_master 상태는 미변경=결합 최소화).

- DB(V115): payment_batch(배치 마스터, status DRAFT/FINALIZED/CANCELLED, total_count/
  total_payable) + payment_batch_item(건, settle_id FK, payable 스냅샷, included/exclude_reason,
  UNIQUE(batch_id,settle_id)) + 메뉴 PAYMENT_TARGET(GRP_SETTLE) + 권한 READ/CREATE/UPDATE/
  EXECUTE + 공통코드 PAYMENT_BATCH_STATUS.
- core: PaymentBatch/Item VO/Resp/SaveReq/SearchParam/Enum + Mapper. insertFromSettleMaster
  (INSERT-SELECT 자동채움: settle_month·payable>0·status!=PAID) + recalcTotals + updateInclude.
- api: PaymentBatchService create(자동채움+집계)/list/detail/updateItem(DRAFT만,재집계)/
  finalize(포함0건 거부)/cancel/export. Controller /api/payment-batches.
- frontend: PaymentTarget 화면(배치 목록 + 구성 모달 + 상세 Drawer items 포함/제외 토글 +
  합계 + 확정/취소/엑셀) + api 모듈 + App 라우트. export 권한 READ로 정렬.

검증: build+test SUCCESSFUL, Flyway V115(schema v115). 라이브 e2e: 202603 create→39건
자동채움(합 15,438,386.9) → 1건 제외 → 38건 재집계(정확히 −35,079) → finalize → 확정후
수정 400 차단 → cancel. 테스트 배치 정리. 스펙 docs/DOMAIN_GAP_P12.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-06-05 01:10:50 +09:00
parent 934e45d295
commit 00ead64c4c
19 changed files with 1675 additions and 0 deletions
@@ -0,0 +1,48 @@
package com.ga.core.mapper.settle;
import com.ga.core.vo.settle.PaymentBatchItemResp;
import com.ga.core.vo.settle.PaymentBatchItemVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* payment_batch_item Mapper — 지급대상건 (V115)
*/
@Mapper
public interface PaymentBatchItemMapper {
/**
* settle_master 에서 해당 정산월 지급대상(payable_amount>0, status!='PAID')을
* payment_batch_item 으로 일괄 INSERT-SELECT.
* included=true 로 전체 포함 초기화.
* 배치 생성(create) 트랜잭션 내 단일 SQL 로 처리 — 효율적.
*/
int insertFromSettleMaster(@Param("batchId") Long batchId,
@Param("settleMonth") String settleMonth);
/**
* VO 리스트 일괄 INSERT (수동 구성 또는 단위 테스트용).
* insertFromSettleMaster 가 메인 경로이므로 보조 용도.
*/
int insertBatch(@Param("list") List<PaymentBatchItemVO> list);
/**
* 배치 ID 기준 전체 items 조회 — 설계사명(agentName) 조인 포함.
* PaymentBatchMapper.selectById 의 collection 에서도 참조.
*/
List<PaymentBatchItemResp> selectByBatchId(@Param("batchId") Long batchId);
/** 단건 조회 (updateInclude 전 DRAFT 검증 등 Service 용도) */
PaymentBatchItemVO selectById(@Param("itemId") Long itemId);
/**
* 포함/제외 수정.
* included=false 시 excludeReason 기록; included=true 로 복원 시 excludeReason=null 로 초기화.
* 호출 후 PaymentBatchMapper.recalcTotals 반드시 실행.
*/
int updateInclude(@Param("itemId") Long itemId,
@Param("included") Boolean included,
@Param("excludeReason") String excludeReason);
}
@@ -0,0 +1,53 @@
package com.ga.core.mapper.settle;
import com.ga.core.vo.settle.PaymentBatchResp;
import com.ga.core.vo.settle.PaymentBatchSearchParam;
import com.ga.core.vo.settle.PaymentBatchVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* payment_batch Mapper — 지급대상 배치 마스터 (V115)
*/
@Mapper
public interface PaymentBatchMapper {
/**
* 배치 생성 (DRAFT 상태로 INSERT).
* useGeneratedKeys=true 로 vo.batchId 에 채번된 PK 주입.
*/
int insert(PaymentBatchVO vo);
/**
* 배치 목록 조회 — settleMonth / status 필터, PageHelper 호환.
* 상태명(statusName) 포함, items 는 null.
*/
List<PaymentBatchResp> selectList(PaymentBatchSearchParam param);
/**
* 배치 단건 상세 조회.
* ResultMap 의 collection 으로 items(PaymentBatchItemResp) 자동 populate.
*/
PaymentBatchResp selectById(@Param("batchId") Long batchId);
/**
* 포함 건수 / 포함 payable 합계 재집계 UPDATE.
* total_count = COUNT(*) WHERE batch_id=? AND included=true
* total_payable = SUM(payable_amount) WHERE batch_id=? AND included=true
* updateItem 후 반드시 호출.
*/
int recalcTotals(@Param("batchId") Long batchId);
/**
* 배치 상태 변경 (DRAFT→FINALIZED / DRAFT→CANCELLED 등).
* FINALIZED 시 finalized_at=now(), finalized_by=userId 도 함께 기록.
*/
int updateStatus(@Param("batchId") Long batchId,
@Param("status") String status,
@Param("userId") Long userId);
/** 페이징 count 쿼리 */
int countByCondition(PaymentBatchSearchParam param);
}
@@ -0,0 +1,25 @@
package com.ga.core.vo.settle;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 지급대상건 API 응답 — 설계사명 조인 포함.
*/
@Data
public class PaymentBatchItemResp {
private Long itemId;
private Long batchId;
private Long settleId;
private Long agentId;
/** agent.agent_name 조인 */
private String agentName;
private BigDecimal payableAmount;
/** 포함 여부 */
private Boolean included;
/** 제외 사유 */
private String excludeReason;
private LocalDateTime createdAt;
}
@@ -0,0 +1,17 @@
package com.ga.core.vo.settle;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 지급대상건 포함/제외 수정 요청.
* included=false 시 excludeReason 필수는 Service 레이어에서 검증.
*/
@Data
public class PaymentBatchItemUpdateReq {
/** 포함 여부 */
@NotNull
private Boolean included;
/** 제외 사유 (included=false 시 권장) */
private String excludeReason;
}
@@ -0,0 +1,28 @@
package com.ga.core.vo.settle;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* payment_batch_item — 지급대상건 (V115)
* INSERT / UPDATE 전용. 조인 필드 없음.
*/
@Data
public class PaymentBatchItemVO {
private Long itemId;
/** FK payment_batch.batch_id */
private Long batchId;
/** FK settle_master.settle_id (스냅샷 원본 키) */
private Long settleId;
/** FK agent.agent_id */
private Long agentId;
/** settle_master.payable_amount 스냅샷 */
private BigDecimal payableAmount;
/** 포함 여부 (기본 true) */
private Boolean included;
/** 제외 사유 (included=false 시 기록) */
private String excludeReason;
private LocalDateTime createdAt;
}
@@ -0,0 +1,33 @@
package com.ga.core.vo.settle;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
/**
* 지급대상 배치 API 응답 — 상태명 조인 포함.
* selectById 시 items(List<PaymentBatchItemResp>)가 채워진다.
*/
@Data
public class PaymentBatchResp {
private Long batchId;
private String settleMonth;
private String batchName;
/** DRAFT / FINALIZED / CANCELLED */
private String status;
/** 공통코드 PAYMENT_BATCH_STATUS */
private String statusName;
private Integer totalCount;
private BigDecimal totalPayable;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime finalizedAt;
private Long finalizedBy;
/** users.user_name 조인 */
private String finalizedByName;
private LocalDateTime updatedAt;
/** 상세 조회 시 populate (목록 조회에서는 null) */
private List<PaymentBatchItemResp> items;
}
@@ -0,0 +1,19 @@
package com.ga.core.vo.settle;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 지급대상 배치 생성 요청.
* settleMonth: 배치 구성 대상 정산월 (settle_master 조회 기준)
* batchName: 배치 구분명 (동일월 복수 배치 허용)
*/
@Data
public class PaymentBatchSaveReq {
/** 정산월 (YYYYMM) */
@NotBlank
private String settleMonth;
/** 배치명 */
@NotBlank
private String batchName;
}
@@ -0,0 +1,16 @@
package com.ga.core.vo.settle;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 지급대상 배치 목록 검색 조건.
* status 는 부모 SearchParam 에 이미 있음.
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class PaymentBatchSearchParam extends SearchParam {
/** 정산월 (YYYYMM) */
private String settleMonth;
}
@@ -0,0 +1,11 @@
package com.ga.core.vo.settle;
/**
* 지급대상 배치 상태 코드 (common_code_group: PAYMENT_BATCH_STATUS, V115)
* DB payment_batch.status: DRAFT / FINALIZED / CANCELLED
*/
public enum PaymentBatchStatus {
DRAFT, // 작성중
FINALIZED, // 확정
CANCELLED; // 취소
}
@@ -0,0 +1,32 @@
package com.ga.core.vo.settle;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* payment_batch — 지급대상 배치 마스터 (V115)
* INSERT / UPDATE 전용. 조인 필드 없음.
*/
@Data
public class PaymentBatchVO {
private Long batchId;
/** 정산월 (YYYYMM) */
private String settleMonth;
/** 배치명 */
private String batchName;
/** DRAFT / FINALIZED / CANCELLED */
private String status;
/** 포함 건수 (included=true 합산) */
private Integer totalCount;
/** 포함 건 payable 합계 */
private BigDecimal totalPayable;
private LocalDateTime createdAt;
private Long createdBy;
/** 확정 일시 (FINALIZED 시 기록) */
private LocalDateTime finalizedAt;
/** 확정 처리자 (FINALIZED 시 기록) */
private Long finalizedBy;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,115 @@
<?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.settle.PaymentBatchItemMapper">
<!-- VO ResultMap: INSERT/UPDATE 전용 -->
<resultMap id="VOMap" type="com.ga.core.vo.settle.PaymentBatchItemVO"/>
<!-- Resp ResultMap: 설계사명 조인 포함 -->
<resultMap id="RespMap" type="com.ga.core.vo.settle.PaymentBatchItemResp">
<id property="itemId" column="item_id"/>
<result property="batchId" column="batch_id"/>
<result property="settleId" column="settle_id"/>
<result property="agentId" column="agent_id"/>
<result property="agentName" column="agent_name"/>
<result property="payableAmount" column="payable_amount"/>
<result property="included" column="included"/>
<result property="excludeReason" column="exclude_reason"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<!--
settle_master → payment_batch_item 일괄 INSERT-SELECT.
배치 생성(create) 시 단 1번의 SQL 로 지급대상 전체를 스냅샷.
조건:
settle_month = #{settleMonth}
payable_amount > 0 (지급할 금액이 있는 건만)
status != 'PAID' (이미 지급완료된 건 제외)
inserted 컬럼:
batch_id = #{batchId} (생성된 배치 PK)
settle_id = 원본 settle_master.settle_id
agent_id = 원본 settle_master.agent_id
payable_amount = 원본 settle_master.payable_amount (스냅샷)
included = true (기본 전체 포함)
created_at = NOW()
-->
<insert id="insertFromSettleMaster">
INSERT INTO payment_batch_item
(batch_id, settle_id, agent_id, payable_amount, included, created_at)
SELECT
#{batchId},
sm.settle_id,
sm.agent_id,
sm.payable_amount,
true,
NOW()
FROM settle_master sm
WHERE sm.settle_month = #{settleMonth}
AND sm.payable_amount > 0
AND sm.status != 'PAID'
</insert>
<!--
VO 리스트 일괄 INSERT (수동 구성 또는 단위 테스트 보조용).
insertFromSettleMaster 가 운영 메인 경로.
-->
<insert id="insertBatch">
INSERT INTO payment_batch_item
(batch_id, settle_id, agent_id, payable_amount, included, exclude_reason, created_at)
VALUES
<foreach collection="list" item="i" separator=",">
(#{i.batchId}, #{i.settleId}, #{i.agentId}, #{i.payableAmount},
#{i.included}, #{i.excludeReason}, NOW())
</foreach>
</insert>
<!--
배치 items 전체 조회 — 설계사명 조인 포함.
PaymentBatchMapper 의 RespDetailMap collection 에서 참조하며
Service 직접 호출도 지원.
정렬: 설계사 ID ASC (화면 일관성).
-->
<select id="selectByBatchId" resultMap="RespMap">
SELECT i.item_id,
i.batch_id,
i.settle_id,
i.agent_id,
a.agent_name,
i.payable_amount,
i.included,
i.exclude_reason,
i.created_at
FROM payment_batch_item i
LEFT JOIN agent a ON a.agent_id = i.agent_id
WHERE i.batch_id = #{batchId}
ORDER BY i.agent_id ASC, i.item_id ASC
</select>
<!--
단건 조회 (VO — 조인 없음).
Service 에서 batchId 로 DRAFT 여부 검증 후 updateInclude 호출 패턴.
-->
<select id="selectById" resultMap="VOMap">
SELECT item_id, batch_id, settle_id, agent_id,
payable_amount, included, exclude_reason, created_at
FROM payment_batch_item
WHERE item_id = #{itemId}
</select>
<!--
포함/제외 수정.
included=true 로 복원 시 exclude_reason 은 NULL 로 초기화.
included=false 시 전달된 excludeReason 기록.
호출 후 PaymentBatchMapper.recalcTotals(batchId) 반드시 실행.
-->
<update id="updateInclude">
UPDATE payment_batch_item
SET included = #{included},
exclude_reason = CASE WHEN #{included} = true THEN NULL ELSE #{excludeReason} END
WHERE item_id = #{itemId}
</update>
</mapper>
@@ -0,0 +1,163 @@
<?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.settle.PaymentBatchMapper">
<!-- VO ResultMap: INSERT/UPDATE 전용, 조인 없음 -->
<resultMap id="VOMap" type="com.ga.core.vo.settle.PaymentBatchVO"/>
<!--
목록 조회용 ResultMap: 상태명 포함, items 없음.
목록에서 items 를 채울 필요가 없으므로 collection 미포함.
-->
<resultMap id="RespListMap" type="com.ga.core.vo.settle.PaymentBatchResp">
<id property="batchId" column="batch_id"/>
<result property="settleMonth" column="settle_month"/>
<result property="batchName" column="batch_name"/>
<result property="status" column="status"/>
<result property="statusName" column="status_name"/>
<result property="totalCount" column="total_count"/>
<result property="totalPayable" column="total_payable"/>
<result property="createdAt" column="created_at"/>
<result property="createdBy" column="created_by"/>
<result property="finalizedAt" column="finalized_at"/>
<result property="finalizedBy" column="finalized_by"/>
<result property="finalizedByName" column="finalized_by_name"/>
<result property="updatedAt" column="updated_at"/>
</resultMap>
<!--
상세 조회용 ResultMap: RespListMap 의 모든 컬럼 +
items collection (PaymentBatchItemMapper.selectByBatchId 호출).
select N+1 방지를 위해 별도 mapper select 참조.
-->
<resultMap id="RespDetailMap" type="com.ga.core.vo.settle.PaymentBatchResp"
extends="RespListMap">
<collection property="items"
ofType="com.ga.core.vo.settle.PaymentBatchItemResp"
select="com.ga.core.mapper.settle.PaymentBatchItemMapper.selectByBatchId"
column="batch_id"/>
</resultMap>
<!-- 목록 SELECT 공통 컬럼 -->
<sql id="batchCols">
b.batch_id,
b.settle_month,
b.batch_name,
b.status,
(SELECT cc.code_name FROM common_code cc
WHERE cc.group_code = 'PAYMENT_BATCH_STATUS' AND cc.code = b.status) AS status_name,
b.total_count,
b.total_payable,
b.created_at,
b.created_by,
b.finalized_at,
b.finalized_by,
u.user_name AS finalized_by_name,
b.updated_at
</sql>
<!-- JOIN 절: finalized_by → users -->
<sql id="batchJoins">
FROM payment_batch b
LEFT JOIN users u ON u.user_id = b.finalized_by
</sql>
<!--
배치 생성 INSERT.
useGeneratedKeys=true 로 채번된 batch_id 를 vo.batchId 에 주입.
status 기본값 DRAFT, total_count/total_payable 기본 0.
-->
<insert id="insert" parameterType="com.ga.core.vo.settle.PaymentBatchVO"
useGeneratedKeys="true" keyProperty="batchId">
INSERT INTO payment_batch
(settle_month, batch_name, status, total_count, total_payable,
created_at, created_by)
VALUES
(#{settleMonth}, #{batchName}, 'DRAFT', 0, 0,
NOW(), #{createdBy})
</insert>
<!--
배치 목록 조회 — PageHelper startPage() 호환.
settleMonth / status (SearchParam 상속) 필터.
최신 순(batch_id DESC) 정렬.
-->
<select id="selectList" resultMap="RespListMap">
SELECT <include refid="batchCols"/>
<include refid="batchJoins"/>
<where>
<if test="settleMonth != null and settleMonth != ''">
AND b.settle_month = #{settleMonth}
</if>
<if test="status != null and status != ''">
AND b.status = #{status}
</if>
</where>
ORDER BY b.batch_id DESC
</select>
<!--
배치 단건 상세 조회.
RespDetailMap 의 collection 이 selectByBatchId 를 자동 호출해
items 목록을 채운다.
-->
<select id="selectById" resultMap="RespDetailMap">
SELECT <include refid="batchCols"/>
<include refid="batchJoins"/>
WHERE b.batch_id = #{batchId}
</select>
<!--
포함 건수 / 합계 재집계.
updateItem 호출 직후 반드시 실행.
total_count : included=true 인 항목 수
total_payable: included=true 인 항목의 payable_amount 합계 (없으면 0)
-->
<update id="recalcTotals">
UPDATE payment_batch
SET total_count = (
SELECT COUNT(*)
FROM payment_batch_item
WHERE batch_id = #{batchId}
AND included = true
),
total_payable = (
SELECT COALESCE(SUM(payable_amount), 0)
FROM payment_batch_item
WHERE batch_id = #{batchId}
AND included = true
),
updated_at = NOW()
WHERE batch_id = #{batchId}
</update>
<!--
배치 상태 변경.
FINALIZED 시에만 finalized_at / finalized_by 기록.
CANCELLED 등 다른 상태 전이 시에는 NULL 로 두지 않고 기존 값 유지.
-->
<update id="updateStatus">
UPDATE payment_batch
SET status = #{status},
finalized_at = CASE WHEN #{status} = 'FINALIZED' THEN NOW() ELSE finalized_at END,
finalized_by = CASE WHEN #{status} = 'FINALIZED' THEN #{userId} ELSE finalized_by END,
updated_at = NOW()
WHERE batch_id = #{batchId}
</update>
<!-- 페이징 count 쿼리 -->
<select id="countByCondition" resultType="int">
SELECT COUNT(*)
FROM payment_batch b
<where>
<if test="settleMonth != null and settleMonth != ''">
AND b.settle_month = #{settleMonth}
</if>
<if test="status != null and status != ''">
AND b.status = #{status}
</if>
</where>
</select>
</mapper>