feat: incar 갭 반영 수수료/운영 14개 도메인 풀스택 + 배치통합 + 신입교육자료

incar CMS(116메뉴) 벤치마킹 갭분석으로 ga-pro에 없던 14개 도메인을 풀스택 추가.

P7 수수료 계산 7종 (V88~V96):
- 수입수수료+수지차, 소개·이관수수료, 정착지원금, 인정실적+환산율,
  시상, 생보운영지원수수료, 지점장수당
- 각 rule/ledger + 공식(보험료×요율, 수입−지급 등) Service 내장

P8 운영/정산 7종 (V97~V105):
- 단계마감, 본사정산, 등급평가, 공동계약, 보증보험, 적립금, 수수료시뮬레이터
- close/reopen/apply/simulate/upsert 등 액션 + 손익·잔액·등급 산정 로직

배치 통합 (V106):
- settle_master.other_commission_total 컬럼 추가(DEFAULT 0, 백필)
- 설계사 지급성 9종 원장 aggregateByAgent → AggregateStep gross 합산
- 빈 원장 0 → 기존 정산결과·무결성 불변(비파괴적)

테스트: P7/P8 Service 공식 단위테스트 21건(ga-api:test 57건 전체 통과)
프론트: 화면 14개 + React.lazy 코드 스플리팅(단일 4MB → 130+ 청크)
문서: DOMAIN_GAP_P7/P8.md, 신입교육_보험과수수료_완전기초.md, DOMAIN_KNOWLEDGE/HANDOFF 갱신

