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:
@@ -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>) =>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user