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,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 */ }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user