검증: 전체 ./gradlew build(test 포함) SUCCESSFUL, Flyway V88~V106 success,
GET+액션POST smoke 5xx 0건, 9개 집계쿼리 SQL 유효성 확인, 공식 런타임 실측.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-05-31 15:45:10 +09:00
parent 70620463b4
commit bfc724669b
231 changed files with 13325 additions and 189 deletions
+189
View File
@@ -0,0 +1,189 @@
import { useState } from 'react';
import { Alert, Col, Form, Input, InputNumber, Modal, Row, Space, Statistic, Tabs, message } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { ColDef } 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 { operationApi, HqSettleRow } from '@/api/operation';
import { GRAY, RADIUS, SHADOW, COLOR_SUCCESS, COLOR_ERROR } from '@/theme/tokens';
const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-';
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
const COLS: ColDef<HqSettleRow>[] = [
{ field: 'settleMonth', headerName: '정산월', width: 110 },
{ field: 'itemType', headerName: '항목유형', width: 100,
valueFormatter: (p) => p.value === 'INCOME' ? '수입' : '비용' },
{ field: 'itemName', headerName: '항목명', flex: 1 },
{ field: 'amount', headerName: '금액', width: 160, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) },
{ field: 'memo', headerName: '메모', flex: 1 },
{ field: 'status', headerName: '상태', width: 100 },
];
export default function HqSettle() {
const qc = useQueryClient();
const [settleMonth, setSettleMonth] = useState(dayjs().format('YYYYMM'));
const [modalOpen, setModalOpen] = useState(false);
const [editingItem, setEditingItem] = useState<HqSettleRow | null>(null);
const [form] = Form.useForm();
const { data, isLoading, isError } = useQuery({
queryKey: ['hq-settle', 'list', settleMonth],
queryFn: () => operationApi.hqSettleList({ settleMonth }),
retry: false,
});
const { data: summary } = useQuery({
queryKey: ['hq-settle', 'summary', settleMonth],
queryFn: () => operationApi.hqSettleSummary(settleMonth),
retry: false,
enabled: !!settleMonth,
});
const rows = data?.list ?? [];
const openCreate = () => { setEditingItem(null); form.resetFields(); setModalOpen(true); };
const openEdit = (row: HqSettleRow) => { setEditingItem(row); form.setFieldsValue(row); setModalOpen(true); };
const handleSave = async () => {
const values = await form.validateFields();
try {
if (editingItem) {
await operationApi.hqSettleUpdate(editingItem.itemId, values);
message.success('수정 완료');
} else {
await operationApi.hqSettleCreate(values);
message.success('등록 완료');
}
qc.invalidateQueries({ queryKey: ['hq-settle'] });
setModalOpen(false);
} catch { /* request.ts 처리 */ }
};
const handleDelete = (id: number) => Modal.confirm({
title: '항목 삭제',
content: '삭제하시겠습니까?',
onOk: async () => {
await operationApi.hqSettleDelete(id);
message.success('삭제 완료');
qc.invalidateQueries({ queryKey: ['hq-settle'] });
},
});
const actionCol: ColDef<HqSettleRow> = {
headerName: '액션',
width: 160,
pinned: 'right',
cellRenderer: (p: { data: HqSettleRow }) => (
<Space>
<PermissionButton menuCode="HQ_SETTLE" permCode="UPDATE" size="small" onClick={() => openEdit(p.data)}></PermissionButton>
<PermissionButton menuCode="HQ_SETTLE" permCode="DELETE" size="small" danger onClick={() => handleDelete(p.data.itemId)}></PermissionButton>
</Space>
),
};
return (
<PageContainer title="본사정산" description="GA 본사 수입/비용 정산 항목 관리">
{isError && (
<Alert type="warning" showIcon message="API 미응답" description="/api/hq-settles 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<SearchForm
conditions={[
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
]}
initialValues={{ settleMonth: dayjs() }}
onSearch={(v) => setSettleMonth(v.settleMonth as string ?? dayjs().format('YYYYMM'))}
onReset={() => setSettleMonth(dayjs().format('YYYYMM'))}
/>
{/* 손익 요약 카드 */}
{summary && (
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={8}>
<ProCard style={cardStyle}>
<Statistic title="총 수입" value={summary.incomeTotal} formatter={(v) => (v as number).toLocaleString()} suffix="원" valueStyle={{ color: COLOR_SUCCESS }} />
</ProCard>
</Col>
<Col span={8}>
<ProCard style={cardStyle}>
<Statistic title="총 비용" value={summary.expenseTotal} formatter={(v) => (v as number).toLocaleString()} suffix="원" valueStyle={{ color: COLOR_ERROR }} />
</ProCard>
</Col>
<Col span={8}>
<ProCard style={cardStyle}>
<Statistic
title="손익"
value={summary.profitLoss}
formatter={(v) => (v as number).toLocaleString()}
suffix="원"
valueStyle={{ color: summary.profitLoss >= 0 ? COLOR_SUCCESS : COLOR_ERROR }}
/>
</ProCard>
</Col>
</Row>
)}
<Tabs
defaultActiveKey="items"
items={[
{
key: 'items',
label: '정산 항목',
children: (
<ProCard style={cardStyle}>
<div style={{ marginBottom: 12 }}>
<PermissionButton menuCode="HQ_SETTLE" permCode="CREATE" type="primary" onClick={openCreate}>
+
</PermissionButton>
</div>
<DataGrid<HqSettleRow>
rows={rows}
columns={[...COLS, actionCol]}
loading={isLoading}
height={520}
rowKey="itemId"
pinnedBottomRow={rows.length > 0 ? {
itemName: '합계',
amount: rows.reduce((s, r) => s + (r.amount ?? 0), 0),
} as Partial<HqSettleRow> : undefined}
/>
</ProCard>
),
},
]}
/>
<Modal
title={editingItem ? '항목 수정' : '항목 등록'}
open={modalOpen}
onOk={handleSave}
onCancel={() => setModalOpen(false)}
okText="저장"
cancelText="취소"
>
<Form form={form} layout="vertical">
<Form.Item name="settleMonth" label="정산월(YYYYMM)" rules={[{ required: true }]}>
<Input placeholder="202506" />
</Form.Item>
<Form.Item name="itemType" label="항목유형" rules={[{ required: true }]}>
<Input placeholder="INCOME / EXPENSE" />
</Form.Item>
<Form.Item name="itemName" label="항목명" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="amount" label="금액" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} min={0} />
</Form.Item>
<Form.Item name="memo" label="메모">
<Input.TextArea rows={3} />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}
+154
View File
@@ -0,0 +1,154 @@
import { useState } from 'react';
import { Alert, Button, Form, Input, Modal, Space, Tag, message } from 'antd';
import { ProCard } from '@ant-design/pro-components';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { ColDef } 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 { operationApi, StepCloseRow } from '@/api/operation';
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm };
const STATUS_COLOR: Record<string, string> = { OPEN: 'green', CLOSED: 'red' };
const STATUS_LABEL: Record<string, string> = { OPEN: '열림', CLOSED: '마감' };
const STEP_LABEL: Record<string, string> = { RECEIVE: '수신마감', CALC: '계산마감', VERIFY: '검증마감', PAY: '지급마감' };
const COLS: ColDef<StepCloseRow>[] = [
{ field: 'settleMonth', headerName: '정산월', width: 110 },
{ field: 'stepSeq', headerName: '순서', width: 70, type: 'numericColumn' },
{ field: 'stepCode', headerName: '단계코드', width: 120,
valueFormatter: (p) => STEP_LABEL[p.value as string] ?? p.value },
{ field: 'status', headerName: '상태', width: 100,
cellRenderer: (p: { value: string }) => (
<Tag color={STATUS_COLOR[p.value] ?? 'default'}>{STATUS_LABEL[p.value] ?? p.value}</Tag>
) },
{ field: 'closedAt', headerName: '마감일시', flex: 1 },
];
export default function StepClose() {
const qc = useQueryClient();
const [searchParams, setSearchParams] = useState<{ settleMonth?: string }>({
settleMonth: dayjs().format('YYYYMM'),
});
const [createOpen, setCreateOpen] = useState(false);
const [form] = Form.useForm();
const { data, isLoading, isError } = useQuery({
queryKey: ['step-close', 'list', searchParams],
queryFn: () => operationApi.stepCloseList(searchParams),
retry: false,
});
const rows = data?.list ?? [];
const handleClose = (id: number) => Modal.confirm({
title: '단계 마감',
content: '해당 단계를 마감하시겠습니까?',
onOk: async () => {
await operationApi.stepCloseClose(id);
message.success('마감 완료');
qc.invalidateQueries({ queryKey: ['step-close', 'list'] });
},
});
const handleReopen = (id: number) => Modal.confirm({
title: '단계 재오픈',
content: '해당 단계를 재오픈하시겠습니까?',
onOk: async () => {
await operationApi.stepCloseReopen(id);
message.success('재오픈 완료');
qc.invalidateQueries({ queryKey: ['step-close', 'list'] });
},
});
const handleCreate = async () => {
const values = await form.validateFields();
try {
await operationApi.stepCloseCreate(values);
message.success('등록 완료');
qc.invalidateQueries({ queryKey: ['step-close', 'list'] });
setCreateOpen(false);
form.resetFields();
} catch { /* request.ts 처리 */ }
};
const actionCol: ColDef<StepCloseRow> = {
headerName: '액션',
width: 160,
pinned: 'right',
cellRenderer: (p: { data: StepCloseRow }) => (
<Space>
{p.data.status === 'OPEN' ? (
<PermissionButton
menuCode="STEP_CLOSE"
permCode="APPROVE"
size="small"
type="primary"
onClick={() => handleClose(p.data.stepCloseId)}
>
</PermissionButton>
) : (
<PermissionButton
menuCode="STEP_CLOSE"
permCode="APPROVE"
size="small"
onClick={() => handleReopen(p.data.stepCloseId)}
>
</PermissionButton>
)}
</Space>
),
};
return (
<PageContainer title="단계마감" description="월 정산 단계별 마감 관리">
{isError && (
<Alert type="warning" showIcon message="API 미응답" description="/api/step-closes 를 확인하세요." style={{ marginBottom: 16 }} />
)}
<SearchForm
conditions={[
{ type: 'month', name: 'settleMonth', label: '정산월', span: 6 },
]}
initialValues={{ settleMonth: dayjs() }}
onSearch={(v) => setSearchParams({ settleMonth: v.settleMonth as string })}
onReset={() => setSearchParams({ settleMonth: dayjs().format('YYYYMM') })}
/>
<ProCard style={cardStyle}>
<div style={{ marginBottom: 12 }}>
<PermissionButton menuCode="STEP_CLOSE" permCode="CREATE" type="primary" onClick={() => setCreateOpen(true)}>
+
</PermissionButton>
</div>
<DataGrid<StepCloseRow>
rows={rows}
columns={[...COLS, actionCol]}
loading={isLoading}
height={520}
rowKey="stepCloseId"
/>
</ProCard>
<Modal title="단계 등록" open={createOpen} onOk={handleCreate} onCancel={() => { setCreateOpen(false); form.resetFields(); }} okText="저장" cancelText="취소">
<Form form={form} layout="vertical">
<Form.Item name="settleMonth" label="정산월(YYYYMM)" rules={[{ required: true }]}>
<Input placeholder="202506" />
</Form.Item>
<Form.Item name="stepCode" label="단계코드" rules={[{ required: true }]}>
<Input placeholder="RECEIVE / CALC / VERIFY / PAY" />
</Form.Item>
<Form.Item name="stepSeq" label="순서" rules={[{ required: true }]}>
<Input type="number" placeholder="1~4" />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}