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:
@@ -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>
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user