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,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 */ }
}
}