feat: P11(#1) 시상/신인시상 환수 — award_ledger 환수 처리 (PL 자율진행)
incar 유지율시상환수/신인도입유치자시상환수 대응. award_ledger(V92)는 지급만 있고
환수가 없던 갭. 정착지원금/생보운영지원/지점장수당의 clawback_amount+net_amount 패턴으로
통일(신인시상=award_type NEW_RECRUIT로 기존재 → 별도 테이블 불필요).
- DB(V114): award_ledger에 clawback_amount/net_amount 컬럼 추가 + net=award 백필 +
CHECK(clawback>=0) + status CHECK에 CLAWED_BACK 추가(pg_constraint 동적 탐색 DROP/재생성).
- core: AwardLedgerVO/Resp/SaveReq에 clawbackAmount/netAmount. AwardLedgerMapper
aggregateByAgent SUM(award_amount)→SUM(net_amount), insert에 두 컬럼, applyClawback UPDATE.
- api: AwardService.createLedger가 net=award−clawback 계산(clawback≤award 검증). 신규
applyClawback(ledgerId, {clawbackAmount,reason}) — 초과/취소상태 E411, status=CLAWED_BACK.
PUT /api/awards/ledgers/{id}/clawback.
- frontend: Award 원장 그리드에 환수액/실지급 컬럼 + 합계 + 환수처리 모달(PermissionButton UPDATE).
- AggregateStep 무변경: aggregateByAgent가 net 반환 → 환수가 other_commission 자동 차감,
net<0이면 P9 환수채권 이월에 자연 연계.
검증: build+test SUCCESSFUL, Flyway V114(schema v114). 라이브: award 100000 원장 →
clawback 30000 → net 70000·CLAWED_BACK, 초과환수 200000 → E411, aggregate net=70000 반영
확인. 테스트 데이터 정리. 스펙 docs/DOMAIN_GAP_P11.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
|||||||
|
# P11 — 시상/신인시상 환수 (#1)
|
||||||
|
|
||||||
|
작성: PL, 2026-06-04. 후속 갭 3종 중 두 번째.
|
||||||
|
근거: incar `유지율시상환수`/`신인도입유치자시상환수`/`시상내역 조회`. GA award_ledger(V92)는
|
||||||
|
지급만 있고 환수(clawback) 처리가 없음. 정착지원금/생보운영지원/지점장수당은 이미
|
||||||
|
`clawback_amount`+`net_amount` 패턴 보유 → **award_ledger도 동일 패턴으로 통일**.
|
||||||
|
|
||||||
|
신인시상은 award_type='NEW_RECRUIT'로 이미 존재 → 별도 테이블 불필요. 모든 award_type에 환수 적용.
|
||||||
|
Flyway: **V113 최신 → V114**. 금액 NUMERIC(15,2), HALF_UP.
|
||||||
|
|
||||||
|
## 1. 현재 award_ledger (V92)
|
||||||
|
ledger_id / agent_id / settle_month / award_type(NEW_RECRUIT/CORPORATE/RETENTION/PERFORMANCE) /
|
||||||
|
base_amount / award_amount / status(CALCULATED/CONFIRMED/PAID/CANCELLED) / created_at / updated_at.
|
||||||
|
aggregateByAgent = SUM(award_amount) WHERE status!=CANCELLED.
|
||||||
|
|
||||||
|
**문제**: clawback_amount/net_amount 없음. settling/life/branch는 net=support−clawback, aggregate=SUM(net).
|
||||||
|
|
||||||
|
## 2. 스키마 변경 (V114) — settling_support_ledger 패턴 미러
|
||||||
|
```sql
|
||||||
|
ALTER TABLE award_ledger
|
||||||
|
ADD COLUMN clawback_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN net_amount NUMERIC(15,2) NOT NULL DEFAULT 0;
|
||||||
|
-- 기존 행 백필: 환수 없던 과거 = net = award
|
||||||
|
UPDATE award_ledger SET net_amount = award_amount WHERE net_amount = 0 AND clawback_amount = 0;
|
||||||
|
-- clawback 음수 방지 + 상태에 CLAWED_BACK 추가(settling 패턴과 통일)
|
||||||
|
ALTER TABLE award_ledger ADD CONSTRAINT chk_award_clawback CHECK (clawback_amount >= 0);
|
||||||
|
ALTER TABLE award_ledger DROP CONSTRAINT <기존 status CHECK 이름>; -- \d 로 확인 (예: award_ledger_status_check)
|
||||||
|
ALTER TABLE award_ledger ADD CONSTRAINT chk_award_status
|
||||||
|
CHECK (status IN ('CALCULATED','CONFIRMED','PAID','CANCELLED','CLAWED_BACK'));
|
||||||
|
```
|
||||||
|
> 기존 status CHECK 제약명은 `\d award_ledger` 또는 information_schema 로 정확히 확인 후 DROP. 추정명 사용 금지.
|
||||||
|
> net_amount는 음수 허용(환수>시상 시 음수 가능)이나, **§4 검증에서 clawback ≤ award_amount 강제**하므로 실제 net≥0. (지급성이므로 음수 차단 — settling 동일)
|
||||||
|
|
||||||
|
메뉴 변경 없음(award 화면 기존). 공통코드 AWARD_STATUS에 CLAWED_BACK 있으면 추가(없으면 생략).
|
||||||
|
|
||||||
|
## 3. Core
|
||||||
|
- AwardLedgerVO/Resp/SaveReq에 `clawbackAmount`, `netAmount` 추가.
|
||||||
|
- `AwardLedgerMapper.aggregateByAgent` XML: `SUM(award_amount)` → **`SUM(net_amount)`** (net=award−clawback). status!=CANCELLED 유지.
|
||||||
|
- 신규 Mapper 메서드 `applyClawback(@Param ledgerId, @Param clawbackAmount, @Param netAmount, @Param status)` (+XML): clawback_amount/net_amount/status='CLAWED_BACK'/updated_at 갱신.
|
||||||
|
- createLedger 시 net_amount = award_amount − clawback_amount 저장(insert XML에 두 컬럼 포함).
|
||||||
|
|
||||||
|
## 4. API (AwardService 확장)
|
||||||
|
- `createLedger`: clawback_amount(SaveReq, 기본0) 받아 **clawback ≤ award_amount 검증(초과 시 E411)**, net = award − clawback 계산해 저장.
|
||||||
|
- **신규 액션** `applyClawback(ledgerId, AwardClawbackReq{clawbackAmount, reason})` @Transactional:
|
||||||
|
- 원장 조회(없으면 NOT_FOUND). 이미 CANCELLED면 E411.
|
||||||
|
- clawbackAmount > award_amount 면 E411("환수액이 시상액 초과").
|
||||||
|
- net = award_amount − clawbackAmount. mapper.applyClawback(ledgerId, clawback, net, 'CLAWED_BACK').
|
||||||
|
- @DataChangeLog.
|
||||||
|
- Controller: `PUT /api/awards/ledgers/{id}/clawback` (@RequirePermission(menu=AWARD, action=UPDATE)). 기존 award 컨트롤러 경로 패턴 확인.
|
||||||
|
|
||||||
|
> **AggregateStep 무변경**: awardMapper.aggregateByAgent가 SUM(net_amount) 반환하므로 환수가 자동으로 other_commission 차감에 반영. net<0이면 P9 환수채권 이월에 자연 연계.
|
||||||
|
|
||||||
|
## 5. Frontend (기존 Award 화면 확장)
|
||||||
|
- award 원장 그리드에 환수액/실지급(net) 컬럼 추가.
|
||||||
|
- 행 액션 "환수처리"(PermissionButton UPDATE) → 모달(환수액 입력 + 사유) → PUT /clawback. 성공 시 invalidate.
|
||||||
|
- api 모듈에 applyClawback 추가. 기존 award api/페이지 파일명 확인 후 확장.
|
||||||
|
|
||||||
|
## 6. 검증(PL)
|
||||||
|
build+test, Flyway V114(award_ledger 컬럼/제약/백필), 라이브: award 원장 1건 생성(net=award) →
|
||||||
|
applyClawback(부분환수) → net 감소·status CLAWED_BACK 확인 → clawback>award E411 → aggregateByAgent
|
||||||
|
가 net 반영하는지 SQL 확인. 테스트 데이터 정리.
|
||||||
|
</content>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.ga.api.controller.commission;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.DecimalMin;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 시상 원장 환수 처리 요청 (PUT /api/awards/ledgers/{id}/clawback).
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class AwardClawbackReq {
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@DecimalMin("0")
|
||||||
|
private BigDecimal clawbackAmount;
|
||||||
|
|
||||||
|
private String reason;
|
||||||
|
}
|
||||||
@@ -79,4 +79,13 @@ public class AwardController {
|
|||||||
public ApiResponse<Long> createLedger(@Valid @RequestBody AwardLedgerSaveReq req) {
|
public ApiResponse<Long> createLedger(@Valid @RequestBody AwardLedgerSaveReq req) {
|
||||||
return ApiResponse.ok(service.createLedger(req));
|
return ApiResponse.ok(service.createLedger(req));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PutMapping("/ledgers/{ledgerId}/clawback")
|
||||||
|
@RequirePermission(menu = "AWARD", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "AWARD", table = "award_ledger")
|
||||||
|
public ApiResponse<Void> applyClawback(@PathVariable Long ledgerId,
|
||||||
|
@Valid @RequestBody AwardClawbackReq req) {
|
||||||
|
service.applyClawback(ledgerId, req);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,14 +112,38 @@ public class AwardService {
|
|||||||
.setScale(2, RoundingMode.HALF_UP);
|
.setScale(2, RoundingMode.HALF_UP);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BigDecimal clawback = req.getClawbackAmount() != null ? req.getClawbackAmount() : BigDecimal.ZERO;
|
||||||
|
if (clawback.compareTo(awardAmount) > 0) {
|
||||||
|
throw new BizException(ErrorCode.INVALID_PARAMETER, "환수액이 시상액을 초과합니다");
|
||||||
|
}
|
||||||
|
BigDecimal netAmount = awardAmount.subtract(clawback).setScale(2, RoundingMode.HALF_UP);
|
||||||
|
|
||||||
AwardLedgerVO vo = new AwardLedgerVO();
|
AwardLedgerVO vo = new AwardLedgerVO();
|
||||||
vo.setAgentId(req.getAgentId());
|
vo.setAgentId(req.getAgentId());
|
||||||
vo.setSettleMonth(req.getSettleMonth());
|
vo.setSettleMonth(req.getSettleMonth());
|
||||||
vo.setAwardType(req.getAwardType());
|
vo.setAwardType(req.getAwardType());
|
||||||
vo.setBaseAmount(req.getBaseAmount());
|
vo.setBaseAmount(req.getBaseAmount());
|
||||||
vo.setAwardAmount(awardAmount);
|
vo.setAwardAmount(awardAmount);
|
||||||
|
vo.setClawbackAmount(clawback);
|
||||||
|
vo.setNetAmount(netAmount);
|
||||||
vo.setStatus("CALCULATED");
|
vo.setStatus("CALCULATED");
|
||||||
ledgerMapper.insert(vo);
|
ledgerMapper.insert(vo);
|
||||||
return vo.getLedgerId();
|
return vo.getLedgerId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void applyClawback(Long ledgerId, com.ga.api.controller.commission.AwardClawbackReq req) {
|
||||||
|
AwardLedgerResp ledger = ledgerMapper.selectById(ledgerId);
|
||||||
|
if (ledger == null) throw new BizException(ErrorCode.NOT_FOUND);
|
||||||
|
if ("CANCELLED".equals(ledger.getStatus())) {
|
||||||
|
throw new BizException(ErrorCode.INVALID_PARAMETER, "취소된 원장에는 환수를 적용할 수 없습니다");
|
||||||
|
}
|
||||||
|
if (req.getClawbackAmount().compareTo(ledger.getAwardAmount()) > 0) {
|
||||||
|
throw new BizException(ErrorCode.INVALID_PARAMETER, "환수액이 시상액을 초과합니다");
|
||||||
|
}
|
||||||
|
BigDecimal netAmount = ledger.getAwardAmount()
|
||||||
|
.subtract(req.getClawbackAmount())
|
||||||
|
.setScale(2, RoundingMode.HALF_UP);
|
||||||
|
ledgerMapper.applyClawback(ledgerId, req.getClawbackAmount(), netAmount, "CLAWED_BACK");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
-- V114: 시상 환수 (award_ledger 컬럼 추가 + status 확장)
|
||||||
|
-- settling_support_ledger(V90) 패턴 미러:
|
||||||
|
-- clawback_amount NUMERIC(15,2) NOT NULL DEFAULT 0
|
||||||
|
-- net_amount NUMERIC(15,2) NOT NULL DEFAULT 0
|
||||||
|
-- 기존 행 백필: 환수 없던 과거 데이터는 net = award
|
||||||
|
-- status CHECK에 'CLAWED_BACK' 추가 (기존 제약은 DO 블록으로 동적 탐색 후 DROP)
|
||||||
|
-- 공통코드: AWARD_STATUS 그룹이 V92/V96/V105 어디에도 없으므로 INSERT 생략
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 1. 컬럼 추가
|
||||||
|
-- ============================================================
|
||||||
|
ALTER TABLE award_ledger
|
||||||
|
ADD COLUMN clawback_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN net_amount NUMERIC(15,2) NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 2. 기존 행 백필: 환수 없던 과거 = net = award
|
||||||
|
-- ============================================================
|
||||||
|
UPDATE award_ledger
|
||||||
|
SET net_amount = award_amount
|
||||||
|
WHERE net_amount = 0
|
||||||
|
AND clawback_amount = 0;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 3. clawback 음수 방지 제약
|
||||||
|
-- ============================================================
|
||||||
|
ALTER TABLE award_ledger
|
||||||
|
ADD CONSTRAINT chk_award_clawback CHECK (clawback_amount >= 0);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 4. status CHECK 재생성 — CLAWED_BACK 추가
|
||||||
|
-- 기존 인라인 CHECK 이름을 추정하지 않고 pg_constraint에서 동적 조회 후 DROP
|
||||||
|
-- ============================================================
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
c text;
|
||||||
|
BEGIN
|
||||||
|
SELECT conname INTO c
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conrelid = 'award_ledger'::regclass
|
||||||
|
AND contype = 'c'
|
||||||
|
AND pg_get_constraintdef(oid) ILIKE '%status%';
|
||||||
|
IF c IS NOT NULL THEN
|
||||||
|
EXECUTE 'ALTER TABLE award_ledger DROP CONSTRAINT ' || quote_ident(c);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
ALTER TABLE award_ledger
|
||||||
|
ADD CONSTRAINT chk_award_status
|
||||||
|
CHECK (status IN ('CALCULATED','CONFIRMED','PAID','CANCELLED','CLAWED_BACK'));
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 5. 컬럼 코멘트
|
||||||
|
-- ============================================================
|
||||||
|
COMMENT ON COLUMN award_ledger.clawback_amount IS '시상 환수액';
|
||||||
|
COMMENT ON COLUMN award_ledger.net_amount IS '실지급 시상액 = award_amount − clawback_amount';
|
||||||
@@ -31,5 +31,14 @@ public interface AwardLedgerMapper {
|
|||||||
|
|
||||||
int updateStatus(@Param("ledgerId") Long ledgerId, @Param("status") String status);
|
int updateStatus(@Param("ledgerId") Long ledgerId, @Param("status") String status);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 환수 처리 — clawback_amount/net_amount/status/updated_at 갱신.
|
||||||
|
* status 는 통상 'CLAWED_BACK'. Service에서 clawback ≤ award_amount 검증 후 호출.
|
||||||
|
*/
|
||||||
|
int applyClawback(@Param("ledgerId") Long ledgerId,
|
||||||
|
@Param("clawbackAmount") BigDecimal clawbackAmount,
|
||||||
|
@Param("netAmount") BigDecimal netAmount,
|
||||||
|
@Param("status") String status);
|
||||||
|
|
||||||
int delete(@Param("ledgerId") Long ledgerId);
|
int delete(@Param("ledgerId") Long ledgerId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ public class AwardLedgerResp {
|
|||||||
private String awardTypeName;
|
private String awardTypeName;
|
||||||
private BigDecimal baseAmount;
|
private BigDecimal baseAmount;
|
||||||
private BigDecimal awardAmount;
|
private BigDecimal awardAmount;
|
||||||
|
/** 환수액 (V114) */
|
||||||
|
private BigDecimal clawbackAmount;
|
||||||
|
/** 실지급액 = award_amount - clawback_amount (V114) */
|
||||||
|
private BigDecimal netAmount;
|
||||||
private String status;
|
private String status;
|
||||||
/** 공통코드 INCOME_STATUS */
|
/** 공통코드 INCOME_STATUS */
|
||||||
private String statusName;
|
private String statusName;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.ga.core.vo.commission;
|
package com.ga.core.vo.commission;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.DecimalMin;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import jakarta.validation.constraints.Positive;
|
import jakarta.validation.constraints.Positive;
|
||||||
@@ -10,6 +11,7 @@ import java.math.BigDecimal;
|
|||||||
/**
|
/**
|
||||||
* award_ledger 등록 요청.
|
* award_ledger 등록 요청.
|
||||||
* award_amount 는 Service가 계산 후 VO에 set.
|
* award_amount 는 Service가 계산 후 VO에 set.
|
||||||
|
* net_amount 는 Service가 award_amount - clawbackAmount 계산 후 VO에 set.
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
public class AwardLedgerSaveReq {
|
public class AwardLedgerSaveReq {
|
||||||
@@ -26,4 +28,8 @@ public class AwardLedgerSaveReq {
|
|||||||
@NotNull
|
@NotNull
|
||||||
@Positive
|
@Positive
|
||||||
private BigDecimal baseAmount;
|
private BigDecimal baseAmount;
|
||||||
|
|
||||||
|
/** 등록 시 환수액 (선택, 기본 0). clawback ≤ award_amount 검증은 Service에서 수행. */
|
||||||
|
@DecimalMin("0")
|
||||||
|
private BigDecimal clawbackAmount;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import java.math.BigDecimal;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* award_ledger — 시상 원장 (V92)
|
* award_ledger — 시상 원장 (V92, clawback 컬럼 V114 추가)
|
||||||
* award_amount = base_amount × award_rate (RATE) or award_fixed (FIXED)
|
* award_amount = base_amount × award_rate (RATE) or award_fixed (FIXED)
|
||||||
|
* net_amount = award_amount - clawback_amount
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
public class AwardLedgerVO {
|
public class AwardLedgerVO {
|
||||||
@@ -21,7 +22,11 @@ public class AwardLedgerVO {
|
|||||||
private BigDecimal baseAmount;
|
private BigDecimal baseAmount;
|
||||||
/** 시상금 */
|
/** 시상금 */
|
||||||
private BigDecimal awardAmount;
|
private BigDecimal awardAmount;
|
||||||
/** CALCULATED / CONFIRMED / PAID / CANCELLED */
|
/** 환수액 (V114) */
|
||||||
|
private BigDecimal clawbackAmount;
|
||||||
|
/** 실지급액 = award_amount - clawback_amount (V114) */
|
||||||
|
private BigDecimal netAmount;
|
||||||
|
/** CALCULATED / CONFIRMED / PAID / CANCELLED / CLAWED_BACK */
|
||||||
private String status;
|
private String status;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<resultMap id="VOMap" type="com.ga.core.vo.commission.AwardLedgerVO"/>
|
<resultMap id="VOMap" type="com.ga.core.vo.commission.AwardLedgerVO"/>
|
||||||
<resultMap id="RespMap" type="com.ga.core.vo.commission.AwardLedgerResp"/>
|
<resultMap id="RespMap" type="com.ga.core.vo.commission.AwardLedgerResp"/>
|
||||||
|
|
||||||
<!-- 목록/단건 조회 컬럼 — 설계사명·조직명·공통코드명 조인 포함 -->
|
<!-- 목록/단건 조회 컬럼 — 설계사명·조직명·공통코드명 조인 포함 (V114: clawback_amount, net_amount 추가) -->
|
||||||
<sql id="ledgerJoinCols">
|
<sql id="ledgerJoinCols">
|
||||||
l.ledger_id,
|
l.ledger_id,
|
||||||
l.agent_id, a.agent_name, o.org_name,
|
l.agent_id, a.agent_name, o.org_name,
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
(SELECT cc.code_name FROM common_code cc
|
(SELECT cc.code_name FROM common_code cc
|
||||||
WHERE cc.group_code = 'AWARD_TYPE' AND cc.code = l.award_type) AS award_type_name,
|
WHERE cc.group_code = 'AWARD_TYPE' AND cc.code = l.award_type) AS award_type_name,
|
||||||
l.base_amount, l.award_amount,
|
l.base_amount, l.award_amount,
|
||||||
|
l.clawback_amount, l.net_amount,
|
||||||
l.status,
|
l.status,
|
||||||
(SELECT cc.code_name FROM common_code cc
|
(SELECT cc.code_name FROM common_code cc
|
||||||
WHERE cc.group_code = 'INCOME_STATUS' AND cc.code = l.status) AS status_name
|
WHERE cc.group_code = 'INCOME_STATUS' AND cc.code = l.status) AS status_name
|
||||||
@@ -54,12 +55,14 @@
|
|||||||
AND status != 'CANCELLED'
|
AND status != 'CANCELLED'
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- award_amount 는 Service가 계산 후 VO에 set, status 기본 CALCULATED -->
|
<!-- award_amount/net_amount 는 Service가 계산 후 VO에 set, status 기본 CALCULATED (V114: clawback_amount, net_amount 포함) -->
|
||||||
<insert id="insert" useGeneratedKeys="true" keyProperty="ledgerId">
|
<insert id="insert" useGeneratedKeys="true" keyProperty="ledgerId">
|
||||||
INSERT INTO award_ledger
|
INSERT INTO award_ledger
|
||||||
(agent_id, settle_month, award_type, base_amount, award_amount, status)
|
(agent_id, settle_month, award_type, base_amount, award_amount,
|
||||||
|
clawback_amount, net_amount, status)
|
||||||
VALUES
|
VALUES
|
||||||
(#{agentId}, #{settleMonth}, #{awardType}, #{baseAmount}, #{awardAmount},
|
(#{agentId}, #{settleMonth}, #{awardType}, #{baseAmount}, #{awardAmount},
|
||||||
|
COALESCE(#{clawbackAmount}, 0), #{netAmount},
|
||||||
COALESCE(#{status}, 'CALCULATED'))
|
COALESCE(#{status}, 'CALCULATED'))
|
||||||
</insert>
|
</insert>
|
||||||
|
|
||||||
@@ -73,14 +76,24 @@
|
|||||||
DELETE FROM award_ledger WHERE ledger_id = #{ledgerId}
|
DELETE FROM award_ledger WHERE ledger_id = #{ledgerId}
|
||||||
</delete>
|
</delete>
|
||||||
|
|
||||||
<!-- 정산월 기준 설계사별 시상금 합계 -->
|
<!-- 정산월 기준 설계사별 시상금 합계 — net_amount 기준(V114: 환수 자동 차감). CANCELLED 제외. -->
|
||||||
<select id="aggregateByAgent" resultType="map">
|
<select id="aggregateByAgent" resultType="map">
|
||||||
SELECT agent_id AS "agentId",
|
SELECT agent_id AS "agentId",
|
||||||
SUM(award_amount) AS "total"
|
SUM(net_amount) AS "total"
|
||||||
FROM award_ledger
|
FROM award_ledger
|
||||||
WHERE settle_month = #{settleMonth}
|
WHERE settle_month = #{settleMonth}
|
||||||
AND status != 'CANCELLED'
|
AND status != 'CANCELLED'
|
||||||
GROUP BY agent_id
|
GROUP BY agent_id
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!-- 환수 처리 — clawback_amount/net_amount/status/updated_at 갱신 -->
|
||||||
|
<update id="applyClawback">
|
||||||
|
UPDATE award_ledger
|
||||||
|
SET clawback_amount = #{clawbackAmount},
|
||||||
|
net_amount = #{netAmount},
|
||||||
|
status = #{status},
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE ledger_id = #{ledgerId}
|
||||||
|
</update>
|
||||||
|
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
@@ -262,9 +262,16 @@ export interface AwardLedgerRow extends Record<string, unknown> {
|
|||||||
awardType: string;
|
awardType: string;
|
||||||
baseAmount: number;
|
baseAmount: number;
|
||||||
awardAmount: number;
|
awardAmount: number;
|
||||||
|
clawbackAmount: number;
|
||||||
|
netAmount: number;
|
||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AwardClawbackReq {
|
||||||
|
clawbackAmount: number;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ── P7: LifeSupport ───────────────────────────────
|
// ── P7: LifeSupport ───────────────────────────────
|
||||||
export interface LifeSupportRuleRow extends Record<string, unknown> {
|
export interface LifeSupportRuleRow extends Record<string, unknown> {
|
||||||
ruleId: number;
|
ruleId: number;
|
||||||
@@ -472,6 +479,8 @@ export const commissionApi = {
|
|||||||
unwrap<PageResponse<AwardLedgerRow>>(
|
unwrap<PageResponse<AwardLedgerRow>>(
|
||||||
api.get('/api/awards/ledgers', { params: p }),
|
api.get('/api/awards/ledgers', { params: p }),
|
||||||
),
|
),
|
||||||
|
awardLedgerClawback: (ledgerId: number, body: AwardClawbackReq) =>
|
||||||
|
unwrap<void>(api.put(`/api/awards/ledgers/${ledgerId}/clawback`, body)),
|
||||||
|
|
||||||
// ── P7: LifeSupport ───────────────────────────────
|
// ── P7: LifeSupport ───────────────────────────────
|
||||||
lifeSupportRules: (p?: Partial<CommissionSearchParam>) =>
|
lifeSupportRules: (p?: Partial<CommissionSearchParam>) =>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Alert, Form, Input, InputNumber, Modal, Select, Space, Tabs, message } from 'antd';
|
import { Alert, Form, Input, InputNumber, Modal, Select, Space, Tabs, message } from 'antd';
|
||||||
import { ProCard } from '@ant-design/pro-components';
|
import { ProCard } from '@ant-design/pro-components';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import type { ColDef } from '@ag-grid-community/core';
|
import type { ColDef } from '@ag-grid-community/core';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import PageContainer from '@/components/common/PageContainer';
|
import PageContainer from '@/components/common/PageContainer';
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
commissionApi,
|
commissionApi,
|
||||||
AwardRuleRow,
|
AwardRuleRow,
|
||||||
AwardLedgerRow,
|
AwardLedgerRow,
|
||||||
|
AwardClawbackReq,
|
||||||
CommissionSearchParam,
|
CommissionSearchParam,
|
||||||
} from '@/api/commission';
|
} from '@/api/commission';
|
||||||
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
|
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
|
||||||
@@ -41,7 +42,9 @@ const LEDGER_COLS: ColDef<AwardLedgerRow>[] = [
|
|||||||
{ field: 'awardType', headerName: '시상유형', width: 120 },
|
{ field: 'awardType', headerName: '시상유형', width: 120 },
|
||||||
{ field: 'baseAmount', headerName: '기준실적', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
{ field: 'baseAmount', headerName: '기준실적', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||||
{ field: 'awardAmount', headerName: '시상금', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
{ field: 'awardAmount', headerName: '시상금', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||||
{ field: 'status', headerName: '상태', width: 100 },
|
{ field: 'clawbackAmount', headerName: '환수액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||||
|
{ field: 'netAmount', headerName: '실지급', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
|
||||||
|
{ field: 'status', headerName: '상태', width: 110 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Award() {
|
export default function Award() {
|
||||||
@@ -53,6 +56,25 @@ export default function Award() {
|
|||||||
const [editingRule, setEditingRule] = useState<AwardRuleRow | null>(null);
|
const [editingRule, setEditingRule] = useState<AwardRuleRow | null>(null);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const [clawbackTarget, setClawbackTarget] = useState<AwardLedgerRow | null>(null);
|
||||||
|
const [clawbackForm] = Form.useForm();
|
||||||
|
|
||||||
|
const clawbackMutation = useMutation({
|
||||||
|
mutationFn: ({ ledgerId, body }: { ledgerId: number; body: AwardClawbackReq }) =>
|
||||||
|
commissionApi.awardLedgerClawback(ledgerId, body),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('환수처리 완료');
|
||||||
|
qc.invalidateQueries({ queryKey: ['award', 'ledgers'] });
|
||||||
|
setClawbackTarget(null);
|
||||||
|
clawbackForm.resetFields();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleClawbackOk = async () => {
|
||||||
|
const values = await clawbackForm.validateFields();
|
||||||
|
clawbackMutation.mutate({ ledgerId: clawbackTarget!.ledgerId, body: values });
|
||||||
|
};
|
||||||
|
|
||||||
const { data: rulesData, isLoading: rulesLoading, isError: rulesError } = useQuery({
|
const { data: rulesData, isLoading: rulesLoading, isError: rulesError } = useQuery({
|
||||||
queryKey: ['award', 'rules'],
|
queryKey: ['award', 'rules'],
|
||||||
queryFn: () => commissionApi.awardRules(),
|
queryFn: () => commissionApi.awardRules(),
|
||||||
@@ -99,6 +121,8 @@ export default function Award() {
|
|||||||
agentName: '합계',
|
agentName: '합계',
|
||||||
baseAmount: ledgers.reduce((s, r) => s + (r.baseAmount ?? 0), 0),
|
baseAmount: ledgers.reduce((s, r) => s + (r.baseAmount ?? 0), 0),
|
||||||
awardAmount: ledgers.reduce((s, r) => s + (r.awardAmount ?? 0), 0),
|
awardAmount: ledgers.reduce((s, r) => s + (r.awardAmount ?? 0), 0),
|
||||||
|
clawbackAmount: ledgers.reduce((s, r) => s + (r.clawbackAmount ?? 0), 0),
|
||||||
|
netAmount: ledgers.reduce((s, r) => s + (r.netAmount ?? 0), 0),
|
||||||
} as Partial<AwardLedgerRow> : undefined;
|
} as Partial<AwardLedgerRow> : undefined;
|
||||||
|
|
||||||
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
|
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
|
||||||
@@ -159,7 +183,23 @@ export default function Award() {
|
|||||||
<ProCard style={cardStyle}>
|
<ProCard style={cardStyle}>
|
||||||
<DataGrid<AwardLedgerRow>
|
<DataGrid<AwardLedgerRow>
|
||||||
rows={ledgers}
|
rows={ledgers}
|
||||||
columns={LEDGER_COLS}
|
columns={[
|
||||||
|
...LEDGER_COLS,
|
||||||
|
{
|
||||||
|
headerName: '액션', width: 110, pinned: 'right',
|
||||||
|
cellRenderer: (p: { data: AwardLedgerRow }) => (
|
||||||
|
<PermissionButton
|
||||||
|
menuCode="AWARD"
|
||||||
|
permCode="UPDATE"
|
||||||
|
size="small"
|
||||||
|
disabled={p.data?.status === 'CANCELLED'}
|
||||||
|
onClick={() => { setClawbackTarget(p.data); clawbackForm.resetFields(); }}
|
||||||
|
>
|
||||||
|
환수처리
|
||||||
|
</PermissionButton>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
loading={ledgersLoading}
|
loading={ledgersLoading}
|
||||||
height={520}
|
height={520}
|
||||||
rowKey="ledgerId"
|
rowKey="ledgerId"
|
||||||
@@ -204,6 +244,38 @@ export default function Award() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="환수처리"
|
||||||
|
open={!!clawbackTarget}
|
||||||
|
onOk={handleClawbackOk}
|
||||||
|
onCancel={() => { setClawbackTarget(null); clawbackForm.resetFields(); }}
|
||||||
|
okText="처리"
|
||||||
|
cancelText="취소"
|
||||||
|
confirmLoading={clawbackMutation.isPending}
|
||||||
|
>
|
||||||
|
{clawbackTarget && (
|
||||||
|
<div style={{ marginBottom: 12, color: '#666', fontSize: 13 }}>
|
||||||
|
{clawbackTarget.agentName} / {clawbackTarget.settleMonth} / 시상금 {fmt(clawbackTarget.awardAmount)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Form form={clawbackForm} layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
name="clawbackAmount"
|
||||||
|
label="환수액"
|
||||||
|
rules={[{ required: true, message: '환수액을 입력하세요' }]}
|
||||||
|
>
|
||||||
|
<InputNumber style={{ width: '100%' }} min={0} placeholder="환수할 금액" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="reason"
|
||||||
|
label="사유"
|
||||||
|
rules={[{ required: true, message: '사유를 입력하세요' }]}
|
||||||
|
>
|
||||||
|
<Input.TextArea rows={3} placeholder="환수 사유를 입력하세요" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user