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:
GA Pro
2026-06-05 00:59:48 +09:00
parent 284ecc4203
commit 934e45d295
12 changed files with 307 additions and 18 deletions
+9
View File
@@ -262,9 +262,16 @@ export interface AwardLedgerRow extends Record<string, unknown> {
awardType: string;
baseAmount: number;
awardAmount: number;
clawbackAmount: number;
netAmount: number;
status: string;
}
export interface AwardClawbackReq {
clawbackAmount: number;
reason: string;
}
// ── P7: LifeSupport ───────────────────────────────
export interface LifeSupportRuleRow extends Record<string, unknown> {
ruleId: number;
@@ -472,6 +479,8 @@ export const commissionApi = {
unwrap<PageResponse<AwardLedgerRow>>(
api.get('/api/awards/ledgers', { params: p }),
),
awardLedgerClawback: (ledgerId: number, body: AwardClawbackReq) =>
unwrap<void>(api.put(`/api/awards/ledgers/${ledgerId}/clawback`, body)),
// ── P7: LifeSupport ───────────────────────────────
lifeSupportRules: (p?: Partial<CommissionSearchParam>) =>
+82 -10
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { Alert, Form, Input, InputNumber, Modal, Select, Space, Tabs, message } from 'antd';
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 dayjs from 'dayjs';
import PageContainer from '@/components/common/PageContainer';
@@ -12,6 +12,7 @@ import {
commissionApi,
AwardRuleRow,
AwardLedgerRow,
AwardClawbackReq,
CommissionSearchParam,
} from '@/api/commission';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
@@ -36,12 +37,14 @@ const RULE_COLS: ColDef<AwardRuleRow>[] = [
];
const LEDGER_COLS: ColDef<AwardLedgerRow>[] = [
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
{ field: 'agentName', headerName: '설계사', width: 130 },
{ field: 'awardType', headerName: '시상유형', width: 120 },
{ 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: 'status', headerName: '상태', width: 100 },
{ field: 'settleMonth', headerName: '정산월', width: 110, pinned: 'left' },
{ field: 'agentName', headerName: '설계사', width: 130 },
{ field: 'awardType', headerName: '시상유형', width: 120 },
{ 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: '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() {
@@ -53,6 +56,25 @@ export default function Award() {
const [editingRule, setEditingRule] = useState<AwardRuleRow | null>(null);
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({
queryKey: ['award', 'rules'],
queryFn: () => commissionApi.awardRules(),
@@ -97,8 +119,10 @@ export default function Award() {
const pinnedLedger = ledgers.length > 0 ? {
agentName: '합계',
baseAmount: ledgers.reduce((s, r) => s + (r.baseAmount ?? 0), 0),
awardAmount: ledgers.reduce((s, r) => s + (r.awardAmount ?? 0), 0),
baseAmount: ledgers.reduce((s, r) => s + (r.baseAmount ?? 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;
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}>
<DataGrid<AwardLedgerRow>
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}
height={520}
rowKey="ledgerId"
@@ -204,6 +244,38 @@ export default function Award() {
</Form.Item>
</Form>
</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>
);
}