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,65 @@
# P12 — 지급대상건 구성·수정 (#2)
작성: PL, 2026-06-04. 후속 갭 3종 중 마지막.
근거: incar `지급대상건구성`/`지급대상건수정`/`지급대상건조회`. GA는 settle_master를 건별
confirm/hold/release만 가능, **정산월 단위 지급대상 배치 구성(일괄 포함/제외 후 확정)** 레이어 부재.
범위: 풀스택. 기존 settle_master 위의 **구성/스냅샷 레이어**(settle_master 상태는 본 범위에서 변경 안 함 — 결합 최소화). Flyway: **V114 최신 → V115**. 금액 NUMERIC(15,2).
## 1. 개념
- 정산월의 지급대상(settle_master, payable_amount>0)을 모아 **배치(payment_batch)** 로 구성.
- 각 건(payment_batch_item)을 **포함/제외**(사유) 편집 → 합계 재계산.
- **확정(finalize)**: 배치 잠금(FINALIZED). 확정된 포함건 목록 = 지급대상 확정본(스냅샷).
- AccountingClose(V75 DRAFT/마감/재오픈) 패턴 준용. **settle_master.status는 본 범위에서 미변경**(향후 finalize→지급실행 연계는 별도).
## 2. 스키마 (V115)
### payment_batch (지급대상 배치)
| 컬럼 | 타입 | 설명 |
|---|---|---|
| batch_id | BIGSERIAL PK | |
| settle_month | CHAR(6) NN | |
| batch_name | VARCHAR(100) NN | |
| status | VARCHAR(20) NN DEFAULT 'DRAFT' | DRAFT/FINALIZED/CANCELLED |
| total_count | INT NN DEFAULT 0 | 포함건수 |
| total_payable | NUMERIC(15,2) NN DEFAULT 0 | 포함건 payable 합 |
| created_at/created_by/finalized_at/finalized_by/updated_at | | |
- idx(settle_month, status). CHECK status IN('DRAFT','FINALIZED','CANCELLED').
### payment_batch_item (지급대상건)
| 컬럼 | 타입 | 설명 |
|---|---|---|
| item_id | BIGSERIAL PK | |
| batch_id | BIGINT NN FK payment_batch | |
| settle_id | BIGINT NN FK settle_master | |
| agent_id | BIGINT NN FK agent | |
| payable_amount | NUMERIC(15,2) NN | settle_master.payable_amount 스냅샷 |
| included | BOOLEAN NN DEFAULT true | 포함여부 |
| exclude_reason | VARCHAR(300) | 제외사유 |
| created_at | | |
- UNIQUE(batch_id, settle_id). idx(batch_id).
### 메뉴/권한/코드
- 메뉴 `PAYMENT_TARGET`(지급대상관리), GRP_SETTLE, `/settle/payment-target`, `settle/PaymentTarget`.
- 권한 READ/CREATE/UPDATE/EXECUTE(확정) + 역할(SUPER_ADMIN·ADMIN 전체, MANAGER READ).
- 공통코드 PAYMENT_BATCH_STATUS(DRAFT=작성중/FINALIZED=확정/CANCELLED=취소).
## 3. API (PaymentBatchService, `/api/payment-batches`)
- `POST` create(settleMonth, batchName) @Transactional: payment_batch(DRAFT) INSERT → settle_master 조회(settle_month=, payable_amount>0, status != 'PAID') → 각 행 payment_batch_item(included=true, payable 스냅샷) INSERT → total_count/total_payable 집계 UPDATE. 동일월 DRAFT 중복 생성 허용(이름으로 구분).
- `GET` list(settleMonth/status 필터, PageResponse) / `GET /{id}` detail(배치 + items).
- `PUT /{id}/items/{itemId}` updateItem(included, excludeReason) @Transactional: DRAFT 배치만(FINALIZED/CANCELLED 시 E411). 항목 갱신 후 배치 total 재집계.
- `PUT /{id}/finalize` @Transactional, @RequirePermission EXECUTE: DRAFT→FINALIZED, finalized_at/by. 포함건 0이면 E411.
- `PUT /{id}/cancel`: DRAFT/FINALIZED→CANCELLED.
- `GET /{id}/export` 엑셀(@Transactional).
- @RequirePermission(PAYMENT_TARGET, *) + @DataChangeLog(쓰기).
총계 재집계: `total_count = COUNT(included=true)`, `total_payable = SUM(payable WHERE included)` — Mapper에 recalcTotals(batchId) 제공.
## 4. Frontend (PaymentTarget.tsx)
- 배치 목록(정산월/상태 필터) + "구성" 버튼(정산월+배치명 입력 → create).
- 배치 행 클릭 → 상세: 배치 헤더(상태/포함건수/합계) + items AG Grid(설계사/payable/포함체크/제외사유). DRAFT일 때 포함토글·사유 편집(PermissionButton UPDATE) → updateItem. 합계행.
- "확정" 버튼(PermissionButton EXECUTE, DRAFT만) → finalize. "취소" 버튼. 엑셀.
- React Query, 변경 후 invalidate.
## 5. 검증(PL)
build+test, Flyway V115, 라이브: create(특정월) → items 자동생성 확인 → updateItem 제외 → total 재집계 확인 → finalize → 잠금(이후 updateItem E411) → cancel. 테스트 데이터 정리.
</content>
@@ -0,0 +1,84 @@
package com.ga.api.controller.settle;
import com.ga.api.service.settle.PaymentBatchService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.settle.PaymentBatchItemUpdateReq;
import com.ga.core.vo.settle.PaymentBatchResp;
import com.ga.core.vo.settle.PaymentBatchSaveReq;
import com.ga.core.vo.settle.PaymentBatchSearchParam;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@Tag(name = "지급대상 배치 관리")
@RestController
@RequestMapping("/api/payment-batches")
@RequiredArgsConstructor
public class PaymentBatchController {
private final PaymentBatchService service;
@PostMapping
@RequirePermission(menu = "PAYMENT_TARGET", perm = "CREATE")
@DataChangeLog(menu = "PAYMENT_TARGET", table = "payment_batch")
@Operation(summary = "배치 생성", description = "정산월의 지급대상(payable_amount>0) 건으로 DRAFT 배치를 구성합니다")
public ApiResponse<Long> create(@Valid @RequestBody PaymentBatchSaveReq req) {
return ApiResponse.ok(service.create(req));
}
@GetMapping
@RequirePermission(menu = "PAYMENT_TARGET", perm = "READ")
@Operation(summary = "배치 목록 조회")
public ApiResponse<PageResponse<PaymentBatchResp>> list(@ModelAttribute PaymentBatchSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{id}")
@RequirePermission(menu = "PAYMENT_TARGET", perm = "READ")
@Operation(summary = "배치 상세 조회", description = "배치 헤더 + items 포함")
public ApiResponse<PaymentBatchResp> detail(@PathVariable Long id) {
return ApiResponse.ok(service.detail(id));
}
@PutMapping("/{id}/items/{itemId}")
@RequirePermission(menu = "PAYMENT_TARGET", perm = "UPDATE")
@DataChangeLog(menu = "PAYMENT_TARGET", table = "payment_batch_item")
@Operation(summary = "지급대상건 포함/제외 수정", description = "DRAFT 배치만 수정 가능합니다")
public ApiResponse<Void> updateItem(@PathVariable Long id,
@PathVariable Long itemId,
@Valid @RequestBody PaymentBatchItemUpdateReq req) {
service.updateItem(id, itemId, req);
return ApiResponse.ok();
}
@PutMapping("/{id}/finalize")
@RequirePermission(menu = "PAYMENT_TARGET", perm = "EXECUTE")
@DataChangeLog(menu = "PAYMENT_TARGET", table = "payment_batch")
@Operation(summary = "배치 확정", description = "DRAFT → FINALIZED. 포함건이 없으면 확정 불가")
public ApiResponse<Void> finalize(@PathVariable Long id) {
service.finalize(id);
return ApiResponse.ok();
}
@PutMapping("/{id}/cancel")
@RequirePermission(menu = "PAYMENT_TARGET", perm = "UPDATE")
@DataChangeLog(menu = "PAYMENT_TARGET", table = "payment_batch")
@Operation(summary = "배치 취소")
public ApiResponse<Void> cancel(@PathVariable Long id) {
service.cancel(id);
return ApiResponse.ok();
}
@GetMapping("/{id}/export")
@RequirePermission(menu = "PAYMENT_TARGET", perm = "READ")
@Operation(summary = "배치 items 엑셀 다운로드")
public void export(@PathVariable Long id, HttpServletResponse response) {
service.export(id, response);
}
}
@@ -0,0 +1,194 @@
package com.ga.api.service.settle;
import com.ga.common.annotation.ExcelColumn;
import com.ga.common.excel.ExcelService;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.common.util.SecurityUtil;
import com.ga.core.mapper.settle.PaymentBatchItemMapper;
import com.ga.core.mapper.settle.PaymentBatchMapper;
import com.ga.core.vo.settle.PaymentBatchItemResp;
import com.ga.core.vo.settle.PaymentBatchItemUpdateReq;
import com.ga.core.vo.settle.PaymentBatchItemVO;
import com.ga.core.vo.settle.PaymentBatchResp;
import com.ga.core.vo.settle.PaymentBatchSaveReq;
import com.ga.core.vo.settle.PaymentBatchSearchParam;
import com.ga.core.vo.settle.PaymentBatchStatus;
import com.ga.core.vo.settle.PaymentBatchVO;
import jakarta.servlet.http.HttpServletResponse;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.apache.ibatis.cursor.Cursor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.Iterator;
import java.util.List;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PaymentBatchService {
private final PaymentBatchMapper batchMapper;
private final PaymentBatchItemMapper itemMapper;
private final ExcelService excelService;
/**
* 배치 생성.
* DRAFT 상태로 INSERT → 정산월 지급대상 items 일괄 INSERT → totals 재집계.
* @return 생성된 batchId
*/
@Transactional
public Long create(PaymentBatchSaveReq req) {
PaymentBatchVO vo = new PaymentBatchVO();
vo.setSettleMonth(req.getSettleMonth());
vo.setBatchName(req.getBatchName());
vo.setStatus(PaymentBatchStatus.DRAFT.name());
vo.setCreatedBy(SecurityUtil.getCurrentUserId());
batchMapper.insert(vo); // batchId 채번
Long batchId = vo.getBatchId();
itemMapper.insertFromSettleMaster(batchId, req.getSettleMonth());
batchMapper.recalcTotals(batchId);
return batchId;
}
public PageResponse<PaymentBatchResp> list(PaymentBatchSearchParam param) {
param.startPage();
return PageResponse.of(batchMapper.selectList(param));
}
public PaymentBatchResp detail(Long batchId) {
PaymentBatchResp resp = batchMapper.selectById(batchId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
/**
* 지급대상건 포함/제외 수정.
* DRAFT 배치만 허용. item이 해당 배치 소속인지 검증 후 갱신 → totals 재집계.
*/
@Transactional
public void updateItem(Long batchId, Long itemId, PaymentBatchItemUpdateReq req) {
PaymentBatchResp batch = batchMapper.selectById(batchId);
if (batch == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!PaymentBatchStatus.DRAFT.name().equals(batch.getStatus())) {
throw new BizException(ErrorCode.INVALID_STATUS, "확정/취소된 배치는 수정 불가");
}
PaymentBatchItemVO item = itemMapper.selectById(itemId);
if (item == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!batchId.equals(item.getBatchId())) {
throw new BizException(ErrorCode.INVALID_PARAMETER, "해당 배치 소속 항목이 아닙니다");
}
itemMapper.updateInclude(itemId, req.getIncluded(), req.getExcludeReason());
batchMapper.recalcTotals(batchId);
}
/**
* 배치 확정.
* DRAFT → FINALIZED. 포함건 0이면 거부.
*/
@Transactional
public void finalize(Long batchId) {
PaymentBatchResp batch = batchMapper.selectById(batchId);
if (batch == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!PaymentBatchStatus.DRAFT.name().equals(batch.getStatus())) {
throw new BizException(ErrorCode.INVALID_STATUS, "확정/취소된 배치는 수정 불가");
}
batchMapper.recalcTotals(batchId);
// totals 재조회 후 포함건 수 확인
PaymentBatchResp refreshed = batchMapper.selectById(batchId);
if (refreshed.getTotalCount() == null || refreshed.getTotalCount() == 0) {
throw new BizException(ErrorCode.INVALID_STATUS, "포함건 없음");
}
batchMapper.updateStatus(batchId, PaymentBatchStatus.FINALIZED.name(), SecurityUtil.getCurrentUserId());
}
/**
* 배치 취소.
* 이미 CANCELLED면 거부.
*/
@Transactional
public void cancel(Long batchId) {
PaymentBatchResp batch = batchMapper.selectById(batchId);
if (batch == null) throw new BizException(ErrorCode.NOT_FOUND);
if (PaymentBatchStatus.CANCELLED.name().equals(batch.getStatus())) {
throw new BizException(ErrorCode.INVALID_STATUS, "이미 취소된 배치입니다");
}
batchMapper.updateStatus(batchId, PaymentBatchStatus.CANCELLED.name(), SecurityUtil.getCurrentUserId());
}
/**
* 배치 items 엑셀 다운로드.
*/
@Transactional(readOnly = true)
public void export(Long batchId, HttpServletResponse response) {
PaymentBatchResp batch = batchMapper.selectById(batchId);
if (batch == null) throw new BizException(ErrorCode.NOT_FOUND);
List<PaymentBatchItemResp> items = itemMapper.selectByBatchId(batchId);
List<ItemExcelRow> rows = items.stream().map(ItemExcelRow::from).toList();
excelService.exportLargeExcel(
"지급대상건_" + batch.getSettleMonth(),
ItemExcelRow.class,
() -> new ListCursor<>(rows),
response
);
}
// ── 엑셀 출력용 내부 VO ───────────────────────────────────────────────
@Data
public static class ItemExcelRow {
@ExcelColumn(header = "항목ID", order = 1, width = 10)
private Long itemId;
@ExcelColumn(header = "설계사명", order = 2, width = 14)
private String agentName;
@ExcelColumn(header = "설계사ID", order = 3, width = 10)
private Long agentId;
@ExcelColumn(header = "지급가능금액", order = 4, width = 16)
private BigDecimal payableAmount;
@ExcelColumn(header = "포함여부", order = 5, width = 10)
private Boolean included;
@ExcelColumn(header = "제외사유", order = 6, width = 30)
private String excludeReason;
static ItemExcelRow from(PaymentBatchItemResp r) {
ItemExcelRow row = new ItemExcelRow();
row.setItemId(r.getItemId());
row.setAgentName(r.getAgentName());
row.setAgentId(r.getAgentId());
row.setPayableAmount(r.getPayableAmount());
row.setIncluded(r.getIncluded());
row.setExcludeReason(r.getExcludeReason());
return row;
}
}
/** List를 MyBatis Cursor 인터페이스로 감싸는 어댑터. */
private static class ListCursor<T> implements Cursor<T> {
private final List<T> list;
ListCursor(List<T> list) { this.list = list; }
@Override public boolean isOpen() { return true; }
@Override public boolean isConsumed() { return false; }
@Override public int getCurrentIndex() { return -1; }
@Override public Iterator<T> iterator() { return list.iterator(); }
@Override public void close() { /* no-op */ }
}
}
@@ -0,0 +1,112 @@
-- V115: 지급대상건 구성·수정 (P12 §2)
-- payment_batch / payment_batch_item 테이블
-- 메뉴 PAYMENT_TARGET (GRP_SETTLE 하위, sort_order 120)
-- 권한 READ/CREATE/UPDATE/EXECUTE + 역할부여 (V113 패턴 동일)
-- 공통코드 PAYMENT_BATCH_STATUS (V96/V111 패턴, ON CONFLICT DO NOTHING 멱등)
-- ============================================================
-- 1. payment_batch (지급대상 배치)
-- ============================================================
CREATE TABLE payment_batch (
batch_id BIGSERIAL PRIMARY KEY,
settle_month CHAR(6) NOT NULL,
batch_name VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT',
total_count INT NOT NULL DEFAULT 0,
total_payable NUMERIC(15,2) NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT now(),
created_by BIGINT,
finalized_at TIMESTAMP,
finalized_by BIGINT,
updated_at TIMESTAMP,
CONSTRAINT chk_payment_batch_status CHECK (status IN ('DRAFT', 'FINALIZED', 'CANCELLED'))
);
CREATE INDEX idx_payment_batch_month_status ON payment_batch (settle_month, status);
-- ============================================================
-- 2. payment_batch_item (지급대상건)
-- ============================================================
CREATE TABLE payment_batch_item (
item_id BIGSERIAL PRIMARY KEY,
batch_id BIGINT NOT NULL REFERENCES payment_batch(batch_id),
settle_id BIGINT NOT NULL REFERENCES settle_master(settle_id),
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
payable_amount NUMERIC(15,2) NOT NULL,
included BOOLEAN NOT NULL DEFAULT true,
exclude_reason VARCHAR(300),
created_at TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT uq_payment_batch_item UNIQUE (batch_id, settle_id)
);
CREATE INDEX idx_payment_batch_item_batch ON payment_batch_item (batch_id);
-- ============================================================
-- 3. 메뉴 INSERT: PAYMENT_TARGET (GRP_SETTLE 하위 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
('PAYMENT_TARGET', '지급대상관리', '/settle/payment-target', 'settle/PaymentTarget', 120)
) AS x(code, name, path, cmpt, sort)
ON p.menu_code = 'GRP_SETTLE'
WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code);
-- ============================================================
-- 4. 메뉴 권한 항목 등록 (READ / CREATE / UPDATE / EXECUTE)
-- ============================================================
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),
('CREATE', '등록', 20),
('UPDATE', '수정', 30),
('EXECUTE', '확정', 40)
) AS p(code, name, sort)
WHERE m.menu_code = 'PAYMENT_TARGET'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
-- ============================================================
-- 5. 역할별 권한 부여
-- ============================================================
-- SUPER_ADMIN / ADMIN — 전체 권한 (READ / CREATE / UPDATE / EXECUTE)
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')
AND m.menu_code = 'PAYMENT_TARGET'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- MANAGER — READ 만
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 = 'MANAGER'
AND mp.perm_code = 'READ'
AND m.menu_code = 'PAYMENT_TARGET'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- ============================================================
-- 6. 공통코드: PAYMENT_BATCH_STATUS
-- ============================================================
INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
('PAYMENT_BATCH_STATUS', '지급배치상태', 'DRAFT=작성중/FINALIZED=확정/CANCELLED=취소', 'Y', 450)
ON CONFLICT (group_code) DO NOTHING;
INSERT INTO common_code (group_code, code, code_name, sort_order) VALUES
('PAYMENT_BATCH_STATUS', 'DRAFT', '작성중', 10),
('PAYMENT_BATCH_STATUS', 'FINALIZED', '확정', 20),
('PAYMENT_BATCH_STATUS', 'CANCELLED', '취소', 30)
ON CONFLICT (group_code, code) DO NOTHING;
@@ -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>
+6
View File
@@ -36,6 +36,9 @@ const ClawbackReceivable = React.lazy(() => import('@/pages/commission/Claw
// P10: 환수자료관리
const ChargebackData = React.lazy(() => import('@/pages/settle/ChargebackData'));
// P12: 지급대상 배치 구성
const PaymentTarget = React.lazy(() => import('@/pages/settle/PaymentTarget'));
// P7: 수수료 계산 7도메인
const IncomeCommission = React.lazy(() => import('@/pages/commission/IncomeCommission'));
const ReferralCommission = React.lazy(() => import('@/pages/commission/ReferralCommission'));
@@ -236,6 +239,9 @@ export default function App() {
{/* P10: 환수자료관리 */}
<Route path="settle/chargeback-data" element={<ChargebackData />} />
{/* P12: 지급대상 배치 구성 */}
<Route path="settle/payment-target" element={<PaymentTarget />} />
{/* P7: 수수료 계산 7도메인 */}
<Route path="commission/income" element={<IncomeCommission />} />
<Route path="commission/referral" element={<ReferralCommission />} />
+77
View File
@@ -0,0 +1,77 @@
import api, { PageResponse, unwrap } from './request';
// ── Types ──────────────────────────────────────────────────────────────────
export type PaymentBatchStatus = 'DRAFT' | 'FINALIZED' | 'CANCELLED';
export interface PaymentBatchResp extends Record<string, unknown> {
batchId: number;
settleMonth: string;
batchName: string;
status: PaymentBatchStatus;
statusName: string;
totalCount: number;
totalPayable: number;
finalizedAt?: string;
createdAt: string;
}
export interface PaymentBatchItemResp extends Record<string, unknown> {
itemId: number;
settleId: number;
agentId: number;
agentName: string;
payableAmount: number;
included: boolean;
excludeReason?: string;
}
export interface PaymentBatchDetailResp extends PaymentBatchResp {
items: PaymentBatchItemResp[];
}
export interface PaymentBatchCreateReq {
settleMonth: string;
batchName: string;
}
export interface PaymentBatchItemUpdateReq {
included: boolean;
excludeReason?: string;
}
export interface PaymentBatchSearchParam {
settleMonth?: string;
status?: string;
pageNum?: number;
pageSize?: number;
}
// ── API ────────────────────────────────────────────────────────────────────
export const paymentBatchApi = {
list: (p: PaymentBatchSearchParam) =>
unwrap<PageResponse<PaymentBatchResp>>(
api.get('/api/payment-batches', { params: p }),
),
detail: (id: number) =>
unwrap<PaymentBatchDetailResp>(
api.get(`/api/payment-batches/${id}`),
),
create: (req: PaymentBatchCreateReq) =>
unwrap<number>(api.post('/api/payment-batches', req)),
updateItem: (id: number, itemId: number, req: PaymentBatchItemUpdateReq) =>
unwrap<void>(api.put(`/api/payment-batches/${id}/items/${itemId}`, req)),
finalize: (id: number) =>
unwrap<void>(api.put(`/api/payment-batches/${id}/finalize`)),
cancel: (id: number) =>
unwrap<void>(api.put(`/api/payment-batches/${id}/cancel`)),
export: (id: number) =>
api.get(`/api/payment-batches/${id}/export`, { responseType: 'blob' }),
};
@@ -0,0 +1,577 @@
import { useState } from 'react';
import {
Drawer, Form, Input, Modal, Space, Tag, Typography, message,
} from 'antd';
import { FileExcelOutlined } from '@ant-design/icons';
import { ProCard } from '@ant-design/pro-components';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { ColDef, ICellRendererParams } from '@ag-grid-community/core';
import dayjs from 'dayjs';
import PageContainer from '@/components/common/PageContainer';
import SearchForm from '@/components/common/SearchForm';
import DataGrid from '@/components/common/DataGrid';
import PermissionButton from '@/components/common/PermissionButton';
import {
paymentBatchApi,
PaymentBatchResp,
PaymentBatchItemResp,
PaymentBatchStatus,
} from '@/api/paymentBatch';
import {
GRAY, RADIUS, SHADOW,
COLOR_PRIMARY, COLOR_SUCCESS, COLOR_WARNING, COLOR_ERROR,
} from '@/theme/tokens';
const { Text } = Typography;
// ── 상수 ──────────────────────────────────────────────────────────────────
const MENU_CODE = 'PAYMENT_TARGET';
const STATUS_META: Record<
PaymentBatchStatus,
{ label: string; color: string; bg: string; border: string }
> = {
DRAFT: { label: '작성중', color: COLOR_WARNING, bg: '#FFF6E6', border: '#F6D08A' },
FINALIZED: { label: '확정', color: COLOR_SUCCESS, bg: '#E8FAF3', border: '#A3E6C8' },
CANCELLED: { label: '취소', color: COLOR_ERROR, bg: '#FEF0F1', border: '#F9B0B6' },
};
function BatchStatusTag({ status, statusName }: { status: string; statusName?: string }) {
const meta = STATUS_META[status as PaymentBatchStatus];
if (!meta) return <Tag>{statusName ?? status}</Tag>;
return (
<Tag
style={{
color: meta.color,
background: meta.bg,
border: `1px solid ${meta.border}`,
borderRadius: RADIUS.sm,
fontWeight: 600,
fontSize: 12,
margin: 0,
padding: '0 8px',
height: 22,
lineHeight: '20px',
}}
>
{statusName ?? meta.label}
</Tag>
);
}
// YYYYMM 유효성
function isValidYYYYMM(v: string): boolean {
if (!/^\d{6}$/.test(v)) return false;
const year = parseInt(v.slice(0, 4), 10);
const month = parseInt(v.slice(4, 6), 10);
return year >= 2000 && year <= 2099 && month >= 1 && month <= 12;
}
const fmtMoney = (v: unknown) =>
typeof v === 'number' ? v.toLocaleString() : (v as string | undefined) ?? '-';
const fmtDt = (v?: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-');
// ── 배치 목록 컬럼 ────────────────────────────────────────────────────────
const BATCH_COLS: ColDef<PaymentBatchResp>[] = [
{ field: 'batchName', headerName: '배치명', flex: 2, pinned: 'left' },
{ field: 'settleMonth', headerName: '정산월', width: 110 },
{
field: 'status',
headerName: '상태',
width: 110,
cellRenderer: (p: ICellRendererParams<PaymentBatchResp>) =>
p.data ? (
<BatchStatusTag status={p.data.status} statusName={p.data.statusName} />
) : null,
},
{
field: 'totalCount',
headerName: '포함건수',
width: 110,
type: 'numericColumn',
valueFormatter: (p) => `${p.value ?? 0}`,
},
{
field: 'totalPayable',
headerName: '지급합계',
flex: 1,
type: 'numericColumn',
valueFormatter: (p) => `${fmtMoney(p.value)}`,
},
{
field: 'finalizedAt',
headerName: '확정일시',
width: 150,
valueFormatter: (p) => fmtDt(p.value as string | undefined),
},
{
field: 'createdAt',
headerName: '생성일시',
width: 150,
valueFormatter: (p) => fmtDt(p.value as string | undefined),
},
];
// ── 배치 아이템 컬럼 (상세 Drawer) ────────────────────────────────────────
function buildItemCols(
isDraft: boolean,
onToggleIncluded: (item: PaymentBatchItemResp) => void,
): ColDef<PaymentBatchItemResp>[] {
return [
{ field: 'agentName', headerName: '설계사', width: 130, pinned: 'left' },
{
field: 'payableAmount',
headerName: '지급액',
flex: 1,
type: 'numericColumn',
valueFormatter: (p) => `${fmtMoney(p.value)}`,
},
{
field: 'included',
headerName: '포함',
width: 80,
cellRenderer: (p: ICellRendererParams<PaymentBatchItemResp>) => {
if (!p.data) return null;
const item = p.data;
if (isDraft) {
return (
<input
type="checkbox"
checked={item.included}
style={{ cursor: 'pointer', width: 16, height: 16 }}
onChange={() => onToggleIncluded(item)}
/>
);
}
return (
<input
type="checkbox"
checked={item.included}
disabled
style={{ width: 16, height: 16 }}
/>
);
},
},
{
field: 'excludeReason',
headerName: '제외사유',
flex: 2,
valueFormatter: (p) => (p.value as string | undefined) ?? '',
},
];
}
// ── 메인 컴포넌트 ──────────────────────────────────────────────────────────
export default function PaymentTarget() {
const qc = useQueryClient();
// 검색 파라미터
const [searchParams, setSearchParams] = useState<{
settleMonth?: string;
status?: string;
}>({});
// 상세 Drawer
const [selectedBatchId, setSelectedBatchId] = useState<number | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
// 구성 모달
const [createModalOpen, setCreateModalOpen] = useState(false);
const [createForm] = Form.useForm();
// 제외사유 편집 모달
const [reasonModal, setReasonModal] = useState<{
open: boolean;
item?: PaymentBatchItemResp;
pendingIncluded?: boolean;
}>({ open: false });
const [reasonForm] = Form.useForm();
// ── 목록 조회 ────────────────────────────────────────
const { data: listData, isLoading: listLoading } = useQuery({
queryKey: ['payment-batches', searchParams],
queryFn: () => paymentBatchApi.list({ ...searchParams, pageSize: 200 }),
retry: false,
});
// ── 상세 조회 ────────────────────────────────────────
const { data: detail, isLoading: detailLoading } = useQuery({
queryKey: ['payment-batch', selectedBatchId],
queryFn: () => paymentBatchApi.detail(selectedBatchId!),
enabled: !!selectedBatchId,
retry: false,
});
// ── 생성 뮤테이션 ────────────────────────────────────
const createMutation = useMutation({
mutationFn: paymentBatchApi.create,
onSuccess: (newId) => {
message.success('배치가 생성되었습니다');
qc.invalidateQueries({ queryKey: ['payment-batches'] });
setCreateModalOpen(false);
createForm.resetFields();
setSelectedBatchId(newId);
setDrawerOpen(true);
},
});
// ── 아이템 업데이트 뮤테이션 ─────────────────────────
const updateItemMutation = useMutation({
mutationFn: ({ itemId, included, excludeReason }: {
itemId: number;
included: boolean;
excludeReason?: string;
}) => paymentBatchApi.updateItem(selectedBatchId!, itemId, { included, excludeReason }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['payment-batch', selectedBatchId] });
qc.invalidateQueries({ queryKey: ['payment-batches'] });
},
});
// ── 확정 뮤테이션 ────────────────────────────────────
const finalizeMutation = useMutation({
mutationFn: () => paymentBatchApi.finalize(selectedBatchId!),
onSuccess: () => {
message.success('배치가 확정되었습니다');
qc.invalidateQueries({ queryKey: ['payment-batch', selectedBatchId] });
qc.invalidateQueries({ queryKey: ['payment-batches'] });
},
});
// ── 취소 뮤테이션 ────────────────────────────────────
const cancelMutation = useMutation({
mutationFn: () => paymentBatchApi.cancel(selectedBatchId!),
onSuccess: () => {
message.success('배치가 취소되었습니다');
qc.invalidateQueries({ queryKey: ['payment-batch', selectedBatchId] });
qc.invalidateQueries({ queryKey: ['payment-batches'] });
},
});
// ── 엑셀 다운로드 ────────────────────────────────────
const handleExport = async () => {
if (!selectedBatchId) return;
try {
const res = await paymentBatchApi.export(selectedBatchId);
const url = URL.createObjectURL(new Blob([res.data as BlobPart]));
const a = document.createElement('a');
a.href = url;
a.download = `지급대상_${detail?.settleMonth ?? ''}_${dayjs().format('YYYYMMDD')}.xlsx`;
a.click();
URL.revokeObjectURL(url);
} catch {
/* request.ts 에서 처리 */
}
};
// ── 포함 체크박스 토글 핸들러 ────────────────────────
const handleToggleIncluded = (item: PaymentBatchItemResp) => {
// 포함 → 제외 전환 시 사유 입력 모달 표시
if (item.included) {
reasonForm.setFieldsValue({ excludeReason: item.excludeReason ?? '' });
setReasonModal({ open: true, item, pendingIncluded: false });
} else {
// 제외 → 포함 전환은 사유 없이 바로 처리
updateItemMutation.mutate({
itemId: item.itemId,
included: true,
excludeReason: undefined,
});
}
};
const handleReasonSubmit = async () => {
if (!reasonModal.item) return;
const values = await reasonForm.validateFields();
updateItemMutation.mutate({
itemId: reasonModal.item.itemId,
included: false,
excludeReason: values.excludeReason || undefined,
});
setReasonModal({ open: false });
reasonForm.resetFields();
};
const handleCreateSubmit = async () => {
const values = await createForm.validateFields();
createMutation.mutate({
settleMonth: values.settleMonth,
batchName: values.batchName,
});
};
const rows = listData?.list ?? [];
const isDraft = detail?.status === 'DRAFT';
// 합계행
const batchPinnedBottom =
rows.length > 0
? ({
batchName: '합계',
totalCount: rows.reduce((s, r) => s + (r.totalCount ?? 0), 0),
totalPayable: rows.reduce((s, r) => s + (r.totalPayable ?? 0), 0),
} as Partial<PaymentBatchResp>)
: undefined;
const items = detail?.items ?? [];
const itemPinnedBottom =
items.length > 0
? ({
agentName: '합계',
payableAmount: items
.filter((i) => i.included)
.reduce((s, i) => s + (i.payableAmount ?? 0), 0),
} as Partial<PaymentBatchItemResp>)
: undefined;
const itemCols = buildItemCols(isDraft, handleToggleIncluded);
const cardStyle = {
background: '#fff',
borderRadius: RADIUS.lg,
border: `1px solid ${GRAY[100]}`,
boxShadow: SHADOW.sm,
};
return (
<PageContainer
title="지급대상 관리"
description="정산월 지급대상 배치 구성 · 편집 · 확정"
>
{/* ── 검색 폼 ─────────────────────────────────── */}
<SearchForm
conditions={[
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
{
type: 'code',
name: 'status',
label: '상태',
span: 6,
groupCode: 'PAYMENT_BATCH_STATUS',
},
]}
onSearch={(v) => setSearchParams(v as { settleMonth?: string; status?: string })}
onReset={() => setSearchParams({})}
/>
{/* ── 배치 목록 그리드 ─────────────────────────── */}
<ProCard
style={cardStyle}
extra={
<PermissionButton
menuCode={MENU_CODE}
permCode="CREATE"
type="primary"
onClick={() => {
createForm.resetFields();
setCreateModalOpen(true);
}}
>
+
</PermissionButton>
}
title={
<Text style={{ fontWeight: 600, color: GRAY[700], fontSize: 15 }}>
</Text>
}
>
<DataGrid<PaymentBatchResp>
rows={rows}
columns={BATCH_COLS}
loading={listLoading}
height={400}
rowKey="batchId"
pinnedBottomRow={batchPinnedBottom}
onSelectionChanged={(e) => {
const sel = e.api.getSelectedRows();
if (sel.length > 0) {
setSelectedBatchId(sel[0].batchId);
setDrawerOpen(true);
}
}}
/>
</ProCard>
{/* ── 상세 Drawer ──────────────────────────────── */}
<Drawer
title={
detail
? `${detail.settleMonth}${detail.batchName}`
: '배치 상세'
}
width={760}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
<Space>
<PermissionButton
menuCode={MENU_CODE}
permCode="READ"
icon={<FileExcelOutlined />}
onClick={handleExport}
disabled={!detail}
>
</PermissionButton>
<PermissionButton
menuCode={MENU_CODE}
permCode="UPDATE"
danger
disabled={!detail || detail.status === 'CANCELLED'}
loading={cancelMutation.isPending}
onClick={() => {
Modal.confirm({
title: '배치를 취소하시겠습니까?',
okText: '취소 처리',
okType: 'danger',
cancelText: '닫기',
onOk: () => cancelMutation.mutateAsync(),
});
}}
>
</PermissionButton>
<PermissionButton
menuCode={MENU_CODE}
permCode="EXECUTE"
type="primary"
disabled={!detail || !isDraft}
loading={finalizeMutation.isPending}
onClick={() => {
Modal.confirm({
title: '배치를 확정하시겠습니까?',
content: '확정 후에는 포함 항목을 수정할 수 없습니다.',
okText: '확정',
cancelText: '취소',
onOk: () => finalizeMutation.mutateAsync(),
});
}}
>
</PermissionButton>
</Space>
}
>
{/* 배치 헤더 요약 */}
{detail && (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: 12,
marginBottom: 16,
padding: '12px 16px',
background: GRAY[50],
borderRadius: RADIUS.md,
border: `1px solid ${GRAY[100]}`,
}}
>
<HeaderStat label="상태" value={<BatchStatusTag status={detail.status} statusName={detail.statusName} />} />
<HeaderStat label="포함건수" value={`${detail.totalCount.toLocaleString()}`} />
<HeaderStat label="지급합계" value={`${fmtMoney(detail.totalPayable)}`} />
</div>
)}
{/* 아이템 그리드 */}
{detailLoading ? (
<Text style={{ color: GRAY[400] }}> ...</Text>
) : (
<DataGrid<PaymentBatchItemResp>
rows={items}
columns={itemCols}
loading={detailLoading}
height={500}
rowKey="itemId"
pinnedBottomRow={itemPinnedBottom}
/>
)}
{/* DRAFT 안내 */}
{isDraft && (
<Text
style={{
display: 'block',
marginTop: 8,
fontSize: 12,
color: GRAY[500],
}}
>
/ .
</Text>
)}
</Drawer>
{/* ── 배치 구성 모달 ────────────────────────────── */}
<Modal
title="지급대상 배치 구성"
open={createModalOpen}
onOk={handleCreateSubmit}
onCancel={() => { setCreateModalOpen(false); createForm.resetFields(); }}
okText="구성"
cancelText="취소"
confirmLoading={createMutation.isPending}
destroyOnClose
>
<Form form={createForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="settleMonth"
label="정산월"
rules={[
{ required: true, message: '정산월을 입력하세요' },
{
validator: (_, v) =>
!v || isValidYYYYMM(v)
? Promise.resolve()
: Promise.reject(new Error('YYYYMM 6자리로 입력하세요 (예: 202501)')),
},
]}
>
<Input placeholder="예: 202501" maxLength={6} style={{ width: 180 }} />
</Form.Item>
<Form.Item
name="batchName"
label="배치명"
rules={[{ required: true, message: '배치명을 입력하세요' }]}
>
<Input placeholder="예: 2025년 01월 지급대상" maxLength={100} />
</Form.Item>
</Form>
</Modal>
{/* ── 제외사유 입력 모달 ────────────────────────── */}
<Modal
title="제외 처리"
open={reasonModal.open}
onOk={handleReasonSubmit}
onCancel={() => { setReasonModal({ open: false }); reasonForm.resetFields(); }}
okText="제외"
okType="danger"
cancelText="취소"
confirmLoading={updateItemMutation.isPending}
destroyOnClose
>
<div style={{ marginBottom: 12, color: GRAY[600], fontSize: 13 }}>
: <strong>{reasonModal.item?.agentName}</strong>
</div>
<Form form={reasonForm} layout="vertical">
<Form.Item name="excludeReason" label="제외사유">
<Input.TextArea rows={3} placeholder="제외사유를 입력하세요 (선택)" maxLength={300} showCount />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
// ── 헤더 요약 아이템 ──────────────────────────────────────────────────────
function HeaderStat({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div>
<Text style={{ fontSize: 12, color: GRAY[500], display: 'block' }}>{label}</Text>
<Text style={{ fontSize: 14, fontWeight: 600, color: GRAY[800] }}>{value}</Text>
</div>
);
}