From 00ead64c4c81dddb2d465175e5e2903ca81ed359 Mon Sep 17 00:00:00 2001 From: GA Pro Date: Fri, 5 Jun 2026 01:10:50 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20P12(#2)=20=EC=A7=80=EA=B8=89=EB=8C=80?= =?UTF-8?q?=EC=83=81=EA=B1=B4=20=EA=B5=AC=EC=84=B1=C2=B7=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=E2=80=94=20=EC=A0=95=EC=82=B0=EC=9B=94=20=EC=A7=80=EA=B8=89?= =?UTF-8?q?=EB=8C=80=EC=83=81=20=EB=B0=B0=EC=B9=98=20=EA=B5=AC=EC=84=B1/?= =?UTF-8?q?=ED=99=95=EC=A0=95=20(PL=20=EC=9E=90=EC=9C=A8=EC=A7=84=ED=96=89?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/DOMAIN_GAP_P12_지급대상건구성.md | 65 ++ .../settle/PaymentBatchController.java | 84 +++ .../service/settle/PaymentBatchService.java | 194 ++++++ .../db/migration/V115__지급대상배치.sql | 112 ++++ .../mapper/settle/PaymentBatchItemMapper.java | 48 ++ .../mapper/settle/PaymentBatchMapper.java | 53 ++ .../core/vo/settle/PaymentBatchItemResp.java | 25 + .../vo/settle/PaymentBatchItemUpdateReq.java | 17 + .../ga/core/vo/settle/PaymentBatchItemVO.java | 28 + .../ga/core/vo/settle/PaymentBatchResp.java | 33 + .../core/vo/settle/PaymentBatchSaveReq.java | 19 + .../vo/settle/PaymentBatchSearchParam.java | 16 + .../ga/core/vo/settle/PaymentBatchStatus.java | 11 + .../com/ga/core/vo/settle/PaymentBatchVO.java | 32 + .../mapper/settle/PaymentBatchItemMapper.xml | 115 ++++ .../mapper/settle/PaymentBatchMapper.xml | 163 +++++ ga-frontend/src/App.tsx | 6 + ga-frontend/src/api/paymentBatch.ts | 77 +++ .../src/pages/settle/PaymentTarget.tsx | 577 ++++++++++++++++++ 19 files changed, 1675 insertions(+) create mode 100644 docs/DOMAIN_GAP_P12_지급대상건구성.md create mode 100644 ga-api/src/main/java/com/ga/api/controller/settle/PaymentBatchController.java create mode 100644 ga-api/src/main/java/com/ga/api/service/settle/PaymentBatchService.java create mode 100644 ga-common/src/main/resources/db/migration/V115__지급대상배치.sql create mode 100644 ga-core/src/main/java/com/ga/core/mapper/settle/PaymentBatchItemMapper.java create mode 100644 ga-core/src/main/java/com/ga/core/mapper/settle/PaymentBatchMapper.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchItemResp.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchItemUpdateReq.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchItemVO.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchResp.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchSaveReq.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchSearchParam.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchStatus.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchVO.java create mode 100644 ga-core/src/main/resources/mapper/settle/PaymentBatchItemMapper.xml create mode 100644 ga-core/src/main/resources/mapper/settle/PaymentBatchMapper.xml create mode 100644 ga-frontend/src/api/paymentBatch.ts create mode 100644 ga-frontend/src/pages/settle/PaymentTarget.tsx diff --git a/docs/DOMAIN_GAP_P12_지급대상건구성.md b/docs/DOMAIN_GAP_P12_지급대상건구성.md new file mode 100644 index 0000000..c431ebf --- /dev/null +++ b/docs/DOMAIN_GAP_P12_지급대상건구성.md @@ -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. 테스트 데이터 정리. + diff --git a/ga-api/src/main/java/com/ga/api/controller/settle/PaymentBatchController.java b/ga-api/src/main/java/com/ga/api/controller/settle/PaymentBatchController.java new file mode 100644 index 0000000..77dc984 --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/settle/PaymentBatchController.java @@ -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 create(@Valid @RequestBody PaymentBatchSaveReq req) { + return ApiResponse.ok(service.create(req)); + } + + @GetMapping + @RequirePermission(menu = "PAYMENT_TARGET", perm = "READ") + @Operation(summary = "배치 목록 조회") + public ApiResponse> list(@ModelAttribute PaymentBatchSearchParam param) { + return ApiResponse.ok(service.list(param)); + } + + @GetMapping("/{id}") + @RequirePermission(menu = "PAYMENT_TARGET", perm = "READ") + @Operation(summary = "배치 상세 조회", description = "배치 헤더 + items 포함") + public ApiResponse 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 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 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 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); + } +} diff --git a/ga-api/src/main/java/com/ga/api/service/settle/PaymentBatchService.java b/ga-api/src/main/java/com/ga/api/service/settle/PaymentBatchService.java new file mode 100644 index 0000000..6180b4b --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/settle/PaymentBatchService.java @@ -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 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 items = itemMapper.selectByBatchId(batchId); + List 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 implements Cursor { + private final List list; + ListCursor(List 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 iterator() { return list.iterator(); } + @Override public void close() { /* no-op */ } + } +} diff --git a/ga-common/src/main/resources/db/migration/V115__지급대상배치.sql b/ga-common/src/main/resources/db/migration/V115__지급대상배치.sql new file mode 100644 index 0000000..2016764 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V115__지급대상배치.sql @@ -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; diff --git a/ga-core/src/main/java/com/ga/core/mapper/settle/PaymentBatchItemMapper.java b/ga-core/src/main/java/com/ga/core/mapper/settle/PaymentBatchItemMapper.java new file mode 100644 index 0000000..0e2251f --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/mapper/settle/PaymentBatchItemMapper.java @@ -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 list); + + /** + * 배치 ID 기준 전체 items 조회 — 설계사명(agentName) 조인 포함. + * PaymentBatchMapper.selectById 의 collection 에서도 참조. + */ + List 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); +} diff --git a/ga-core/src/main/java/com/ga/core/mapper/settle/PaymentBatchMapper.java b/ga-core/src/main/java/com/ga/core/mapper/settle/PaymentBatchMapper.java new file mode 100644 index 0000000..ab654bc --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/mapper/settle/PaymentBatchMapper.java @@ -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 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); +} diff --git a/ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchItemResp.java b/ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchItemResp.java new file mode 100644 index 0000000..4dbe7cd --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchItemResp.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchItemUpdateReq.java b/ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchItemUpdateReq.java new file mode 100644 index 0000000..5c2baf1 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchItemUpdateReq.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchItemVO.java b/ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchItemVO.java new file mode 100644 index 0000000..9000045 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchItemVO.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchResp.java b/ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchResp.java new file mode 100644 index 0000000..2d14c35 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchResp.java @@ -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)가 채워진다. + */ +@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 items; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchSaveReq.java b/ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchSaveReq.java new file mode 100644 index 0000000..750c92f --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchSaveReq.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchSearchParam.java b/ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchSearchParam.java new file mode 100644 index 0000000..11bac18 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchSearchParam.java @@ -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; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchStatus.java b/ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchStatus.java new file mode 100644 index 0000000..b4fa6a1 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchStatus.java @@ -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; // 취소 +} diff --git a/ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchVO.java b/ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchVO.java new file mode 100644 index 0000000..d22f4c1 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/settle/PaymentBatchVO.java @@ -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; +} diff --git a/ga-core/src/main/resources/mapper/settle/PaymentBatchItemMapper.xml b/ga-core/src/main/resources/mapper/settle/PaymentBatchItemMapper.xml new file mode 100644 index 0000000..58c78c7 --- /dev/null +++ b/ga-core/src/main/resources/mapper/settle/PaymentBatchItemMapper.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + 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 INTO payment_batch_item + (batch_id, settle_id, agent_id, payable_amount, included, exclude_reason, created_at) + VALUES + + (#{i.batchId}, #{i.settleId}, #{i.agentId}, #{i.payableAmount}, + #{i.included}, #{i.excludeReason}, NOW()) + + + + + + + + + + + + UPDATE payment_batch_item + SET included = #{included}, + exclude_reason = CASE WHEN #{included} = true THEN NULL ELSE #{excludeReason} END + WHERE item_id = #{itemId} + + + diff --git a/ga-core/src/main/resources/mapper/settle/PaymentBatchMapper.xml b/ga-core/src/main/resources/mapper/settle/PaymentBatchMapper.xml new file mode 100644 index 0000000..85fb014 --- /dev/null +++ b/ga-core/src/main/resources/mapper/settle/PaymentBatchMapper.xml @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + + FROM payment_batch b + LEFT JOIN users u ON u.user_id = b.finalized_by + + + + + 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}) + + + + + + + + + + + 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 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} + + + + + + diff --git a/ga-frontend/src/App.tsx b/ga-frontend/src/App.tsx index f2ca106..09f58c7 100644 --- a/ga-frontend/src/App.tsx +++ b/ga-frontend/src/App.tsx @@ -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: 환수자료관리 */} } /> + {/* P12: 지급대상 배치 구성 */} + } /> + {/* P7: 수수료 계산 7도메인 */} } /> } /> diff --git a/ga-frontend/src/api/paymentBatch.ts b/ga-frontend/src/api/paymentBatch.ts new file mode 100644 index 0000000..966169e --- /dev/null +++ b/ga-frontend/src/api/paymentBatch.ts @@ -0,0 +1,77 @@ +import api, { PageResponse, unwrap } from './request'; + +// ── Types ────────────────────────────────────────────────────────────────── + +export type PaymentBatchStatus = 'DRAFT' | 'FINALIZED' | 'CANCELLED'; + +export interface PaymentBatchResp extends Record { + batchId: number; + settleMonth: string; + batchName: string; + status: PaymentBatchStatus; + statusName: string; + totalCount: number; + totalPayable: number; + finalizedAt?: string; + createdAt: string; +} + +export interface PaymentBatchItemResp extends Record { + 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>( + api.get('/api/payment-batches', { params: p }), + ), + + detail: (id: number) => + unwrap( + api.get(`/api/payment-batches/${id}`), + ), + + create: (req: PaymentBatchCreateReq) => + unwrap(api.post('/api/payment-batches', req)), + + updateItem: (id: number, itemId: number, req: PaymentBatchItemUpdateReq) => + unwrap(api.put(`/api/payment-batches/${id}/items/${itemId}`, req)), + + finalize: (id: number) => + unwrap(api.put(`/api/payment-batches/${id}/finalize`)), + + cancel: (id: number) => + unwrap(api.put(`/api/payment-batches/${id}/cancel`)), + + export: (id: number) => + api.get(`/api/payment-batches/${id}/export`, { responseType: 'blob' }), +}; diff --git a/ga-frontend/src/pages/settle/PaymentTarget.tsx b/ga-frontend/src/pages/settle/PaymentTarget.tsx new file mode 100644 index 0000000..3a213bf --- /dev/null +++ b/ga-frontend/src/pages/settle/PaymentTarget.tsx @@ -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 {statusName ?? status}; + return ( + + {statusName ?? meta.label} + + ); +} + +// 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[] = [ + { field: 'batchName', headerName: '배치명', flex: 2, pinned: 'left' }, + { field: 'settleMonth', headerName: '정산월', width: 110 }, + { + field: 'status', + headerName: '상태', + width: 110, + cellRenderer: (p: ICellRendererParams) => + p.data ? ( + + ) : 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[] { + 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) => { + if (!p.data) return null; + const item = p.data; + if (isDraft) { + return ( + onToggleIncluded(item)} + /> + ); + } + return ( + + ); + }, + }, + { + 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(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) + : 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) + : undefined; + + const itemCols = buildItemCols(isDraft, handleToggleIncluded); + + const cardStyle = { + background: '#fff', + borderRadius: RADIUS.lg, + border: `1px solid ${GRAY[100]}`, + boxShadow: SHADOW.sm, + }; + + return ( + + {/* ── 검색 폼 ─────────────────────────────────── */} + setSearchParams(v as { settleMonth?: string; status?: string })} + onReset={() => setSearchParams({})} + /> + + {/* ── 배치 목록 그리드 ─────────────────────────── */} + { + createForm.resetFields(); + setCreateModalOpen(true); + }} + > + + 구성 + + } + title={ + + 배치 목록 + + } + > + + 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); + } + }} + /> + + + {/* ── 상세 Drawer ──────────────────────────────── */} + setDrawerOpen(false)} + extra={ + + } + onClick={handleExport} + disabled={!detail} + > + 엑셀 + + { + Modal.confirm({ + title: '배치를 취소하시겠습니까?', + okText: '취소 처리', + okType: 'danger', + cancelText: '닫기', + onOk: () => cancelMutation.mutateAsync(), + }); + }} + > + 취소 + + { + Modal.confirm({ + title: '배치를 확정하시겠습니까?', + content: '확정 후에는 포함 항목을 수정할 수 없습니다.', + okText: '확정', + cancelText: '취소', + onOk: () => finalizeMutation.mutateAsync(), + }); + }} + > + 확정 + + + } + > + {/* 배치 헤더 요약 */} + {detail && ( +
+ } /> + + +
+ )} + + {/* 아이템 그리드 */} + {detailLoading ? ( + 불러오는 중... + ) : ( + + rows={items} + columns={itemCols} + loading={detailLoading} + height={500} + rowKey="itemId" + pinnedBottomRow={itemPinnedBottom} + /> + )} + + {/* DRAFT 안내 */} + {isDraft && ( + + 체크박스를 클릭하여 포함/제외를 변경할 수 있습니다. + + )} +
+ + {/* ── 배치 구성 모달 ────────────────────────────── */} + { setCreateModalOpen(false); createForm.resetFields(); }} + okText="구성" + cancelText="취소" + confirmLoading={createMutation.isPending} + destroyOnClose + > +
+ + !v || isValidYYYYMM(v) + ? Promise.resolve() + : Promise.reject(new Error('YYYYMM 6자리로 입력하세요 (예: 202501)')), + }, + ]} + > + + + + + +
+
+ + {/* ── 제외사유 입력 모달 ────────────────────────── */} + { setReasonModal({ open: false }); reasonForm.resetFields(); }} + okText="제외" + okType="danger" + cancelText="취소" + confirmLoading={updateItemMutation.isPending} + destroyOnClose + > +
+ 설계사: {reasonModal.item?.agentName} +
+
+ + + +
+
+
+ ); +} + +// ── 헤더 요약 아이템 ────────────────────────────────────────────────────── +function HeaderStat({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value} +
+ ); +}