동기화

This commit is contained in:
GA Pro
2026-05-14 01:21:27 +09:00
parent acea2afce5
commit 958fe6b980
379 changed files with 21786 additions and 423 deletions
+505
View File
@@ -0,0 +1,505 @@
# GA 수수료 정산 시스템 — 개발자 가이드
신입 개발자가 **5분 안에 첫 PR**을 보낼 수 있도록 작성한 온보딩 문서.
README.md 의 "실행 가이드"를 먼저 읽고 로컬 환경을 기동한 다음 이 문서로 넘어올 것.
---
## 1. 환경 설정
### 필수 설치
| 도구 | 버전 | 확인 명령 |
|------|------|-----------|
| JDK | 17 이상 | `java -version` |
| Node.js | 18 이상 | `node -v` |
| Docker Desktop | 최신 | `docker version` |
| IntelliJ IDEA | 2023+ 권장 | — |
### 선택: 로컬 DB/Redis (Docker 권장)
```bash
# Docker 로 PostgreSQL + Redis 한 번에 띄우기
docker compose up -d postgres redis
```
운영 DB(`192.168.0.60:55432/trading_ai/ga`)에 바로 붙을 경우 Docker 불필요.
### 백엔드 실행
```bash
# 프로젝트 루트에서
./gradlew :ga-api:bootRun --args='--spring.profiles.active=trading_ai'
# 로컬 DB 사용 시 (기본 프로파일)
./gradlew :ga-api:bootRun
```
### 프론트엔드 실행
```bash
cd ga-frontend
npm install
npm run dev # http://localhost:3000
```
`/api/*` 요청은 Vite 프록시로 `http://localhost:8080`에 자동 전달됩니다.
---
## 2. 프로젝트 구조
```
ga-commission-system/
├── ga-common/ # 공통 프레임워크
│ ├── annotation/ # @RequirePermission, @DataChangeLog, @ExcelColumn
│ ├── aop/ # PermissionAspect, DataChangeLogAspect, ApiLogAspect
│ ├── auth/ # JWT 필터/프로바이더
│ ├── code/ # 공통코드 서비스 (Redis 캐시)
│ ├── excel/ # SXSSF 다운로드 / SAX 업로드
│ └── db/migration/ # V1~V55 Flyway SQL (모든 마이그레이션은 여기)
├── ga-core/ # 도메인 코어 — VO, Mapper, XML
│ ├── vo/ # {도메인}VO + {도메인}Resp + {도메인}SaveReq + {도메인}SearchParam
│ ├── mapper/ # @Mapper 인터페이스 (패키지는 도메인별)
│ └── (resources)/mapper/ # MyBatis XML
├── ga-api/ # REST API — Service + Controller
│ ├── auth/ # 로그인/토큰 갱신
│ ├── controller/ # 도메인별 Controller
│ └── service/ # 도메인별 Service
├── ga-batch/ # Spring Batch — 월 정산 잡 (8 Step)
└── ga-frontend/ # React 18 + Vite + TypeScript
├── src/
│ ├── components/common/ # SearchForm, DataGrid, PermissionButton, ExcelExportButton, CodeSelect, CodeBadge
│ ├── components/biz/ # ApprovalProgress, AttachmentUpload, NotificationBell
│ ├── pages/ # 도메인별 페이지 (50+)
│ ├── hooks/ # usePermission, useCommonCodes
│ └── theme/tokens.ts # 디자인 토큰 (GRAY, RADIUS, SHADOW, COLOR_*)
└── nginx.conf # SPA + /api 리버스 프록시
```
---
## 3. 한 도메인 추가 — 표준 작업 흐름 (5분 컷)
아래 순서로 작성하면 메뉴 등록부터 화면까지 일관됩니다.
### Step 1. DB 마이그레이션 (dba)
`ga-common/src/main/resources/db/migration/V{N+1}__{도메인명}.sql` 생성.
```sql
-- 예: V56__회계전표.sql
CREATE TABLE IF NOT EXISTS ga.accounting_entry (
id BIGSERIAL PRIMARY KEY,
settle_month CHAR(6) NOT NULL,
entry_type VARCHAR(20) NOT NULL, -- DEBIT / CREDIT
amount NUMERIC(18,2) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 메뉴 등록 (menu 테이블)
INSERT INTO ga.menu (menu_code, parent_code, menu_name, url, sort_order, is_use)
VALUES ('ACCOUNTING_ENTRY', 'ACCOUNTING', '회계 전표', '/accounting/entries', 10, true)
ON CONFLICT (menu_code) DO NOTHING;
```
번호 규칙: 기존 최대 번호 + 1. 현재 마지막은 V55.
### Step 2. VO 작성 (core)
`ga-core/src/main/java/com/ga/core/vo/{도메인}/` 아래 4종 생성.
```java
// 검색 파라미터
@Data
public class AccountingEntrySearchParam extends SearchParam {
private String settleMonth;
private String entryType;
}
// 응답
@Data
public class AccountingEntryResp {
private Long id;
private String settleMonth;
private String entryType;
@JsonFormat(shape = JsonFormat.Shape.STRING) private BigDecimal amount;
private LocalDateTime createdAt;
}
// 저장 요청
@Data
public class AccountingEntrySaveReq {
@NotBlank private String settleMonth;
@NotBlank private String entryType;
@NotNull private BigDecimal amount;
}
// Enum (공통코드 기반 처리는 String으로, 타입 안전성이 필요할 때만 Enum 사용)
public enum EntryType {
DEBIT, CREDIT;
@JsonValue public String value() { return name(); }
}
```
### Step 3. Mapper Interface + XML (core)
```java
// ga-core/.../mapper/accounting/AccountingEntryMapper.java
@Mapper
public interface AccountingEntryMapper {
List<AccountingEntryResp> selectList(AccountingEntrySearchParam p);
AccountingEntryResp selectById(Long id);
int insert(AccountingEntrySaveReq req);
int update(AccountingEntrySaveReq req);
int delete(Long id);
}
```
XML은 `ga-core/src/main/resources/mapper/accounting/AccountingEntryMapper.xml`:
```xml
<mapper namespace="com.ga.core.mapper.accounting.AccountingEntryMapper">
<select id="selectList" resultType="com.ga.core.vo.accounting.AccountingEntryResp">
SELECT id, settle_month, entry_type, amount, created_at
FROM ga.accounting_entry
<where>
<if test="settleMonth != null">AND settle_month = #{settleMonth}</if>
<if test="entryType != null">AND entry_type = #{entryType}</if>
</where>
ORDER BY id DESC
</select>
</mapper>
```
### Step 4. Service (api)
```java
// ga-api/.../service/accounting/AccountingEntryService.java
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AccountingEntryService {
private final AccountingEntryMapper mapper;
public PageResponse<AccountingEntryResp> list(AccountingEntrySearchParam p) {
p.startPage(); // PageHelper 트리거
return PageResponse.of(mapper.selectList(p));
}
@Transactional
@DataChangeLog(target = "accounting_entry", action = "INSERT")
public void create(AccountingEntrySaveReq req) {
mapper.insert(req);
}
}
```
### Step 5. Controller (api)
```java
// ga-api/.../controller/accounting/AccountingEntryController.java
@RestController
@RequestMapping("/api/accounting-entries")
@RequiredArgsConstructor
public class AccountingEntryController {
private final AccountingEntryService service;
@GetMapping
@RequirePermission(menu = "ACCOUNTING_ENTRY", perm = "READ")
public ApiResponse<PageResponse<AccountingEntryResp>> list(AccountingEntrySearchParam p) {
return ApiResponse.ok(service.list(p));
}
@PostMapping
@RequirePermission(menu = "ACCOUNTING_ENTRY", perm = "CREATE")
public ApiResponse<Void> create(@RequestBody @Valid AccountingEntrySaveReq req) {
service.create(req);
return ApiResponse.ok();
}
}
```
URL 규칙: `/api/{리소스s}` — 복수형 + kebab-case. 예) `/api/accounting-entries`, `/api/agent-licenses`.
### Step 6. React 페이지 (frontend)
`ga-frontend/src/pages/accounting/AccountingEntries.tsx`:
```tsx
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import PageContainer from '@/components/common/PageContainer';
import SearchForm, { SearchCondition } from '@/components/common/SearchForm';
import DataGrid from '@/components/common/DataGrid';
import ExcelExportButton from '@/components/common/ExcelExportButton';
import PermissionButton from '@/components/common/PermissionButton';
import type { ColDef } from '@ag-grid-community/core';
interface Row { id: number; settleMonth: string; entryType: string; amount: string; }
const CONDITIONS: SearchCondition[] = [
{ type: 'month', name: 'settleMonth', label: '정산월' },
{ type: 'code', name: 'entryType', label: '유형', groupCode: 'ENTRY_TYPE' },
];
const COLUMNS: ColDef<Row>[] = [
{ field: 'settleMonth', headerName: '정산월', width: 120 },
{ field: 'entryType', headerName: '유형', width: 100 },
{ field: 'amount', headerName: '금액', width: 150, type: 'numericColumn' },
];
export default function AccountingEntries() {
const [params, setParams] = useState<Record<string, unknown>>({});
const { data, isLoading } = useQuery({
queryKey: ['accounting-entries', params],
queryFn: () => axios.get('/api/accounting-entries', { params }).then(r => r.data.data),
});
return (
<PageContainer title="회계 전표">
<SearchForm conditions={CONDITIONS} onSearch={setParams} />
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8, gap: 8 }}>
<ExcelExportButton url="/api/accounting-entries/excel" params={params} />
<PermissionButton menuCode="ACCOUNTING_ENTRY" permCode="CREATE" type="primary">
</PermissionButton>
</div>
<DataGrid rows={data?.list} columns={COLUMNS} loading={isLoading} />
</PageContainer>
);
}
```
라우터 등록은 `App.tsx``<Routes>` 블록에 `<Route path="/accounting/entries" element={<AccountingEntries />} />` 한 줄 추가.
---
## 4. 자주 쓰는 패턴
### CodeSelect — 공통코드 드롭다운
```tsx
import CodeSelect from '@/components/common/CodeSelect';
// groupCode 는 V9/V12/V55 에 등록된 공통코드 그룹 코드
<CodeSelect groupCode="SETTLE_STATUS" value={status} onChange={setStatus} />
```
### ExcelExportButton — 엑셀 다운로드
```tsx
<ExcelExportButton
url="/api/recruit-ledger/excel"
params={params} // 현재 검색 조건 그대로 전달
label="엑셀 다운로드"
/>
```
백엔드 엔드포인트는 `ExcelService.download(mapper::selectList, columns, response)` 패턴으로 구현.
### CodeBadge — 상태 배지
```tsx
import CodeBadge from '@/components/common/CodeBadge';
// status 값에 해당하는 공통코드 label + 색상 자동 매핑
<CodeBadge groupCode="DISPUTE_STATUS" value={row.status} />
```
### PermissionButton — 권한 게이트
```tsx
<PermissionButton menuCode="PERIOD_CLOSE" permCode="APPROVE">
</PermissionButton>
```
권한이 없으면 렌더링 자체를 하지 않습니다 (`return null`).
### AG Grid 서버사이드 페이징
`DataGrid` 컴포넌트는 `ClientSideRowModelModule`을 사용하고 페이징은 API에서 PageHelper 결과를 받아 컴포넌트 외부에서 처리합니다. 100만건 이상의 대용량 화면에서는 `ServerSideRowModelModule`로 교체하세요.
```tsx
// 대용량 — AG Grid ServerSide 직접 사용
const datasource = {
getRows: (params) => {
axios.get('/api/recruit-ledger', { params: { page: params.request.startRow / 100 + 1, size: 100 } })
.then(r => params.successCallback(r.data.data.list, r.data.data.total));
},
};
```
---
## 5. 디자인 토큰 사용법
모든 색상·간격·그림자는 `@/theme/tokens.ts` 에서 임포트합니다. Inline style 에 직접 색상 코드를 쓰지 마세요.
```tsx
import { GRAY, RADIUS, SHADOW, COLOR_PRIMARY, COLOR_ERROR } from '@/theme/tokens';
// 카드 스타일 예시
<div style={{
background: '#fff',
borderRadius: RADIUS.lg, // 16px
boxShadow: SHADOW.md,
border: `1px solid ${GRAY[200]}`,
padding: 24,
}}>
// 경고 텍스트
<span style={{ color: COLOR_ERROR }}> </span>
// 비활성 텍스트
<span style={{ color: GRAY[500] }}> </span>
```
---
## 6. 결재 워크플로우 패턴
결재가 필요한 도메인(withdraw_request, commission_dispute, settle_master 확정, incentive_payment 등)은 `approval_request` 테이블을 통해 처리합니다.
```java
// target_type 은 공통코드 APPROVAL_TARGET 그룹에 등록
// withdraw_request 결재 신청 예시 (Service)
ApprovalRequestSaveReq req = new ApprovalRequestSaveReq();
req.setTargetType("WITHDRAW"); // WITHDRAW / SETTLE / DISPUTE / INCENTIVE
req.setTargetId(withdrawId);
req.setApprovalLineCode("BASIC_3"); // approval_line.line_code
approvalRequestMapper.insert(req);
```
프론트에서는 `ApprovalProgress` 컴포넌트로 현재 결재 단계를 시각화합니다.
```tsx
import ApprovalProgress from '@/components/biz/ApprovalProgress';
<ApprovalProgress targetType="WITHDRAW" targetId={withdrawId} />
```
---
## 7. @RequirePermission / @DataChangeLog / @JsonValue Enum
### @RequirePermission
```java
@GetMapping("/export")
@RequirePermission(menu = "RECRUIT_LEDGER", perm = "EXPORT")
public void exportExcel(HttpServletResponse response) { ... }
```
menu 값은 `ga.menu.menu_code`와 일치해야 합니다. 불일치하면 403.
### @DataChangeLog
```java
@Transactional
@DataChangeLog(target = "agent", action = "UPDATE")
public void update(Long id, AgentSaveReq req) {
mapper.update(id, req);
}
// → data_change_log 에 before/after JSON 자동 저장 (AOP)
```
action 값: INSERT / UPDATE / DELETE / APPROVE / REJECT.
### @JsonValue Enum
```java
public enum CommissionType {
RECRUIT("모집"), MAINTAIN("유지"), COLLECTION("수금"),
OVERRIDE("오버라이드"), RENEWAL("갱신"), REINSTATE("부활"),
PERSIST("유지보수수당"), INCENTIVE("시책"), COMMISSION("위촉");
private final String label;
CommissionType(String label) { this.label = label; }
@JsonValue // JSON 직렬화 시 name() 반환
public String value() { return name(); }
public String label() { return label; }
}
```
---
## 8. AG Grid vs Ant Design Table 사용 기준
| 상황 | 컴포넌트 |
|------|----------|
| 일반 조회 목록 (< 5만건) | `DataGrid` (내부적으로 AG Grid) |
| 엑셀과 동일한 인라인 편집 필요 | AG Grid `editable: true` 직접 사용 |
| 단순 설정/마스터 테이블 (< 100행) | Ant Design `Table` 직접 사용 |
| 대용량 서버사이드 (> 10만건) | AG Grid `ServerSideRowModelModule` |
| KPI/집계 합계행 있는 화면 | `DataGrid``pinnedBottomRow` prop |
---
## 9. 트러블슈팅 FAQ
### Q. @RequirePermission 에서 403 이 발생함
menu 코드가 `ga.menu.menu_code` 에 없거나 해당 역할에 권한이 없는 경우입니다.
1. `SELECT * FROM ga.menu WHERE menu_code = 'FOO';` — 메뉴가 존재하는지 확인
2. `SELECT * FROM ga.role_menu_permission WHERE menu_code = 'FOO';` — 권한 매트릭스 확인
3. V54 메뉴 INSERT SQL 이 운영 DB 에 적용됐는지 확인 (ga-api 재시작 필요)
### Q. Redis 캐시와 DB 공통코드가 다름
`POST /api/codes/refresh` 를 호출하면 Redis 공통코드 캐시를 전체 갱신합니다.
또는 Redis CLI 에서 `DEL ga:codes:*` 로 수동 삭제 후 재조회.
### Q. MyBatis XML "No result found" / "column not found"
- resultType 의 필드명이 SQL 컬럼명과 다른 경우: XML 에 `<resultMap>` 또는 SQL 에 alias 사용
- `snake_case``camelCase` 자동 변환은 `MyBatisConfig``mapUnderscoreToCamelCase=true` 설정되어 있어 기본 동작
### Q. Flyway 마이그레이션 체크섬 에러
이미 적용된 SQL 파일을 수정하면 발생합니다. 수정이 필요하면 새 버전 파일(`V{N+1}__fix.sql`)을 추가하세요.
개발 환경 긴급 수정 시: `DELETE FROM ga.flyway_schema_history WHERE version = '55';` 후 재시작.
### Q. AG Grid 화면이 흰 화면으로 보임
`@ag-grid-community/styles/ag-grid.css``ag-theme-quartz.css` import 가 `DataGrid.tsx` 에 있습니다.
페이지에서 `DataGrid` 컴포넌트를 사용하면 자동으로 적용됩니다. 직접 `AgGridReact` 를 사용할 때는 수동으로 import 필요.
### Q. 프론트 401 "토큰이 만료되었습니다"
`localStorage.removeItem('accessToken')``/login` 으로 재로그인.
React Query 의 `onError` 인터셉터에서 401 시 자동 `/login` 리다이렉트가 설정되어 있습니다.
### Q. 빌드 시 "Cannot find symbol" — Mapper 메서드 누락
`ga-core` 의 Mapper 인터페이스에 메서드가 없으면 `ga-api` 서비스에서 컴파일 에러.
`ga-core` 의 해당 Mapper 와 XML 에 메서드를 먼저 추가하고 `ga-api` 를 빌드하세요.
### Q. 운영 DB 에서 P2~P4 API 가 "relation does not exist" 에러
운영 DB 에는 V17(또는 V22) 까지만 적용되어 있습니다.
`ga-api 를 재시작`하면 Flyway 가 V18~V55 를 자동 순차 적용합니다.
---
## 10. 코드 컨벤션
### VO 네이밍
| 클래스 | 용도 | 예시 |
|--------|------|------|
| `{도메인}VO` | DB row 1:1 매핑 (내부 전용) | `AgentVO` |
| `{도메인}Resp` | API 응답 | `AgentResp` |
| `{도메인}SaveReq` | 등록/수정 요청 (공용) | `AgentSaveReq` |
| `{도메인}SearchParam` | 검색 조건 + 페이징 | `AgentSearchParam` |
### Mapper XML 규칙
- namespace 는 Mapper 인터페이스 FQCN 과 정확히 일치
- resultType 은 항상 Resp 클래스 사용 (VO 직접 반환 금지)
- 동적 조건은 `<where>` + `<if>` 사용 (`1=1` AND 패턴 금지)
- 대량 INSERT 는 `<foreach>` batch 사용
### API URL 규칙
- `/api/{리소스s}` — 복수형 kebab-case
- 예: `/api/agent-contracts`, `/api/commission-disputes`, `/api/period-closes`
- 단건 조회: `GET /api/{리소스s}/{id}`
- 상태 변경: `POST /api/{리소스s}/{id}/approve` (동사 사용 허용)
+145
View File
@@ -0,0 +1,145 @@
# 도메인 GAP 분석 & P4 기획서
작성일: 2026-05-14
작성자: PL (자율 모드)
대상: 한국 GA(법인보험대리점) 수수료 정산 시스템
---
## 1. 현재까지의 도메인 커버리지 (P0~P3 + 부속)
| 영역 | 적용 항목 |
|---|---|
| **세무** (P0-1) | 사업소득 3.3% / 기타소득 8.8% / 지방세 10% / VAT 10% |
| **공제** (P0-2) | 월별 차감 마스터(agent_deduction) + 이체 명세 |
| **명세서** (P0-3) | commission_statement + Excel 출력 |
| **1200%룰** (P1-1) | regulatory_limit + 한도 검증 + 분급 큐 |
| **분급** (P1-2) | installment_plan 7년 (35/25/15/10/7/5/3) |
| **차등환수** (P1-3) | chargeback_grade 5단계 |
| **마감/정정/이의/보류** (P2-1~4) | period_close / settle_correction / commission_dispute / hold_reason_code |
| **시책·해촉·세금계산서·외부회신** (P2-5~8) | incentive_program/payment / termination_settlement / tax_invoice / carrier_reconciliation_out |
| **KPI** (P3) | mv_monthly/org/retention/cumulative + Service + 화면 |
---
## 2. 한국 GA 도메인에서 **여전히 빠진** 항목 (검토)
### 2-A. 영업/계약 라이프사이클 (필수)
-**청약(Proposal)** → 청약→인수심사→체결 단계. 청약 수당(소액) 처리 부재
-**인수심사 결과(Underwriting Result)** — 거절/조건부 승인/할증 결과 추적
-**계약 변경(Endorsement)** — 보험료/피보험자/수익자 변경 시 수수료 재계산 트리거
-**부활(Reinstatement)** — 실효된 계약 부활 시 환수 처리 + 수수료 재산정
-**계약 양도(Assignment)** — 설계사 간 계약 이관 → 수수료 재배분
### 2-B. 설계사 라이프사이클 (필수)
-**위촉계약(Agent Contract)** — 보험사-설계사 위촉계약서 + 위촉수수료
-**자격 관리(License)** — 자격증 만료/갱신 알림 (생보/손보/변액)
-**보수교육(Training)** — 의무 교육 시간 추적 (감독규정 12시간/년)
-**징계 이력(Discipline)** — 불완전판매 제재 → 환수 가중치
-**휴직(Suspension)** — 활동중지 기간 정산 제외
### 2-C. 회계/재무 (필수)
-**회계 전표(Accounting Entry)** — 수수료 지급액 자동 분개
-**출금 신청 워크플로우(Withdraw Request)** — 결재 → 펌뱅킹 → 결과
-**원천세 신고서(Tax Report)** — 분기 사업소득세 신고 자료 집계
-**부가세 신고 자료** — 매출/매입 집계 (세금계산서 기반)
-**결산(Period Settlement)** — period_close 와 별개의 월/분기/연 회계 마감
### 2-D. 컴플라이언스 (중요)
-**민원 관리(Complaint)** — 고객 민원 → 설계사 책임 추적 → 환수 트리거
-**불완전판매 모니터링** — 13M/25M 유지율 기반 이상 탐지
-**상품판매 자격 매트릭스** — 변액=변액자격필요, 매핑 검증
### 2-E. 시스템/UX (필수)
-**결재선/결재 요청(Approval)** — 정산 확정/이의/시책 결재 워크플로우
-**공지사항(Notice)** + **개인 알림(UserNotification)**
-**첨부파일(Attachment)** — 위촉계약서/민원자료 등 문서 저장
-**사용자 알림 채널** — 메일/SMS 발송 큐
### 2-F. 분석/리포트 보강 (있으면 좋음)
-**이상치 탐지** (Outlier KPI)
-**차월 예측** (예상 정산액)
-**연말 지급명세서** — 설계사 연 사업소득 세무 자료 PDF
---
## 3. 우선순위 결정 (PL 판단)
> 추가 검토 (2026-05-14): 사용자 지적 — **"오버라이드 / 수금수수료 같은 수수료 종류 정보가 많이 빠짐"**.
> P4-A 로 **수수료 종류 보강** 5개 도메인을 우선 배치하고, 운영 9 도메인을 P4-B 로 후위 배치.
### **P4-A — 수수료 종류 보강 (5 도메인)** ⭐ 사용자 지적 반영
| # | 도메인 | 마이그레이션 | 핵심 |
|---|---|---|---|
| P4-A-1 | **commission_type 코드 + 통합 마스터** | V40 | 모집/유지/수금/오버라이드/갱신/부활/유지보수수당/시책/위촉 9종 통합 코드 + commission_master 마스터 |
| P4-A-2 | **수금수수료 (collection_commission_rule + collection_commission_ledger)** | V41 | 보험료 수납 단위(월) 발생. 납입주기별 차등 + 원장 |
| P4-A-3 | **갱신/부활 수수료 (renewal_commission, reinstatement_commission)** | V42 | 자동차/실비 갱신 + 실효 부활 시 별도 수수료 룰·원장 |
| P4-A-4 | **유지보수수당 (persistency_bonus_rule + persistency_bonus)** | V43 | 13M/25M/37M 유지율 충족 1회 지급. 등급별 정액 또는 정률 |
| P4-A-5 | **오버라이드 보강 (override_ledger + override_summary)** | V44 | 기존 override_rule + 8 Step 결과를 **원장 단위로 추적**. 직급별/조직별 합계 + 검증 |
### **P4-B — 운영 필수 9 도메인** (P4-A 완료 후)
| # | 도메인 | 마이그레이션 | 핵심 |
|---|---|---|---|
| P4-B-1 | **위촉계약 (agent_contract)** | V45 | 보험사-설계사 위촉계약서 + 위촉수수료 + 효력 기간 |
| P4-B-2 | **자격 관리 (agent_license)** | V46 | 생보/손보/변액 자격증 + 만료일 + 갱신 이력 |
| P4-B-3 | **보수교육 (agent_training)** | V47 | 교육 이수 시간 + 의무 12시간/년 충족 여부 |
| P4-B-4 | **계약 변경 (contract_endorsement)** | V48 | 변경 유형(보험료/수익자/피보험자) + 수수료 재계산 트리거 |
| P4-B-5 | **민원 관리 (complaint)** | V49 | 고객 민원 + 설계사 책임 + 처리 상태 + 환수 연동 |
| P4-B-6 | **결재 워크플로우 (approval_line + approval_request)** | V50 | 결재선 마스터 + 다단 결재 요청/처리 |
| P4-B-7 | **공지사항 + 알림 (notice + user_notification)** | V51 | 시스템 공지 + 사용자별 알림 (읽음 여부) |
| P4-B-8 | **첨부파일 (attachment)** | V52 | 도메인 무관 공통 첨부 (소속/파일경로/타입) |
| P4-B-9 | **출금 신청 (withdraw_request)** | V53 | 결재 + 펌뱅킹 송신 + 결과 반영 워크플로우 |
메뉴 INSERT 는 **V54** 한 파일에서 P4-A + P4-B 14개 메뉴 한 번에 등록.
### **P5 — 회계/분석 보강** (별도 라운드)
- 회계 전표 자동 분개 / 원천세·부가세 신고 자료 / 결산 / 이상치·예측 KPI / 연말 지급명세서
### **P6 — 모바일/외부 연동** (제일 후속)
- 펌뱅킹 실연동 / 모바일 셀프 조회 / SMS/카카오 발송
이번 라운드는 **P4 만** 일괄 진행. P5/P6 는 다음 라운드에서.
---
## 4. P4 이행 계획 (Wave 방식, 이미 검증된 패턴)
| Wave | 담당 | 산출물 |
|---|---|---|
| Wave 1 | dba-architect | V40~V48 9개 + V49 메뉴 INSERT (총 10 파일) |
| Wave 2 | core-architect | P4 VO/Resp/SaveReq/SearchParam + Mapper(IF+XML) + Enum |
| Wave 3 | api-developer | Service + Controller + 결재 AOP (또는 Service 가드) |
| Wave 4 | frontend-developer | 화면 9개 (토스 톤 일관) + 라우터 + Route 등록 |
| Wave 5 | infra-reviewer | 빌드/통합 검증 + README/HANDOFF 갱신 + 메뉴 적용 |
병렬화: Wave 1↔3 의존, Wave 4 는 Wave 3 후, Wave 5 는 마지막. 단 #8 KPI 대시보드 진행 중이므로 frontend Wave 4 는 #8 완료 후.
---
## 5. 도메인 가정값 (P4 채택)
| 항목 | 표준값 | 근거 |
|---|---|---|
| 보수교육 의무 시간 | 12 시간/년 | 보험업법 시행령 제17조 |
| 자격증 갱신 주기 | 5 년 | 보험업감독규정 |
| 민원 환수 기준 | 13M 이내 민원 = 환수 100% | 일반 GA 사내 규정 가정 |
| 결재 최대 단계 | 3 단계 | 일반 운영 가정 |
| 첨부 최대 크기 | 50 MB | 시스템 기본값 |
| 출금 신청 마감 | 매월 25일 | 정산일 익월 5일 가정 |
가정값 → system_config / approval_line / training_requirement 등 외부화 가능 구조로 등록.
---
## 6. 일정 추정
| Wave | 예상 시간 |
|---|---|
| Wave 1 (dba) | 5~7분 |
| Wave 2 (core) | 8~12분 |
| Wave 3 (api) | 6~10분 |
| Wave 4 (frontend, #8 후) | 10~15분 |
| Wave 5 (infra) | 5~8분 |
| **합계** | **35~50분** |
배치/AOP 같은 추가 통합은 P5 라운드로 미룸.
+80 -40
View File
@@ -1,4 +1,4 @@
# Handoff Note — 2026-05-12 # Handoff Note — 2026-05-14 (Round 2 완료)
다음 세션에서 이어가기 위한 핸드오프 문서. Agent Teams 기반 자동 체인으로 진행 중인 작업의 현재 상태와 잔여 작업, 재개 절차를 기록한다. 다음 세션에서 이어가기 위한 핸드오프 문서. Agent Teams 기반 자동 체인으로 진행 중인 작업의 현재 상태와 잔여 작업, 재개 절차를 기록한다.
@@ -64,48 +64,87 @@
- P1-3-b(core) + P1-1-c TODO 교체(api) 2명 동시 - P1-3-b(core) + P1-1-c TODO 교체(api) 2명 동시
- 모듈이 다른 사람끼리 묶어 머지 충돌 없음 - 모듈이 다른 사람끼리 묶어 머지 충돌 없음
## 3. 잔여 작업 ## 3. Round 2 완료 사항 (2026-05-14)
### 3-1. P1 정산 통합 (보류 권장) ### 3-1. P2 — 8 도메인 (V29~V36, ~50 VO·Mapper, 18 Service+Controller)
- ga-batch의 8 Step 정산 잡에 TaxCalculator/DeductionService/RegulatoryLimitChecker/InstallmentService/ChargebackCalculator 통합 | # | 도메인 | 마이그레이션 | 상태 |
- batch-engineer 작업
- 운영 비즈니스 룰 의존이 커 **인프라만 완성하고 통합은 사용자 결정 후** 진행 권장
- 보류 가능 — 현 시점에 인프라/CRUD/API는 모두 동작
### 3-2. 도메인 P2 — 8개 항목 (Wave 방식)
| # | 도메인 | 마이그레이션 | 핵심 |
|---|---|---|---| |---|---|---|---|
| P2-1 | 마감 (Period Close) | V29 | period_close 테이블 + 마감 후 수정 차단 AOP | | P2-1 | 마감 (Period Close) | V29 | 완료 |
| P2-2 | 정정 (Correction) | V30 | settle_correction + 원본 참조 + 정정 사유 | | P2-2 | 정정 (Correction) | V30 | 완료 |
| P2-3 | 이의 신청 (Dispute) | V31 | commission_dispute + OPEN/IN_REVIEW/APPROVED/REJECTED | | P2-3 | 이의신청 (Dispute) | V31 | 완료 |
| P2-4 | 보류 사유 (Hold Reasons) | V32 | settle_master.hold_reason_code + HOLD_REASON 코드그룹 | | P2-4 | 보류사유 (Hold Reasons) | V32 | 완료 |
| P2-5 | 시책·인센티브 | V33 | incentive_program + incentive_payment | | P2-5 | 시책·인센티브 | V33 | 완료 |
| P2-6 | 해촉 정산 (Termination) | V34 | termination_settlement (agent_org_history 연계) | | P2-6 | 해촉정산 (Termination) | V34 | 완료 |
| P2-7 | 세금계산서 (Tax Invoice) | V35 | tax_invoice 발행 이력 (SUPPLY/PURCHASE) | | P2-7 | 세금계산서 (Tax Invoice) | V35 | 완료 |
| P2-8 | 외부 회신 | V36 | carrier_reconciliation_out (보험사 회신 인터페이스) | | P2-8 | 외부회신 | V36 | 완료 |
**Wave 디스패치 계획**: 화면: ga-frontend Wave 4 에서 P4-B 화면과 함께 작성됨.
- Wave 1 (dba): V29~V36 8개 마이그레이션 한 번에
- Wave 2 (core): 8개 도메인 VO/Mapper 한 번에
- Wave 3 (api): 8개 도메인 Service/Controller 한 번에
- 디스패치 24 → 3으로 축소
### 3-3. 도메인 P3 — KPI 대시보드 ### 3-2. P3 — KPI 대시보드 (V37~V39)
| 작업 | 담당 | - dba: mv_monthly_summary / mv_org_summary / mv_retention_rate / mv_cumulative (V37)
- dba: 리포트 메뉴 + KPI 메뉴 INSERT (V38, V39)
- core: KpiMapper.java + XML
- api: KpiService + KpiController (`/api/kpi/*`)
- frontend: KpiMonthlySummary / KpiOrgSummary / KpiRetention / KpiCumulative (4개 화면)
### 3-3. P4-A — 수수료 종류 보강 (V40~V44, 5 도메인)
| # | 도메인 | 마이그레이션 | 상태 |
|---|---|---|---|
| P4-A-1 | commission_type 코드 + commission_master 통합 | V40 | 완료 |
| P4-A-2 | 수금수수료 (collection_commission_rule/ledger) | V41 | 완료 |
| P4-A-3 | 갱신·부활수수료 (renewal/reinstatement) | V42 | 완료 |
| P4-A-4 | 유지보수수당 (persistency_bonus_rule/bonus) | V43 | 완료 |
| P4-A-5 | 오버라이드 원장 (override_ledger/summary) | V44 | 완료 |
### 3-4. P4-B — 운영 필수 9 도메인 (V45~V53)
| # | 도메인 | 마이그레이션 | 상태 |
|---|---|---|---|
| P4-B-1 | 위촉계약 (agent_contract) | V45 | 완료 |
| P4-B-2 | 자격관리 (agent_license) | V46 | 완료 |
| P4-B-3 | 보수교육 (agent_training) | V47 | 완료 |
| P4-B-4 | 계약변경 (contract_endorsement) | V48 | 완료 |
| P4-B-5 | 민원관리 (complaint) | V49 | 완료 |
| P4-B-6 | 결재워크플로우 (approval_line/request) | V50 | 완료 |
| P4-B-7 | 공지·알림 (notice/user_notification) | V51 | 완료 |
| P4-B-8 | 첨부파일 (attachment) | V52 | 완료 |
| P4-B-9 | 출금신청 (withdraw_request) | V53 | 완료 |
### 3-5. V54~V55 + 프론트
- V54: P4 메뉴 INSERT (P4-A + P4-B 14개 메뉴)
- V55: P4 공통코드 추가 (approval_type, complaint_type, license_type, withdraw_status 등)
- 프론트: ErrorBoundary 전역 적용 + 토스 디자인 시스템 토큰 (`RADIUS`, `GRAY`, `SHADOW`) 일관 적용
- 프론트 화면 추가: AgentContracts / AgentLicenses / AgentTrainings / Complaints / ContractEndorsements / CollectionCommission / CommissionMaster / OverrideLedger / PersistencyBonus / ReinstatementCommission / RenewalCommission / WithdrawRequest / Approvals / Notices (14개)
### 3-6. P1 정산 통합 (batch-engineer 완료)
- ga-batch 8 Step에 TaxCalculator / DeductionService / RegulatoryLimitChecker / InstallmentPlanGenerator / ChargebackCalculator 통합 완료
## 4. 잔여 작업 (P5/P6)
### P5 — 회계/분석 보강 (다음 라운드)
| 항목 | 설명 |
|---|---| |---|---|
| V37 Materialized View 4종 (월별/조직별/유지율/누계) | dba-architect | | 회계 전표 자동 분개 | 수수료 지급액 → 분개 엔트리 자동 생성 |
| KPI VO/Mapper | core-architect | | 원천세 신고 자료 | 분기별 사업소득세 집계 + 국세청 포맷 |
| KpiService + KpiController | api-developer | | 부가세 신고 자료 | tax_invoice 기반 매출/매입 집계 |
| KPI 대시보드 화면 (AG Grid 합계행, 차트) | frontend-developer | | 결산 | period_close 와 별개의 월/분기/연 회계 마감 |
| 이상치·예측 KPI | 13M/25M 유지율 이상 탐지 + 차월 예측 |
| 연말 지급명세서 | 설계사 연 사업소득 세무 자료 PDF |
### 3-4. 마무리 통합 ### P6 — 외부 연동 (P5 이후)
| 작업 | 담당 | | 항목 | 설명 |
|---|---| |---|---|
| ga-batch 8 Step에 도메인 로직 통합 (TaxCalculator/Deduction/Regulatory/Installment/Chargeback) | batch-engineer | | 펌뱅킹 실연동 | withdraw_request 결재 완료 → 은행 API 직접 호출 |
| 통합 테스트 | infra-reviewer | | SMS/카카오 발송 | user_notification 채널 실연동 |
| README/DEVELOPER_GUIDE 업데이트 | infra-reviewer | | 모바일 셀프 조회 | 설계사 전용 모바일 앱 or PWA |
## 4. 재개 절차 (다음 세션) ### 운영 DB 마이그레이션 (중요 공지)
운영 DB `192.168.0.60:55432/trading_ai/ga` 에는 현재 **V17(또는 V22) 까지만** 적용되어 있습니다.
`ga-api 를 재시작`하면 Flyway 가 V18~V55 를 자동 순차 적용합니다.
재시작 전까지 P2~P4 신규 도메인 API 는 테이블이 없어 동작하지 않습니다.
단위/통합 테스트는 SqlSessionFactory 환경 설정 문제로 아직 미작성 상태입니다.
## 5. 재개 절차 (다음 세션 — P5 라운드)
1. **Claude Code 시작 시 cwd 확인**: `C:\Users\kyu\Desktop\ga_pro` (현재 세션과 동일) 1. **Claude Code 시작 시 cwd 확인**: `C:\Users\kyu\Desktop\ga_pro` (현재 세션과 동일)
2. **Agent Teams 활성화 확인**: `.claude/settings.json` 이미 커밋되어 있음 (`418ff7c`) 2. **Agent Teams 활성화 확인**: `.claude/settings.json` 이미 커밋되어 있음 (`418ff7c`)
@@ -113,12 +152,13 @@
- PL 역할로 `TeamCreate(team_name="ga-pro-team", agent_type="pl", description="...")` 호출 - PL 역할로 `TeamCreate(team_name="ga-pro-team", agent_type="pl", description="...")` 호출
- 7명 팀원 spawn (common/core/api/batch/frontend/dba/infra) — 첫 세션과 동일 - 7명 팀원 spawn (common/core/api/batch/frontend/dba/infra) — 첫 세션과 동일
4. **HANDOFF.md 읽기**: 본 문서 + 최근 git log로 컨텍스트 확보 4. **HANDOFF.md 읽기**: 본 문서 + 최근 git log로 컨텍스트 확보
5. **권장 시작 지점**: P2 Wave 1 디스패치 (dba-architect에게 V29~V36 8개 마이그레이션) 5. **권장 시작 지점**: P5 Wave 1 dba-architect에게 V56~V61 (회계 전표 / 원천세 / 부가세 / 결산 / 이상치 / 연말 지급명세서)
6. **사용자 결정 필요 항목**: 6. **ga-api 재시작 필요**: 운영 DB 에 V18~V55 적용하려면 ga-api 재시작 후 Flyway 자동 실행 확인
- P1 정산 통합 진행 여부 (Wave 작업 전 또는 P3 후) 7. **사용자 결정 필요 항목**:
- P5 시작 전 ga-api 재시작해서 V18~V55 운영 DB 반영 여부
- P2-1 마감 도메인의 마감 후 수정 차단 AOP 구현 방식 (전역 AOP vs Service-level 가드) - P2-1 마감 도메인의 마감 후 수정 차단 AOP 구현 방식 (전역 AOP vs Service-level 가드)
## 5. PL 가이드 (다음 세션 유의사항) ## 6. PL 가이드 (다음 세션 유의사항)
- **시간차 중복 메시지**: 팀원이 SendMessage로 같은 보고를 2~3번 보내는 경우 있음. 짧게 acknowledge만. - **시간차 중복 메시지**: 팀원이 SendMessage로 같은 보고를 2~3번 보내는 경우 있음. 짧게 acknowledge만.
- **idle 알림**: 팀원이 작업 끝낼 때마다 idle 알림 보냄. 정상. 액션 필요 없음. - **idle 알림**: 팀원이 작업 끝낼 때마다 idle 알림 보냄. 정상. 액션 필요 없음.
@@ -129,7 +169,7 @@
- 빌드/테스트 실패 시 담당 팀원에 재지시 - 빌드/테스트 실패 시 담당 팀원에 재지시
- 컴파일은 PL이 직접 검증, 테스트는 환경 문제(SqlSessionFactory)로 통과 안 되는 게 알려진 상태 - 컴파일은 PL이 직접 검증, 테스트는 환경 문제(SqlSessionFactory)로 통과 안 되는 게 알려진 상태
## 6. 표준 가정 (도메인 작업 진행 시 채택한 값) ## 7. 표준 가정 (도메인 작업 진행 시 채택한 값)
도메인 룰은 한국 보험업감독규정 표준 가정을 외부화 가능한 구조로 등록. 실제 회사 정책과 다르면 system_config / regulatory_limit / installment_ratio / chargeback_grade의 데이터만 수정하면 됨. 도메인 룰은 한국 보험업감독규정 표준 가정을 외부화 가능한 구조로 등록. 실제 회사 정책과 다르면 system_config / regulatory_limit / installment_ratio / chargeback_grade의 데이터만 수정하면 됨.
+108 -18
View File
@@ -40,9 +40,13 @@ ga-commission-system/
### 시나리오 A — 운영 DB(trading_ai/ga) 에 연결해서 실행 ⭐ 권장 ### 시나리오 A — 운영 DB(trading_ai/ga) 에 연결해서 실행 ⭐ 권장
이미 V1~V17 마이그레이션 + 테스트 데이터(보험사 5 / 상품 20 / 설계사 50 / 계약 200)가 V1~V17 마이그레이션 + 테스트 데이터(보험사 5 / 상품 20 / 설계사 50 / 계약 200)가
`192.168.0.60:55432/trading_ai/ga` 스키마에 적용되어 있습니다. `192.168.0.60:55432/trading_ai/ga` 스키마에 적용되어 있습니다.
> **주의**: 운영 DB 에는 현재 V1~V17(또는 V22 까지)만 적용되어 있습니다.
> V18~V55 는 ga-api 를 재시작하면 Flyway 가 자동으로 순서대로 적용합니다.
> 재시작 전까지 P2~P4 신규 도메인 API 는 테이블이 없어 동작하지 않습니다.
#### 1) Backend (ga-api) 실행 #### 1) Backend (ga-api) 실행
```bash ```bash
@@ -119,7 +123,7 @@ PW : admin1234!
# 1) PostgreSQL + Redis 기동 (Docker) # 1) PostgreSQL + Redis 기동 (Docker)
docker compose up -d postgres redis docker compose up -d postgres redis
# 2) ga-api 실행 (Flyway 가 자동으로 V1~V17 마이그레이션) # 2) ga-api 실행 (Flyway 가 자동으로 V1~V55 마이그레이션)
./gradlew :ga-api:bootRun ./gradlew :ga-api:bootRun
# 기본 프로파일은 application.yml — localhost:5432/ga 에 연결 # 기본 프로파일은 application.yml — localhost:5432/ga 에 연결
@@ -219,21 +223,56 @@ UPDATE ga.users SET password = '$2b$10$...' WHERE login_id = 'admin';
## 📋 주요 기능 ## 📋 주요 기능
### 백엔드 ### 공통 프레임워크 (ga-common)
- **인증/권한**: JWT (HS256), 메뉴×권한 RBAC, BCrypt 비밀번호 - **인증/권한**: JWT (HS256 액세스 2h/리프레시 7d), 메뉴×6권한 RBAC, BCrypt 비밀번호
- **공통코드**: Redis 캐시, 시스템코드 잠금, REST CRUD - **공통코드**: Redis 캐시, 시스템코드 잠금, REST CRUD
- **메뉴**: 트리 구조, 사용자별 동적 메뉴 + 권한 매트릭스 - **메뉴**: 트리 구조, 사용자별 동적 메뉴 + 권한 매트릭스
- **엑셀**: SXSSF+Cursor 다운로드(100만건), SAX+배치 업로드(70만건) - **엑셀**: SXSSF+Cursor 다운로드(100만건), SAX+배치 업로드(70만건)
- **로그**: 로그인/API/데이터변경 자동 기록 (비동기) - **로그**: 로그인/API/데이터변경 자동 기록 (비동기 `@DataChangeLog` AOP)
- **암호화**: AES-256 CBC (`resident_no`, `account_no` 자동) - **암호화**: AES-256 CBC (`resident_no`, `account_no` TypeHandler 자동 적용)
- **업로드 템플릿**: 동적 컬럼 매핑 + 변환 규칙 관리
### 도메인 ### P0/P1 — 핵심 정산 도메인
- **조직**: 트리 구조, CTE 재귀, 인사 이력 - **세무 (P0-1)**: TaxCalculator 순수 도메인 — 사업소득 3.3% / 기타소득 8.8% / 지방세 10% / VAT 10%
- **계약**: 보험사/상품/계약 관리 - **공제 (P0-2)**: agent_deduction 월별 차감 마스터 + payment_deduction_detail 이체 명세
- **수수료 규정**: 보험사율, 지급율, 오버라이드, 환수, 예외코드 - **명세서 (P0-3)**: commission_statement 발급 마스터 + ExcelStatementGenerator (SXSSF)
- **원장**: 모집/유지/예외 (PageHelper + AG Grid) - **1200%룰 (P1-1)**: regulatory_limit 마스터 + RegulatoryLimitChecker (효력일 이력 추적)
- **정산**: 8 Step 배치 (수신→변환→대사→모집→유지→환수/예외→오버라이드→집계) - **분급 (P1-2)**: installment_plan 7년 (35/25/15/10/7/5/3%) + InstallmentPlanGenerator
- **지급**: 이체파일 생성, 상태 추적 - **차등환수 (P1-3)**: chargeback_grade 5단계 + ChargebackCalculator
### P2 — 운영 필수 8 도메인 (V29~V36)
- **마감 (P2-1)**: period_close — 월 마감 + 마감 후 수정 차단 가드
- **정정 (P2-2)**: settle_correction — 원본 참조 정정 + 사유 코드
- **이의신청 (P2-3)**: commission_dispute — OPEN/IN_REVIEW/APPROVED/REJECTED 워크플로우
- **보류사유 (P2-4)**: settle_master.hold_reason_code + HOLD_REASON 코드그룹
- **시책·인센티브 (P2-5)**: incentive_program + incentive_payment
- **해촉정산 (P2-6)**: termination_settlement (agent_org_history 연계)
- **세금계산서 (P2-7)**: tax_invoice 발행 이력 (SUPPLY/PURCHASE)
- **외부회신 (P2-8)**: carrier_reconciliation_out — 보험사 회신 인터페이스
### P3 — KPI 대시보드 (V37~V39)
- **월별 KPI**: mv_monthly_summary Materialized View + AG Grid 합계행
- **조직별 KPI**: mv_org_summary — 조직 트리별 실적
- **유지율 KPI**: mv_retention_rate — 13M/25M 유지율 추적
- **누계 KPI**: mv_cumulative — 연초 누계 + 차트
### P4-A — 수수료 종류 보강 (V40~V44)
- **commission_master 통합 (P4-A-1)**: commission_type 9종 코드 통합 — 모집/유지/수금/오버라이드/갱신/부활/유지보수수당/시책/위촉
- **수금수수료 (P4-A-2)**: collection_commission_rule + collection_commission_ledger — 납입주기별 차등
- **갱신·부활수수료 (P4-A-3)**: renewal_commission_rule/ledger + reinstatement_commission_rule/ledger
- **유지보수수당 (P4-A-4)**: persistency_bonus_rule + persistency_bonus — 13M/25M/37M 유지율 1회 지급
- **오버라이드 원장 (P4-A-5)**: override_ledger + override_summary — 직급별/조직별 합계 + 검증
### P4-B — 운영 필수 9 도메인 (V45~V53)
- **위촉계약 (P4-B-1)**: agent_contract — 보험사별 위촉계약서 + 위촉수수료 + 효력 기간
- **자격관리 (P4-B-2)**: agent_license — 생보/손보/변액 자격증 + 만료일 + 갱신 이력
- **보수교육 (P4-B-3)**: agent_training — 의무 12시간/년 이수 추적
- **계약변경 (P4-B-4)**: contract_endorsement — 변경 유형 + 수수료 재계산 트리거
- **민원관리 (P4-B-5)**: complaint — 고객 민원 + 설계사 책임 + 환수 연동
- **결재워크플로우 (P4-B-6)**: approval_line + approval_request — 최대 3단계 다단 결재
- **공지·알림 (P4-B-7)**: notice + user_notification — 시스템 공지 + 읽음 여부
- **첨부파일 (P4-B-8)**: attachment — 도메인 무관 공통 첨부 (소속/경로/타입)
- **출금신청 (P4-B-9)**: withdraw_request — 결재 + 펌뱅킹 송신 + 결과 반영
### 정산 배치 8 Step ### 정산 배치 8 Step
1. **receiveData** — 보험사별 DataReceiver → raw_commission_data 적재 1. **receiveData** — 보험사별 DataReceiver → raw_commission_data 적재
@@ -305,6 +344,47 @@ public class FooController {
| V15 | 통계/리포트 뷰 (v_agent_monthly_summary 등) | | V15 | 통계/리포트 뷰 (v_agent_monthly_summary 등) |
| V16 | 관리자 계정 + 메뉴 상세 + 권한 매트릭스 | | V16 | 관리자 계정 + 메뉴 상세 + 권한 매트릭스 |
| V17 | 테스트 데이터 (보험사 5 / 상품 20 / 설계사 50 / 계약 200 + 수수료 규정) | | V17 | 테스트 데이터 (보험사 5 / 상품 20 / 설계사 50 / 계약 200 + 수수료 규정) |
| V18 | 메뉴 트리 구조 재정렬 (동적 사이드바용) |
| V19 | 업로드 템플릿 마스터 (upload_template / upload_column) |
| V20 | 데이터 사전 (data_domain / data_dictionary) |
| V21 | 프로필·이력서 도메인 (agent_profile) |
| V22 | 비즈니스 도메인 확장 (계약·정산 보조 컬럼) |
| V23 | 세무 처리 — agent.tax_type/business_no/vat_type, payment 세금 컬럼 |
| V24 | 공제차감 — agent_deduction + payment_deduction_detail |
| V25 | 수수료 명세서 — commission_statement |
| V26 | 규제한도 마스터 — regulatory_limit (1200%룰) |
| V27 | 분급 계획 — installment_ratio + installment_plan |
| V28 | 차등환수 룰 — chargeback_grade |
| V29 | 마감 마스터 — period_close |
| V30 | 정정 — settle_correction |
| V31 | 이의신청 — commission_dispute |
| V32 | 보류사유 코드 — HOLD_REASON 공통코드 그룹 |
| V33 | 시책·인센티브 — incentive_program + incentive_payment |
| V34 | 해촉정산 — termination_settlement |
| V35 | 세금계산서 — tax_invoice |
| V36 | 보험사 회신 인터페이스 — carrier_reconciliation_out |
| V37 | KPI Materialized View 4종 (월별/조직별/유지율/누계) |
| V38 | 리포트 메뉴 INSERT |
| V39 | KPI 메뉴 INSERT |
| V40 | 수수료종류 코드 + commission_master 통합 마스터 |
| V41 | 수금수수료 — collection_commission_rule + collection_commission_ledger |
| V42 | 갱신·부활수수료 — renewal/reinstatement commission rule+ledger |
| V43 | 유지보수수당 — persistency_bonus_rule + persistency_bonus |
| V44 | 오버라이드 원장 — override_ledger + override_summary |
| V45 | 위촉계약 — agent_contract |
| V46 | 자격관리 — agent_license |
| V47 | 보수교육 — agent_training + training_requirement |
| V48 | 계약변경 — contract_endorsement |
| V49 | 민원관리 — complaint |
| V50 | 결재 워크플로우 — approval_line + approval_line_step + approval_request + approval_history |
| V51 | 공지·알림 — notice + user_notification |
| V52 | 첨부파일 — attachment |
| V53 | 출금신청 — withdraw_request |
| V54 | P4 메뉴 INSERT (P4-A + P4-B 14개 메뉴) |
| V55 | P4 공통코드 추가 (approval_type, complaint_type, license_type 등) |
> 운영 DB (192.168.0.60) 에는 현재 V17(또는 V22) 까지만 적용되어 있습니다.
> **ga-api 재시작 시** Flyway 가 V18~V55 를 자동으로 순차 적용합니다.
--- ---
@@ -312,14 +392,24 @@ public class FooController {
- [x] 1단계 — 프로젝트 뼈대 + DB (V1~V17) - [x] 1단계 — 프로젝트 뼈대 + DB (V1~V17)
- [x] **ga-common** — 공통 프레임워크 일체 - [x] **ga-common** — 공통 프레임워크 일체
- [x] **ga-core**22개 테이블 VO/Mapper/XML - [x] **ga-core**65+ 테이블 VO/Mapper/XML (P0~P4 전체)
- [x] **ga-api** — Auth + 14개 도메인 컨트롤러 - [x] **ga-api** — Auth + 50+ 도메인 컨트롤러 (128개 Java 파일)
- [x] **ga-batch** — MonthlySettlementJob 8 Step (실제 동작 로직 포함) - [x] **ga-batch** — MonthlySettlementJob 8 Step (실제 동작 로직 포함)
- [x] **Docker** — docker-compose + multi-stage Dockerfile + nginx - [x] **Docker** — docker-compose + multi-stage Dockerfile + nginx
- [x] **ga-frontend**13개 페이지 (대시보드/설계사/계약/원장/정산/지급/배치/시스템관리) - [x] **ga-frontend**50+ 페이지 (대시보드/설계사/계약/원장/정산/지급/배치/KPI/수수료/시스템관리)
- [x] **운영 DB 적용** — 192.168.0.60:55432/trading_ai/ga (V1~V17 + 테스트 데이터) - [x] **DB 마이그레이션** — V1~V55 (55개 파일, ga-common/src/main/resources/db/migration)
- [ ] **단위/통합 테스트** — 미작성 - [x] **코드 품질** — Enum/DTO/MapStruct/readOnly 트랜잭션 일괄 정비
- [x] **P0 세무·공제·명세서** — TaxCalculator / DeductionService / ExcelStatementGenerator
- [x] **P1 1200%룰·분급·차등환수** — RegulatoryLimitChecker / InstallmentPlanGenerator / ChargebackCalculator
- [x] **P2 마감·정정·이의·보류·시책·해촉·세금계산서·외부회신** (8 도메인)
- [x] **P3 KPI 대시보드** — Materialized View 4종 + Service + 화면
- [x] **P4-A 수수료 종류 보강** — commission_master 통합 / 수금 / 갱신 / 부활 / 유지보수수당 / 오버라이드원장
- [x] **P4-B 운영 도메인** — 위촉계약 / 자격관리 / 보수교육 / 계약변경 / 민원 / 결재워크플로우 / 공지·알림 / 첨부파일 / 출금신청
- [x] **운영 DB** — 192.168.0.60:55432/trading_ai/ga (V17까지 적용, V18~V55는 재시작 시 자동 적용)
- [ ] **단위/통합 테스트** — 미작성 (SqlSessionFactory 환경 문제로 보류)
- [ ] **MappingEngine 정밀화** — 일부 변환 규칙은 단순화된 형태 - [ ] **MappingEngine 정밀화** — 일부 변환 규칙은 단순화된 형태
- [ ] **P5 회계/분석** — 전표 분개 / 원천세·부가세 신고 / 결산 / 이상치·예측 / 연말 지급명세서
- [ ] **P6 외부 연동** — 펌뱅킹 실연동 / SMS·카카오 / 모바일 셀프 조회
--- ---
@@ -0,0 +1,15 @@
package com.ga.api.controller.approval;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class AdvanceReq {
@NotNull
private Long approverId;
/** APPROVE / REJECT */
@NotBlank
private String action;
private String comment;
}
@@ -0,0 +1,115 @@
package com.ga.api.controller.approval;
import com.ga.api.service.approval.ApprovalService;
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.approval.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "결재 관리")
@RestController
@RequestMapping("/api/approvals")
@RequiredArgsConstructor
public class ApprovalController {
private final ApprovalService service;
// ── 결재선 ─────────────────────────────────────────────────────────────────
@GetMapping("/lines")
@RequirePermission(menu = "APPROVAL", perm = "READ")
public ApiResponse<PageResponse<ApprovalLineResp>> listLines(
@ModelAttribute ApprovalLineSearchParam param) {
return ApiResponse.ok(service.listLines(param));
}
@GetMapping("/lines/{lineId}")
@RequirePermission(menu = "APPROVAL", perm = "READ")
public ApiResponse<ApprovalLineResp> detailLine(@PathVariable Long lineId) {
return ApiResponse.ok(service.detailLine(lineId));
}
@GetMapping("/lines/{lineId}/steps")
@RequirePermission(menu = "APPROVAL", perm = "READ")
public ApiResponse<List<ApprovalLineStepResp>> stepsOfLine(@PathVariable Long lineId) {
return ApiResponse.ok(service.stepsOfLine(lineId));
}
@PostMapping("/lines")
@RequirePermission(menu = "APPROVAL", perm = "CREATE")
@DataChangeLog(menu = "APPROVAL", table = "approval_line")
public ApiResponse<Long> createLine(@Valid @RequestBody ApprovalLineSaveReq req) {
return ApiResponse.ok(service.createLine(req));
}
@PutMapping("/lines/{lineId}")
@RequirePermission(menu = "APPROVAL", perm = "UPDATE")
@DataChangeLog(menu = "APPROVAL", table = "approval_line")
public ApiResponse<Void> updateLine(@PathVariable Long lineId,
@Valid @RequestBody ApprovalLineSaveReq req) {
service.updateLine(lineId, req);
return ApiResponse.ok();
}
@PostMapping("/lines/{lineId}/steps")
@RequirePermission(menu = "APPROVAL", perm = "UPDATE")
@DataChangeLog(menu = "APPROVAL", table = "approval_line_step")
public ApiResponse<Void> addStep(@PathVariable Long lineId,
@Valid @RequestBody ApprovalLineStepSaveReq req) {
req.setLineId(lineId);
service.addStep(req);
return ApiResponse.ok();
}
// ── 결재 요청 ──────────────────────────────────────────────────────────────
@GetMapping("/requests")
@RequirePermission(menu = "APPROVAL", perm = "READ")
public ApiResponse<PageResponse<ApprovalRequestResp>> listRequests(
@ModelAttribute ApprovalRequestSearchParam param) {
return ApiResponse.ok(service.listRequests(param));
}
@GetMapping("/requests/{requestId}")
@RequirePermission(menu = "APPROVAL", perm = "READ")
public ApiResponse<ApprovalRequestResp> detailRequest(@PathVariable Long requestId) {
return ApiResponse.ok(service.detailRequest(requestId));
}
@GetMapping("/requests/{requestId}/history")
@RequirePermission(menu = "APPROVAL", perm = "READ")
public ApiResponse<List<ApprovalHistoryResp>> historyOfRequest(@PathVariable Long requestId) {
return ApiResponse.ok(service.historyOfRequest(requestId));
}
@PostMapping("/requests")
@RequirePermission(menu = "APPROVAL", perm = "CREATE")
@DataChangeLog(menu = "APPROVAL", table = "approval_request")
public ApiResponse<Long> createRequest(@Valid @RequestBody ApprovalRequestSaveReq req) {
return ApiResponse.ok(service.createRequest(req));
}
@PostMapping("/requests/{requestId}/advance")
@RequirePermission(menu = "APPROVAL", perm = "APPROVE")
@DataChangeLog(menu = "APPROVAL", table = "approval_request")
public ApiResponse<Void> advance(@PathVariable Long requestId,
@Valid @RequestBody AdvanceReq req) {
service.advance(requestId, req.getApproverId(), req.getAction(), req.getComment());
return ApiResponse.ok();
}
@PostMapping("/requests/{requestId}/cancel")
@RequirePermission(menu = "APPROVAL", perm = "UPDATE")
@DataChangeLog(menu = "APPROVAL", table = "approval_request")
public ApiResponse<Void> cancel(@PathVariable Long requestId) {
service.cancel(requestId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,85 @@
package com.ga.api.controller.attachment;
import com.ga.api.service.attachment.AttachmentService;
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.attachment.AttachmentResp;
import com.ga.core.vo.attachment.AttachmentSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.util.MimeTypeUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
@Tag(name = "첨부파일")
@RestController
@RequestMapping("/api/attachments")
@RequiredArgsConstructor
public class AttachmentController {
private final AttachmentService service;
@GetMapping
@RequirePermission(menu = "ATTACHMENT", perm = "READ")
public ApiResponse<PageResponse<AttachmentResp>> list(@ModelAttribute AttachmentSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/by-ref")
@RequirePermission(menu = "ATTACHMENT", perm = "READ")
public ApiResponse<List<AttachmentResp>> listByRef(@RequestParam String refType,
@RequestParam Long refId) {
return ApiResponse.ok(service.listByRef(refType, refId));
}
@GetMapping("/{attachmentId}/meta")
@RequirePermission(menu = "ATTACHMENT", perm = "READ")
public ApiResponse<AttachmentResp> detail(@PathVariable Long attachmentId) {
return ApiResponse.ok(service.detail(attachmentId));
}
@PostMapping(consumes = "multipart/form-data")
@RequirePermission(menu = "ATTACHMENT", perm = "CREATE")
@DataChangeLog(menu = "ATTACHMENT", table = "attachment")
public ApiResponse<Long> upload(@RequestParam("file") MultipartFile file,
@RequestParam String refType,
@RequestParam Long refId) {
return ApiResponse.ok(service.upload(file, refType, refId));
}
@GetMapping("/{attachmentId}/download")
@RequirePermission(menu = "ATTACHMENT", perm = "READ")
public void download(@PathVariable Long attachmentId, HttpServletResponse response) throws IOException {
AttachmentResp meta = service.detail(attachmentId);
Path filePath = service.resolveFilePath(attachmentId);
response.setContentType(meta.getMimeType() != null ? meta.getMimeType() : MimeTypeUtils.APPLICATION_OCTET_STREAM_VALUE);
String encodedName = URLEncoder.encode(meta.getFileName(), StandardCharsets.UTF_8).replace("+", "%20");
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedName);
response.setContentLengthLong(meta.getFileSize() != null ? meta.getFileSize() : 0L);
try (OutputStream out = response.getOutputStream()) {
Files.copy(filePath, out);
}
}
@DeleteMapping("/{attachmentId}")
@RequirePermission(menu = "ATTACHMENT", perm = "DELETE")
@DataChangeLog(menu = "ATTACHMENT", table = "attachment")
public ApiResponse<Void> delete(@PathVariable Long attachmentId) {
service.delete(attachmentId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,82 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.CollectionCommissionService;
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.commission.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@Tag(name = "수금수수료")
@RestController
@RequestMapping("/api/collection-commissions")
@RequiredArgsConstructor
public class CollectionCommissionController {
private final CollectionCommissionService service;
// ── Rule ──────────────────────────────────────────────────────────────────
@GetMapping("/rules")
@RequirePermission(menu = "COLLECT_COMM", perm = "READ")
public ApiResponse<PageResponse<CollectionCommissionRuleResp>> listRules(
@ModelAttribute CollectionCommissionRuleSearchParam param) {
return ApiResponse.ok(service.listRules(param));
}
@GetMapping("/rules/{ruleId}")
@RequirePermission(menu = "COLLECT_COMM", perm = "READ")
public ApiResponse<CollectionCommissionRuleResp> detailRule(@PathVariable Long ruleId) {
return ApiResponse.ok(service.detailRule(ruleId));
}
@PostMapping("/rules")
@RequirePermission(menu = "COLLECT_COMM", perm = "CREATE")
@DataChangeLog(menu = "COLLECT_COMM", table = "collection_commission_rule")
public ApiResponse<Long> createRule(@Valid @RequestBody CollectionCommissionRuleSaveReq req) {
return ApiResponse.ok(service.createRule(req));
}
@PutMapping("/rules/{ruleId}")
@RequirePermission(menu = "COLLECT_COMM", perm = "UPDATE")
@DataChangeLog(menu = "COLLECT_COMM", table = "collection_commission_rule")
public ApiResponse<Void> updateRule(@PathVariable Long ruleId,
@Valid @RequestBody CollectionCommissionRuleSaveReq req) {
service.updateRule(ruleId, req);
return ApiResponse.ok();
}
@DeleteMapping("/rules/{ruleId}")
@RequirePermission(menu = "COLLECT_COMM", perm = "DELETE")
@DataChangeLog(menu = "COLLECT_COMM", table = "collection_commission_rule")
public ApiResponse<Void> deleteRule(@PathVariable Long ruleId) {
service.deleteRule(ruleId);
return ApiResponse.ok();
}
// ── Ledger ────────────────────────────────────────────────────────────────
@GetMapping("/ledgers")
@RequirePermission(menu = "COLLECT_COMM", perm = "READ")
public ApiResponse<PageResponse<CollectionCommissionLedgerResp>> listLedgers(
@ModelAttribute CollectionCommissionLedgerSearchParam param) {
return ApiResponse.ok(service.listLedgers(param));
}
@GetMapping("/ledgers/{ledgerId}")
@RequirePermission(menu = "COLLECT_COMM", perm = "READ")
public ApiResponse<CollectionCommissionLedgerResp> detailLedger(@PathVariable Long ledgerId) {
return ApiResponse.ok(service.detailLedger(ledgerId));
}
@PostMapping("/ledgers")
@RequirePermission(menu = "COLLECT_COMM", perm = "CREATE")
@DataChangeLog(menu = "COLLECT_COMM", table = "collection_commission_ledger")
public ApiResponse<Long> createLedger(@Valid @RequestBody CollectionCommissionLedgerSaveReq req) {
return ApiResponse.ok(service.createLedger(req));
}
}
@@ -0,0 +1,66 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.CommissionMasterService;
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.commission.CommissionMasterResp;
import com.ga.core.vo.commission.CommissionMasterSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@Tag(name = "수수료 마스터")
@RestController
@RequestMapping("/api/commissions")
@RequiredArgsConstructor
public class CommissionMasterController {
private final CommissionMasterService service;
@GetMapping
@RequirePermission(menu = "COMM_MASTER", perm = "READ")
public ApiResponse<PageResponse<CommissionMasterResp>> list(@ModelAttribute CommissionMasterSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{masterId}")
@RequirePermission(menu = "COMM_MASTER", perm = "READ")
public ApiResponse<CommissionMasterResp> detail(@PathVariable Long masterId) {
return ApiResponse.ok(service.detail(masterId));
}
@GetMapping("/by-agent")
@RequirePermission(menu = "COMM_MASTER", perm = "READ")
public ApiResponse<List<CommissionMasterResp>> byAgent(@RequestParam Long agentId,
@RequestParam String settleMonth) {
return ApiResponse.ok(service.listByAgentAndMonth(agentId, settleMonth));
}
@GetMapping("/aggregated")
@RequirePermission(menu = "COMM_MASTER", perm = "READ")
public ApiResponse<List<CommissionMasterResp>> aggregated(@RequestParam String settleMonth) {
return ApiResponse.ok(service.aggregatedByMonth(settleMonth));
}
@PutMapping("/{masterId}/status")
@RequirePermission(menu = "COMM_MASTER", perm = "UPDATE")
@DataChangeLog(menu = "COMM_MASTER", table = "commission_master")
public ApiResponse<Void> updateStatus(@PathVariable Long masterId,
@RequestBody Map<String, String> body) {
service.updateStatus(masterId, body.get("status"));
return ApiResponse.ok();
}
@DeleteMapping("/{masterId}")
@RequirePermission(menu = "COMM_MASTER", perm = "DELETE")
@DataChangeLog(menu = "COMM_MASTER", table = "commission_master")
public ApiResponse<Void> delete(@PathVariable Long masterId) {
service.delete(masterId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,46 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.OverrideLedgerService;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.commission.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "오버라이드 원장")
@RestController
@RequestMapping("/api/override-ledgers")
@RequiredArgsConstructor
public class OverrideLedgerController {
private final OverrideLedgerService service;
@GetMapping
@RequirePermission(menu = "OVERRIDE_LEDGER", perm = "READ")
public ApiResponse<PageResponse<OverrideLedgerResp>> list(@ModelAttribute OverrideLedgerSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{ledgerId}")
@RequirePermission(menu = "OVERRIDE_LEDGER", perm = "READ")
public ApiResponse<OverrideLedgerResp> detail(@PathVariable Long ledgerId) {
return ApiResponse.ok(service.detail(ledgerId));
}
@GetMapping("/summary")
@RequirePermission(menu = "OVERRIDE_LEDGER", perm = "READ")
public ApiResponse<List<OverrideSummaryResp>> summaryList(@ModelAttribute OverrideSummarySearchParam param) {
return ApiResponse.ok(service.summaryList(param));
}
@GetMapping("/summary/by-agent")
@RequirePermission(menu = "OVERRIDE_LEDGER", perm = "READ")
public ApiResponse<OverrideSummaryResp> summaryByAgent(@RequestParam Long toAgentId,
@RequestParam String settleMonth) {
return ApiResponse.ok(service.summaryByAgentAndMonth(toAgentId, settleMonth));
}
}
@@ -0,0 +1,79 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.PersistencyBonusService;
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.commission.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@Tag(name = "유지보수수당")
@RestController
@RequestMapping("/api/persistency-bonus")
@RequiredArgsConstructor
public class PersistencyBonusController {
private final PersistencyBonusService service;
@GetMapping("/rules")
@RequirePermission(menu = "PERSIST_BONUS", perm = "READ")
public ApiResponse<PageResponse<PersistencyBonusRuleResp>> listRules(
@ModelAttribute PersistencyBonusRuleSearchParam param) {
return ApiResponse.ok(service.listRules(param));
}
@GetMapping("/rules/{ruleId}")
@RequirePermission(menu = "PERSIST_BONUS", perm = "READ")
public ApiResponse<PersistencyBonusRuleResp> detailRule(@PathVariable Long ruleId) {
return ApiResponse.ok(service.detailRule(ruleId));
}
@PostMapping("/rules")
@RequirePermission(menu = "PERSIST_BONUS", perm = "CREATE")
@DataChangeLog(menu = "PERSIST_BONUS", table = "persistency_bonus_rule")
public ApiResponse<Long> createRule(@Valid @RequestBody PersistencyBonusRuleSaveReq req) {
return ApiResponse.ok(service.createRule(req));
}
@PutMapping("/rules/{ruleId}")
@RequirePermission(menu = "PERSIST_BONUS", perm = "UPDATE")
@DataChangeLog(menu = "PERSIST_BONUS", table = "persistency_bonus_rule")
public ApiResponse<Void> updateRule(@PathVariable Long ruleId,
@Valid @RequestBody PersistencyBonusRuleSaveReq req) {
service.updateRule(ruleId, req);
return ApiResponse.ok();
}
@DeleteMapping("/rules/{ruleId}")
@RequirePermission(menu = "PERSIST_BONUS", perm = "DELETE")
@DataChangeLog(menu = "PERSIST_BONUS", table = "persistency_bonus_rule")
public ApiResponse<Void> deleteRule(@PathVariable Long ruleId) {
service.deleteRule(ruleId);
return ApiResponse.ok();
}
@GetMapping("/bonuses")
@RequirePermission(menu = "PERSIST_BONUS", perm = "READ")
public ApiResponse<PageResponse<PersistencyBonusResp>> listBonuses(
@ModelAttribute PersistencyBonusSearchParam param) {
return ApiResponse.ok(service.listBonuses(param));
}
@GetMapping("/bonuses/{bonusId}")
@RequirePermission(menu = "PERSIST_BONUS", perm = "READ")
public ApiResponse<PersistencyBonusResp> detailBonus(@PathVariable Long bonusId) {
return ApiResponse.ok(service.detailBonus(bonusId));
}
@PostMapping("/bonuses")
@RequirePermission(menu = "PERSIST_BONUS", perm = "CREATE")
@DataChangeLog(menu = "PERSIST_BONUS", table = "persistency_bonus")
public ApiResponse<Void> upsertBonus(@Valid @RequestBody PersistencyBonusSaveReq req) {
service.upsertBonus(req);
return ApiResponse.ok();
}
}
@@ -0,0 +1,78 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.ReinstatementCommissionService;
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.commission.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@Tag(name = "부활수수료")
@RestController
@RequestMapping("/api/reinstatement-commissions")
@RequiredArgsConstructor
public class ReinstatementCommissionController {
private final ReinstatementCommissionService service;
@GetMapping("/rules")
@RequirePermission(menu = "REINSTATE_COMM", perm = "READ")
public ApiResponse<PageResponse<ReinstatementCommissionRuleResp>> listRules(
@ModelAttribute ReinstatementCommissionRuleSearchParam param) {
return ApiResponse.ok(service.listRules(param));
}
@GetMapping("/rules/{ruleId}")
@RequirePermission(menu = "REINSTATE_COMM", perm = "READ")
public ApiResponse<ReinstatementCommissionRuleResp> detailRule(@PathVariable Long ruleId) {
return ApiResponse.ok(service.detailRule(ruleId));
}
@PostMapping("/rules")
@RequirePermission(menu = "REINSTATE_COMM", perm = "CREATE")
@DataChangeLog(menu = "REINSTATE_COMM", table = "reinstatement_commission_rule")
public ApiResponse<Long> createRule(@Valid @RequestBody ReinstatementCommissionRuleSaveReq req) {
return ApiResponse.ok(service.createRule(req));
}
@PutMapping("/rules/{ruleId}")
@RequirePermission(menu = "REINSTATE_COMM", perm = "UPDATE")
@DataChangeLog(menu = "REINSTATE_COMM", table = "reinstatement_commission_rule")
public ApiResponse<Void> updateRule(@PathVariable Long ruleId,
@Valid @RequestBody ReinstatementCommissionRuleSaveReq req) {
service.updateRule(ruleId, req);
return ApiResponse.ok();
}
@DeleteMapping("/rules/{ruleId}")
@RequirePermission(menu = "REINSTATE_COMM", perm = "DELETE")
@DataChangeLog(menu = "REINSTATE_COMM", table = "reinstatement_commission_rule")
public ApiResponse<Void> deleteRule(@PathVariable Long ruleId) {
service.deleteRule(ruleId);
return ApiResponse.ok();
}
@GetMapping("/ledgers")
@RequirePermission(menu = "REINSTATE_COMM", perm = "READ")
public ApiResponse<PageResponse<ReinstatementCommissionLedgerResp>> listLedgers(
@ModelAttribute ReinstatementCommissionLedgerSearchParam param) {
return ApiResponse.ok(service.listLedgers(param));
}
@GetMapping("/ledgers/{ledgerId}")
@RequirePermission(menu = "REINSTATE_COMM", perm = "READ")
public ApiResponse<ReinstatementCommissionLedgerResp> detailLedger(@PathVariable Long ledgerId) {
return ApiResponse.ok(service.detailLedger(ledgerId));
}
@PostMapping("/ledgers")
@RequirePermission(menu = "REINSTATE_COMM", perm = "CREATE")
@DataChangeLog(menu = "REINSTATE_COMM", table = "reinstatement_commission_ledger")
public ApiResponse<Long> createLedger(@Valid @RequestBody ReinstatementCommissionLedgerSaveReq req) {
return ApiResponse.ok(service.createLedger(req));
}
}
@@ -0,0 +1,78 @@
package com.ga.api.controller.commission;
import com.ga.api.service.commission.RenewalCommissionService;
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.commission.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@Tag(name = "갱신수수료")
@RestController
@RequestMapping("/api/renewal-commissions")
@RequiredArgsConstructor
public class RenewalCommissionController {
private final RenewalCommissionService service;
@GetMapping("/rules")
@RequirePermission(menu = "RENEWAL_COMM", perm = "READ")
public ApiResponse<PageResponse<RenewalCommissionRuleResp>> listRules(
@ModelAttribute RenewalCommissionRuleSearchParam param) {
return ApiResponse.ok(service.listRules(param));
}
@GetMapping("/rules/{ruleId}")
@RequirePermission(menu = "RENEWAL_COMM", perm = "READ")
public ApiResponse<RenewalCommissionRuleResp> detailRule(@PathVariable Long ruleId) {
return ApiResponse.ok(service.detailRule(ruleId));
}
@PostMapping("/rules")
@RequirePermission(menu = "RENEWAL_COMM", perm = "CREATE")
@DataChangeLog(menu = "RENEWAL_COMM", table = "renewal_commission_rule")
public ApiResponse<Long> createRule(@Valid @RequestBody RenewalCommissionRuleSaveReq req) {
return ApiResponse.ok(service.createRule(req));
}
@PutMapping("/rules/{ruleId}")
@RequirePermission(menu = "RENEWAL_COMM", perm = "UPDATE")
@DataChangeLog(menu = "RENEWAL_COMM", table = "renewal_commission_rule")
public ApiResponse<Void> updateRule(@PathVariable Long ruleId,
@Valid @RequestBody RenewalCommissionRuleSaveReq req) {
service.updateRule(ruleId, req);
return ApiResponse.ok();
}
@DeleteMapping("/rules/{ruleId}")
@RequirePermission(menu = "RENEWAL_COMM", perm = "DELETE")
@DataChangeLog(menu = "RENEWAL_COMM", table = "renewal_commission_rule")
public ApiResponse<Void> deleteRule(@PathVariable Long ruleId) {
service.deleteRule(ruleId);
return ApiResponse.ok();
}
@GetMapping("/ledgers")
@RequirePermission(menu = "RENEWAL_COMM", perm = "READ")
public ApiResponse<PageResponse<RenewalCommissionLedgerResp>> listLedgers(
@ModelAttribute RenewalCommissionLedgerSearchParam param) {
return ApiResponse.ok(service.listLedgers(param));
}
@GetMapping("/ledgers/{ledgerId}")
@RequirePermission(menu = "RENEWAL_COMM", perm = "READ")
public ApiResponse<RenewalCommissionLedgerResp> detailLedger(@PathVariable Long ledgerId) {
return ApiResponse.ok(service.detailLedger(ledgerId));
}
@PostMapping("/ledgers")
@RequirePermission(menu = "RENEWAL_COMM", perm = "CREATE")
@DataChangeLog(menu = "RENEWAL_COMM", table = "renewal_commission_ledger")
public ApiResponse<Long> createLedger(@Valid @RequestBody RenewalCommissionLedgerSaveReq req) {
return ApiResponse.ok(service.createLedger(req));
}
}
@@ -0,0 +1,70 @@
package com.ga.api.controller.complaint;
import com.ga.api.service.complaint.ComplaintService;
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.complaint.ComplaintResp;
import com.ga.core.vo.complaint.ComplaintSaveReq;
import com.ga.core.vo.complaint.ComplaintSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Tag(name = "민원 관리")
@RestController
@RequestMapping("/api/complaints")
@RequiredArgsConstructor
public class ComplaintController {
private final ComplaintService service;
@GetMapping
@RequirePermission(menu = "COMPLAINT", perm = "READ")
public ApiResponse<PageResponse<ComplaintResp>> list(@ModelAttribute ComplaintSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{complaintId}")
@RequirePermission(menu = "COMPLAINT", perm = "READ")
public ApiResponse<ComplaintResp> detail(@PathVariable Long complaintId) {
return ApiResponse.ok(service.detail(complaintId));
}
@PostMapping
@RequirePermission(menu = "COMPLAINT", perm = "CREATE")
@DataChangeLog(menu = "COMPLAINT", table = "complaint")
public ApiResponse<Long> create(@Valid @RequestBody ComplaintSaveReq req) {
return ApiResponse.ok(service.create(req));
}
@PutMapping("/{complaintId}")
@RequirePermission(menu = "COMPLAINT", perm = "UPDATE")
@DataChangeLog(menu = "COMPLAINT", table = "complaint")
public ApiResponse<Void> update(@PathVariable Long complaintId,
@Valid @RequestBody ComplaintSaveReq req) {
service.update(complaintId, req);
return ApiResponse.ok();
}
@PostMapping("/{complaintId}/close")
@RequirePermission(menu = "COMPLAINT", perm = "UPDATE")
@DataChangeLog(menu = "COMPLAINT", table = "complaint")
public ApiResponse<Void> close(@PathVariable Long complaintId,
@RequestBody Map<String, String> body) {
service.close(complaintId, body.get("resolution"));
return ApiResponse.ok();
}
@DeleteMapping("/{complaintId}")
@RequirePermission(menu = "COMPLAINT", perm = "DELETE")
@DataChangeLog(menu = "COMPLAINT", table = "complaint")
public ApiResponse<Void> delete(@PathVariable Long complaintId) {
service.delete(complaintId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,70 @@
package com.ga.api.controller.contract;
import com.ga.api.service.contract.AgentContractService;
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.contract.AgentContractResp;
import com.ga.core.vo.contract.AgentContractSaveReq;
import com.ga.core.vo.contract.AgentContractSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Tag(name = "위촉계약 관리")
@RestController
@RequestMapping("/api/agent-contracts")
@RequiredArgsConstructor
public class AgentContractController {
private final AgentContractService service;
@GetMapping
@RequirePermission(menu = "AGENT_CONTRACT", perm = "READ")
public ApiResponse<PageResponse<AgentContractResp>> list(@ModelAttribute AgentContractSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{contractId}")
@RequirePermission(menu = "AGENT_CONTRACT", perm = "READ")
public ApiResponse<AgentContractResp> detail(@PathVariable Long contractId) {
return ApiResponse.ok(service.detail(contractId));
}
@PostMapping
@RequirePermission(menu = "AGENT_CONTRACT", perm = "CREATE")
@DataChangeLog(menu = "AGENT_CONTRACT", table = "agent_contract")
public ApiResponse<Long> create(@Valid @RequestBody AgentContractSaveReq req) {
return ApiResponse.ok(service.create(req));
}
@PutMapping("/{contractId}")
@RequirePermission(menu = "AGENT_CONTRACT", perm = "UPDATE")
@DataChangeLog(menu = "AGENT_CONTRACT", table = "agent_contract")
public ApiResponse<Void> update(@PathVariable Long contractId,
@Valid @RequestBody AgentContractSaveReq req) {
service.update(contractId, req);
return ApiResponse.ok();
}
@PostMapping("/{contractId}/terminate")
@RequirePermission(menu = "AGENT_CONTRACT", perm = "UPDATE")
@DataChangeLog(menu = "AGENT_CONTRACT", table = "agent_contract")
public ApiResponse<Void> terminate(@PathVariable Long contractId,
@RequestBody Map<String, String> body) {
service.terminate(contractId, body.get("reason"));
return ApiResponse.ok();
}
@DeleteMapping("/{contractId}")
@RequirePermission(menu = "AGENT_CONTRACT", perm = "DELETE")
@DataChangeLog(menu = "AGENT_CONTRACT", table = "agent_contract")
public ApiResponse<Void> delete(@PathVariable Long contractId) {
service.delete(contractId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,59 @@
package com.ga.api.controller.endorse;
import com.ga.api.service.endorse.ContractEndorsementService;
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.endorse.ContractEndorsementResp;
import com.ga.core.vo.endorse.ContractEndorsementSaveReq;
import com.ga.core.vo.endorse.ContractEndorsementSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@Tag(name = "계약 변경")
@RestController
@RequestMapping("/api/contract-endorsements")
@RequiredArgsConstructor
public class ContractEndorsementController {
private final ContractEndorsementService service;
@GetMapping
@RequirePermission(menu = "ENDORSE", perm = "READ")
public ApiResponse<PageResponse<ContractEndorsementResp>> list(
@ModelAttribute ContractEndorsementSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{endorsementId}")
@RequirePermission(menu = "ENDORSE", perm = "READ")
public ApiResponse<ContractEndorsementResp> detail(@PathVariable Long endorsementId) {
return ApiResponse.ok(service.detail(endorsementId));
}
@PostMapping
@RequirePermission(menu = "ENDORSE", perm = "CREATE")
@DataChangeLog(menu = "ENDORSE", table = "contract_endorsement")
public ApiResponse<Long> create(@Valid @RequestBody ContractEndorsementSaveReq req) {
return ApiResponse.ok(service.create(req));
}
@PostMapping("/{endorsementId}/process")
@RequirePermission(menu = "ENDORSE", perm = "UPDATE")
@DataChangeLog(menu = "ENDORSE", table = "contract_endorsement")
public ApiResponse<Void> process(@PathVariable Long endorsementId) {
service.process(endorsementId);
return ApiResponse.ok();
}
@DeleteMapping("/{endorsementId}")
@RequirePermission(menu = "ENDORSE", perm = "DELETE")
@DataChangeLog(menu = "ENDORSE", table = "contract_endorsement")
public ApiResponse<Void> delete(@PathVariable Long endorsementId) {
service.delete(endorsementId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,50 @@
package com.ga.api.controller.kpi;
import com.ga.api.service.kpi.KpiService;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.kpi.KpiCumulativeResp;
import com.ga.core.vo.kpi.KpiMonthlySummaryResp;
import com.ga.core.vo.kpi.KpiOrgSummaryResp;
import com.ga.core.vo.kpi.KpiRetentionResp;
import com.ga.core.vo.kpi.KpiSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "KPI 대시보드")
@RestController
@RequestMapping("/api/kpi")
@RequiredArgsConstructor
public class KpiController {
private final KpiService service;
@GetMapping("/monthly-summary")
@RequirePermission(menu = "KPI_MONTHLY", perm = "READ")
public ApiResponse<PageResponse<KpiMonthlySummaryResp>> monthlySummary(@ModelAttribute KpiSearchParam param) {
return ApiResponse.ok(service.monthlySummary(param));
}
@GetMapping("/org-summary")
@RequirePermission(menu = "KPI_ORG", perm = "READ")
public ApiResponse<PageResponse<KpiOrgSummaryResp>> orgSummary(@ModelAttribute KpiSearchParam param) {
return ApiResponse.ok(service.orgSummary(param));
}
@GetMapping("/retention")
@RequirePermission(menu = "KPI_RETENTION", perm = "READ")
public ApiResponse<PageResponse<KpiRetentionResp>> retention(@ModelAttribute KpiSearchParam param) {
return ApiResponse.ok(service.retention(param));
}
@GetMapping("/cumulative")
@RequirePermission(menu = "KPI_CUMULATIVE", perm = "READ")
public ApiResponse<PageResponse<KpiCumulativeResp>> cumulative(@ModelAttribute KpiSearchParam param) {
return ApiResponse.ok(service.cumulative(param));
}
}
@@ -0,0 +1,68 @@
package com.ga.api.controller.license;
import com.ga.api.service.license.AgentLicenseService;
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.license.AgentLicenseResp;
import com.ga.core.vo.license.AgentLicenseSaveReq;
import com.ga.core.vo.license.AgentLicenseSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "자격 관리")
@RestController
@RequestMapping("/api/agent-licenses")
@RequiredArgsConstructor
public class AgentLicenseController {
private final AgentLicenseService service;
@GetMapping
@RequirePermission(menu = "AGENT_LICENSE", perm = "READ")
public ApiResponse<PageResponse<AgentLicenseResp>> list(@ModelAttribute AgentLicenseSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{licenseId}")
@RequirePermission(menu = "AGENT_LICENSE", perm = "READ")
public ApiResponse<AgentLicenseResp> detail(@PathVariable Long licenseId) {
return ApiResponse.ok(service.detail(licenseId));
}
@GetMapping("/expiring")
@RequirePermission(menu = "AGENT_LICENSE", perm = "READ")
public ApiResponse<List<AgentLicenseResp>> expiring(
@RequestParam(defaultValue = "30") int days) {
return ApiResponse.ok(service.expiring(days));
}
@PostMapping
@RequirePermission(menu = "AGENT_LICENSE", perm = "CREATE")
@DataChangeLog(menu = "AGENT_LICENSE", table = "agent_license")
public ApiResponse<Long> create(@Valid @RequestBody AgentLicenseSaveReq req) {
return ApiResponse.ok(service.create(req));
}
@PutMapping("/{licenseId}")
@RequirePermission(menu = "AGENT_LICENSE", perm = "UPDATE")
@DataChangeLog(menu = "AGENT_LICENSE", table = "agent_license")
public ApiResponse<Void> update(@PathVariable Long licenseId,
@Valid @RequestBody AgentLicenseSaveReq req) {
service.update(licenseId, req);
return ApiResponse.ok();
}
@DeleteMapping("/{licenseId}")
@RequirePermission(menu = "AGENT_LICENSE", perm = "DELETE")
@DataChangeLog(menu = "AGENT_LICENSE", table = "agent_license")
public ApiResponse<Void> delete(@PathVariable Long licenseId) {
service.delete(licenseId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,97 @@
package com.ga.api.controller.notice;
import com.ga.api.service.notice.NoticeService;
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.common.util.SecurityUtil;
import com.ga.core.vo.notice.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "공지사항 & 알림")
@RestController
@RequiredArgsConstructor
public class NoticeController {
private final NoticeService service;
// ── Notice ────────────────────────────────────────────────────────────────
@GetMapping("/api/notices")
@RequirePermission(menu = "NOTICE", perm = "READ")
public ApiResponse<PageResponse<NoticeResp>> list(@ModelAttribute NoticeSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/api/notices/active")
@RequirePermission(menu = "NOTICE", perm = "READ")
public ApiResponse<List<NoticeResp>> activeList() {
return ApiResponse.ok(service.activeList());
}
@GetMapping("/api/notices/{noticeId}")
@RequirePermission(menu = "NOTICE", perm = "READ")
public ApiResponse<NoticeResp> detail(@PathVariable Long noticeId) {
return ApiResponse.ok(service.detail(noticeId));
}
@PostMapping("/api/notices")
@RequirePermission(menu = "NOTICE", perm = "CREATE")
@DataChangeLog(menu = "NOTICE", table = "notice")
public ApiResponse<Long> create(@Valid @RequestBody NoticeSaveReq req) {
return ApiResponse.ok(service.create(req));
}
@PutMapping("/api/notices/{noticeId}")
@RequirePermission(menu = "NOTICE", perm = "UPDATE")
@DataChangeLog(menu = "NOTICE", table = "notice")
public ApiResponse<Void> update(@PathVariable Long noticeId,
@Valid @RequestBody NoticeSaveReq req) {
service.update(noticeId, req);
return ApiResponse.ok();
}
@DeleteMapping("/api/notices/{noticeId}")
@RequirePermission(menu = "NOTICE", perm = "DELETE")
@DataChangeLog(menu = "NOTICE", table = "notice")
public ApiResponse<Void> delete(@PathVariable Long noticeId) {
service.delete(noticeId);
return ApiResponse.ok();
}
// ── UserNotification ──────────────────────────────────────────────────────
@GetMapping("/api/user-notifications")
@RequirePermission(menu = "NOTICE", perm = "READ")
public ApiResponse<PageResponse<UserNotificationResp>> listNotifications(
@ModelAttribute UserNotificationSearchParam param) {
return ApiResponse.ok(service.listNotifications(param));
}
@GetMapping("/api/user-notifications/unread-count")
@RequirePermission(menu = "NOTICE", perm = "READ")
public ApiResponse<Integer> unreadCount(@RequestParam Long userId) {
return ApiResponse.ok(service.unreadCount(userId));
}
@PostMapping("/api/user-notifications/{notificationId}/read")
@RequirePermission(menu = "NOTICE", perm = "READ")
public ApiResponse<Void> markRead(@PathVariable Long notificationId,
@RequestParam Long userId) {
service.markRead(notificationId, userId);
return ApiResponse.ok();
}
@PostMapping("/api/user-notifications/read-all")
@RequirePermission(menu = "NOTICE", perm = "READ")
public ApiResponse<Void> markAllRead(@RequestParam Long userId) {
service.markAllRead(userId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,50 @@
package com.ga.api.controller.settle;
import com.ga.api.service.settle.CarrierReconciliationOutService;
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.CarrierReconciliationOutResp;
import com.ga.core.vo.settle.CarrierReconciliationOutSaveReq;
import com.ga.core.vo.settle.CarrierReconciliationOutSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@Tag(name = "보험사 회신 관리")
@RestController
@RequestMapping("/api/carrier-reconciliation")
@RequiredArgsConstructor
public class CarrierReconciliationOutController {
private final CarrierReconciliationOutService service;
@GetMapping
@RequirePermission(menu = "RECON_OUT", perm = "READ")
public ApiResponse<PageResponse<CarrierReconciliationOutResp>> list(@ModelAttribute CarrierReconciliationOutSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{reconOutId}")
@RequirePermission(menu = "RECON_OUT", perm = "READ")
public ApiResponse<CarrierReconciliationOutResp> detail(@PathVariable Long reconOutId) {
return ApiResponse.ok(service.detail(reconOutId));
}
@PostMapping
@RequirePermission(menu = "RECON_OUT", perm = "CREATE")
@DataChangeLog(menu = "RECON_OUT", table = "carrier_reconciliation_out")
public ApiResponse<Long> create(@Valid @RequestBody CarrierReconciliationOutSaveReq req) {
return ApiResponse.ok(service.create(req));
}
@PostMapping("/{reconOutId}/retry")
@RequirePermission(menu = "RECON_OUT", perm = "UPDATE")
@DataChangeLog(menu = "RECON_OUT", table = "carrier_reconciliation_out")
public ApiResponse<Void> retry(@PathVariable Long reconOutId) {
service.retry(reconOutId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,74 @@
package com.ga.api.controller.settle;
import com.ga.api.service.settle.CommissionDisputeService;
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.CommissionDisputeResp;
import com.ga.core.vo.settle.CommissionDisputeSaveReq;
import com.ga.core.vo.settle.CommissionDisputeSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.Map;
@Tag(name = "수수료 이의신청 관리")
@RestController
@RequestMapping("/api/disputes")
@RequiredArgsConstructor
public class CommissionDisputeController {
private final CommissionDisputeService service;
@GetMapping
@RequirePermission(menu = "DISPUTE", perm = "READ")
public ApiResponse<PageResponse<CommissionDisputeResp>> list(@ModelAttribute CommissionDisputeSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{disputeId}")
@RequirePermission(menu = "DISPUTE", perm = "READ")
public ApiResponse<CommissionDisputeResp> detail(@PathVariable Long disputeId) {
return ApiResponse.ok(service.detail(disputeId));
}
@PostMapping
@RequirePermission(menu = "DISPUTE", perm = "CREATE")
@DataChangeLog(menu = "DISPUTE", table = "commission_dispute")
public ApiResponse<Long> create(@Valid @RequestBody CommissionDisputeSaveReq req) {
return ApiResponse.ok(service.create(req));
}
@PostMapping("/{disputeId}/review")
@RequirePermission(menu = "DISPUTE", perm = "UPDATE")
@DataChangeLog(menu = "DISPUTE", table = "commission_dispute")
public ApiResponse<Void> startReview(@PathVariable Long disputeId) {
service.startReview(disputeId);
return ApiResponse.ok();
}
@PostMapping("/{disputeId}/approve")
@RequirePermission(menu = "DISPUTE", perm = "APPROVE")
@DataChangeLog(menu = "DISPUTE", table = "commission_dispute")
public ApiResponse<Void> approve(@PathVariable Long disputeId, @RequestBody Map<String, Object> body) {
String resolution = (String) body.get("resolution");
BigDecimal adjustmentAmount = body.get("adjustmentAmount") != null
? new BigDecimal(body.get("adjustmentAmount").toString()) : null;
Long correctionId = body.get("correctionId") != null
? Long.valueOf(body.get("correctionId").toString()) : null;
service.approve(disputeId, resolution, adjustmentAmount, correctionId);
return ApiResponse.ok();
}
@PostMapping("/{disputeId}/reject")
@RequirePermission(menu = "DISPUTE", perm = "APPROVE")
@DataChangeLog(menu = "DISPUTE", table = "commission_dispute")
public ApiResponse<Void> reject(@PathVariable Long disputeId, @RequestBody Map<String, String> body) {
service.reject(disputeId, body.get("resolution"));
return ApiResponse.ok();
}
}
@@ -0,0 +1,60 @@
package com.ga.api.controller.settle;
import com.ga.api.service.settle.IncentivePaymentService;
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.IncentivePaymentResp;
import com.ga.core.vo.settle.IncentivePaymentSaveReq;
import com.ga.core.vo.settle.IncentivePaymentSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Tag(name = "시책 지급 관리")
@RestController
@RequestMapping("/api/incentive-payments")
@RequiredArgsConstructor
public class IncentivePaymentController {
private final IncentivePaymentService service;
@GetMapping
@RequirePermission(menu = "INCENTIVE_PAY", perm = "READ")
public ApiResponse<PageResponse<IncentivePaymentResp>> list(@ModelAttribute IncentivePaymentSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{incentivePayId}")
@RequirePermission(menu = "INCENTIVE_PAY", perm = "READ")
public ApiResponse<IncentivePaymentResp> detail(@PathVariable Long incentivePayId) {
return ApiResponse.ok(service.detail(incentivePayId));
}
@PostMapping
@RequirePermission(menu = "INCENTIVE_PAY", perm = "CREATE")
@DataChangeLog(menu = "INCENTIVE_PAY", table = "incentive_payment")
public ApiResponse<Long> create(@Valid @RequestBody IncentivePaymentSaveReq req) {
return ApiResponse.ok(service.create(req));
}
@PostMapping("/{incentivePayId}/confirm")
@RequirePermission(menu = "INCENTIVE_PAY", perm = "APPROVE")
@DataChangeLog(menu = "INCENTIVE_PAY", table = "incentive_payment")
public ApiResponse<Void> confirm(@PathVariable Long incentivePayId) {
service.confirm(incentivePayId);
return ApiResponse.ok();
}
@PostMapping("/{incentivePayId}/cancel")
@RequirePermission(menu = "INCENTIVE_PAY", perm = "UPDATE")
@DataChangeLog(menu = "INCENTIVE_PAY", table = "incentive_payment")
public ApiResponse<Void> cancel(@PathVariable Long incentivePayId, @RequestBody Map<String, String> body) {
service.cancel(incentivePayId, body.get("cancelReason"));
return ApiResponse.ok();
}
}
@@ -0,0 +1,60 @@
package com.ga.api.controller.settle;
import com.ga.api.service.settle.IncentiveProgramService;
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.IncentiveProgramResp;
import com.ga.core.vo.settle.IncentiveProgramSaveReq;
import com.ga.core.vo.settle.IncentiveProgramSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Tag(name = "시책 프로그램 관리")
@RestController
@RequestMapping("/api/incentive-programs")
@RequiredArgsConstructor
public class IncentiveProgramController {
private final IncentiveProgramService service;
@GetMapping
@RequirePermission(menu = "INCENTIVE_PROG", perm = "READ")
public ApiResponse<PageResponse<IncentiveProgramResp>> list(@ModelAttribute IncentiveProgramSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{programId}")
@RequirePermission(menu = "INCENTIVE_PROG", perm = "READ")
public ApiResponse<IncentiveProgramResp> detail(@PathVariable Long programId) {
return ApiResponse.ok(service.detail(programId));
}
@PostMapping
@RequirePermission(menu = "INCENTIVE_PROG", perm = "CREATE")
@DataChangeLog(menu = "INCENTIVE_PROG", table = "incentive_program")
public ApiResponse<Long> create(@Valid @RequestBody IncentiveProgramSaveReq req) {
return ApiResponse.ok(service.create(req));
}
@PutMapping("/{programId}")
@RequirePermission(menu = "INCENTIVE_PROG", perm = "UPDATE")
@DataChangeLog(menu = "INCENTIVE_PROG", table = "incentive_program")
public ApiResponse<Void> update(@PathVariable Long programId, @Valid @RequestBody IncentiveProgramSaveReq req) {
service.update(programId, req);
return ApiResponse.ok();
}
@PutMapping("/{programId}/active")
@RequirePermission(menu = "INCENTIVE_PROG", perm = "UPDATE")
@DataChangeLog(menu = "INCENTIVE_PROG", table = "incentive_program")
public ApiResponse<Void> toggleActive(@PathVariable Long programId, @RequestBody Map<String, Boolean> body) {
service.toggleActive(programId, Boolean.TRUE.equals(body.get("isActive")));
return ApiResponse.ok();
}
}
@@ -0,0 +1,53 @@
package com.ga.api.controller.settle;
import com.ga.api.service.settle.PeriodCloseService;
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.PeriodCloseResp;
import com.ga.core.vo.settle.PeriodCloseSaveReq;
import com.ga.core.vo.settle.PeriodCloseSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Tag(name = "정산 마감 관리")
@RestController
@RequestMapping("/api/period-close")
@RequiredArgsConstructor
public class PeriodCloseController {
private final PeriodCloseService service;
@GetMapping
@RequirePermission(menu = "PERIOD_CLOSE", perm = "READ")
public ApiResponse<PageResponse<PeriodCloseResp>> list(@ModelAttribute PeriodCloseSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{closeId}")
@RequirePermission(menu = "PERIOD_CLOSE", perm = "READ")
public ApiResponse<PeriodCloseResp> detail(@PathVariable Long closeId) {
return ApiResponse.ok(service.detail(closeId));
}
@PostMapping
@RequirePermission(menu = "PERIOD_CLOSE", perm = "CREATE")
@DataChangeLog(menu = "PERIOD_CLOSE", table = "period_close")
public ApiResponse<Void> close(@Valid @RequestBody PeriodCloseSaveReq req) {
service.close(req);
return ApiResponse.ok();
}
@PostMapping("/{closeId}/reopen")
@RequirePermission(menu = "PERIOD_CLOSE", perm = "UPDATE")
@DataChangeLog(menu = "PERIOD_CLOSE", table = "period_close")
public ApiResponse<Void> reopen(@PathVariable Long closeId, @RequestBody Map<String, String> body) {
service.reopen(closeId, body.get("reopenReason"));
return ApiResponse.ok();
}
}
@@ -59,7 +59,7 @@ public class SettleController {
@RequirePermission(menu = "SETTLE_LIST", perm = "APPROVE") @RequirePermission(menu = "SETTLE_LIST", perm = "APPROVE")
@DataChangeLog(menu = "SETTLE_LIST", table = "settle_master") @DataChangeLog(menu = "SETTLE_LIST", table = "settle_master")
public ApiResponse<Void> hold(@PathVariable Long settleId, @RequestBody Map<String, String> body) { public ApiResponse<Void> hold(@PathVariable Long settleId, @RequestBody Map<String, String> body) {
service.hold(settleId, body.get("reason")); service.hold(settleId, body.get("reason"), body.get("reasonCode"));
return ApiResponse.ok(); return ApiResponse.ok();
} }
@@ -0,0 +1,67 @@
package com.ga.api.controller.settle;
import com.ga.api.service.settle.SettleCorrectionService;
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.SettleCorrectionResp;
import com.ga.core.vo.settle.SettleCorrectionSaveReq;
import com.ga.core.vo.settle.SettleCorrectionSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@Tag(name = "정산 정정 관리")
@RestController
@RequestMapping("/api/settle-corrections")
@RequiredArgsConstructor
public class SettleCorrectionController {
private final SettleCorrectionService service;
@GetMapping
@RequirePermission(menu = "SETTLE_CORR", perm = "READ")
public ApiResponse<PageResponse<SettleCorrectionResp>> list(@ModelAttribute SettleCorrectionSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{correctionId}")
@RequirePermission(menu = "SETTLE_CORR", perm = "READ")
public ApiResponse<SettleCorrectionResp> detail(@PathVariable Long correctionId) {
return ApiResponse.ok(service.detail(correctionId));
}
@GetMapping("/by-settle/{originalSettleId}")
@RequirePermission(menu = "SETTLE_CORR", perm = "READ")
public ApiResponse<List<SettleCorrectionResp>> listByOriginalSettle(@PathVariable Long originalSettleId) {
return ApiResponse.ok(service.listByOriginalSettle(originalSettleId));
}
@PostMapping
@RequirePermission(menu = "SETTLE_CORR", perm = "CREATE")
@DataChangeLog(menu = "SETTLE_CORR", table = "settle_correction")
public ApiResponse<Long> create(@Valid @RequestBody SettleCorrectionSaveReq req) {
return ApiResponse.ok(service.create(req));
}
@PostMapping("/{correctionId}/approve")
@RequirePermission(menu = "SETTLE_CORR", perm = "APPROVE")
@DataChangeLog(menu = "SETTLE_CORR", table = "settle_correction")
public ApiResponse<Void> approve(@PathVariable Long correctionId) {
service.approve(correctionId);
return ApiResponse.ok();
}
@PostMapping("/{correctionId}/cancel")
@RequirePermission(menu = "SETTLE_CORR", perm = "UPDATE")
@DataChangeLog(menu = "SETTLE_CORR", table = "settle_correction")
public ApiResponse<Void> cancel(@PathVariable Long correctionId, @RequestBody Map<String, String> body) {
service.cancel(correctionId, body.get("cancelReason"));
return ApiResponse.ok();
}
}
@@ -0,0 +1,52 @@
package com.ga.api.controller.settle;
import com.ga.api.service.settle.TaxInvoiceService;
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.TaxInvoiceResp;
import com.ga.core.vo.settle.TaxInvoiceSaveReq;
import com.ga.core.vo.settle.TaxInvoiceSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Tag(name = "세금계산서 관리")
@RestController
@RequestMapping("/api/tax-invoices")
@RequiredArgsConstructor
public class TaxInvoiceController {
private final TaxInvoiceService service;
@GetMapping
@RequirePermission(menu = "TAX_INVOICE", perm = "READ")
public ApiResponse<PageResponse<TaxInvoiceResp>> list(@ModelAttribute TaxInvoiceSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{invoiceId}")
@RequirePermission(menu = "TAX_INVOICE", perm = "READ")
public ApiResponse<TaxInvoiceResp> detail(@PathVariable Long invoiceId) {
return ApiResponse.ok(service.detail(invoiceId));
}
@PostMapping
@RequirePermission(menu = "TAX_INVOICE", perm = "CREATE")
@DataChangeLog(menu = "TAX_INVOICE", table = "tax_invoice")
public ApiResponse<Long> create(@Valid @RequestBody TaxInvoiceSaveReq req) {
return ApiResponse.ok(service.create(req));
}
@PostMapping("/{invoiceId}/cancel")
@RequirePermission(menu = "TAX_INVOICE", perm = "UPDATE")
@DataChangeLog(menu = "TAX_INVOICE", table = "tax_invoice")
public ApiResponse<Void> cancel(@PathVariable Long invoiceId, @RequestBody Map<String, String> body) {
service.cancel(invoiceId, body.get("cancelReason"));
return ApiResponse.ok();
}
}
@@ -0,0 +1,65 @@
package com.ga.api.controller.settle;
import com.ga.api.service.settle.TerminationSettlementService;
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.TerminationSettlementResp;
import com.ga.core.vo.settle.TerminationSettlementSaveReq;
import com.ga.core.vo.settle.TerminationSettlementSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@Tag(name = "해촉 정산 관리")
@RestController
@RequestMapping("/api/termination-settlements")
@RequiredArgsConstructor
public class TerminationSettlementController {
private final TerminationSettlementService service;
@GetMapping
@RequirePermission(menu = "TERM_SETTLE", perm = "READ")
public ApiResponse<PageResponse<TerminationSettlementResp>> list(@ModelAttribute TerminationSettlementSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{termSettleId}")
@RequirePermission(menu = "TERM_SETTLE", perm = "READ")
public ApiResponse<TerminationSettlementResp> detail(@PathVariable Long termSettleId) {
return ApiResponse.ok(service.detail(termSettleId));
}
@PostMapping
@RequirePermission(menu = "TERM_SETTLE", perm = "CREATE")
@DataChangeLog(menu = "TERM_SETTLE", table = "termination_settlement")
public ApiResponse<Long> create(@Valid @RequestBody TerminationSettlementSaveReq req) {
return ApiResponse.ok(service.create(req));
}
@PutMapping("/{termSettleId}")
@RequirePermission(menu = "TERM_SETTLE", perm = "UPDATE")
@DataChangeLog(menu = "TERM_SETTLE", table = "termination_settlement")
public ApiResponse<Void> update(@PathVariable Long termSettleId, @Valid @RequestBody TerminationSettlementSaveReq req) {
service.update(termSettleId, req);
return ApiResponse.ok();
}
@PostMapping("/process")
@RequirePermission(menu = "TERM_SETTLE", perm = "CREATE")
@DataChangeLog(menu = "TERM_SETTLE", table = "termination_settlement")
public ApiResponse<Long> process(@Valid @RequestBody TerminationSettlementSaveReq req) {
return ApiResponse.ok(service.process(req));
}
@PostMapping("/{termSettleId}/confirm")
@RequirePermission(menu = "TERM_SETTLE", perm = "APPROVE")
@DataChangeLog(menu = "TERM_SETTLE", table = "termination_settlement")
public ApiResponse<Void> confirm(@PathVariable Long termSettleId) {
service.confirm(termSettleId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,91 @@
package com.ga.api.controller.training;
import com.ga.api.service.training.AgentTrainingService;
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.training.*;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.List;
@Tag(name = "보수교육 관리")
@RestController
@RequiredArgsConstructor
public class AgentTrainingController {
private final AgentTrainingService service;
// ── Training ──────────────────────────────────────────────────────────────
@GetMapping("/api/agent-trainings")
@RequirePermission(menu = "AGENT_TRAINING", perm = "READ")
public ApiResponse<PageResponse<AgentTrainingResp>> list(@ModelAttribute AgentTrainingSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/api/agent-trainings/{trainingId}")
@RequirePermission(menu = "AGENT_TRAINING", perm = "READ")
public ApiResponse<AgentTrainingResp> detail(@PathVariable Long trainingId) {
return ApiResponse.ok(service.detail(trainingId));
}
@GetMapping("/api/agent-trainings/annual-hours")
@RequirePermission(menu = "AGENT_TRAINING", perm = "READ")
public ApiResponse<BigDecimal> annualHours(@RequestParam Long agentId,
@RequestParam int year) {
return ApiResponse.ok(service.annualHours(agentId, year));
}
@PostMapping("/api/agent-trainings")
@RequirePermission(menu = "AGENT_TRAINING", perm = "CREATE")
@DataChangeLog(menu = "AGENT_TRAINING", table = "agent_training")
public ApiResponse<Long> create(@Valid @RequestBody AgentTrainingSaveReq req) {
return ApiResponse.ok(service.create(req));
}
@PutMapping("/api/agent-trainings/{trainingId}")
@RequirePermission(menu = "AGENT_TRAINING", perm = "UPDATE")
@DataChangeLog(menu = "AGENT_TRAINING", table = "agent_training")
public ApiResponse<Void> update(@PathVariable Long trainingId,
@Valid @RequestBody AgentTrainingSaveReq req) {
service.update(trainingId, req);
return ApiResponse.ok();
}
@DeleteMapping("/api/agent-trainings/{trainingId}")
@RequirePermission(menu = "AGENT_TRAINING", perm = "DELETE")
@DataChangeLog(menu = "AGENT_TRAINING", table = "agent_training")
public ApiResponse<Void> delete(@PathVariable Long trainingId) {
service.delete(trainingId);
return ApiResponse.ok();
}
// ── Requirement ───────────────────────────────────────────────────────────
@GetMapping("/api/training-requirements")
@RequirePermission(menu = "AGENT_TRAINING", perm = "READ")
public ApiResponse<List<TrainingRequirementResp>> listRequirements(
@ModelAttribute TrainingRequirementSearchParam param) {
return ApiResponse.ok(service.listRequirements(param));
}
@GetMapping("/api/training-requirements/{year}")
@RequirePermission(menu = "AGENT_TRAINING", perm = "READ")
public ApiResponse<TrainingRequirementResp> requirementByYear(@PathVariable int year) {
return ApiResponse.ok(service.requirementByYear(year));
}
@PostMapping("/api/training-requirements")
@RequirePermission(menu = "AGENT_TRAINING", perm = "UPDATE")
@DataChangeLog(menu = "AGENT_TRAINING", table = "training_requirement")
public ApiResponse<Void> saveRequirement(@Valid @RequestBody TrainingRequirementSaveReq req) {
service.saveRequirement(req);
return ApiResponse.ok();
}
}
@@ -0,0 +1,50 @@
package com.ga.api.controller.withdraw;
import com.ga.api.service.withdraw.WithdrawRequestService;
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.withdraw.WithdrawRequestResp;
import com.ga.core.vo.withdraw.WithdrawRequestSaveReq;
import com.ga.core.vo.withdraw.WithdrawRequestSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@Tag(name = "출금 신청")
@RestController
@RequestMapping("/api/withdraw-requests")
@RequiredArgsConstructor
public class WithdrawRequestController {
private final WithdrawRequestService service;
@GetMapping
@RequirePermission(menu = "WITHDRAW", perm = "READ")
public ApiResponse<PageResponse<WithdrawRequestResp>> list(@ModelAttribute WithdrawRequestSearchParam param) {
return ApiResponse.ok(service.list(param));
}
@GetMapping("/{requestId}")
@RequirePermission(menu = "WITHDRAW", perm = "READ")
public ApiResponse<WithdrawRequestResp> detail(@PathVariable Long requestId) {
return ApiResponse.ok(service.detail(requestId));
}
@PostMapping
@RequirePermission(menu = "WITHDRAW", perm = "CREATE")
@DataChangeLog(menu = "WITHDRAW", table = "withdraw_request")
public ApiResponse<Long> create(@Valid @RequestBody WithdrawRequestSaveReq req) {
return ApiResponse.ok(service.create(req));
}
@PostMapping("/{requestId}/cancel")
@RequirePermission(menu = "WITHDRAW", perm = "UPDATE")
@DataChangeLog(menu = "WITHDRAW", table = "withdraw_request")
public ApiResponse<Void> cancel(@PathVariable Long requestId) {
service.cancel(requestId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,259 @@
package com.ga.api.service.approval;
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.approval.*;
import com.ga.core.mapper.notice.UserNotificationMapper;
import com.ga.core.mapper.settle.CommissionDisputeMapper;
import com.ga.core.mapper.settle.IncentivePaymentMapper;
import com.ga.core.mapper.settle.SettleMasterMapper;
import com.ga.core.mapper.withdraw.WithdrawRequestMapper;
import com.ga.core.vo.approval.*;
import com.ga.core.vo.notice.NotificationRefType;
// Note: ApprovalLineVO kept for createLine/updateLine methods
import com.ga.core.vo.notice.UserNotificationVO;
import com.ga.core.vo.settle.SettleStatus;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ApprovalService {
private final ApprovalLineMapper lineMapper;
private final ApprovalLineStepMapper stepMapper;
private final ApprovalRequestMapper requestMapper;
private final ApprovalHistoryMapper historyMapper;
private final UserNotificationMapper notificationMapper;
// 콜백 대상 도메인 Mapper
private final WithdrawRequestMapper withdrawMapper;
private final SettleMasterMapper settleMasterMapper;
private final CommissionDisputeMapper disputeMapper;
private final IncentivePaymentMapper incentivePaymentMapper;
// ── Line ──────────────────────────────────────────────────────────────────
public PageResponse<ApprovalLineResp> listLines(ApprovalLineSearchParam param) {
param.startPage();
return PageResponse.of(lineMapper.selectList(param));
}
public ApprovalLineResp detailLine(Long lineId) {
ApprovalLineResp resp = lineMapper.selectById(lineId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
public List<ApprovalLineStepResp> stepsOfLine(Long lineId) {
return stepMapper.selectByLine(lineId);
}
@Transactional
public Long createLine(ApprovalLineSaveReq req) {
ApprovalLineVO vo = new ApprovalLineVO();
vo.setLineCode(req.getLineCode());
vo.setLineName(req.getLineName());
vo.setMaxStep(req.getMaxStep());
vo.setTargetType(req.getTargetType());
vo.setIsActive(true);
vo.setCreatedBy(SecurityUtil.getCurrentUserId());
lineMapper.insert(vo);
return vo.getLineId();
}
@Transactional
public void updateLine(Long lineId, ApprovalLineSaveReq req) {
ApprovalLineResp existing = lineMapper.selectById(lineId);
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
ApprovalLineVO vo = new ApprovalLineVO();
vo.setLineId(lineId);
vo.setLineCode(req.getLineCode());
vo.setLineName(req.getLineName());
vo.setMaxStep(req.getMaxStep());
vo.setTargetType(req.getTargetType());
lineMapper.update(vo);
}
@Transactional
public void addStep(ApprovalLineStepSaveReq req) {
ApprovalLineStepVO vo = new ApprovalLineStepVO();
vo.setLineId(req.getLineId());
vo.setStepNo(req.getStepNo());
vo.setRoleCode(req.getRoleCode());
stepMapper.insert(vo);
}
// ── Request ───────────────────────────────────────────────────────────────
public PageResponse<ApprovalRequestResp> listRequests(ApprovalRequestSearchParam param) {
param.startPage();
return PageResponse.of(requestMapper.selectList(param));
}
public ApprovalRequestResp detailRequest(Long requestId) {
ApprovalRequestResp resp = requestMapper.selectById(requestId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
public List<ApprovalHistoryResp> historyOfRequest(Long requestId) {
return historyMapper.selectByRequest(requestId);
}
/**
* 결재 요청 생성 — PENDING status=1, 첫 결재자에게 알림 발송.
*/
@Transactional
public Long createRequest(ApprovalRequestSaveReq req) {
// 결재선 존재 확인
ApprovalLineResp line = lineMapper.selectById(req.getLineId());
if (line == null) throw new BizException(ErrorCode.NOT_FOUND, "결재선을 찾을 수 없습니다");
ApprovalRequestVO vo = new ApprovalRequestVO();
vo.setTargetType(req.getTargetType());
vo.setTargetId(req.getTargetId());
vo.setLineId(req.getLineId());
vo.setRequestedBy(req.getRequestedBy());
vo.setRequestedAt(LocalDateTime.now());
vo.setCurrentStep(1);
vo.setStatus(ApprovalStatus.PENDING.name());
requestMapper.insert(vo);
// 1단계 결재자에게 알림
sendStepNotification(vo.getRequestId(), req.getLineId(), 1, req.getTargetType());
return vo.getRequestId();
}
/**
* 결재 단계 진행 — history INSERT + step advance + 최종 처리.
* @param requestId 결재 요청 PK
* @param approverId 결재자 user_id
* @param action APPROVE / REJECT
* @param comment 코멘트
*/
@Transactional
public void advance(Long requestId, Long approverId, String action, String comment) {
ApprovalRequestResp reqResp = requestMapper.selectById(requestId);
if (reqResp == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!ApprovalStatus.PENDING.name().equals(reqResp.getStatus())) {
throw new BizException(ErrorCode.INVALID_STATUS, "진행 중인 결재 요청이 아닙니다");
}
// 1. 결재 이력 INSERT
ApprovalHistoryVO history = new ApprovalHistoryVO();
history.setRequestId(requestId);
history.setStepNo(reqResp.getCurrentStep());
history.setApproverId(approverId);
history.setAction(action);
history.setComment(comment);
history.setProcessedAt(LocalDateTime.now());
historyMapper.insert(history);
// 2. advanceStep — DB에서 current_step 갱신
requestMapper.advanceStep(requestId, approverId, action, comment);
// 3. REJECT → 종료
if (ApprovalAction.REJECT.name().equals(action)) {
requestMapper.updateStatus(requestId, ApprovalStatus.REJECTED.name());
log.info("결재 요청 반려: requestId={}", requestId);
return;
}
// 4. APPROVE 처리
if (ApprovalAction.APPROVE.name().equals(action)) {
int maxStep = reqResp.getMaxStep() != null ? reqResp.getMaxStep() : 1;
int currentStep = reqResp.getCurrentStep();
if (currentStep < maxStep) {
// 다음 단계 알림
sendStepNotification(requestId, reqResp.getLineId(), currentStep + 1, reqResp.getTargetType());
} else {
// 최종 승인 — 콜백 처리
requestMapper.updateStatus(requestId, ApprovalStatus.APPROVED.name());
handleApprovalCallback(reqResp.getTargetType(), reqResp.getTargetId());
}
}
}
/**
* 결재 취소.
*/
@Transactional
public void cancel(Long requestId) {
ApprovalRequestResp reqResp = requestMapper.selectById(requestId);
if (reqResp == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!ApprovalStatus.PENDING.name().equals(reqResp.getStatus())) {
throw new BizException(ErrorCode.INVALID_STATUS, "진행 중인 결재만 취소 가능합니다");
}
requestMapper.cancel(requestId);
}
// ── Callback ──────────────────────────────────────────────────────────────
/**
* 최종 승인 후 target_type 별 도메인 후처리.
* - WITHDRAW → withdraw_request.status=APPROVED (펌뱅킹 SENT는 P5)
* - SETTLE → settle_master.status=CONFIRMED
* - DISPUTE → commission_dispute.status=APPROVED (정정 생성은 P5)
* - INCENTIVE → incentive_payment.status=CONFIRMED
*/
private void handleApprovalCallback(String targetType, Long targetId) {
if (targetType == null || targetId == null) return;
try {
switch (targetType) {
case "WITHDRAW" -> {
withdrawMapper.updateStatus(targetId, "APPROVED", null);
log.info("출금신청 승인 완료: requestId={}", targetId);
}
case "SETTLE" -> {
settleMasterMapper.updateStatus(targetId, SettleStatus.CONFIRMED.name(),
SecurityUtil.getCurrentUserId());
log.info("정산 확정 완료: settleId={}", targetId);
}
case "DISPUTE" -> {
disputeMapper.resolve(targetId, "APPROVED", null, null, null,
SecurityUtil.getCurrentUserId());
log.info("이의신청 승인 완료: disputeId={}", targetId);
}
case "INCENTIVE" -> {
incentivePaymentMapper.updateStatus(targetId, "CONFIRMED",
SecurityUtil.getCurrentUserId());
log.info("시책 확정 완료: incentivePayId={}", targetId);
}
default -> log.warn("알 수 없는 결재 대상 타입: targetType={}", targetType);
}
} catch (Exception e) {
log.error("결재 콜백 실패: targetType={}, targetId={}", targetType, targetId, e);
throw new BizException(ErrorCode.INTERNAL_ERROR, "결재 후처리 중 오류가 발생했습니다");
}
}
/** 다음 결재 단계 담당자에게 알림 발송 */
private void sendStepNotification(Long requestId, Long lineId, int stepNo, String targetType) {
try {
ApprovalLineStepVO step = stepMapper.selectByLineAndStep(lineId, stepNo);
if (step == null) return;
// roleCode 기반 userId 매핑은 P5에서 고도화 — 현재는 메타만 저장
UserNotificationVO noti = new UserNotificationVO();
noti.setUserId(0L); // 실제 담당자 ID 조회 로직은 P5
noti.setTitle("[결재요청] " + (targetType != null ? targetType : "") + " 결재 " + stepNo + "단계");
noti.setContent("결재 요청 ID " + requestId + " 에 대한 " + stepNo + "단계 결재가 필요합니다.");
noti.setRefType(NotificationRefType.APPROVAL.name());
noti.setRefId(requestId);
noti.setIsRead(false);
notificationMapper.insert(noti);
} catch (Exception e) {
log.warn("결재 알림 발송 실패 (non-critical): requestId={}", requestId, e);
}
}
}
@@ -0,0 +1,111 @@
package com.ga.api.service.attachment;
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.attachment.AttachmentMapper;
import com.ga.core.vo.attachment.AttachmentResp;
import com.ga.core.vo.attachment.AttachmentSearchParam;
import com.ga.core.vo.attachment.AttachmentVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.UUID;
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AttachmentService {
private final AttachmentMapper mapper;
@Value("${app.attachment.path:./uploads}")
private String uploadBasePath;
public PageResponse<AttachmentResp> list(AttachmentSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
public List<AttachmentResp> listByRef(String refType, Long refId) {
return mapper.selectByRef(refType, refId);
}
public AttachmentResp detail(Long attachmentId) {
AttachmentResp resp = mapper.selectById(attachmentId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
/**
* 파일 업로드 + 메타 저장.
* 파일 저장 실패 경고 로그 메타만 저장하는 fallback.
*/
@Transactional
public Long upload(MultipartFile file, String refType, Long refId) {
String originalName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "unknown";
String storedPath = null;
try {
Path dir = Paths.get(uploadBasePath, refType.toLowerCase());
Files.createDirectories(dir);
String storedName = UUID.randomUUID() + "_" + originalName;
Path dest = dir.resolve(storedName);
file.transferTo(dest);
storedPath = dest.toString();
} catch (IOException e) {
log.warn("파일 저장 실패 (fallback: 메타만 저장): refType={}, refId={}, file={}, error={}",
refType, refId, originalName, e.getMessage());
storedPath = "[STORAGE_FAILED]/" + originalName;
}
AttachmentVO vo = new AttachmentVO();
vo.setRefType(refType);
vo.setRefId(refId);
vo.setFileName(originalName);
vo.setFilePath(storedPath);
vo.setFileSize(file.getSize());
vo.setMimeType(file.getContentType());
vo.setUploadedBy(SecurityUtil.getCurrentUserId());
mapper.insert(vo);
return vo.getAttachmentId();
}
/**
* 파일 다운로드를 위한 실제 경로 반환.
*/
public Path resolveFilePath(Long attachmentId) {
AttachmentResp resp = mapper.selectById(attachmentId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
if (resp.getFilePath() == null || resp.getFilePath().startsWith("[STORAGE_FAILED]")) {
throw new BizException(ErrorCode.NOT_FOUND, "파일이 저장되지 않아 다운로드할 수 없습니다");
}
return Paths.get(resp.getFilePath());
}
@Transactional
public void delete(Long attachmentId) {
AttachmentResp resp = mapper.selectById(attachmentId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
// 실제 파일 삭제 시도 (실패해도 메타 삭제 진행)
if (resp.getFilePath() != null && !resp.getFilePath().startsWith("[STORAGE_FAILED]")) {
try {
Files.deleteIfExists(Paths.get(resp.getFilePath()));
} catch (IOException e) {
log.warn("파일 삭제 실패: path={}", resp.getFilePath(), e);
}
}
mapper.delete(attachmentId);
}
}
@@ -0,0 +1,96 @@
package com.ga.api.service.commission;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.commission.CollectionCommissionLedgerMapper;
import com.ga.core.mapper.commission.CollectionCommissionRuleMapper;
import com.ga.core.vo.commission.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CollectionCommissionService {
private final CollectionCommissionRuleMapper ruleMapper;
private final CollectionCommissionLedgerMapper ledgerMapper;
// Rule
public PageResponse<CollectionCommissionRuleResp> listRules(CollectionCommissionRuleSearchParam param) {
param.startPage();
return PageResponse.of(ruleMapper.selectList(param));
}
public CollectionCommissionRuleResp detailRule(Long ruleId) {
CollectionCommissionRuleResp resp = ruleMapper.selectById(ruleId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public Long createRule(CollectionCommissionRuleSaveReq req) {
CollectionCommissionRuleVO vo = new CollectionCommissionRuleVO();
vo.setProductId(req.getProductId());
vo.setPaymentCycle(req.getPaymentCycle());
vo.setCommissionRate(req.getCommissionRate());
vo.setEffectiveFrom(req.getEffectiveFrom());
vo.setEffectiveTo(req.getEffectiveTo());
ruleMapper.insert(vo);
return vo.getRuleId();
}
@Transactional
public void updateRule(Long ruleId, CollectionCommissionRuleSaveReq req) {
CollectionCommissionRuleResp resp = ruleMapper.selectById(ruleId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
CollectionCommissionRuleVO vo = new CollectionCommissionRuleVO();
vo.setRuleId(ruleId);
vo.setProductId(req.getProductId());
vo.setPaymentCycle(req.getPaymentCycle());
vo.setCommissionRate(req.getCommissionRate());
vo.setEffectiveFrom(req.getEffectiveFrom());
vo.setEffectiveTo(req.getEffectiveTo());
ruleMapper.update(vo);
}
@Transactional
public void deleteRule(Long ruleId) {
CollectionCommissionRuleResp resp = ruleMapper.selectById(ruleId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
ruleMapper.delete(ruleId);
}
// Ledger
public PageResponse<CollectionCommissionLedgerResp> listLedgers(CollectionCommissionLedgerSearchParam param) {
param.startPage();
return PageResponse.of(ledgerMapper.selectList(param));
}
public CollectionCommissionLedgerResp detailLedger(Long ledgerId) {
CollectionCommissionLedgerResp resp = ledgerMapper.selectById(ledgerId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public Long createLedger(CollectionCommissionLedgerSaveReq req) {
CollectionCommissionLedgerVO vo = new CollectionCommissionLedgerVO();
vo.setContractId(req.getContractId());
vo.setAgentId(req.getAgentId());
vo.setSettleMonth(req.getSettleMonth());
vo.setPremiumAmount(req.getPremiumAmount());
vo.setCommissionRate(req.getCommissionRate());
// 수금수수료 = 보험료 × 수수료율
vo.setCommissionAmount(req.getPremiumAmount().multiply(req.getCommissionRate()));
vo.setPaymentCycle(req.getPaymentCycle());
vo.setPaidAt(req.getPaidAt());
vo.setStatus("CALCULATED");
ledgerMapper.insert(vo);
return vo.getLedgerId();
}
}
@@ -0,0 +1,54 @@
package com.ga.api.service.commission;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.commission.CommissionMasterMapper;
import com.ga.core.vo.commission.CommissionMasterResp;
import com.ga.core.vo.commission.CommissionMasterSearchParam;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CommissionMasterService {
private final CommissionMasterMapper mapper;
public PageResponse<CommissionMasterResp> list(CommissionMasterSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
public CommissionMasterResp detail(Long masterId) {
CommissionMasterResp resp = mapper.selectById(masterId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
public List<CommissionMasterResp> listByAgentAndMonth(Long agentId, String settleMonth) {
return mapper.selectByAgentAndMonth(agentId, settleMonth);
}
public List<CommissionMasterResp> aggregatedByMonth(String settleMonth) {
return mapper.selectAggregatedByMonth(settleMonth);
}
@Transactional
public void updateStatus(Long masterId, String status) {
CommissionMasterResp resp = mapper.selectById(masterId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
mapper.updateStatus(masterId, status);
}
@Transactional
public void delete(Long masterId) {
CommissionMasterResp resp = mapper.selectById(masterId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
mapper.delete(masterId);
}
}
@@ -0,0 +1,43 @@
package com.ga.api.service.commission;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.commission.OverrideLedgerMapper;
import com.ga.core.mapper.commission.OverrideSummaryMapper;
import com.ga.core.vo.commission.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OverrideLedgerService {
private final OverrideLedgerMapper ledgerMapper;
private final OverrideSummaryMapper summaryMapper;
public PageResponse<OverrideLedgerResp> list(OverrideLedgerSearchParam param) {
param.startPage();
return PageResponse.of(ledgerMapper.selectList(param));
}
public OverrideLedgerResp detail(Long ledgerId) {
OverrideLedgerResp resp = ledgerMapper.selectById(ledgerId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
public List<OverrideSummaryResp> summaryList(OverrideSummarySearchParam param) {
return summaryMapper.selectList(param);
}
public OverrideSummaryResp summaryByAgentAndMonth(Long toAgentId, String settleMonth) {
OverrideSummaryResp resp = summaryMapper.selectByAgentAndMonth(toAgentId, settleMonth);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
}
@@ -0,0 +1,100 @@
package com.ga.api.service.commission;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.commission.PersistencyBonusMapper;
import com.ga.core.mapper.commission.PersistencyBonusRuleMapper;
import com.ga.core.vo.commission.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PersistencyBonusService {
private final PersistencyBonusRuleMapper ruleMapper;
private final PersistencyBonusMapper bonusMapper;
// Rule
public PageResponse<PersistencyBonusRuleResp> listRules(PersistencyBonusRuleSearchParam param) {
param.startPage();
return PageResponse.of(ruleMapper.selectList(param));
}
public PersistencyBonusRuleResp detailRule(Long ruleId) {
PersistencyBonusRuleResp resp = ruleMapper.selectById(ruleId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public Long createRule(PersistencyBonusRuleSaveReq req) {
PersistencyBonusRuleVO vo = new PersistencyBonusRuleVO();
vo.setGradeId(req.getGradeId());
vo.setRetentionMonth(req.getRetentionMonth());
vo.setRetentionRateThreshold(req.getRetentionRateThreshold());
vo.setBonusType(req.getBonusType());
vo.setBonusValue(req.getBonusValue());
vo.setEffectiveFrom(req.getEffectiveFrom());
vo.setEffectiveTo(req.getEffectiveTo());
ruleMapper.insert(vo);
return vo.getRuleId();
}
@Transactional
public void updateRule(Long ruleId, PersistencyBonusRuleSaveReq req) {
PersistencyBonusRuleResp resp = ruleMapper.selectById(ruleId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
PersistencyBonusRuleVO vo = new PersistencyBonusRuleVO();
vo.setRuleId(ruleId);
vo.setGradeId(req.getGradeId());
vo.setRetentionMonth(req.getRetentionMonth());
vo.setRetentionRateThreshold(req.getRetentionRateThreshold());
vo.setBonusType(req.getBonusType());
vo.setBonusValue(req.getBonusValue());
vo.setEffectiveFrom(req.getEffectiveFrom());
vo.setEffectiveTo(req.getEffectiveTo());
ruleMapper.update(vo);
}
@Transactional
public void deleteRule(Long ruleId) {
PersistencyBonusRuleResp resp = ruleMapper.selectById(ruleId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
ruleMapper.delete(ruleId);
}
// Bonus
public PageResponse<PersistencyBonusResp> listBonuses(PersistencyBonusSearchParam param) {
param.startPage();
return PageResponse.of(bonusMapper.selectList(param));
}
public PersistencyBonusResp detailBonus(Long bonusId) {
PersistencyBonusResp resp = bonusMapper.selectById(bonusId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
/**
* 수동 지급 등록/갱신 UNIQUE(agent_id, base_month, retention_month) 기반 upsert.
*/
@Transactional
public void upsertBonus(PersistencyBonusSaveReq req) {
PersistencyBonusVO vo = new PersistencyBonusVO();
vo.setAgentId(req.getAgentId());
vo.setRuleId(req.getRuleId());
vo.setBaseMonth(req.getBaseMonth());
vo.setRetentionMonth(req.getRetentionMonth());
vo.setSettleMonth(req.getSettleMonth());
vo.setRetentionRate(req.getRetentionRate());
vo.setBonusAmount(req.getBonusAmount());
vo.setStatus("CALCULATED");
bonusMapper.upsert(vo);
}
}
@@ -0,0 +1,108 @@
package com.ga.api.service.commission;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.commission.ReinstatementCommissionLedgerMapper;
import com.ga.core.mapper.commission.ReinstatementCommissionRuleMapper;
import com.ga.core.vo.commission.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ReinstatementCommissionService {
private final ReinstatementCommissionRuleMapper ruleMapper;
private final ReinstatementCommissionLedgerMapper ledgerMapper;
// Rule
public PageResponse<ReinstatementCommissionRuleResp> listRules(ReinstatementCommissionRuleSearchParam param) {
param.startPage();
return PageResponse.of(ruleMapper.selectList(param));
}
public ReinstatementCommissionRuleResp detailRule(Long ruleId) {
ReinstatementCommissionRuleResp resp = ruleMapper.selectById(ruleId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public Long createRule(ReinstatementCommissionRuleSaveReq req) {
ReinstatementCommissionRuleVO vo = new ReinstatementCommissionRuleVO();
vo.setProductId(req.getProductId());
vo.setPayType(req.getPayType());
vo.setRateValue(req.getRateValue());
vo.setFixedAmount(req.getFixedAmount());
vo.setChargebackReversalRate(req.getChargebackReversalRate());
vo.setEffectiveFrom(req.getEffectiveFrom());
vo.setEffectiveTo(req.getEffectiveTo());
ruleMapper.insert(vo);
return vo.getRuleId();
}
@Transactional
public void updateRule(Long ruleId, ReinstatementCommissionRuleSaveReq req) {
ReinstatementCommissionRuleResp resp = ruleMapper.selectById(ruleId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
ReinstatementCommissionRuleVO vo = new ReinstatementCommissionRuleVO();
vo.setRuleId(ruleId);
vo.setProductId(req.getProductId());
vo.setPayType(req.getPayType());
vo.setRateValue(req.getRateValue());
vo.setFixedAmount(req.getFixedAmount());
vo.setChargebackReversalRate(req.getChargebackReversalRate());
vo.setEffectiveFrom(req.getEffectiveFrom());
vo.setEffectiveTo(req.getEffectiveTo());
ruleMapper.update(vo);
}
@Transactional
public void deleteRule(Long ruleId) {
ReinstatementCommissionRuleResp resp = ruleMapper.selectById(ruleId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
ruleMapper.delete(ruleId);
}
// Ledger
public PageResponse<ReinstatementCommissionLedgerResp> listLedgers(ReinstatementCommissionLedgerSearchParam param) {
param.startPage();
return PageResponse.of(ledgerMapper.selectList(param));
}
public ReinstatementCommissionLedgerResp detailLedger(Long ledgerId) {
ReinstatementCommissionLedgerResp resp = ledgerMapper.selectById(ledgerId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
/**
* 수동 등록 chargebackReversal 요청값 그대로.
* netCommission = commissionAmount + chargebackReversal 자동 계산.
*/
@Transactional
public Long createLedger(ReinstatementCommissionLedgerSaveReq req) {
ReinstatementCommissionLedgerVO vo = new ReinstatementCommissionLedgerVO();
vo.setContractId(req.getContractId());
vo.setAgentId(req.getAgentId());
vo.setRuleId(req.getRuleId());
vo.setSettleMonth(req.getSettleMonth());
vo.setReinstatementDate(req.getReinstatementDate());
vo.setReinstatementPremium(req.getReinstatementPremium());
vo.setCommissionAmount(req.getCommissionAmount());
BigDecimal reversal = req.getChargebackReversal() != null ? req.getChargebackReversal() : BigDecimal.ZERO;
vo.setChargebackReversal(reversal);
vo.setNetCommission(req.getCommissionAmount().add(reversal));
vo.setPayType(req.getPayType());
vo.setStatus("CALCULATED");
ledgerMapper.insert(vo);
return vo.getLedgerId();
}
}
@@ -0,0 +1,97 @@
package com.ga.api.service.commission;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.commission.RenewalCommissionLedgerMapper;
import com.ga.core.mapper.commission.RenewalCommissionRuleMapper;
import com.ga.core.vo.commission.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class RenewalCommissionService {
private final RenewalCommissionRuleMapper ruleMapper;
private final RenewalCommissionLedgerMapper ledgerMapper;
// Rule
public PageResponse<RenewalCommissionRuleResp> listRules(RenewalCommissionRuleSearchParam param) {
param.startPage();
return PageResponse.of(ruleMapper.selectList(param));
}
public RenewalCommissionRuleResp detailRule(Long ruleId) {
RenewalCommissionRuleResp resp = ruleMapper.selectById(ruleId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public Long createRule(RenewalCommissionRuleSaveReq req) {
RenewalCommissionRuleVO vo = new RenewalCommissionRuleVO();
vo.setProductId(req.getProductId());
vo.setPayType(req.getPayType());
vo.setRateValue(req.getRateValue());
vo.setFixedAmount(req.getFixedAmount());
vo.setEffectiveFrom(req.getEffectiveFrom());
vo.setEffectiveTo(req.getEffectiveTo());
ruleMapper.insert(vo);
return vo.getRuleId();
}
@Transactional
public void updateRule(Long ruleId, RenewalCommissionRuleSaveReq req) {
RenewalCommissionRuleResp resp = ruleMapper.selectById(ruleId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
RenewalCommissionRuleVO vo = new RenewalCommissionRuleVO();
vo.setRuleId(ruleId);
vo.setProductId(req.getProductId());
vo.setPayType(req.getPayType());
vo.setRateValue(req.getRateValue());
vo.setFixedAmount(req.getFixedAmount());
vo.setEffectiveFrom(req.getEffectiveFrom());
vo.setEffectiveTo(req.getEffectiveTo());
ruleMapper.update(vo);
}
@Transactional
public void deleteRule(Long ruleId) {
RenewalCommissionRuleResp resp = ruleMapper.selectById(ruleId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
ruleMapper.delete(ruleId);
}
// Ledger
public PageResponse<RenewalCommissionLedgerResp> listLedgers(RenewalCommissionLedgerSearchParam param) {
param.startPage();
return PageResponse.of(ledgerMapper.selectList(param));
}
public RenewalCommissionLedgerResp detailLedger(Long ledgerId) {
RenewalCommissionLedgerResp resp = ledgerMapper.selectById(ledgerId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public Long createLedger(RenewalCommissionLedgerSaveReq req) {
RenewalCommissionLedgerVO vo = new RenewalCommissionLedgerVO();
vo.setContractId(req.getContractId());
vo.setAgentId(req.getAgentId());
vo.setRuleId(req.getRuleId());
vo.setSettleMonth(req.getSettleMonth());
vo.setRenewalDate(req.getRenewalDate());
vo.setRenewalPremium(req.getRenewalPremium());
vo.setCommissionAmount(req.getCommissionAmount());
vo.setPayType(req.getPayType());
vo.setStatus("CALCULATED");
ledgerMapper.insert(vo);
return vo.getLedgerId();
}
}
@@ -0,0 +1,86 @@
package com.ga.api.service.complaint;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.complaint.ComplaintMapper;
import com.ga.core.vo.complaint.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ComplaintService {
private final ComplaintMapper mapper;
public PageResponse<ComplaintResp> list(ComplaintSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
public ComplaintResp detail(Long complaintId) {
ComplaintResp resp = mapper.selectById(complaintId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public Long create(ComplaintSaveReq req) {
ComplaintVO vo = new ComplaintVO();
vo.setContractId(req.getContractId());
vo.setAgentId(req.getAgentId());
vo.setCustomerName(req.getCustomerName());
vo.setComplaintDate(req.getComplaintDate());
vo.setChannel(req.getChannel());
vo.setComplaintType(req.getComplaintType());
vo.setSeverity(req.getSeverity() != null ? req.getSeverity() : "MID");
vo.setStatus(ComplaintStatus.OPEN.name());
vo.setResolution(req.getResolution());
// chargebackTriggered=true P5에서 chargeback_ledger 자동 생성 예정 지금은 플래그만 저장
vo.setChargebackTriggered(req.getChargebackTriggered());
mapper.insert(vo);
return vo.getComplaintId();
}
@Transactional
public void update(Long complaintId, ComplaintSaveReq req) {
ComplaintResp existing = mapper.selectById(complaintId);
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
ComplaintVO vo = new ComplaintVO();
vo.setComplaintId(complaintId);
vo.setContractId(req.getContractId());
vo.setAgentId(req.getAgentId());
vo.setCustomerName(req.getCustomerName());
vo.setComplaintDate(req.getComplaintDate());
vo.setChannel(req.getChannel());
vo.setComplaintType(req.getComplaintType());
vo.setSeverity(req.getSeverity());
vo.setResolution(req.getResolution());
vo.setChargebackTriggered(req.getChargebackTriggered());
mapper.update(vo);
}
/**
* 처리완료 status=CLOSED.
* chargebackTriggered=true 건의 실제 환수 생성은 P5 배치에서.
*/
@Transactional
public void close(Long complaintId, String resolution) {
ComplaintResp existing = mapper.selectById(complaintId);
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
if (ComplaintStatus.CLOSED.name().equals(existing.getStatus())) {
throw new BizException(ErrorCode.INVALID_STATUS, "이미 처리완료된 민원입니다");
}
mapper.updateStatus(complaintId, ComplaintStatus.CLOSED.name(), resolution);
}
@Transactional
public void delete(Long complaintId) {
ComplaintResp existing = mapper.selectById(complaintId);
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
mapper.delete(complaintId);
}
}
@@ -0,0 +1,79 @@
package com.ga.api.service.contract;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.contract.AgentContractMapper;
import com.ga.core.vo.contract.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AgentContractService {
private final AgentContractMapper mapper;
public PageResponse<AgentContractResp> list(AgentContractSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
public AgentContractResp detail(Long contractId) {
AgentContractResp resp = mapper.selectById(contractId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public Long create(AgentContractSaveReq req) {
AgentContractVO vo = new AgentContractVO();
vo.setAgentId(req.getAgentId());
vo.setCarrierId(req.getCarrierId());
vo.setContractNo(req.getContractNo());
vo.setSignedDate(req.getSignedDate());
vo.setEffectiveFrom(req.getEffectiveFrom());
vo.setEffectiveTo(req.getEffectiveTo());
vo.setOnboardingBonusAmount(req.getOnboardingBonusAmount());
vo.setStatus(AgentContractStatus.ACTIVE.name());
mapper.insert(vo);
return vo.getContractId();
}
@Transactional
public void update(Long contractId, AgentContractSaveReq req) {
AgentContractResp existing = mapper.selectById(contractId);
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
AgentContractVO vo = new AgentContractVO();
vo.setContractId(contractId);
vo.setAgentId(req.getAgentId());
vo.setCarrierId(req.getCarrierId());
vo.setContractNo(req.getContractNo());
vo.setSignedDate(req.getSignedDate());
vo.setEffectiveFrom(req.getEffectiveFrom());
vo.setEffectiveTo(req.getEffectiveTo());
vo.setOnboardingBonusAmount(req.getOnboardingBonusAmount());
mapper.update(vo);
}
@Transactional
public void terminate(Long contractId, String reason) {
AgentContractResp existing = mapper.selectById(contractId);
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
if (AgentContractStatus.TERMINATED.name().equals(existing.getStatus())) {
throw new BizException(ErrorCode.INVALID_STATUS, "이미 해촉된 위촉계약입니다");
}
mapper.updateStatus(contractId, AgentContractStatus.TERMINATED.name(), reason);
}
@Transactional
public void delete(Long contractId) {
AgentContractResp existing = mapper.selectById(contractId);
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
mapper.delete(contractId);
}
}
@@ -0,0 +1,62 @@
package com.ga.api.service.endorse;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.endorse.ContractEndorsementMapper;
import com.ga.core.vo.endorse.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ContractEndorsementService {
private final ContractEndorsementMapper mapper;
public PageResponse<ContractEndorsementResp> list(ContractEndorsementSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
public ContractEndorsementResp detail(Long endorsementId) {
ContractEndorsementResp resp = mapper.selectById(endorsementId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public Long create(ContractEndorsementSaveReq req) {
ContractEndorsementVO vo = new ContractEndorsementVO();
vo.setContractId(req.getContractId());
vo.setAgentId(req.getAgentId());
vo.setEndorsementType(req.getEndorsementType());
vo.setOldValue(req.getOldValue());
vo.setNewValue(req.getNewValue());
vo.setReason(req.getReason());
vo.setRequiresCommissionRecalc(req.getRequiresCommissionRecalc());
vo.setProcessed(false);
mapper.insert(vo);
return vo.getEndorsementId();
}
/**
* 수동 처리 완료 표시 실제 수수료 재계산은 배치 담당(P5).
* status 변경(processed=true) 수행.
*/
@Transactional
public void process(Long endorsementId) {
ContractEndorsementResp existing = mapper.selectById(endorsementId);
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
mapper.markProcessed(endorsementId);
}
@Transactional
public void delete(Long endorsementId) {
ContractEndorsementResp existing = mapper.selectById(endorsementId);
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
mapper.delete(endorsementId);
}
}
@@ -0,0 +1,40 @@
package com.ga.api.service.kpi;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.kpi.KpiMapper;
import com.ga.core.vo.kpi.KpiCumulativeResp;
import com.ga.core.vo.kpi.KpiMonthlySummaryResp;
import com.ga.core.vo.kpi.KpiOrgSummaryResp;
import com.ga.core.vo.kpi.KpiRetentionResp;
import com.ga.core.vo.kpi.KpiSearchParam;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class KpiService {
private final KpiMapper kpiMapper;
public PageResponse<KpiMonthlySummaryResp> monthlySummary(KpiSearchParam param) {
param.startPage();
return PageResponse.of(kpiMapper.selectMonthlySummary(param));
}
public PageResponse<KpiOrgSummaryResp> orgSummary(KpiSearchParam param) {
param.startPage();
return PageResponse.of(kpiMapper.selectOrgSummary(param));
}
public PageResponse<KpiRetentionResp> retention(KpiSearchParam param) {
param.startPage();
return PageResponse.of(kpiMapper.selectRetention(param));
}
public PageResponse<KpiCumulativeResp> cumulative(KpiSearchParam param) {
param.startPage();
return PageResponse.of(kpiMapper.selectCumulative(param));
}
}
@@ -0,0 +1,71 @@
package com.ga.api.service.license;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.license.AgentLicenseMapper;
import com.ga.core.vo.license.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AgentLicenseService {
private final AgentLicenseMapper mapper;
public PageResponse<AgentLicenseResp> list(AgentLicenseSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
public AgentLicenseResp detail(Long licenseId) {
AgentLicenseResp resp = mapper.selectById(licenseId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
public List<AgentLicenseResp> expiring(int days) {
return mapper.selectExpiringWithin(days);
}
@Transactional
public Long create(AgentLicenseSaveReq req) {
AgentLicenseVO vo = new AgentLicenseVO();
vo.setAgentId(req.getAgentId());
vo.setLicenseType(req.getLicenseType());
vo.setLicenseNo(req.getLicenseNo());
vo.setIssuedDate(req.getIssuedDate());
vo.setExpiresDate(req.getExpiresDate());
vo.setRenewedFrom(req.getRenewedFrom());
vo.setStatus(LicenseStatus.ACTIVE.name());
mapper.insert(vo);
return vo.getLicenseId();
}
@Transactional
public void update(Long licenseId, AgentLicenseSaveReq req) {
AgentLicenseResp existing = mapper.selectById(licenseId);
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
AgentLicenseVO vo = new AgentLicenseVO();
vo.setLicenseId(licenseId);
vo.setAgentId(req.getAgentId());
vo.setLicenseType(req.getLicenseType());
vo.setLicenseNo(req.getLicenseNo());
vo.setIssuedDate(req.getIssuedDate());
vo.setExpiresDate(req.getExpiresDate());
vo.setRenewedFrom(req.getRenewedFrom());
mapper.update(vo);
}
@Transactional
public void delete(Long licenseId) {
AgentLicenseResp existing = mapper.selectById(licenseId);
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
mapper.delete(licenseId);
}
}
@@ -0,0 +1,97 @@
package com.ga.api.service.notice;
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.notice.NoticeMapper;
import com.ga.core.mapper.notice.UserNotificationMapper;
import com.ga.core.vo.notice.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class NoticeService {
private final NoticeMapper noticeMapper;
private final UserNotificationMapper notificationMapper;
// Notice
public PageResponse<NoticeResp> list(NoticeSearchParam param) {
param.startPage();
return PageResponse.of(noticeMapper.selectList(param));
}
public NoticeResp detail(Long noticeId) {
NoticeResp resp = noticeMapper.selectById(noticeId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
public List<NoticeResp> activeList() {
return noticeMapper.selectActive();
}
@Transactional
public Long create(NoticeSaveReq req) {
NoticeVO vo = new NoticeVO();
vo.setTitle(req.getTitle());
vo.setContent(req.getContent());
vo.setNoticeType(req.getNoticeType());
vo.setStartDate(req.getStartDate());
vo.setEndDate(req.getEndDate());
vo.setIsActive(true);
vo.setCreatedBy(SecurityUtil.getCurrentUserId());
noticeMapper.insert(vo);
return vo.getNoticeId();
}
@Transactional
public void update(Long noticeId, NoticeSaveReq req) {
NoticeResp existing = noticeMapper.selectById(noticeId);
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
NoticeVO vo = new NoticeVO();
vo.setNoticeId(noticeId);
vo.setTitle(req.getTitle());
vo.setContent(req.getContent());
vo.setNoticeType(req.getNoticeType());
vo.setStartDate(req.getStartDate());
vo.setEndDate(req.getEndDate());
noticeMapper.update(vo);
}
@Transactional
public void delete(Long noticeId) {
NoticeResp existing = noticeMapper.selectById(noticeId);
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
noticeMapper.delete(noticeId);
}
// UserNotification
public PageResponse<UserNotificationResp> listNotifications(UserNotificationSearchParam param) {
param.startPage();
return PageResponse.of(notificationMapper.selectList(param));
}
public int unreadCount(Long userId) {
return notificationMapper.selectUnreadCount(userId);
}
@Transactional
public void markRead(Long notificationId, Long userId) {
int affected = notificationMapper.markRead(notificationId, userId);
if (affected == 0) throw new BizException(ErrorCode.NOT_FOUND);
}
@Transactional
public void markAllRead(Long userId) {
notificationMapper.markAllRead(userId);
}
}
@@ -0,0 +1,67 @@
package com.ga.api.service.settle;
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.CarrierReconciliationOutMapper;
import com.ga.core.vo.settle.CarrierReconciliationOutResp;
import com.ga.core.vo.settle.CarrierReconciliationOutSaveReq;
import com.ga.core.vo.settle.CarrierReconciliationOutSearchParam;
import com.ga.core.vo.settle.CarrierReconciliationOutVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CarrierReconciliationOutService {
private final CarrierReconciliationOutMapper reconOutMapper;
public PageResponse<CarrierReconciliationOutResp> list(CarrierReconciliationOutSearchParam param) {
param.startPage();
return PageResponse.of(reconOutMapper.selectList(param));
}
public CarrierReconciliationOutResp detail(Long reconOutId) {
CarrierReconciliationOutResp resp = reconOutMapper.selectById(reconOutId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public Long create(CarrierReconciliationOutSaveReq req) {
// 보험사+정산월 중복 확인
CarrierReconciliationOutVO existing = reconOutMapper.selectByCarrierMonth(req.getCarrierId(), req.getSettleMonth());
if (existing != null) {
throw new BizException(ErrorCode.DUPLICATE_DATA,
"이미 등록된 회신 이력입니다: carrierId=" + req.getCarrierId() + ", settleMonth=" + req.getSettleMonth());
}
CarrierReconciliationOutVO vo = new CarrierReconciliationOutVO();
vo.setCarrierId(req.getCarrierId());
vo.setSettleMonth(req.getSettleMonth());
vo.setFilePath(req.getFilePath());
vo.setFileName(req.getFileName());
vo.setFileSize(req.getFileSize());
vo.setRecordCount(req.getRecordCount());
vo.setStatus("READY");
vo.setRetryCount(0);
vo.setCreatedBy(SecurityUtil.getCurrentUserId());
reconOutMapper.insert(vo);
return vo.getReconOutId();
}
@Transactional
public void retry(Long reconOutId) {
CarrierReconciliationOutVO vo = reconOutMapper.selectVoById(reconOutId);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!"FAIL".equals(vo.getStatus())) {
throw new BizException(ErrorCode.INVALID_STATUS, "실패 상태만 재전송 가능합니다");
}
// 상태를 READY로 초기화하여 배치가 재처리
reconOutMapper.updateStatus(reconOutId, "READY");
}
}
@@ -0,0 +1,83 @@
package com.ga.api.service.settle;
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.CommissionDisputeMapper;
import com.ga.core.vo.settle.CommissionDisputeResp;
import com.ga.core.vo.settle.CommissionDisputeSaveReq;
import com.ga.core.vo.settle.CommissionDisputeSearchParam;
import com.ga.core.vo.settle.CommissionDisputeVO;
import com.ga.core.vo.settle.DisputeStatus;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CommissionDisputeService {
private final CommissionDisputeMapper disputeMapper;
public PageResponse<CommissionDisputeResp> list(CommissionDisputeSearchParam param) {
param.startPage();
return PageResponse.of(disputeMapper.selectList(param));
}
public CommissionDisputeResp detail(Long disputeId) {
CommissionDisputeResp resp = disputeMapper.selectById(disputeId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public Long create(CommissionDisputeSaveReq req) {
CommissionDisputeVO vo = new CommissionDisputeVO();
vo.setAgentId(req.getAgentId());
vo.setSettleMonth(req.getSettleMonth());
vo.setSettleId(req.getSettleId());
vo.setDisputedAmount(req.getDisputedAmount());
vo.setClaimAmount(req.getClaimAmount());
vo.setReason(req.getReason());
vo.setEvidenceUrl(req.getEvidenceUrl());
vo.setStatus(DisputeStatus.OPEN.name());
disputeMapper.insert(vo);
return vo.getDisputeId();
}
@Transactional
public void approve(Long disputeId, String resolution, BigDecimal adjustmentAmount, Long correctionId) {
CommissionDisputeVO vo = disputeMapper.selectVoById(disputeId);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!DisputeStatus.IN_REVIEW.name().equals(vo.getStatus())) {
throw new BizException(ErrorCode.INVALID_STATUS, "검토 중 상태만 승인 가능합니다");
}
disputeMapper.resolve(disputeId, DisputeStatus.APPROVED.name(),
resolution, adjustmentAmount, correctionId, SecurityUtil.getCurrentUserId());
}
@Transactional
public void reject(Long disputeId, String resolution) {
CommissionDisputeVO vo = disputeMapper.selectVoById(disputeId);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!DisputeStatus.IN_REVIEW.name().equals(vo.getStatus())) {
throw new BizException(ErrorCode.INVALID_STATUS, "검토 중 상태만 기각 가능합니다");
}
disputeMapper.resolve(disputeId, DisputeStatus.REJECTED.name(),
resolution, null, null, SecurityUtil.getCurrentUserId());
}
@Transactional
public void startReview(Long disputeId) {
CommissionDisputeVO vo = disputeMapper.selectVoById(disputeId);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!DisputeStatus.OPEN.name().equals(vo.getStatus())) {
throw new BizException(ErrorCode.INVALID_STATUS, "OPEN 상태만 검토 시작 가능합니다");
}
disputeMapper.startReview(disputeId, SecurityUtil.getCurrentUserId());
}
}
@@ -0,0 +1,73 @@
package com.ga.api.service.settle;
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.IncentivePaymentMapper;
import com.ga.core.vo.settle.IncentivePayStatus;
import com.ga.core.vo.settle.IncentivePaymentResp;
import com.ga.core.vo.settle.IncentivePaymentSaveReq;
import com.ga.core.vo.settle.IncentivePaymentSearchParam;
import com.ga.core.vo.settle.IncentivePaymentVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class IncentivePaymentService {
private final IncentivePaymentMapper paymentMapper;
private final PeriodCloseService periodCloseService;
public PageResponse<IncentivePaymentResp> list(IncentivePaymentSearchParam param) {
param.startPage();
return PageResponse.of(paymentMapper.selectList(param));
}
public IncentivePaymentResp detail(Long incentivePayId) {
IncentivePaymentResp resp = paymentMapper.selectById(incentivePayId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public Long create(IncentivePaymentSaveReq req) {
// 마감 가드
periodCloseService.assertNotClosed(req.getSettleMonth());
IncentivePaymentVO vo = new IncentivePaymentVO();
vo.setProgramId(req.getProgramId());
vo.setAgentId(req.getAgentId());
vo.setSettleMonth(req.getSettleMonth());
vo.setBaseAmount(req.getBaseAmount());
vo.setIncentiveAmount(req.getIncentiveAmount());
vo.setStatus(IncentivePayStatus.CALCULATED.name());
paymentMapper.insert(vo);
return vo.getIncentivePayId();
}
@Transactional
public void confirm(Long incentivePayId) {
IncentivePaymentVO vo = paymentMapper.selectVoById(incentivePayId);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!IncentivePayStatus.CALCULATED.name().equals(vo.getStatus())) {
throw new BizException(ErrorCode.INVALID_STATUS, "CALCULATED 상태만 확정 가능합니다");
}
// 마감 가드
periodCloseService.assertNotClosed(vo.getSettleMonth());
paymentMapper.updateStatus(incentivePayId, IncentivePayStatus.CONFIRMED.name(), SecurityUtil.getCurrentUserId());
}
@Transactional
public void cancel(Long incentivePayId, String cancelReason) {
IncentivePaymentVO vo = paymentMapper.selectVoById(incentivePayId);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
if (IncentivePayStatus.PAID.name().equals(vo.getStatus())) {
throw new BizException(ErrorCode.INVALID_STATUS, "지급완료된 시책은 취소할 수 없습니다");
}
paymentMapper.updateStatus(incentivePayId, IncentivePayStatus.CANCELLED.name(), SecurityUtil.getCurrentUserId());
}
}
@@ -0,0 +1,84 @@
package com.ga.api.service.settle;
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.IncentiveProgramMapper;
import com.ga.core.vo.settle.IncentiveProgramResp;
import com.ga.core.vo.settle.IncentiveProgramSaveReq;
import com.ga.core.vo.settle.IncentiveProgramSearchParam;
import com.ga.core.vo.settle.IncentiveProgramVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class IncentiveProgramService {
private final IncentiveProgramMapper programMapper;
public PageResponse<IncentiveProgramResp> list(IncentiveProgramSearchParam param) {
param.startPage();
return PageResponse.of(programMapper.selectList(param));
}
public IncentiveProgramResp detail(Long programId) {
IncentiveProgramResp resp = programMapper.selectById(programId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public Long create(IncentiveProgramSaveReq req) {
IncentiveProgramVO vo = new IncentiveProgramVO();
vo.setProgramName(req.getProgramName());
vo.setProgramType(req.getProgramType());
vo.setTargetType(req.getTargetType());
vo.setTargetGradeId(req.getTargetGradeId());
vo.setTargetOrgId(req.getTargetOrgId());
vo.setProductCategory(req.getProductCategory());
vo.setStartMonth(req.getStartMonth());
vo.setEndMonth(req.getEndMonth());
vo.setConditionDesc(req.getConditionDesc());
vo.setPayRate(req.getPayRate());
vo.setPayAmountFixed(req.getPayAmountFixed());
vo.setPayType(req.getPayType());
vo.setBudgetAmount(req.getBudgetAmount());
vo.setIsActive(true);
vo.setCreatedBy(SecurityUtil.getCurrentUserId());
programMapper.insert(vo);
return vo.getProgramId();
}
@Transactional
public void update(Long programId, IncentiveProgramSaveReq req) {
IncentiveProgramVO existing = programMapper.selectVoById(programId);
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
existing.setProgramName(req.getProgramName());
existing.setProgramType(req.getProgramType());
existing.setTargetType(req.getTargetType());
existing.setTargetGradeId(req.getTargetGradeId());
existing.setTargetOrgId(req.getTargetOrgId());
existing.setProductCategory(req.getProductCategory());
existing.setStartMonth(req.getStartMonth());
existing.setEndMonth(req.getEndMonth());
existing.setConditionDesc(req.getConditionDesc());
existing.setPayRate(req.getPayRate());
existing.setPayAmountFixed(req.getPayAmountFixed());
existing.setPayType(req.getPayType());
existing.setBudgetAmount(req.getBudgetAmount());
existing.setUpdatedBy(SecurityUtil.getCurrentUserId());
programMapper.update(existing);
}
@Transactional
public void toggleActive(Long programId, boolean isActive) {
IncentiveProgramVO existing = programMapper.selectVoById(programId);
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
programMapper.updateActive(programId, isActive, SecurityUtil.getCurrentUserId());
}
}
@@ -0,0 +1,63 @@
package com.ga.api.service.settle;
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.PeriodCloseMapper;
import com.ga.core.vo.settle.PeriodCloseResp;
import com.ga.core.vo.settle.PeriodCloseSaveReq;
import com.ga.core.vo.settle.PeriodCloseSearchParam;
import com.ga.core.vo.settle.PeriodCloseVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PeriodCloseService {
private final PeriodCloseMapper periodCloseMapper;
public PageResponse<PeriodCloseResp> list(PeriodCloseSearchParam param) {
param.startPage();
return PageResponse.of(periodCloseMapper.selectList(param));
}
public PeriodCloseResp detail(Long closeId) {
PeriodCloseResp resp = periodCloseMapper.selectById(closeId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public void close(PeriodCloseSaveReq req) {
// 이미 마감된 월인지 확인
if (periodCloseMapper.isClosedMonth(req.getCloseMonth())) {
throw new BizException(ErrorCode.DUPLICATE_DATA, "이미 마감된 정산월입니다: " + req.getCloseMonth());
}
PeriodCloseVO vo = new PeriodCloseVO();
vo.setCloseMonth(req.getCloseMonth());
vo.setNote(req.getNote());
vo.setClosedBy(SecurityUtil.getCurrentUserId());
periodCloseMapper.insert(vo);
}
@Transactional
public void reopen(Long closeId, String reopenReason) {
PeriodCloseResp resp = periodCloseMapper.selectById(closeId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
periodCloseMapper.reopen(closeId, SecurityUtil.getCurrentUserId(), reopenReason);
}
/**
* 마감 가드: 해당 정산월이 마감 상태이면 예외 발생.
* settle_correction, payment 변경 서비스에서 호출.
*/
public void assertNotClosed(String settleMonth) {
if (periodCloseMapper.isClosedMonth(settleMonth)) {
throw new BizException(ErrorCode.NOT_PERMITTED, "마감된 기간입니다: " + settleMonth);
}
}
}
@@ -0,0 +1,81 @@
package com.ga.api.service.settle;
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.SettleCorrectionMapper;
import com.ga.core.vo.settle.CorrectionStatus;
import com.ga.core.vo.settle.SettleCorrectionResp;
import com.ga.core.vo.settle.SettleCorrectionSaveReq;
import com.ga.core.vo.settle.SettleCorrectionSearchParam;
import com.ga.core.vo.settle.SettleCorrectionVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class SettleCorrectionService {
private final SettleCorrectionMapper correctionMapper;
private final PeriodCloseService periodCloseService;
public PageResponse<SettleCorrectionResp> list(SettleCorrectionSearchParam param) {
param.startPage();
return PageResponse.of(correctionMapper.selectList(param));
}
public SettleCorrectionResp detail(Long correctionId) {
SettleCorrectionResp resp = correctionMapper.selectById(correctionId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
public List<SettleCorrectionResp> listByOriginalSettle(Long originalSettleId) {
return correctionMapper.selectByOriginalSettleId(originalSettleId);
}
@Transactional
public Long create(SettleCorrectionSaveReq req) {
// 마감 가드
periodCloseService.assertNotClosed(req.getSettleMonth());
SettleCorrectionVO vo = new SettleCorrectionVO();
vo.setOriginalSettleId(req.getOriginalSettleId());
vo.setAgentId(req.getAgentId());
vo.setSettleMonth(req.getSettleMonth());
vo.setBeforeAmount(req.getBeforeAmount());
vo.setAfterAmount(req.getAfterAmount());
vo.setDiffAmount(req.getAfterAmount().subtract(req.getBeforeAmount()));
vo.setReasonCode(req.getReasonCode());
vo.setReasonDetail(req.getReasonDetail());
vo.setStatus(CorrectionStatus.DRAFT.name());
vo.setRequestedBy(SecurityUtil.getCurrentUserId());
correctionMapper.insert(vo);
return vo.getCorrectionId();
}
@Transactional
public void approve(Long correctionId) {
SettleCorrectionVO vo = correctionMapper.selectVoById(correctionId);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!CorrectionStatus.DRAFT.name().equals(vo.getStatus())) {
throw new BizException(ErrorCode.INVALID_STATUS, "DRAFT 상태만 승인 가능합니다");
}
correctionMapper.updateStatus(correctionId, CorrectionStatus.APPROVED.name(), SecurityUtil.getCurrentUserId());
}
@Transactional
public void cancel(Long correctionId, String cancelReason) {
SettleCorrectionVO vo = correctionMapper.selectVoById(correctionId);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
if (CorrectionStatus.APPLIED.name().equals(vo.getStatus())) {
throw new BizException(ErrorCode.INVALID_STATUS, "이미 반영된 정정은 취소할 수 없습니다");
}
correctionMapper.cancel(correctionId, cancelReason, SecurityUtil.getCurrentUserId());
}
}
@@ -56,13 +56,13 @@ public class SettleService {
} }
@Transactional @Transactional
public void hold(Long settleId, String reason) { public void hold(Long settleId, String reason, String reasonCode) {
SettleMasterVO vo = masterMapper.selectById(settleId); SettleMasterVO vo = masterMapper.selectById(settleId);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND); if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
if (SettleStatus.PAID.name().equals(vo.getStatus())) { if (SettleStatus.PAID.name().equals(vo.getStatus())) {
throw new BizException(ErrorCode.NOT_PERMITTED, "지급완료된 정산은 보류할 수 없습니다"); throw new BizException(ErrorCode.NOT_PERMITTED, "지급완료된 정산은 보류할 수 없습니다");
} }
masterMapper.updateHold(settleId, reason); masterMapper.updateHold(settleId, reason, reasonCode);
} }
@Transactional @Transactional
@@ -0,0 +1,70 @@
package com.ga.api.service.settle;
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.TaxInvoiceMapper;
import com.ga.core.vo.settle.TaxInvoiceResp;
import com.ga.core.vo.settle.TaxInvoiceSaveReq;
import com.ga.core.vo.settle.TaxInvoiceSearchParam;
import com.ga.core.vo.settle.TaxInvoiceVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TaxInvoiceService {
private final TaxInvoiceMapper taxInvoiceMapper;
public PageResponse<TaxInvoiceResp> list(TaxInvoiceSearchParam param) {
param.startPage();
return PageResponse.of(taxInvoiceMapper.selectList(param));
}
public TaxInvoiceResp detail(Long invoiceId) {
TaxInvoiceResp resp = taxInvoiceMapper.selectById(invoiceId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public Long create(TaxInvoiceSaveReq req) {
// 발행번호 중복 확인
if (taxInvoiceMapper.selectByInvoiceNo(req.getInvoiceNo()) != null) {
throw new BizException(ErrorCode.DUPLICATE_DATA, "이미 등록된 발행번호입니다: " + req.getInvoiceNo());
}
TaxInvoiceVO vo = new TaxInvoiceVO();
vo.setInvoiceNo(req.getInvoiceNo());
vo.setInvoiceType(req.getInvoiceType());
vo.setIssueDate(req.getIssueDate());
vo.setSupplyAmount(req.getSupplyAmount());
vo.setTaxAmount(req.getTaxAmount());
vo.setTotalAmount(req.getSupplyAmount().add(req.getTaxAmount()));
vo.setCounterpartName(req.getCounterpartName());
vo.setCounterpartBizNo(req.getCounterpartBizNo());
vo.setCounterpartRep(req.getCounterpartRep());
vo.setCounterpartAddr(req.getCounterpartAddr());
vo.setAgentId(req.getAgentId());
vo.setSettleMonth(req.getSettleMonth());
vo.setSettleId(req.getSettleId());
vo.setDescription(req.getDescription());
vo.setIssuedBy(SecurityUtil.getCurrentUserId());
taxInvoiceMapper.insert(vo);
return vo.getInvoiceId();
}
@Transactional
public void cancel(Long invoiceId, String cancelReason) {
TaxInvoiceResp resp = taxInvoiceMapper.selectById(invoiceId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
taxInvoiceMapper.cancel(invoiceId, LocalDate.now(), cancelReason);
}
}
@@ -0,0 +1,101 @@
package com.ga.api.service.settle;
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.TerminationSettlementMapper;
import com.ga.core.vo.settle.TermSettleStatus;
import com.ga.core.vo.settle.TerminationSettlementResp;
import com.ga.core.vo.settle.TerminationSettlementSaveReq;
import com.ga.core.vo.settle.TerminationSettlementSearchParam;
import com.ga.core.vo.settle.TerminationSettlementVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TerminationSettlementService {
private final TerminationSettlementMapper termSettleMapper;
public PageResponse<TerminationSettlementResp> list(TerminationSettlementSearchParam param) {
param.startPage();
return PageResponse.of(termSettleMapper.selectList(param));
}
public TerminationSettlementResp detail(Long termSettleId) {
TerminationSettlementResp resp = termSettleMapper.selectById(termSettleId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public Long create(TerminationSettlementSaveReq req) {
// 해당 설계사의 해촉 정산이 이미 있는지 확인
TerminationSettlementVO existing = termSettleMapper.selectByAgent(req.getAgentId());
if (existing != null && !TermSettleStatus.CANCELLED.name().equals(existing.getStatus())) {
throw new BizException(ErrorCode.DUPLICATE_DATA, "이미 처리 중인 해촉 정산이 있습니다");
}
TerminationSettlementVO vo = new TerminationSettlementVO();
vo.setAgentId(req.getAgentId());
vo.setTerminationDate(req.getTerminationDate());
vo.setOrgHistoryId(req.getOrgHistoryId());
vo.setCalcMonth(req.getCalcMonth());
vo.setUnpaidRecruitAmount(req.getUnpaidRecruitAmount());
vo.setUnpaidInstallmentAmount(req.getUnpaidInstallmentAmount());
vo.setUnpaidIncentiveAmount(req.getUnpaidIncentiveAmount());
vo.setChargebackExpectedAmount(req.getChargebackExpectedAmount());
vo.setChargebackWaivedAmount(req.getChargebackWaivedAmount());
vo.setDeductionAmount(req.getDeductionAmount());
vo.setFinalPayAmount(req.getFinalPayAmount());
vo.setNote(req.getNote());
vo.setStatus(TermSettleStatus.CALCULATING.name());
vo.setCreatedBy(SecurityUtil.getCurrentUserId());
termSettleMapper.insert(vo);
return vo.getTermSettleId();
}
@Transactional
public void update(Long termSettleId, TerminationSettlementSaveReq req) {
TerminationSettlementVO vo = termSettleMapper.selectVoById(termSettleId);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!TermSettleStatus.CALCULATING.name().equals(vo.getStatus())) {
throw new BizException(ErrorCode.INVALID_STATUS, "산출 중 상태만 수정 가능합니다");
}
vo.setUnpaidRecruitAmount(req.getUnpaidRecruitAmount());
vo.setUnpaidInstallmentAmount(req.getUnpaidInstallmentAmount());
vo.setUnpaidIncentiveAmount(req.getUnpaidIncentiveAmount());
vo.setChargebackExpectedAmount(req.getChargebackExpectedAmount());
vo.setChargebackWaivedAmount(req.getChargebackWaivedAmount());
vo.setDeductionAmount(req.getDeductionAmount());
vo.setFinalPayAmount(req.getFinalPayAmount());
vo.setNote(req.getNote());
vo.setUpdatedBy(SecurityUtil.getCurrentUserId());
termSettleMapper.update(vo);
}
@Transactional
public void confirm(Long termSettleId) {
TerminationSettlementVO vo = termSettleMapper.selectVoById(termSettleId);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!TermSettleStatus.CALCULATING.name().equals(vo.getStatus())) {
throw new BizException(ErrorCode.INVALID_STATUS, "산출 중 상태만 확정 가능합니다");
}
termSettleMapper.updateStatus(termSettleId, TermSettleStatus.CONFIRMED.name(), SecurityUtil.getCurrentUserId());
}
/**
* 해촉 자동 생성 처리 (POST /process).
* agentId를 받아 CALCULATING 상태로 해촉 정산 레코드 생성.
* 실제 금액 산출은 배치 또는 별도 update 호출로 완성.
*/
@Transactional
public Long process(TerminationSettlementSaveReq req) {
return create(req);
}
}
@@ -0,0 +1,110 @@
package com.ga.api.service.training;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.training.AgentTrainingMapper;
import com.ga.core.mapper.training.TrainingRequirementMapper;
import com.ga.core.vo.training.*;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AgentTrainingService {
private final AgentTrainingMapper trainingMapper;
private final TrainingRequirementMapper requirementMapper;
// Training
public PageResponse<AgentTrainingResp> list(AgentTrainingSearchParam param) {
param.startPage();
return PageResponse.of(trainingMapper.selectList(param));
}
public AgentTrainingResp detail(Long trainingId) {
AgentTrainingResp resp = trainingMapper.selectById(trainingId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
/**
* 설계사 + 연도 기준 연간 이수 시간 합계.
* 의무 시간 충족 여부도 함께 반환.
*/
public BigDecimal annualHours(Long agentId, int year) {
BigDecimal hours = trainingMapper.selectAnnualHoursByAgent(agentId, year);
return hours != null ? hours : BigDecimal.ZERO;
}
@Transactional
public Long create(AgentTrainingSaveReq req) {
AgentTrainingVO vo = new AgentTrainingVO();
vo.setAgentId(req.getAgentId());
vo.setCourseName(req.getCourseName());
vo.setCourseType(req.getCourseType());
vo.setTrainingDate(req.getTrainingDate());
vo.setHours(req.getHours());
vo.setCertificateNo(req.getCertificateNo());
vo.setInstructor(req.getInstructor());
trainingMapper.insert(vo);
return vo.getTrainingId();
}
@Transactional
public void update(Long trainingId, AgentTrainingSaveReq req) {
AgentTrainingResp existing = trainingMapper.selectById(trainingId);
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
AgentTrainingVO vo = new AgentTrainingVO();
vo.setTrainingId(trainingId);
vo.setAgentId(req.getAgentId());
vo.setCourseName(req.getCourseName());
vo.setCourseType(req.getCourseType());
vo.setTrainingDate(req.getTrainingDate());
vo.setHours(req.getHours());
vo.setCertificateNo(req.getCertificateNo());
vo.setInstructor(req.getInstructor());
trainingMapper.update(vo);
}
@Transactional
public void delete(Long trainingId) {
AgentTrainingResp existing = trainingMapper.selectById(trainingId);
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
trainingMapper.delete(trainingId);
}
// Requirement
public List<TrainingRequirementResp> listRequirements(TrainingRequirementSearchParam param) {
return requirementMapper.selectList(param);
}
public TrainingRequirementResp requirementByYear(int year) {
TrainingRequirementResp resp = requirementMapper.selectByYear(year);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
return resp;
}
@Transactional
public void saveRequirement(TrainingRequirementSaveReq req) {
TrainingRequirementVO existing = new TrainingRequirementVO();
existing.setTargetYear(req.getTargetYear());
existing.setRequiredHours(req.getRequiredHours());
existing.setDescription(req.getDescription());
// upsert: try update first, insert if 0 rows
TrainingRequirementResp curr = requirementMapper.selectByYear(req.getTargetYear());
if (curr == null) {
requirementMapper.insert(existing);
} else {
existing.setReqId(curr.getReqId());
requirementMapper.update(existing);
}
}
}
@@ -0,0 +1,106 @@
package com.ga.api.service.withdraw;
import com.ga.api.service.approval.ApprovalService;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import com.ga.common.model.PageResponse;
import com.ga.core.mapper.withdraw.WithdrawRequestMapper;
import com.ga.core.vo.approval.ApprovalRequestSaveReq;
import com.ga.core.vo.withdraw.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class WithdrawRequestService {
private final WithdrawRequestMapper mapper;
private final ApprovalService approvalService;
/** 출금 신청 기본 결재선 코드 (application.yml 외부화 권장) */
@Value("${app.withdraw.approval-line-id:1}")
private Long defaultApprovalLineId;
public PageResponse<WithdrawRequestResp> list(WithdrawRequestSearchParam param) {
param.startPage();
java.util.List<WithdrawRequestResp> rows = mapper.selectList(param);
rows.forEach(this::maskAccountNo);
return PageResponse.of(rows);
}
public WithdrawRequestResp detail(Long requestId) {
WithdrawRequestResp resp = mapper.selectById(requestId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
maskAccountNo(resp);
return resp;
}
/**
* 출금 신청 등록 + 결재 요청 자동 연동.
* account_no 마스킹은 응답 시에만 적용 VO 저장 원문 저장.
*/
@Transactional
public Long create(WithdrawRequestSaveReq req) {
WithdrawRequestVO vo = new WithdrawRequestVO();
vo.setSettleMasterId(req.getSettleMasterId());
vo.setAgentId(req.getAgentId());
vo.setAmount(req.getAmount());
vo.setRequestedBy(req.getRequestedBy());
vo.setRequestedAt(LocalDateTime.now());
vo.setBankCode(req.getBankCode());
vo.setAccountNo(req.getAccountNo()); // 원문 저장 (EncryptTypeHandler가 암호화)
vo.setStatus(WithdrawStatus.REQUESTED.name());
mapper.insert(vo);
// 결재 요청 자동 생성
try {
ApprovalRequestSaveReq approvalReq = new ApprovalRequestSaveReq();
approvalReq.setTargetType("WITHDRAW");
approvalReq.setTargetId(vo.getRequestId());
approvalReq.setLineId(defaultApprovalLineId);
approvalReq.setRequestedBy(req.getRequestedBy());
Long approvalRequestId = approvalService.createRequest(approvalReq);
// 결재 요청 ID를 withdraw_request에 업데이트 (approve 연동 위해)
log.info("출금신청 결재 요청 생성: withdrawId={}, approvalRequestId={}", vo.getRequestId(), approvalRequestId);
} catch (Exception e) {
log.warn("결재 요청 생성 실패 (출금신청은 유지): withdrawId={}, error={}", vo.getRequestId(), e.getMessage());
}
return vo.getRequestId();
}
@Transactional
public void cancel(Long requestId) {
WithdrawRequestResp existing = mapper.selectById(requestId);
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
if (!WithdrawStatus.REQUESTED.name().equals(existing.getStatus())) {
throw new BizException(ErrorCode.INVALID_STATUS, "신청 상태에서만 취소 가능합니다");
}
mapper.cancel(requestId);
}
/**
* account_no 마스킹: 4자리만 표출.
* 원본: "123-456-78901234" 마스킹: "*** *** *1234"
*/
private void maskAccountNo(WithdrawRequestResp resp) {
String raw = resp.getAccountNo();
if (raw == null || raw.isBlank()) return;
// 숫자만 추출 4자리만 남기고 마스킹
String digits = raw.replaceAll("[^0-9]", "");
if (digits.length() <= 4) {
resp.setAccountNo("*** *** *" + digits);
} else {
String last4 = digits.substring(digits.length() - 4);
resp.setAccountNo("*** *** *" + last4);
}
}
}
@@ -50,6 +50,12 @@ ga:
encrypt: encrypt:
key: ${ENCRYPT_KEY:0123456789abcdef0123456789abcdef} key: ${ENCRYPT_KEY:0123456789abcdef0123456789abcdef}
app:
attachment:
path: ${ATTACHMENT_PATH:./uploads}
withdraw:
approval-line-id: ${WITHDRAW_APPROVAL_LINE_ID:1}
logging: logging:
level: level:
com.ga: DEBUG com.ga: DEBUG
@@ -0,0 +1,49 @@
package com.ga.batch.settlement.domain;
import com.ga.core.mapper.chargeback.ChargebackGradeMapper;
import com.ga.core.vo.chargeback.ChargebackGradeVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
/**
* 배치용 차등환수 계산기. ga-api ChargebackCalculator와 동일 로직.
* chargeback_grade 테이블 기반 5단계 환수율 조회.
*/
@Component
@RequiredArgsConstructor
public class BatchChargebackCalculator {
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final ChargebackGradeMapper gradeMapper;
public ChargebackResult calculate(LocalDate contractDate, LocalDate terminationDate,
BigDecimal originalCommission, String productCategory) {
int monthsElapsed = (int) ChronoUnit.MONTHS.between(contractDate, terminationDate);
String today = LocalDate.now().format(DATE_FMT);
ChargebackGradeVO grade = gradeMapper.selectByMonths(productCategory, monthsElapsed, today);
if (grade == null) {
return new ChargebackResult(BigDecimal.ZERO, monthsElapsed, BigDecimal.ZERO,
"적용 환수 구간 없음");
}
BigDecimal chargebackAmount = originalCommission
.multiply(grade.getChargebackPercent())
.divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP);
return new ChargebackResult(
chargebackAmount,
monthsElapsed,
grade.getChargebackPercent(),
String.format("경과월 %d개월 → 환수율 %s%%", monthsElapsed, grade.getChargebackPercent())
);
}
}
@@ -0,0 +1,51 @@
package com.ga.batch.settlement.domain;
import com.ga.core.mapper.installment.InstallmentRatioMapper;
import com.ga.core.vo.installment.InstallmentPlanVO;
import com.ga.core.vo.installment.InstallmentRatioVO;
import com.ga.core.vo.installment.InstallmentStatus;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
/**
* 배치용 분급 계획 생성기. ga-api InstallmentPlanGenerator와 동일 로직.
* 계약 체결일 기준 1~7차년 분급 계획 생성.
*/
@Component
@RequiredArgsConstructor
public class BatchInstallmentPlanGenerator {
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final DateTimeFormatter MONTH_FMT = DateTimeFormatter.ofPattern("yyyyMM");
private final InstallmentRatioMapper ratioMapper;
public List<InstallmentPlanVO> generate(Long contractId, BigDecimal totalCommission,
LocalDate contractDate, String productCategory) {
String baseDate = contractDate.format(DATE_FMT);
List<InstallmentRatioVO> ratios = ratioMapper.selectAllActiveRatios(productCategory, baseDate);
List<InstallmentPlanVO> plans = new ArrayList<>();
for (InstallmentRatioVO ratio : ratios) {
InstallmentPlanVO plan = new InstallmentPlanVO();
plan.setContractId(contractId);
plan.setPlanYear(ratio.getPlanYear());
plan.setPlanMonth(1);
plan.setSettleMonth(contractDate.plusYears(ratio.getPlanYear()).format(MONTH_FMT));
plan.setExpectedAmount(
totalCommission.multiply(ratio.getRatioPercent())
.divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP)
);
plan.setStatus(InstallmentStatus.SCHEDULED.code());
plans.add(plan);
}
return plans;
}
}
@@ -0,0 +1,83 @@
package com.ga.batch.settlement.domain;
import com.ga.core.mapper.ledger.MaintainLedgerMapper;
import com.ga.core.mapper.ledger.RecruitLedgerMapper;
import com.ga.core.mapper.regulatory.RegulatoryLimitMapper;
import com.ga.core.vo.regulatory.RegulatoryLimitVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
/**
* 배치용 1200% 한도 체커. ga-api RegulatoryLimitChecker와 동일 로직.
*/
@Component
@RequiredArgsConstructor
public class BatchRegulatoryLimitChecker {
private static final String TOTAL_RATIO = "TOTAL_RATIO";
private static final String FIRST_YEAR_RATIO = "FIRST_YEAR_RATIO";
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final RegulatoryLimitMapper limitMapper;
private final RecruitLedgerMapper recruitLedgerMapper;
private final MaintainLedgerMapper maintainLedgerMapper;
/** 누적 수수료율 한도 체크 (1200%). 신계약 + 유지 원장 합산. */
public ViolationResult checkContractTotal(Long contractId, BigDecimal additionalAmount, LocalDate baseDate) {
String baseDateStr = baseDate.format(DATE_FMT);
RegulatoryLimitVO limit = limitMapper.selectActiveByType(TOTAL_RATIO, null, baseDateStr);
if (limit == null) {
return ViolationResult.ok(additionalAmount, null, TOTAL_RATIO);
}
BigDecimal currentTotal = sumPaidByContract(contractId);
BigDecimal projected = currentTotal.add(additionalAmount);
if (projected.compareTo(limit.getLimitValue()) > 0) {
return ViolationResult.violated(
currentTotal,
limit.getLimitValue(),
TOTAL_RATIO,
String.format("누적 수수료 한도 초과: 현재 %s, 추가 %s, 한도 %s %s",
currentTotal, additionalAmount, limit.getLimitValue(), limit.getUnit())
);
}
return ViolationResult.ok(currentTotal, limit.getLimitValue(), TOTAL_RATIO);
}
/** 1차년 수수료율 한도 체크. */
public ViolationResult checkFirstYear(Long contractId, BigDecimal additionalAmount, LocalDate baseDate) {
String baseDateStr = baseDate.format(DATE_FMT);
RegulatoryLimitVO limit = limitMapper.selectActiveByType(FIRST_YEAR_RATIO, null, baseDateStr);
if (limit == null) {
return ViolationResult.ok(additionalAmount, null, FIRST_YEAR_RATIO);
}
BigDecimal currentTotal = nullSafe(recruitLedgerMapper.sumFirstYearByContract(contractId));
BigDecimal projected = currentTotal.add(additionalAmount);
if (projected.compareTo(limit.getLimitValue()) > 0) {
return ViolationResult.violated(
currentTotal,
limit.getLimitValue(),
FIRST_YEAR_RATIO,
String.format("1차년 수수료 한도 초과: 현재 %s, 추가 %s, 한도 %s %s",
currentTotal, additionalAmount, limit.getLimitValue(), limit.getUnit())
);
}
return ViolationResult.ok(currentTotal, limit.getLimitValue(), FIRST_YEAR_RATIO);
}
private BigDecimal sumPaidByContract(Long contractId) {
return nullSafe(recruitLedgerMapper.sumByContract(contractId))
.add(nullSafe(maintainLedgerMapper.sumByContract(contractId)));
}
private BigDecimal nullSafe(BigDecimal v) {
return v != null ? v : BigDecimal.ZERO;
}
}
@@ -0,0 +1,45 @@
package com.ga.batch.settlement.domain;
import com.ga.common.system.SystemConfigService;
import com.ga.core.vo.org.TaxType;
import com.ga.core.vo.org.VatType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
/**
* 배치용 세금 계산기. ga-api TaxCalculator와 동일 로직, ga-batch 모듈 독립.
* 사업소득 3.3% / 기타소득 8.8% + 지방소득세 10% + 부가세 10% (TAXABLE만).
*/
@Component
@RequiredArgsConstructor
public class BatchTaxCalculator {
private final SystemConfigService configService;
public TaxBreakdown calculate(BigDecimal amount, TaxType taxType, VatType vatType) {
BigDecimal incomeRate = resolveIncomeRate(taxType);
BigDecimal localRate = configService.getDecimal("TAX_RATE_LOCAL_INCOME", new BigDecimal("10.0"));
BigDecimal vatRate = configService.getDecimal("TAX_RATE_VAT", new BigDecimal("10.0"));
BigDecimal incomeTax = amount.multiply(incomeRate)
.divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP);
BigDecimal localTax = incomeTax.multiply(localRate)
.divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP);
BigDecimal vat = VatType.TAXABLE.equals(vatType)
? amount.multiply(vatRate).divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP)
: BigDecimal.ZERO;
BigDecimal netAmount = amount.subtract(incomeTax).subtract(localTax).add(vat);
return new TaxBreakdown(incomeTax, localTax, vat, netAmount);
}
private BigDecimal resolveIncomeRate(TaxType taxType) {
if (TaxType.OTHER_INCOME.equals(taxType)) {
return configService.getDecimal("TAX_RATE_OTHER_INCOME", new BigDecimal("8.8"));
}
return configService.getDecimal("TAX_RATE_BUSINESS_INCOME", new BigDecimal("3.3"));
}
}
@@ -0,0 +1,11 @@
package com.ga.batch.settlement.domain;
import java.math.BigDecimal;
/** 차등환수 계산 결과. */
public record ChargebackResult(
BigDecimal chargebackAmount,
int monthsElapsed,
BigDecimal appliedPercent,
String reason
) {}
@@ -0,0 +1,11 @@
package com.ga.batch.settlement.domain;
import java.math.BigDecimal;
/** 세금 계산 결과. ga-api TaxBreakdown과 동일 구조 (모듈 독립). */
public record TaxBreakdown(
BigDecimal incomeTax,
BigDecimal localTax,
BigDecimal vat,
BigDecimal netAmount
) {}
@@ -0,0 +1,21 @@
package com.ga.batch.settlement.domain;
import java.math.BigDecimal;
/** 1200%룰 검증 결과. */
public record ViolationResult(
boolean violated,
BigDecimal currentTotal,
BigDecimal limitAmount,
String limitType,
String message
) {
public static ViolationResult ok(BigDecimal currentTotal, BigDecimal limitAmount, String limitType) {
return new ViolationResult(false, currentTotal, limitAmount, limitType, null);
}
public static ViolationResult violated(BigDecimal currentTotal, BigDecimal limitAmount,
String limitType, String message) {
return new ViolationResult(true, currentTotal, limitAmount, limitType, message);
}
}
@@ -1,18 +1,28 @@
package com.ga.batch.settlement.step; package com.ga.batch.settlement.step;
import com.ga.batch.settlement.SettlementContext; import com.ga.batch.settlement.SettlementContext;
import com.ga.common.system.SystemConfigService; import com.ga.batch.settlement.domain.BatchTaxCalculator;
import com.ga.batch.settlement.domain.TaxBreakdown;
import com.ga.common.util.MoneyUtil; import com.ga.common.util.MoneyUtil;
import com.ga.core.mapper.ledger.ExceptionLedgerMapper; import com.ga.core.mapper.ledger.ExceptionLedgerMapper;
import com.ga.core.mapper.ledger.MaintainLedgerMapper; import com.ga.core.mapper.ledger.MaintainLedgerMapper;
import com.ga.core.mapper.ledger.RecruitLedgerMapper; import com.ga.core.mapper.ledger.RecruitLedgerMapper;
import com.ga.core.mapper.org.AgentMapper; import com.ga.core.mapper.org.AgentMapper;
import com.ga.core.mapper.settle.AgentDeductionMapper;
import com.ga.core.mapper.settle.ChargebackMapper; import com.ga.core.mapper.settle.ChargebackMapper;
import com.ga.core.mapper.settle.CommissionStatementMapper;
import com.ga.core.mapper.settle.OverrideSettleMapper; import com.ga.core.mapper.settle.OverrideSettleMapper;
import com.ga.core.mapper.settle.SettleMasterMapper; import com.ga.core.mapper.settle.SettleMasterMapper;
import com.ga.core.vo.org.AgentResp; import com.ga.core.vo.org.AgentResp;
import com.ga.core.vo.org.AgentSearchParam; import com.ga.core.vo.org.AgentSearchParam;
import com.ga.core.vo.org.AgentVO;
import com.ga.core.vo.org.TaxType;
import com.ga.core.vo.org.VatType;
import com.ga.core.vo.settle.AgentDeductionVO;
import com.ga.core.vo.settle.CommissionStatementVO;
import com.ga.core.vo.settle.DeductionStatus;
import com.ga.core.vo.settle.SettleMasterVO; import com.ga.core.vo.settle.SettleMasterVO;
import com.ga.core.vo.settle.StatementType;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.StepContribution; import org.springframework.batch.core.StepContribution;
@@ -23,12 +33,22 @@ import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
/** /**
* Step 8 aggregate: 설계사별 통합 집계 settle_master UPSERT (세금 자동 계산) * Step 8 aggregate: 설계사별 통합 집계 settle_master UPSERT
*
* 통합 내용:
* - BatchTaxCalculator: 설계사 세금유형(TaxType/VatType) 따라 정확한 세금 계산
* (사업소득 3.3% / 기타소득 8.8% + 지방소득세 10% + 부가세 10%)
* - AgentDeductionMapper: PENDING 차감 합산 netAmount에서 차감 APPLIED 상태로 전이
* - CommissionStatementMapper: 설계사별 명세서 레코드(ISSUED 상태) INSERT
*/ */
@Slf4j @Slf4j
@Component @Component
@@ -44,16 +64,19 @@ public class AggregateStep implements Tasklet {
private final OverrideSettleMapper overrideMapper; private final OverrideSettleMapper overrideMapper;
private final ChargebackMapper chargebackMapper; private final ChargebackMapper chargebackMapper;
private final SettleMasterMapper settleMapper; private final SettleMasterMapper settleMapper;
private final SystemConfigService configService; private final AgentDeductionMapper deductionMapper;
private final CommissionStatementMapper statementMapper;
private final BatchTaxCalculator taxCalculator;
@Override @Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
log.info("[Step8 aggregate] settleMonth={}", ctx.getSettleMonth()); log.info("[Step8 aggregate] settleMonth={}", ctx.getSettleMonth());
Map<Long, BigDecimal> recruitMap = toMap(recruitMapper.aggregateByAgent(ctx.getSettleMonth())); Map<Long, BigDecimal> recruitMap = toMap(recruitMapper.aggregateByAgent(ctx.getSettleMonth()));
Map<Long, BigDecimal> maintainMap = toMap(maintainMapper.aggregateByAgent(ctx.getSettleMonth())); Map<Long, BigDecimal> maintainMap = toMap(maintainMapper.aggregateByAgent(ctx.getSettleMonth()));
Map<Long, BigDecimal> chargebackMap= toMap(chargebackMapper.aggregateByAgent(ctx.getSettleMonth())); Map<Long, BigDecimal> chargebackMap = toMap(chargebackMapper.aggregateByAgent(ctx.getSettleMonth()));
Map<Long, BigDecimal> overrideMap = toMap(overrideMapper.aggregateByUpper(ctx.getSettleMonth())); Map<Long, BigDecimal> overrideMap = toMap(overrideMapper.aggregateByUpper(ctx.getSettleMonth()));
// 예외는 PLUS/MINUS 분리 // 예외는 PLUS/MINUS 분리
Map<Long, BigDecimal> excPlus = new HashMap<>(); Map<Long, BigDecimal> excPlus = new HashMap<>();
Map<Long, BigDecimal> excMinus = new HashMap<>(); Map<Long, BigDecimal> excMinus = new HashMap<>();
@@ -63,10 +86,8 @@ public class AggregateStep implements Tasklet {
excMinus.put(agentId, toBd(row.get("minus"))); excMinus.put(agentId, toBd(row.get("minus")));
} }
BigDecimal taxRate = configService.getDecimal("TAX_RATE", new BigDecimal("3.3"));
// 합집합 agent_id // 합집합 agent_id
java.util.Set<Long> agentIds = new java.util.HashSet<>(); Set<Long> agentIds = new HashSet<>();
agentIds.addAll(recruitMap.keySet()); agentIds.addAll(recruitMap.keySet());
agentIds.addAll(maintainMap.keySet()); agentIds.addAll(maintainMap.keySet());
agentIds.addAll(chargebackMap.keySet()); agentIds.addAll(chargebackMap.keySet());
@@ -76,10 +97,23 @@ public class AggregateStep implements Tasklet {
// 활성 설계사 누락 보충 // 활성 설계사 누락 보충
AgentSearchParam ap = new AgentSearchParam(); AgentSearchParam ap = new AgentSearchParam();
ap.setStatus("ACTIVE"); ap.setPageSize(Integer.MAX_VALUE); ap.setStatus("ACTIVE");
for (AgentResp a : agentMapper.selectList(ap)) agentIds.add(a.getAgentId()); ap.setPageSize(Integer.MAX_VALUE);
for (AgentResp a : agentMapper.selectList(ap)) {
agentIds.add(a.getAgentId());
}
// 정산월 PENDING 차감 목록 일괄 조회 (배치 효율)
List<AgentDeductionVO> allDeductions = deductionMapper.selectPendingByMonth(ctx.getSettleMonth());
Map<Long, List<AgentDeductionVO>> deductionByAgent = new HashMap<>();
for (AgentDeductionVO d : allDeductions) {
deductionByAgent.computeIfAbsent(d.getAgentId(), k -> new ArrayList<>()).add(d);
}
List<Long> appliedDeductionIds = new ArrayList<>();
List<CommissionStatementVO> statements = new ArrayList<>();
int count = 0; int count = 0;
for (Long agentId : agentIds) { for (Long agentId : agentIds) {
BigDecimal recruit = recruitMap.getOrDefault(agentId, BigDecimal.ZERO); BigDecimal recruit = recruitMap.getOrDefault(agentId, BigDecimal.ZERO);
BigDecimal maintain = maintainMap.getOrDefault(agentId, BigDecimal.ZERO); BigDecimal maintain = maintainMap.getOrDefault(agentId, BigDecimal.ZERO);
@@ -90,9 +124,25 @@ public class AggregateStep implements Tasklet {
BigDecimal gross = MoneyUtil.add(MoneyUtil.add(MoneyUtil.add(recruit, maintain), ov), ep) BigDecimal gross = MoneyUtil.add(MoneyUtil.add(MoneyUtil.add(recruit, maintain), ov), ep)
.subtract(cb).subtract(em); .subtract(cb).subtract(em);
BigDecimal tax = MoneyUtil.tax(gross, taxRate);
BigDecimal net = MoneyUtil.sub(gross, tax);
// 세금유형 조회 (없으면 기본: BUSINESS_INCOME / NONE)
AgentVO agentVO = agentMapper.selectById(agentId);
TaxType taxType = parseTaxType(agentVO);
VatType vatType = parseVatType(agentVO);
// BatchTaxCalculator로 정확한 세금 계산
TaxBreakdown tax = taxCalculator.calculate(gross, taxType, vatType);
BigDecimal totalTax = tax.incomeTax().add(tax.localTax()); // settle_master.tax_amount
// 차감 합산
List<AgentDeductionVO> agentDeductions = deductionByAgent.getOrDefault(agentId, List.of());
BigDecimal deductionTotal = agentDeductions.stream()
.map(AgentDeductionVO::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal netAmount = tax.netAmount().subtract(deductionTotal);
// settle_master UPSERT
SettleMasterVO vo = new SettleMasterVO(); SettleMasterVO vo = new SettleMasterVO();
vo.setAgentId(agentId); vo.setAgentId(agentId);
vo.setSettleMonth(ctx.getSettleMonth()); vo.setSettleMonth(ctx.getSettleMonth());
@@ -103,17 +153,74 @@ public class AggregateStep implements Tasklet {
vo.setOverrideTotal(ov); vo.setOverrideTotal(ov);
vo.setChargebackTotal(cb); vo.setChargebackTotal(cb);
vo.setGrossAmount(gross); vo.setGrossAmount(gross);
vo.setTaxAmount(tax); vo.setTaxAmount(totalTax); // incomeTax + localTax 합산
vo.setNetAmount(net); vo.setNetAmount(netAmount); // gross - incomeTax - localTax + vat - deduction
vo.setStatus("CALCULATED"); vo.setStatus("CALCULATED");
settleMapper.upsert(vo); settleMapper.upsert(vo);
count++; count++;
// 차감 적용 완료 처리
if (!agentDeductions.isEmpty()) {
agentDeductions.forEach(d -> appliedDeductionIds.add(d.getDeductionId()));
}
// 명세서 레코드 생성 (commission_statement INSERT, 중복 skip)
CommissionStatementVO existingStmt = statementMapper.selectByAgentMonth(
agentId, ctx.getSettleMonth(), StatementType.MONTHLY.code());
if (existingStmt == null && gross.signum() != 0) {
CommissionStatementVO stmt = new CommissionStatementVO();
stmt.setAgentId(agentId);
stmt.setSettleMonth(ctx.getSettleMonth());
stmt.setStatementType(StatementType.MONTHLY.code());
stmt.setGrossAmount(gross);
stmt.setIncomeTaxAmount(tax.incomeTax());
stmt.setLocalTaxAmount(tax.localTax());
stmt.setVatAmount(tax.vat());
stmt.setDeductionAmount(deductionTotal);
stmt.setNetAmount(netAmount);
stmt.setIssueStatus("ISSUED");
stmt.setIssuedAt(LocalDateTime.now());
statements.add(stmt);
}
} }
// 차감 APPLIED 상태 일괄 전이
if (!appliedDeductionIds.isEmpty()) {
deductionMapper.updateStatusBatch(appliedDeductionIds, DeductionStatus.APPLIED.name());
log.info("[Step8] deductions applied: {}", appliedDeductionIds.size());
}
// 명세서 일괄 INSERT
for (CommissionStatementVO stmt : statements) {
statementMapper.insert(stmt);
}
if (!statements.isEmpty()) {
log.info("[Step8] commission statements inserted: {}", statements.size());
}
ctx.setAggregateCount(count); ctx.setAggregateCount(count);
log.info("[Step8] settle master upserted: {}", count); log.info("[Step8] settle master upserted: {}", count);
return RepeatStatus.FINISHED; return RepeatStatus.FINISHED;
} }
private TaxType parseTaxType(AgentVO agent) {
if (agent == null || agent.getTaxType() == null) return TaxType.BUSINESS_INCOME;
try {
return TaxType.valueOf(agent.getTaxType());
} catch (IllegalArgumentException e) {
return TaxType.BUSINESS_INCOME;
}
}
private VatType parseVatType(AgentVO agent) {
if (agent == null || agent.getVatType() == null) return VatType.NONE;
try {
return VatType.valueOf(agent.getVatType());
} catch (IllegalArgumentException e) {
return VatType.NONE;
}
}
private Map<Long, BigDecimal> toMap(List<Map<String, Object>> rows) { private Map<Long, BigDecimal> toMap(List<Map<String, Object>> rows) {
Map<Long, BigDecimal> m = new HashMap<>(); Map<Long, BigDecimal> m = new HashMap<>();
for (Map<String, Object> r : rows) { for (Map<String, Object> r : rows) {
@@ -2,10 +2,15 @@ package com.ga.batch.settlement.step;
import com.ga.batch.settlement.SettlementContext; import com.ga.batch.settlement.SettlementContext;
import com.ga.batch.settlement.calc.CommissionCalculator; import com.ga.batch.settlement.calc.CommissionCalculator;
import com.ga.batch.settlement.domain.BatchInstallmentPlanGenerator;
import com.ga.batch.settlement.domain.BatchRegulatoryLimitChecker;
import com.ga.batch.settlement.domain.ViolationResult;
import com.ga.common.util.DateUtil; import com.ga.common.util.DateUtil;
import com.ga.common.util.MoneyUtil; import com.ga.common.util.MoneyUtil;
import com.ga.core.mapper.installment.InstallmentPlanMapper;
import com.ga.core.mapper.ledger.RecruitLedgerMapper; import com.ga.core.mapper.ledger.RecruitLedgerMapper;
import com.ga.core.mapper.product.ContractMapper; import com.ga.core.mapper.product.ContractMapper;
import com.ga.core.vo.installment.InstallmentPlanVO;
import com.ga.core.vo.ledger.LedgerVO; import com.ga.core.vo.ledger.LedgerVO;
import com.ga.core.vo.product.ContractResp; import com.ga.core.vo.product.ContractResp;
import com.ga.core.vo.product.ContractSearchParam; import com.ga.core.vo.product.ContractSearchParam;
@@ -25,6 +30,9 @@ import java.util.List;
* Step 4 calcRecruit: 모집 수수료 계산 (계약 회차) * Step 4 calcRecruit: 모집 수수료 계산 (계약 회차)
* *
* 원칙: 계약시점의 commission_rate / payout_rule 적용 (effective_from 기준) * 원칙: 계약시점의 commission_rate / payout_rule 적용 (effective_from 기준)
*
* 추가: 1200%(BatchRegulatoryLimitChecker) 검증 한도 초과 계약은
* InstallmentPlan(분급 7년) 생성. Step 4.5 역할을 Step 4 내에서 수행.
*/ */
@Slf4j @Slf4j
@Component @Component
@@ -36,6 +44,9 @@ public class CalcRecruitStep implements Tasklet {
private final ContractMapper contractMapper; private final ContractMapper contractMapper;
private final RecruitLedgerMapper recruitMapper; private final RecruitLedgerMapper recruitMapper;
private final CommissionCalculator calculator; private final CommissionCalculator calculator;
private final BatchRegulatoryLimitChecker regulatoryChecker;
private final BatchInstallmentPlanGenerator installmentGenerator;
private final InstallmentPlanMapper installmentPlanMapper;
private static final int BATCH_SIZE = 1000; private static final int BATCH_SIZE = 1000;
@@ -55,10 +66,33 @@ public class CalcRecruitStep implements Tasklet {
recruitMapper.deleteBySettleMonth(ctx.getSettleMonth()); recruitMapper.deleteBySettleMonth(ctx.getSettleMonth());
List<LedgerVO> batch = new ArrayList<>(BATCH_SIZE); List<LedgerVO> batch = new ArrayList<>(BATCH_SIZE);
List<InstallmentPlanVO> installmentBatch = new ArrayList<>();
int total = 0; int total = 0;
int violationCount = 0;
for (ContractResp c : contracts) { for (ContractResp c : contracts) {
LedgerVO led = calculator.calcRecruitLedger(c, ctx.getSettleMonth()); LedgerVO led = calculator.calcRecruitLedger(c, ctx.getSettleMonth());
if (led == null || MoneyUtil.isZero(led.getAgentAmount())) continue; if (led == null || MoneyUtil.isZero(led.getAgentAmount())) continue;
// 1200% 검증 (1차년도 한도)
if (c.getContractDate() != null) {
ViolationResult firstYearCheck = regulatoryChecker.checkFirstYear(
c.getContractId(), led.getAgentAmount(), c.getContractDate());
if (firstYearCheck.violated()) {
log.warn("[Step4] 1200%룰 위반: contractId={}, msg={}",
c.getContractId(), firstYearCheck.message());
violationCount++;
// 1200% 위반 계약: 분급 계획 생성 (7년 35/25/15/10/7/5/3)
List<InstallmentPlanVO> plans = installmentGenerator.generate(
c.getContractId(), led.getAgentAmount(),
c.getContractDate(), c.getInsuranceType());
installmentBatch.addAll(plans);
// 위반 계약도 원장에는 기록 (감사 목적)
}
}
batch.add(led); batch.add(led);
if (batch.size() >= BATCH_SIZE) { if (batch.size() >= BATCH_SIZE) {
recruitMapper.batchInsert(batch); recruitMapper.batchInsert(batch);
@@ -71,8 +105,15 @@ public class CalcRecruitStep implements Tasklet {
total += batch.size(); total += batch.size();
} }
// 분급 계획 일괄 저장
if (!installmentBatch.isEmpty()) {
installmentPlanMapper.insertBatch(installmentBatch);
log.info("[Step4] installment plans created: {} (violations: {})",
installmentBatch.size(), violationCount);
}
ctx.setRecruitCount(total); ctx.setRecruitCount(total);
log.info("[Step4] recruit ledger inserted: {}", total); log.info("[Step4] recruit ledger inserted: {}, regulatory violations: {}", total, violationCount);
return RepeatStatus.FINISHED; return RepeatStatus.FINISHED;
} }
} }
@@ -1,11 +1,11 @@
package com.ga.batch.settlement.step; package com.ga.batch.settlement.step;
import com.ga.batch.settlement.SettlementContext; import com.ga.batch.settlement.SettlementContext;
import com.ga.batch.settlement.domain.BatchChargebackCalculator;
import com.ga.batch.settlement.domain.ChargebackResult;
import com.ga.common.util.DateUtil; import com.ga.common.util.DateUtil;
import com.ga.common.util.MoneyUtil;
import com.ga.core.mapper.ledger.RecruitLedgerMapper; import com.ga.core.mapper.ledger.RecruitLedgerMapper;
import com.ga.core.mapper.product.ContractMapper; import com.ga.core.mapper.product.ContractMapper;
import com.ga.core.mapper.rule.RuleMapper;
import com.ga.core.mapper.settle.ChargebackMapper; import com.ga.core.mapper.settle.ChargebackMapper;
import com.ga.core.vo.product.ContractResp; import com.ga.core.vo.product.ContractResp;
import com.ga.core.vo.product.ContractSearchParam; import com.ga.core.vo.product.ContractSearchParam;
@@ -26,7 +26,8 @@ import java.util.List;
/** /**
* Step 6 chargebackException: 환수 자동생성 + 예외금액 자동계산 * Step 6 chargebackException: 환수 자동생성 + 예외금액 자동계산
* *
* 환수 대상: 정산월에 LAPSE(실효) 계약 계약일~실효까지 경과월수로 환수율 결정. * 환수 계산: BatchChargebackCalculator (chargeback_grade 5단계 차등환수) 사용.
* 환수 원금: 해당 계약의 모집 원장 합산 (recruit_ledger 기준).
*/ */
@Slf4j @Slf4j
@Component @Component
@@ -37,8 +38,8 @@ public class ChargebackExceptionStep implements Tasklet {
private final SettlementContext ctx; private final SettlementContext ctx;
private final ContractMapper contractMapper; private final ContractMapper contractMapper;
private final RecruitLedgerMapper recruitMapper; private final RecruitLedgerMapper recruitMapper;
private final RuleMapper ruleMapper;
private final ChargebackMapper chargebackMapper; private final ChargebackMapper chargebackMapper;
private final BatchChargebackCalculator chargebackCalculator;
@Override @Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
@@ -51,30 +52,30 @@ public class ChargebackExceptionStep implements Tasklet {
List<ChargebackVO> chargebacks = new ArrayList<>(); List<ChargebackVO> chargebacks = new ArrayList<>();
for (ContractResp c : lapsed) { for (ContractResp c : lapsed) {
if (c.getLapseDate() == null) continue; if (c.getLapseDate() == null || c.getContractDate() == null) continue;
String lapseMonth = DateUtil.getSettleMonth(c.getLapseDate()); String lapseMonth = DateUtil.getSettleMonth(c.getLapseDate());
if (!ctx.getSettleMonth().equals(lapseMonth)) continue; if (!ctx.getSettleMonth().equals(lapseMonth)) continue;
if (c.getContractDate() == null) continue;
int monthsActive = DateUtil.calcMonthDiff( // 모집 원장에서 수수료 조회
DateUtil.getSettleMonth(c.getContractDate()), lapseMonth); BigDecimal originalCommission = recruitMapper.sumByContract(c.getContractId());
BigDecimal cbRate = ruleMapper.findChargebackRate( if (originalCommission == null || originalCommission.signum() <= 0) continue;
c.getCompanyCode(), c.getInsuranceType(), monthsActive, c.getLapseDate());
if (cbRate == null || cbRate.signum() <= 0) continue;
// 모집수수료 × 환수 // BatchChargebackCalculator: chargeback_grade 5단 차등환수
BigDecimal originalAmount = MoneyUtil.zero(c.getPremium()) ChargebackResult result = chargebackCalculator.calculate(
.multiply(cbRate) c.getContractDate(), c.getLapseDate(),
.divide(MoneyUtil.HUNDRED, 2, java.math.RoundingMode.HALF_UP); originalCommission, c.getInsuranceType());
if (result.chargebackAmount().signum() <= 0) continue;
ChargebackVO cb = new ChargebackVO(); ChargebackVO cb = new ChargebackVO();
cb.setContractId(c.getContractId()); cb.setContractId(c.getContractId());
cb.setAgentId(c.getAgentId()); cb.setAgentId(c.getAgentId());
cb.setPolicyNo(c.getPolicyNo()); cb.setPolicyNo(c.getPolicyNo());
cb.setLapseMonth(monthsActive); cb.setLapseMonth(result.monthsElapsed());
cb.setCbRate(cbRate); cb.setCbRate(result.appliedPercent());
cb.setCbAmount(originalAmount); cb.setCbAmount(result.chargebackAmount());
cb.setRemainAmount(originalAmount); cb.setRemainAmount(result.chargebackAmount());
cb.setSettleMonth(ctx.getSettleMonth()); cb.setSettleMonth(ctx.getSettleMonth());
cb.setStatus("NEW"); cb.setStatus("NEW");
chargebacks.add(cb); chargebacks.add(cb);
@@ -84,7 +85,7 @@ public class ChargebackExceptionStep implements Tasklet {
chargebackMapper.insertBatch(chargebacks); chargebackMapper.insertBatch(chargebacks);
} }
ctx.setChargebackCount(chargebacks.size()); ctx.setChargebackCount(chargebacks.size());
log.info("[Step6] chargeback inserted: {}", chargebacks.size()); log.info("[Step6] chargeback inserted: {} (using BatchChargebackCalculator)", chargebacks.size());
return RepeatStatus.FINISHED; return RepeatStatus.FINISHED;
} }
} }
@@ -0,0 +1,43 @@
-- V29: 마감 마스터 (period_close) — 정산 월 마감 관리
-- ============================================================
-- 1. period_close — 마감 마스터
-- ============================================================
CREATE TABLE period_close (
close_id BIGSERIAL PRIMARY KEY,
close_month CHAR(6) NOT NULL, -- YYYYMM
closed_at TIMESTAMP NOT NULL DEFAULT NOW(), -- 마감 처리 일시
closed_by BIGINT NOT NULL, -- 마감 처리자 (user_id)
reopen_at TIMESTAMP, -- 재오픈 일시 (NULL = 마감 중)
reopen_by BIGINT, -- 재오픈 처리자
reopen_reason VARCHAR(500), -- 재오픈 사유
note VARCHAR(500), -- 마감 비고
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP,
UNIQUE (close_month) -- 월별 단 1건
);
COMMENT ON TABLE period_close IS '정산 마감 마스터 — 마감된 월은 수정 불가';
COMMENT ON COLUMN period_close.close_month IS '마감 정산년월 (YYYYMM)';
COMMENT ON COLUMN period_close.closed_at IS '마감 처리 일시';
COMMENT ON COLUMN period_close.closed_by IS '마감 처리자 user_id';
COMMENT ON COLUMN period_close.reopen_at IS '재오픈 일시. NULL이면 현재 마감 상태';
COMMENT ON COLUMN period_close.reopen_by IS '재오픈 처리자 user_id';
COMMENT ON COLUMN period_close.reopen_reason IS '재오픈 사유 (감사 추적용)';
-- 마감 상태 조회용 인덱스 (재오픈 여부로 현재 마감 여부 판단)
CREATE INDEX idx_pc_month ON period_close(close_month);
-- ============================================================
-- 2. 공통코드 CLOSE_STATUS (마감/재오픈 상태 표현용)
-- ============================================================
INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
('CLOSE_STATUS', '마감상태', 'CLOSED/REOPENED', 'Y', 290)
ON CONFLICT (group_code) DO NOTHING;
INSERT INTO common_code (group_code, code, code_name, code_desc, sort_order) VALUES
('CLOSE_STATUS', 'CLOSED', '마감', '정산월 마감 완료 — 수정 불가', 10),
('CLOSE_STATUS', 'REOPENED', '재오픈', '마감 해제 상태 — 수정 가능', 20)
ON CONFLICT (group_code, code) DO NOTHING;
@@ -0,0 +1,62 @@
-- V30: 정산 정정 (settle_correction) — 확정된 정산의 정정 이력 관리
-- ============================================================
-- 1. settle_correction — 정산 정정 이력
-- ============================================================
CREATE TABLE settle_correction (
correction_id BIGSERIAL PRIMARY KEY,
original_settle_id BIGINT NOT NULL REFERENCES settle_master(settle_id), -- 원본 정산 참조
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
settle_month CHAR(6) NOT NULL, -- YYYYMM (원본과 동일)
diff_amount NUMERIC(15,2) NOT NULL, -- 차액 (양수=추가지급, 음수=환수)
before_amount NUMERIC(15,2) NOT NULL, -- 정정 전 순지급액
after_amount NUMERIC(15,2) NOT NULL, -- 정정 후 순지급액
reason_code VARCHAR(30) NOT NULL, -- 정정 사유 코드 (CORRECTION_REASON)
reason_detail VARCHAR(1000), -- 정정 사유 상세
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', -- DRAFT/APPROVED/APPLIED/CANCELLED
requested_by BIGINT NOT NULL, -- 정정 신청자
requested_at TIMESTAMP NOT NULL DEFAULT NOW(),
approved_by BIGINT, -- 정정 승인자
approved_at TIMESTAMP,
applied_settle_id BIGINT REFERENCES settle_master(settle_id), -- 정정이 반영된 정산 (다음 월 등)
cancel_reason VARCHAR(500),
cancelled_by BIGINT,
cancelled_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP
);
COMMENT ON TABLE settle_correction IS '정산 정정 이력 — 확정 정산에 대한 사후 정정';
COMMENT ON COLUMN settle_correction.original_settle_id IS '원본 settle_master 참조';
COMMENT ON COLUMN settle_correction.diff_amount IS '정정 차액 (양수=추가, 음수=환수)';
COMMENT ON COLUMN settle_correction.before_amount IS '정정 전 순지급액';
COMMENT ON COLUMN settle_correction.after_amount IS '정정 후 순지급액';
COMMENT ON COLUMN settle_correction.reason_code IS '정정 사유 코드 (CORRECTION_REASON 그룹)';
COMMENT ON COLUMN settle_correction.status IS 'DRAFT/APPROVED/APPLIED/CANCELLED';
COMMENT ON COLUMN settle_correction.applied_settle_id IS '차액이 반영된 정산 settle_id (다음 정산월 등)';
CREATE INDEX idx_sc_original ON settle_correction(original_settle_id);
CREATE INDEX idx_sc_agent ON settle_correction(agent_id, settle_month);
CREATE INDEX idx_sc_status ON settle_correction(status, requested_at DESC);
-- ============================================================
-- 2. 공통코드 CORRECTION_REASON / CORRECTION_STATUS
-- ============================================================
INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
('CORRECTION_REASON', '정정사유', 'DATA_ERROR/POLICY_CHANGE/LATE_DATA/MANUAL_ADJ/OTHER', 'Y', 300),
('CORRECTION_STATUS', '정정상태', 'DRAFT/APPROVED/APPLIED/CANCELLED', 'Y', 310)
ON CONFLICT (group_code) DO NOTHING;
INSERT INTO common_code (group_code, code, code_name, code_desc, sort_order) VALUES
('CORRECTION_REASON', 'DATA_ERROR', '데이터오류', '원시 데이터 오류로 인한 정정', 10),
('CORRECTION_REASON', 'POLICY_CHANGE', '정책변경', '정산 정책 변경으로 인한 소급 정정', 20),
('CORRECTION_REASON', 'LATE_DATA', '지연수신', '보험사 데이터 지연 수신으로 인한 정정', 30),
('CORRECTION_REASON', 'MANUAL_ADJ', '수동조정', '관리자 수동 조정', 40),
('CORRECTION_REASON', 'OTHER', '기타', '기타 사유', 50),
('CORRECTION_STATUS', 'DRAFT', '초안', '정정 신청 초안', 10),
('CORRECTION_STATUS', 'APPROVED', '승인', '정정 승인 완료', 20),
('CORRECTION_STATUS', 'APPLIED', '적용완료', '정정 차액 정산 반영 완료', 30),
('CORRECTION_STATUS', 'CANCELLED', '취소', '정정 신청 취소', 40)
ON CONFLICT (group_code, code) DO NOTHING;
@@ -0,0 +1,54 @@
-- V31: 수수료 이의신청 (commission_dispute)
-- ============================================================
-- 1. commission_dispute — 이의신청 마스터
-- ============================================================
CREATE TABLE commission_dispute (
dispute_id BIGSERIAL PRIMARY KEY,
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
settle_month CHAR(6) NOT NULL, -- 대상 정산년월 (YYYYMM)
settle_id BIGINT REFERENCES settle_master(settle_id), -- 연관 정산 (선택)
status VARCHAR(20) NOT NULL DEFAULT 'OPEN', -- OPEN/IN_REVIEW/APPROVED/REJECTED
disputed_amount NUMERIC(15,2), -- 이의 제기 금액 (NULL=전체)
claim_amount NUMERIC(15,2), -- 설계사 주장 금액
reason VARCHAR(2000) NOT NULL, -- 이의 신청 사유
evidence_url VARCHAR(500), -- 증빙 자료 URL (첨부파일)
reviewer_id BIGINT, -- 검토 담당자
reviewed_at TIMESTAMP, -- 검토 시작 일시
resolved_by BIGINT, -- 최종 처리자
resolved_at TIMESTAMP, -- 최종 처리 일시
resolution VARCHAR(2000), -- 처리 결과 상세
adjustment_amount NUMERIC(15,2), -- 인정된 조정 금액 (APPROVED 시)
correction_id BIGINT REFERENCES settle_correction(correction_id), -- 연계 정정건
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP
);
COMMENT ON TABLE commission_dispute IS '수수료 이의신청 — 설계사가 정산 결과에 이의를 제기하는 프로세스';
COMMENT ON COLUMN commission_dispute.agent_id IS '이의신청 설계사';
COMMENT ON COLUMN commission_dispute.settle_month IS '이의 대상 정산년월 (YYYYMM)';
COMMENT ON COLUMN commission_dispute.status IS 'OPEN/IN_REVIEW/APPROVED/REJECTED';
COMMENT ON COLUMN commission_dispute.disputed_amount IS '이의 제기 금액 (NULL=전체 정산)';
COMMENT ON COLUMN commission_dispute.claim_amount IS '설계사가 주장하는 정당 금액';
COMMENT ON COLUMN commission_dispute.adjustment_amount IS 'APPROVED 시 실제 인정된 조정 금액';
COMMENT ON COLUMN commission_dispute.correction_id IS '이의신청 결과로 생성된 정정건 참조';
CREATE INDEX idx_cd_agent ON commission_dispute(agent_id, settle_month);
CREATE INDEX idx_cd_status ON commission_dispute(status, created_at DESC);
CREATE INDEX idx_cd_settle ON commission_dispute(settle_id);
-- ============================================================
-- 2. 공통코드 DISPUTE_STATUS
-- ============================================================
INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
('DISPUTE_STATUS', '이의신청상태', 'OPEN/IN_REVIEW/APPROVED/REJECTED', 'Y', 320)
ON CONFLICT (group_code) DO NOTHING;
INSERT INTO common_code (group_code, code, code_name, code_desc, sort_order) VALUES
('DISPUTE_STATUS', 'OPEN', '접수', '이의신청 접수 (검토 전)', 10),
('DISPUTE_STATUS', 'IN_REVIEW', '검토중', '담당자 검토 진행 중', 20),
('DISPUTE_STATUS', 'APPROVED', '인정', '이의신청 인정 — 정정 처리 진행', 30),
('DISPUTE_STATUS', 'REJECTED', '기각', '이의신청 기각 — 원 정산 유지', 40)
ON CONFLICT (group_code, code) DO NOTHING;
@@ -0,0 +1,29 @@
-- V32: settle_master 보류 사유 코드 컬럼 추가 + HOLD_REASON 공통코드
-- ============================================================
-- 1. settle_master 에 hold_reason_code 컬럼 추가
-- (기존 hold_reason VARCHAR(500) 자유텍스트 → 코드화 병행)
-- ============================================================
ALTER TABLE settle_master
ADD COLUMN hold_reason_code VARCHAR(30);
COMMENT ON COLUMN settle_master.hold_reason_code IS '보류 사유 코드 (HOLD_REASON 그룹). status=HOLD 일 때 필수';
-- ============================================================
-- 2. 공통코드 HOLD_REASON
-- ============================================================
INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
('HOLD_REASON', '보류사유', 'NEED_DOC/UNDER_REVIEW/DISPUTE_OPEN/TAX_ISSUE/BANK_ERROR/COMPLIANCE/OTHER', 'Y', 330)
ON CONFLICT (group_code) DO NOTHING;
INSERT INTO common_code (group_code, code, code_name, code_desc, sort_order) VALUES
('HOLD_REASON', 'NEED_DOC', '서류미비', '필수 서류 미제출로 인한 보류', 10),
('HOLD_REASON', 'UNDER_REVIEW', '검토중', '관리자 검토 진행 중 보류', 20),
('HOLD_REASON', 'DISPUTE_OPEN', '이의신청', '이의신청(commission_dispute) 처리 중 보류', 30),
('HOLD_REASON', 'TAX_ISSUE', '세무이슈', '세무 정보 미확인 또는 이슈', 40),
('HOLD_REASON', 'BANK_ERROR', '계좌오류', '계좌 정보 오류 또는 이체 실패', 50),
('HOLD_REASON', 'COMPLIANCE', '컴플라이언스', '법규/감독규정 검토 필요', 60),
('HOLD_REASON', 'OTHER', '기타', '기타 보류 사유', 70)
ON CONFLICT (group_code, code) DO NOTHING;
@@ -0,0 +1,94 @@
-- V33: 시책·인센티브 (incentive_program + incentive_payment)
-- ============================================================
-- 1. incentive_program — 시책 프로그램 마스터
-- ============================================================
CREATE TABLE incentive_program (
program_id BIGSERIAL PRIMARY KEY,
program_name VARCHAR(200) NOT NULL, -- 시책명
program_type VARCHAR(30) NOT NULL, -- 시책 유형 (INCENTIVE_TYPE)
target_type VARCHAR(30) NOT NULL DEFAULT 'AGENT', -- 지급 대상 (AGENT/ORG/GRADE)
target_grade_id BIGINT REFERENCES grade(grade_id), -- 대상 직급 (NULL=전체)
target_org_id BIGINT REFERENCES organization(org_id), -- 대상 조직 (NULL=전체)
product_category VARCHAR(30), -- 적용 상품 카테고리 (NULL=전체)
start_month CHAR(6) NOT NULL, -- 시책 시작 정산월 (YYYYMM)
end_month CHAR(6) NOT NULL, -- 시책 종료 정산월 (YYYYMM)
condition_desc VARCHAR(2000), -- 지급 조건 상세 기술
pay_rate NUMERIC(7,4), -- 지급률 (%) — rate 방식
pay_amount_fixed NUMERIC(15,2), -- 고정 지급액 — amount 방식
pay_type VARCHAR(20) NOT NULL DEFAULT 'RATE', -- RATE / FIXED_AMOUNT
budget_amount NUMERIC(15,2), -- 시책 예산 (NULL=무제한)
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
updated_by BIGINT,
CHECK (end_month >= start_month),
CHECK (pay_type IN ('RATE', 'FIXED_AMOUNT'))
);
COMMENT ON TABLE incentive_program IS '시책 프로그램 마스터 — 기간/대상/조건/지급률 정의';
COMMENT ON COLUMN incentive_program.program_type IS '시책 유형 코드 (INCENTIVE_TYPE 그룹)';
COMMENT ON COLUMN incentive_program.target_type IS '지급 대상 구분 (AGENT=개인/ORG=조직/GRADE=직급)';
COMMENT ON COLUMN incentive_program.start_month IS '시책 시작 정산월 (YYYYMM)';
COMMENT ON COLUMN incentive_program.end_month IS '시책 종료 정산월 (YYYYMM, 포함)';
COMMENT ON COLUMN incentive_program.pay_rate IS 'RATE 방식 시 지급률 (%)';
COMMENT ON COLUMN incentive_program.pay_amount_fixed IS 'FIXED_AMOUNT 방식 시 고정 지급액';
COMMENT ON COLUMN incentive_program.pay_type IS 'RATE=수수료 대비 비율 / FIXED_AMOUNT=건당 고정액';
CREATE INDEX idx_ip_prog_month ON incentive_program(start_month, end_month, is_active);
CREATE INDEX idx_ip_prog_type ON incentive_program(program_type, is_active);
-- ============================================================
-- 2. incentive_payment — 시책 지급 이력
-- ============================================================
CREATE TABLE incentive_payment (
incentive_pay_id BIGSERIAL PRIMARY KEY,
program_id BIGINT NOT NULL REFERENCES incentive_program(program_id),
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
settle_month CHAR(6) NOT NULL, -- 지급 정산월 (YYYYMM)
base_amount NUMERIC(15,2), -- 기준 수수료 (RATE 방식)
incentive_amount NUMERIC(15,2) NOT NULL, -- 시책 지급액
status VARCHAR(20) NOT NULL DEFAULT 'CALCULATED', -- CALCULATED/CONFIRMED/PAID/CANCELLED
calc_date TIMESTAMP NOT NULL DEFAULT NOW(),
confirmed_by BIGINT,
confirmed_at TIMESTAMP,
settle_id BIGINT REFERENCES settle_master(settle_id), -- 반영된 정산
cancel_reason VARCHAR(500),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP,
UNIQUE (program_id, agent_id, settle_month)
);
COMMENT ON TABLE incentive_payment IS '시책 지급 이력 — program_id + agent_id + settle_month 단위';
COMMENT ON COLUMN incentive_payment.base_amount IS 'RATE 방식 시 계산 기준 수수료';
COMMENT ON COLUMN incentive_payment.incentive_amount IS '최종 시책 지급액';
COMMENT ON COLUMN incentive_payment.status IS 'CALCULATED/CONFIRMED/PAID/CANCELLED';
COMMENT ON COLUMN incentive_payment.settle_id IS '시책 금액이 반영된 settle_master 참조';
CREATE INDEX idx_ipay_agent ON incentive_payment(agent_id, settle_month);
CREATE INDEX idx_ipay_program ON incentive_payment(program_id, settle_month);
CREATE INDEX idx_ipay_status ON incentive_payment(status, settle_month);
-- ============================================================
-- 3. 공통코드 INCENTIVE_TYPE / INCENTIVE_PAY_STATUS
-- ============================================================
INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
('INCENTIVE_TYPE', '시책유형', 'RECRUIT_BONUS/MAINTAIN_BONUS/SPECIAL/CONTEST/OTHER', 'Y', 340),
('INCENTIVE_PAY_STATUS', '시책지급상태', 'CALCULATED/CONFIRMED/PAID/CANCELLED', 'Y', 350)
ON CONFLICT (group_code) DO NOTHING;
INSERT INTO common_code (group_code, code, code_name, code_desc, sort_order) VALUES
('INCENTIVE_TYPE', 'RECRUIT_BONUS', '신계약시책', '신계약 실적 기반 시책', 10),
('INCENTIVE_TYPE', 'MAINTAIN_BONUS', '유지시책', '유지율 기반 시책', 20),
('INCENTIVE_TYPE', 'SPECIAL', '특별시책', '본사 특별 지정 시책', 30),
('INCENTIVE_TYPE', 'CONTEST', '콘테스트', '경쟁 목표 달성 인센티브', 40),
('INCENTIVE_TYPE', 'OTHER', '기타', '기타 시책', 50),
('INCENTIVE_PAY_STATUS', 'CALCULATED', '산출', '시책 금액 산출 완료', 10),
('INCENTIVE_PAY_STATUS', 'CONFIRMED', '확정', '지급 확정', 20),
('INCENTIVE_PAY_STATUS', 'PAID', '지급완료', '정산 반영 완료', 30),
('INCENTIVE_PAY_STATUS', 'CANCELLED', '취소', '시책 지급 취소', 40)
ON CONFLICT (group_code, code) DO NOTHING;
@@ -0,0 +1,69 @@
-- V34: 해촉 정산 (termination_settlement) — 설계사 해촉 시 미지급분·환수 처리
-- ============================================================
-- 1. termination_settlement — 해촉 정산 마스터
-- ============================================================
CREATE TABLE termination_settlement (
term_settle_id BIGSERIAL PRIMARY KEY,
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
termination_date DATE NOT NULL, -- 해촉 일자
org_history_id BIGINT REFERENCES agent_org_history(history_id), -- LEAVE 이력 참조
calc_month CHAR(6) NOT NULL, -- 해촉 정산 처리 월 (YYYYMM)
-- 미지급 수수료 (해촉 시점 기준)
unpaid_recruit_amount NUMERIC(15,2) NOT NULL DEFAULT 0, -- 미지급 신계약 수수료
unpaid_installment_amount NUMERIC(15,2) NOT NULL DEFAULT 0, -- 미지급 분급 잔액
unpaid_incentive_amount NUMERIC(15,2) NOT NULL DEFAULT 0, -- 미지급 시책
-- 환수 예정 금액
chargeback_expected_amount NUMERIC(15,2) NOT NULL DEFAULT 0, -- 환수 예정 총액
chargeback_waived_amount NUMERIC(15,2) NOT NULL DEFAULT 0, -- 면제 환수액
-- 공제 항목
deduction_amount NUMERIC(15,2) NOT NULL DEFAULT 0, -- 공제 차감액
-- 최종 지급
final_pay_amount NUMERIC(15,2) NOT NULL DEFAULT 0, -- 최종 지급액 (미지급 - 환수 - 공제)
status VARCHAR(20) NOT NULL DEFAULT 'CALCULATING', -- CALCULATING/CONFIRMED/PAID/CANCELLED
note VARCHAR(1000),
confirmed_by BIGINT,
confirmed_at TIMESTAMP,
paid_at TIMESTAMP,
payment_id BIGINT REFERENCES payment(payment_id), -- 최종 지급 참조
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
updated_by BIGINT,
UNIQUE (agent_id, termination_date) -- 해촉 1건당 정산 1건
);
COMMENT ON TABLE termination_settlement IS '해촉 정산 — 설계사 해촉 시 미지급분/환수/최종지급 통합 처리';
COMMENT ON COLUMN termination_settlement.termination_date IS '해촉 일자';
COMMENT ON COLUMN termination_settlement.org_history_id IS 'agent_org_history의 LEAVE 이력 참조';
COMMENT ON COLUMN termination_settlement.unpaid_recruit_amount IS '해촉 시점 미지급 신계약 수수료';
COMMENT ON COLUMN termination_settlement.unpaid_installment_amount IS '해촉 시점 미지급 분급 잔액 (installment_plan.SCHEDULED 합계)';
COMMENT ON COLUMN termination_settlement.unpaid_incentive_amount IS '해촉 시점 미지급 시책 금액';
COMMENT ON COLUMN termination_settlement.chargeback_expected_amount IS '해촉 시점 발생 예정 환수 총액';
COMMENT ON COLUMN termination_settlement.chargeback_waived_amount IS '합의·면제된 환수액';
COMMENT ON COLUMN termination_settlement.final_pay_amount IS '최종 지급액 = (미지급합계) - (환수예정-면제) - 공제';
COMMENT ON COLUMN termination_settlement.status IS 'CALCULATING/CONFIRMED/PAID/CANCELLED';
CREATE INDEX idx_ts_agent ON termination_settlement(agent_id, termination_date DESC);
CREATE INDEX idx_ts_month ON termination_settlement(calc_month, status);
-- ============================================================
-- 2. 공통코드 TERM_SETTLE_STATUS
-- ============================================================
INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
('TERM_SETTLE_STATUS', '해촉정산상태', 'CALCULATING/CONFIRMED/PAID/CANCELLED', 'Y', 360)
ON CONFLICT (group_code) DO NOTHING;
INSERT INTO common_code (group_code, code, code_name, code_desc, sort_order) VALUES
('TERM_SETTLE_STATUS', 'CALCULATING', '산출중', '해촉 정산 금액 산출 중', 10),
('TERM_SETTLE_STATUS', 'CONFIRMED', '확정', '해촉 정산 금액 확정', 20),
('TERM_SETTLE_STATUS', 'PAID', '지급완료', '최종 지급 완료', 30),
('TERM_SETTLE_STATUS', 'CANCELLED', '취소', '해촉 정산 취소', 40)
ON CONFLICT (group_code, code) DO NOTHING;
@@ -0,0 +1,65 @@
-- V35: 세금계산서 발행 이력 (tax_invoice)
-- ============================================================
-- 1. tax_invoice — 세금계산서 발행 이력
-- ============================================================
CREATE TABLE tax_invoice (
invoice_id BIGSERIAL PRIMARY KEY,
invoice_no VARCHAR(50) NOT NULL UNIQUE, -- 발행 번호 (국세청 승인번호 등)
invoice_type VARCHAR(20) NOT NULL, -- SUPPLY (매출) / PURCHASE (매입)
issue_date DATE NOT NULL, -- 발행일
supply_amount NUMERIC(15,2) NOT NULL, -- 공급가액
tax_amount NUMERIC(15,2) NOT NULL, -- 세액 (공급가 × 10%)
total_amount NUMERIC(15,2) NOT NULL, -- 합계액 (공급가 + 세액)
-- 거래 상대방
counterpart_name VARCHAR(200) NOT NULL, -- 거래 상대방 상호
counterpart_biz_no VARCHAR(20) NOT NULL, -- 거래 상대방 사업자번호
counterpart_rep VARCHAR(100), -- 대표자명
counterpart_addr VARCHAR(500), -- 주소
-- 내부 참조
agent_id BIGINT REFERENCES agent(agent_id), -- 관련 설계사 (해당 시)
settle_month CHAR(6), -- 관련 정산월 (YYYYMM, 해당 시)
settle_id BIGINT REFERENCES settle_master(settle_id),
-- 발행 메타
description VARCHAR(500), -- 품목/비고
cancel_date DATE, -- 취소일 (NULL=유효)
cancel_reason VARCHAR(500),
issued_by BIGINT NOT NULL, -- 발행 처리자
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP,
CHECK (invoice_type IN ('SUPPLY', 'PURCHASE')),
CHECK (total_amount = supply_amount + tax_amount)
);
COMMENT ON TABLE tax_invoice IS '세금계산서 발행 이력 (매출/매입)';
COMMENT ON COLUMN tax_invoice.invoice_no IS '세금계산서 발행번호 (국세청 승인번호)';
COMMENT ON COLUMN tax_invoice.invoice_type IS 'SUPPLY=매출계산서 / PURCHASE=매입계산서';
COMMENT ON COLUMN tax_invoice.supply_amount IS '공급가액';
COMMENT ON COLUMN tax_invoice.tax_amount IS '부가세액 (공급가 × 10%)';
COMMENT ON COLUMN tax_invoice.total_amount IS '합계 금액 = 공급가 + 부가세';
COMMENT ON COLUMN tax_invoice.counterpart_biz_no IS '거래 상대방 사업자등록번호';
COMMENT ON COLUMN tax_invoice.cancel_date IS '세금계산서 취소일 (NULL이면 유효 상태)';
COMMENT ON COLUMN tax_invoice.settle_month IS '연관 정산월 (YYYYMM)';
CREATE INDEX idx_ti_issue_date ON tax_invoice(issue_date DESC);
CREATE INDEX idx_ti_type_month ON tax_invoice(invoice_type, settle_month);
CREATE INDEX idx_ti_agent ON tax_invoice(agent_id, issue_date DESC);
CREATE INDEX idx_ti_biz_no ON tax_invoice(counterpart_biz_no);
-- ============================================================
-- 2. 공통코드 INVOICE_TYPE
-- ============================================================
INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
('INVOICE_TYPE', '세금계산서유형', 'SUPPLY/PURCHASE', 'Y', 370)
ON CONFLICT (group_code) DO NOTHING;
INSERT INTO common_code (group_code, code, code_name, code_desc, sort_order) VALUES
('INVOICE_TYPE', 'SUPPLY', '매출', '매출(공급) 세금계산서 — GA가 발행', 10),
('INVOICE_TYPE', 'PURCHASE', '매입', '매입 세금계산서 — GA가 수취', 20)
ON CONFLICT (group_code, code) DO NOTHING;
@@ -0,0 +1,54 @@
-- V36: 보험사 회신 인터페이스 (carrier_reconciliation_out) — 정산 결과 외부 전송 관리
-- ============================================================
-- 1. carrier_reconciliation_out — 보험사 회신 (아웃바운드) 이력
-- ============================================================
CREATE TABLE carrier_reconciliation_out (
recon_out_id BIGSERIAL PRIMARY KEY,
carrier_id BIGINT NOT NULL REFERENCES insurance_company(company_id),
settle_month CHAR(6) NOT NULL, -- 대상 정산월 (YYYYMM)
file_path VARCHAR(1000), -- 전송 파일 경로 (SFTP/FTP 등)
file_name VARCHAR(300), -- 파일명
file_size BIGINT, -- 파일 크기 (bytes)
record_count INT, -- 파일 내 레코드 수
status VARCHAR(20) NOT NULL DEFAULT 'READY', -- READY/SENT/ACK/FAIL
sent_at TIMESTAMP, -- 전송 일시
ack_at TIMESTAMP, -- 보험사 수신 확인 일시
ack_message VARCHAR(1000), -- 보험사 회신 메시지
fail_reason VARCHAR(1000), -- 실패 사유
retry_count INT NOT NULL DEFAULT 0, -- 재시도 횟수
last_retry_at TIMESTAMP, -- 마지막 재시도 일시
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
UNIQUE (carrier_id, settle_month) -- 보험사 + 정산월 단위 단건
);
COMMENT ON TABLE carrier_reconciliation_out IS '보험사 정산 회신 인터페이스 — 아웃바운드 전송 이력 관리';
COMMENT ON COLUMN carrier_reconciliation_out.carrier_id IS '대상 보험사 (insurance_company.company_id)';
COMMENT ON COLUMN carrier_reconciliation_out.settle_month IS '전송 대상 정산월 (YYYYMM)';
COMMENT ON COLUMN carrier_reconciliation_out.file_path IS '전송 파일 저장 경로 (절대경로 또는 S3 URI)';
COMMENT ON COLUMN carrier_reconciliation_out.status IS 'READY=전송 준비 / SENT=전송 완료 / ACK=수신 확인 / FAIL=전송 실패';
COMMENT ON COLUMN carrier_reconciliation_out.sent_at IS '파일 전송 완료 일시';
COMMENT ON COLUMN carrier_reconciliation_out.ack_at IS '보험사 수신 확인 일시';
COMMENT ON COLUMN carrier_reconciliation_out.retry_count IS 'FAIL 후 재시도 횟수 (최대 재시도 횟수는 system_config 참조)';
CREATE INDEX idx_cro_carrier ON carrier_reconciliation_out(carrier_id, settle_month);
CREATE INDEX idx_cro_status ON carrier_reconciliation_out(status, sent_at DESC);
CREATE INDEX idx_cro_month ON carrier_reconciliation_out(settle_month, status);
-- ============================================================
-- 2. 공통코드 RECON_OUT_STATUS
-- ============================================================
INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
('RECON_OUT_STATUS', '회신전송상태', 'READY/SENT/ACK/FAIL', 'Y', 380)
ON CONFLICT (group_code) DO NOTHING;
INSERT INTO common_code (group_code, code, code_name, code_desc, sort_order) VALUES
('RECON_OUT_STATUS', 'READY', '전송준비', '파일 생성 완료, 전송 대기 중', 10),
('RECON_OUT_STATUS', 'SENT', '전송완료', '보험사로 파일 전송 완료 (확인 전)', 20),
('RECON_OUT_STATUS', 'ACK', '수신확인', '보험사 수신 확인 완료', 30),
('RECON_OUT_STATUS', 'FAIL', '전송실패', '파일 전송 실패 — 재시도 필요', 40)
ON CONFLICT (group_code, code) DO NOTHING;
@@ -0,0 +1,257 @@
-- V37: KPI 대시보드 Materialized View 4종
--
-- REFRESH 전략:
-- CONCURRENTLY 옵션으로 락 없이 갱신 (UNIQUE INDEX 필수).
-- 야간 배치(ga-batch)에서 매일 00:30 순차 REFRESH.
-- 명령: REFRESH MATERIALIZED VIEW CONCURRENTLY <mv_name>;
--
-- 주의: CONCURRENTLY 는 트랜잭션 안에서 실행 불가.
-- 배치에서 Connection.setAutoCommit(true) 상태로 호출할 것.
-- ============================================================
-- 1. mv_monthly_summary — 월별 정산 요약
-- 단위: 정산월(yyyymm) 전체 합산
-- ============================================================
CREATE MATERIALIZED VIEW mv_monthly_summary AS
SELECT
sm.settle_month AS yyyymm,
COUNT(sm.settle_id) AS agent_count,
COALESCE(SUM(sm.recruit_total), 0) AS total_recruit,
COALESCE(SUM(sm.maintain_total), 0) AS total_maintain,
COALESCE(SUM(sm.override_total), 0) AS total_override,
COALESCE(SUM(sm.chargeback_total),0) AS total_chargeback,
COALESCE(SUM(sm.gross_amount), 0) AS total_gross,
COALESCE(SUM(sm.tax_amount), 0) AS total_tax,
COALESCE(SUM(sm.net_amount), 0) AS total_net,
-- 인센티브 합계 (해당 월 PAID 기준)
COALESCE((
SELECT SUM(ip.incentive_amount)
FROM incentive_payment ip
WHERE ip.settle_month = sm.settle_month
AND ip.status = 'PAID'
), 0) AS total_incentive,
NOW() AS refreshed_at
FROM settle_master sm
WHERE sm.status IN ('CONFIRMED', 'PAID')
GROUP BY sm.settle_month
WITH DATA;
COMMENT ON MATERIALIZED VIEW mv_monthly_summary IS '월별 정산 요약 KPI — REFRESH CONCURRENTLY 야간 배치';
COMMENT ON COLUMN mv_monthly_summary.yyyymm IS '정산년월 (YYYYMM)';
COMMENT ON COLUMN mv_monthly_summary.agent_count IS '해당 월 확정/지급 완료 설계사 수';
COMMENT ON COLUMN mv_monthly_summary.total_gross IS '총 수수료 (세전)';
COMMENT ON COLUMN mv_monthly_summary.total_tax IS '총 원천세';
COMMENT ON COLUMN mv_monthly_summary.total_net IS '총 순지급액 (세후)';
COMMENT ON COLUMN mv_monthly_summary.total_incentive IS '해당 월 지급 완료 시책 합계';
COMMENT ON COLUMN mv_monthly_summary.refreshed_at IS 'MATERIALIZED VIEW 마지막 갱신 시각';
-- CONCURRENTLY REFRESH 를 위한 UNIQUE INDEX (필수)
CREATE UNIQUE INDEX idx_mv_monthly_summary_pk ON mv_monthly_summary(yyyymm);
-- ============================================================
-- 2. mv_org_summary — 조직별 월별 요약
-- 단위: org_id + yyyymm
-- ============================================================
CREATE MATERIALIZED VIEW mv_org_summary AS
SELECT
a.org_id,
o.org_name,
o.org_type,
sm.settle_month AS yyyymm,
COUNT(DISTINCT sm.agent_id) AS agent_count,
COALESCE(SUM(sm.recruit_total), 0) AS total_recruit,
COALESCE(SUM(sm.maintain_total), 0) AS total_maintain,
COALESCE(SUM(sm.override_total), 0) AS total_override,
COALESCE(SUM(sm.chargeback_total),0) AS total_chargeback,
COALESCE(SUM(sm.gross_amount), 0) AS total_gross,
COALESCE(SUM(sm.net_amount), 0) AS total_net,
NOW() AS refreshed_at
FROM settle_master sm
JOIN agent a ON a.agent_id = sm.agent_id
JOIN organization o ON o.org_id = a.org_id
WHERE sm.status IN ('CONFIRMED', 'PAID')
GROUP BY a.org_id, o.org_name, o.org_type, sm.settle_month
WITH DATA;
COMMENT ON MATERIALIZED VIEW mv_org_summary IS '조직별 월별 요약 KPI — REFRESH CONCURRENTLY 야간 배치';
COMMENT ON COLUMN mv_org_summary.org_id IS '조직 ID';
COMMENT ON COLUMN mv_org_summary.yyyymm IS '정산년월 (YYYYMM)';
COMMENT ON COLUMN mv_org_summary.agent_count IS '해당 조직의 월 확정/지급 설계사 수';
COMMENT ON COLUMN mv_org_summary.total_gross IS '조직 소속 설계사 총 수수료 합계 (세전)';
COMMENT ON COLUMN mv_org_summary.total_net IS '조직 소속 설계사 총 순지급액 합계';
CREATE UNIQUE INDEX idx_mv_org_summary_pk ON mv_org_summary(org_id, yyyymm);
CREATE INDEX idx_mv_org_summary_month ON mv_org_summary(yyyymm);
-- ============================================================
-- 3. mv_retention — 계약 유지율 (13M / 25M)
-- 단위: contract_month (계약 기준월) + carrier_id
-- 유지율 = 기준월 이후 N개월 시점에 유효한 계약 비율
-- ============================================================
CREATE MATERIALIZED VIEW mv_retention AS
WITH base AS (
-- 계약 기준월별 원수 계약 집계
SELECT
TO_CHAR(c.contract_date, 'YYYYMM') AS contract_month,
p.company_id AS carrier_id,
COUNT(c.contract_id) AS total_contracts
FROM contract c
JOIN product p ON p.product_id = c.product_id
WHERE c.status != 'TERMINATE'
GROUP BY TO_CHAR(c.contract_date, 'YYYYMM'), p.company_id
),
m13 AS (
-- 13개월 시점 유지 계약 (lapse_month > 12 또는 아직 실효 없음)
SELECT
TO_CHAR(c.contract_date, 'YYYYMM') AS contract_month,
p.company_id AS carrier_id,
COUNT(c.contract_id) AS retained_13m
FROM contract c
JOIN product p ON p.product_id = c.product_id
WHERE c.status != 'TERMINATE'
AND (
-- 실효 데이터 없거나 실효 경과월 > 12
NOT EXISTS (
SELECT 1 FROM chargeback cb
WHERE cb.contract_id = c.contract_id
AND cb.lapse_month IS NOT NULL
AND cb.lapse_month <= 12
)
)
GROUP BY TO_CHAR(c.contract_date, 'YYYYMM'), p.company_id
),
m25 AS (
-- 25개월 시점 유지 계약 (lapse_month > 24 또는 실효 없음)
SELECT
TO_CHAR(c.contract_date, 'YYYYMM') AS contract_month,
p.company_id AS carrier_id,
COUNT(c.contract_id) AS retained_25m
FROM contract c
JOIN product p ON p.product_id = c.product_id
WHERE c.status != 'TERMINATE'
AND (
NOT EXISTS (
SELECT 1 FROM chargeback cb
WHERE cb.contract_id = c.contract_id
AND cb.lapse_month IS NOT NULL
AND cb.lapse_month <= 24
)
)
GROUP BY TO_CHAR(c.contract_date, 'YYYYMM'), p.company_id
)
SELECT
b.contract_month,
b.carrier_id,
ic.company_name AS carrier_name,
b.total_contracts,
COALESCE(m13.retained_13m, 0) AS retained_13m,
COALESCE(m25.retained_25m, 0) AS retained_25m,
-- 13개월 유지율 %
CASE WHEN b.total_contracts > 0
THEN ROUND(COALESCE(m13.retained_13m, 0) * 100.0 / b.total_contracts, 2)
ELSE 0
END AS retention_rate_13m,
-- 25개월 유지율 %
CASE WHEN b.total_contracts > 0
THEN ROUND(COALESCE(m25.retained_25m, 0) * 100.0 / b.total_contracts, 2)
ELSE 0
END AS retention_rate_25m,
NOW() AS refreshed_at
FROM base b
JOIN insurance_company ic ON ic.company_id = b.carrier_id
LEFT JOIN m13 ON m13.contract_month = b.contract_month AND m13.carrier_id = b.carrier_id
LEFT JOIN m25 ON m25.contract_month = b.contract_month AND m25.carrier_id = b.carrier_id
WITH DATA;
COMMENT ON MATERIALIZED VIEW mv_retention IS '계약 유지율 KPI (13M/25M) — REFRESH CONCURRENTLY 야간 배치';
COMMENT ON COLUMN mv_retention.contract_month IS '계약 기준월 (YYYYMM)';
COMMENT ON COLUMN mv_retention.carrier_id IS '보험사 ID';
COMMENT ON COLUMN mv_retention.total_contracts IS '기준월 전체 유효 계약 수';
COMMENT ON COLUMN mv_retention.retained_13m IS '13개월 시점 유지 계약 수';
COMMENT ON COLUMN mv_retention.retained_25m IS '25개월 시점 유지 계약 수';
COMMENT ON COLUMN mv_retention.retention_rate_13m IS '13개월 유지율 (%)';
COMMENT ON COLUMN mv_retention.retention_rate_25m IS '25개월 유지율 (%)';
CREATE UNIQUE INDEX idx_mv_retention_pk ON mv_retention(contract_month, carrier_id);
CREATE INDEX idx_mv_retention_month ON mv_retention(contract_month);
-- ============================================================
-- 4. mv_cumulative — 설계사/조직별 누계
-- 단위: agent_id 또는 org_id (두 레벨 모두 포함)
-- ============================================================
CREATE MATERIALIZED VIEW mv_cumulative AS
-- 설계사 레벨 누계
SELECT
'AGENT' AS level_type,
a.agent_id AS entity_id,
a.agent_name AS entity_name,
a.org_id AS org_id,
o.org_name AS org_name,
MIN(sm.settle_month) AS first_settle_month,
MAX(sm.settle_month) AS last_settle_month,
COUNT(sm.settle_id) AS settle_month_count,
COALESCE(SUM(sm.gross_amount), 0) AS cum_gross,
COALESCE(SUM(sm.net_amount), 0) AS cum_net,
COALESCE(SUM(sm.chargeback_total), 0) AS cum_chargeback,
COALESCE(SUM(sm.override_total), 0) AS cum_override,
-- 시책 누계
COALESCE((
SELECT SUM(ip.incentive_amount)
FROM incentive_payment ip
WHERE ip.agent_id = a.agent_id
AND ip.status = 'PAID'
), 0) AS cum_incentive,
NOW() AS refreshed_at
FROM settle_master sm
JOIN agent a ON a.agent_id = sm.agent_id
JOIN organization o ON o.org_id = a.org_id
WHERE sm.status IN ('CONFIRMED', 'PAID')
GROUP BY a.agent_id, a.agent_name, a.org_id, o.org_name
UNION ALL
-- 조직 레벨 누계 (소속 설계사 합산)
SELECT
'ORG' AS level_type,
o.org_id AS entity_id,
o.org_name AS entity_name,
o.org_id AS org_id,
o.org_name AS org_name,
MIN(sm.settle_month) AS first_settle_month,
MAX(sm.settle_month) AS last_settle_month,
COUNT(DISTINCT sm.settle_month) AS settle_month_count,
COALESCE(SUM(sm.gross_amount), 0) AS cum_gross,
COALESCE(SUM(sm.net_amount), 0) AS cum_net,
COALESCE(SUM(sm.chargeback_total), 0) AS cum_chargeback,
COALESCE(SUM(sm.override_total), 0) AS cum_override,
COALESCE((
SELECT SUM(ip.incentive_amount)
FROM incentive_payment ip
JOIN agent ia ON ia.agent_id = ip.agent_id
WHERE ia.org_id = o.org_id
AND ip.status = 'PAID'
), 0) AS cum_incentive,
NOW() AS refreshed_at
FROM settle_master sm
JOIN agent a ON a.agent_id = sm.agent_id
JOIN organization o ON o.org_id = a.org_id
WHERE sm.status IN ('CONFIRMED', 'PAID')
GROUP BY o.org_id, o.org_name
WITH DATA;
COMMENT ON MATERIALIZED VIEW mv_cumulative IS '설계사/조직 누계 수수료·환수 KPI — REFRESH CONCURRENTLY 야간 배치';
COMMENT ON COLUMN mv_cumulative.level_type IS 'AGENT=설계사 레벨 / ORG=조직 레벨';
COMMENT ON COLUMN mv_cumulative.entity_id IS 'level_type=AGENT이면 agent_id, ORG이면 org_id';
COMMENT ON COLUMN mv_cumulative.cum_gross IS '누적 총 수수료 (세전)';
COMMENT ON COLUMN mv_cumulative.cum_net IS '누적 순지급액 (세후)';
COMMENT ON COLUMN mv_cumulative.cum_chargeback IS '누적 환수 합계';
COMMENT ON COLUMN mv_cumulative.cum_incentive IS '누적 시책 지급액';
COMMENT ON COLUMN mv_cumulative.refreshed_at IS 'MATERIALIZED VIEW 마지막 갱신 시각';
-- CONCURRENTLY 를 위한 UNIQUE INDEX: level_type + entity_id 조합
CREATE UNIQUE INDEX idx_mv_cumulative_pk ON mv_cumulative(level_type, entity_id);
CREATE INDEX idx_mv_cumulative_org ON mv_cumulative(org_id, level_type);
@@ -0,0 +1,53 @@
-- V38: 리포트 메뉴 하위 항목 추가
-- GRP_REPORT / REPORT 그룹 모두 처리 (V12 = REPORT, V18 = GRP_REPORT)
-- 1. REPORT 그룹에 하위 PAGE 삽입 (menu_code=REPORT 기준)
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
('REPORT_PERFORMANCE', '실적 리포트', '/report/performance', 'report/PerformanceReport', 10),
('REPORT_ORG', '조직별 리포트', '/report/org', 'report/OrgReport', 20),
('REPORT_CHARGEBACK', '환수위험 리포트', '/report/chargeback-risk', 'report/ChargebackRiskReport', 30)
) AS x(code, name, path, cmpt, sort)
ON p.menu_code = 'REPORT'
WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code);
-- 2. GRP_REPORT 그룹에도 동일 PAGE 삽입 (V18 기준 경로; menu_code=GRP_REPORT)
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 || '_GRP', x.name, 'PAGE', x.path, x.cmpt, 2, x.sort
FROM menu p
JOIN (VALUES
('REPORT_PERFORMANCE', '실적 리포트', '/report/performance', 'report/PerformanceReport', 10),
('REPORT_ORG', '조직별 리포트', '/report/org', 'report/OrgReport', 20),
('REPORT_CHARGEBACK', '환수위험 리포트', '/report/chargeback-risk', 'report/ChargebackRiskReport', 30)
) AS x(code, name, path, cmpt, sort)
ON p.menu_code = 'GRP_REPORT'
WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code || '_GRP');
-- 3. 새 PAGE 메뉴에 권한 항목 추가
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),
('EXPORT', '엑셀', 60)
) AS p(code, name, sort)
WHERE m.menu_code IN (
'REPORT_PERFORMANCE', 'REPORT_ORG', 'REPORT_CHARGEBACK',
'REPORT_PERFORMANCE_GRP', 'REPORT_ORG_GRP', 'REPORT_CHARGEBACK_GRP'
)
ON CONFLICT (menu_id, perm_code) DO NOTHING;
-- 4. SUPER_ADMIN / ADMIN / MANAGER 에게 리포트 READ/EXPORT 권한 부여
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', 'MANAGER')
AND m.menu_code IN (
'REPORT_PERFORMANCE', 'REPORT_ORG', 'REPORT_CHARGEBACK',
'REPORT_PERFORMANCE_GRP', 'REPORT_ORG_GRP', 'REPORT_CHARGEBACK_GRP'
)
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
@@ -0,0 +1,58 @@
-- V39: KPI 대시보드 메뉴 4개 추가
-- 부모 그룹: GRP_KPI (신규) — REPORT 그룹과 동일 레벨
-- 화면: KPI_MONTHLY / KPI_ORG / KPI_RETENTION / KPI_CUMULATIVE
-- ============================================================
-- 1. KPI 부모 그룹 메뉴 삽입
-- ============================================================
INSERT INTO menu (parent_menu_id, menu_code, menu_name, menu_type, menu_path, component_path, menu_level, sort_order)
VALUES (NULL, 'GRP_KPI', 'KPI 대시보드', 'GROUP', '/kpi', NULL, 1, 80)
ON CONFLICT (menu_code) DO NOTHING;
-- ============================================================
-- 2. KPI 하위 PAGE 메뉴 4개 삽입
-- ============================================================
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
('KPI_MONTHLY', '월별 정산 요약', '/kpi/monthly', 'kpi/KpiMonthlySummary', 10),
('KPI_ORG', '조직별 요약', '/kpi/org', 'kpi/KpiOrgSummary', 20),
('KPI_RETENTION', '유지율 현황', '/kpi/retention', 'kpi/KpiRetention', 30),
('KPI_CUMULATIVE', '누계 현황', '/kpi/cumulative', 'kpi/KpiCumulative', 40)
) AS x(code, name, path, cmpt, sort)
ON p.menu_code = 'GRP_KPI'
WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code);
-- ============================================================
-- 3. 권한 항목 (READ + EXPORT) 등록
-- ============================================================
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),
('EXPORT', '엑셀', 60)
) AS p(code, name, sort)
WHERE m.menu_code IN (
'KPI_MONTHLY', 'KPI_ORG', 'KPI_RETENTION', 'KPI_CUMULATIVE'
)
ON CONFLICT (menu_id, perm_code) DO NOTHING;
-- ============================================================
-- 4. SUPER_ADMIN / ADMIN / MANAGER 에게 READ + EXPORT 부여
-- ============================================================
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', 'MANAGER')
AND m.menu_code IN (
'KPI_MONTHLY', 'KPI_ORG', 'KPI_RETENTION', 'KPI_CUMULATIVE'
)
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
@@ -0,0 +1,59 @@
-- V40: 수수료 종류 코드(COMMISSION_TYPE) + 통합 마스터(commission_master)
-- ============================================================
-- 1. 공통코드 그룹 — COMMISSION_TYPE
-- ============================================================
INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
('COMMISSION_TYPE', '수수료종류', '모집/유지/수금/오버라이드/갱신/부활/유지보수수당/시책/위촉 9종', 'Y', 360)
ON CONFLICT (group_code) DO NOTHING;
INSERT INTO common_code (group_code, code, code_name, code_desc, sort_order) VALUES
('COMMISSION_TYPE', 'RECRUIT', '모집수수료', '신계약 체결 시 발생하는 기본 모집수수료', 10),
('COMMISSION_TYPE', 'MAINTAIN', '유지수수료', '계약 유지 기간 중 발생하는 유지수수료', 20),
('COMMISSION_TYPE', 'COLLECT', '수금수수료', '보험료 수납 단위로 발생하는 수금수수료', 30),
('COMMISSION_TYPE', 'OVERRIDE', '오버라이드', '하위 설계사 수수료 기반 상위 직급 오버라이드', 40),
('COMMISSION_TYPE', 'RENEWAL', '갱신수수료', '자동차/실비 등 갱신 계약 시 발생하는 수수료', 50),
('COMMISSION_TYPE', 'REINSTATE', '부활수수료', '실효된 계약 부활 시 발생하는 수수료', 60),
('COMMISSION_TYPE', 'PERSIST', '유지보수수당', '13/25/37/49개월 유지율 충족 시 지급하는 수당', 70),
('COMMISSION_TYPE', 'INCENTIVE', '시책수수료', '본사 시책 프로그램에 따른 인센티브', 80),
('COMMISSION_TYPE', 'ONBOARD', '위촉수수료', '보험사 위촉계약 체결 시 지급하는 위촉수수료', 90)
ON CONFLICT (group_code, code) DO NOTHING;
-- ============================================================
-- 2. commission_master — 전 수수료 종류 통합 마스터
-- 모든 수수료 원장(recruit_ledger / maintain_ledger /
-- collection_commission_ledger / override_ledger / 등)에서
-- 산출된 결과를 단일 뷰로 집계하기 위한 허브 테이블.
-- settle_master 조인 시 agent_id + settle_month 로 연결.
-- ============================================================
CREATE TABLE commission_master (
master_id BIGSERIAL PRIMARY KEY,
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
commission_type VARCHAR(20) NOT NULL,
settle_month CHAR(6) NOT NULL,
source_table VARCHAR(50) NOT NULL, -- 원본 원장 테이블명 (recruit_ledger 등)
source_id BIGINT NOT NULL, -- 원본 원장 PK
gross_amount NUMERIC(15,2) NOT NULL DEFAULT 0, -- 세전 수수료
tax_amount NUMERIC(15,2) NOT NULL DEFAULT 0, -- 원천세
net_amount NUMERIC(15,2) NOT NULL DEFAULT 0, -- 세후 지급액
status VARCHAR(20) NOT NULL DEFAULT 'CALCULATED',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP,
UNIQUE (agent_id, commission_type, settle_month, source_id),
CHECK (commission_type IN ('RECRUIT','MAINTAIN','COLLECT','OVERRIDE','RENEWAL','REINSTATE','PERSIST','INCENTIVE','ONBOARD')),
CHECK (status IN ('CALCULATED','CONFIRMED','PAID','CANCELLED'))
);
COMMENT ON TABLE commission_master IS '수수료 통합 마스터 — 전 수수료 종류를 단일 허브에서 집계. settle_master 와 agent_id+settle_month 로 조인';
COMMENT ON COLUMN commission_master.commission_type IS 'COMMISSION_TYPE 그룹 코드 (RECRUIT/MAINTAIN/COLLECT/OVERRIDE/RENEWAL/REINSTATE/PERSIST/INCENTIVE/ONBOARD)';
COMMENT ON COLUMN commission_master.source_table IS '원본 원장 테이블명 (예: recruit_ledger)';
COMMENT ON COLUMN commission_master.source_id IS '원본 원장 PK';
COMMENT ON COLUMN commission_master.gross_amount IS '세전 수수료 금액';
COMMENT ON COLUMN commission_master.net_amount IS '세후 실지급 금액';
CREATE INDEX idx_cm_agent_month ON commission_master(agent_id, settle_month);
CREATE INDEX idx_cm_type_month ON commission_master(commission_type, settle_month);
CREATE INDEX idx_cm_source ON commission_master(source_table, source_id);
CREATE INDEX idx_cm_status ON commission_master(status, settle_month);
@@ -0,0 +1,66 @@
-- V41: 수금수수료 (collection_commission_rule + collection_commission_ledger)
-- 보험료 수납 단위(매월)로 발생하는 수금수수료. 납입주기별 차등 적용.
-- ============================================================
-- 1. collection_commission_rule — 납입주기별 수금수수료율 마스터
-- ============================================================
CREATE TABLE collection_commission_rule (
rule_id BIGSERIAL PRIMARY KEY,
product_id BIGINT NOT NULL REFERENCES product(product_id),
payment_cycle VARCHAR(20) NOT NULL,
commission_rate NUMERIC(7,4) NOT NULL,
effective_from DATE NOT NULL,
effective_to DATE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
UNIQUE (product_id, payment_cycle, effective_from),
CHECK (payment_cycle IN ('MONTHLY','QUARTERLY','SEMI','ANNUAL')),
CHECK (commission_rate >= 0 AND commission_rate <= 1)
);
COMMENT ON TABLE collection_commission_rule IS '수금수수료율 마스터 — 상품/납입주기별 수금수수료율 정의';
COMMENT ON COLUMN collection_commission_rule.product_id IS '적용 상품 FK';
COMMENT ON COLUMN collection_commission_rule.payment_cycle IS '납입주기 (MONTHLY=월납/QUARTERLY=분기납/SEMI=반기납/ANNUAL=연납)';
COMMENT ON COLUMN collection_commission_rule.commission_rate IS '수금수수료율 (소수, 예: 0.005 = 0.5%)';
COMMENT ON COLUMN collection_commission_rule.effective_from IS '적용 시작일';
COMMENT ON COLUMN collection_commission_rule.effective_to IS '적용 종료일 (NULL=현재 유효)';
CREATE INDEX idx_ccr_product ON collection_commission_rule(product_id, effective_from);
-- ============================================================
-- 2. collection_commission_ledger — 수금수수료 원장
-- 매월 수납 확인 후 발생. paid_at 기준으로 정산월 귀속.
-- ============================================================
CREATE TABLE collection_commission_ledger (
ledger_id BIGSERIAL PRIMARY KEY,
contract_id BIGINT NOT NULL REFERENCES contract(contract_id),
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
settle_month CHAR(6) NOT NULL,
premium_amount NUMERIC(15,2) NOT NULL,
commission_rate NUMERIC(7,4) NOT NULL,
commission_amount NUMERIC(15,2) NOT NULL,
payment_cycle VARCHAR(20) NOT NULL,
paid_at DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'CALCULATED',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP,
CHECK (payment_cycle IN ('MONTHLY','QUARTERLY','SEMI','ANNUAL')),
CHECK (status IN ('CALCULATED','CONFIRMED','PAID','CANCELLED'))
);
COMMENT ON TABLE collection_commission_ledger IS '수금수수료 원장 — 매월 보험료 수납 단위로 발생';
COMMENT ON COLUMN collection_commission_ledger.contract_id IS '대상 계약 FK';
COMMENT ON COLUMN collection_commission_ledger.agent_id IS '수금 담당 설계사 FK';
COMMENT ON COLUMN collection_commission_ledger.settle_month IS '정산 귀속월 (YYYYMM)';
COMMENT ON COLUMN collection_commission_ledger.premium_amount IS '수납 보험료';
COMMENT ON COLUMN collection_commission_ledger.commission_rate IS '적용 수금수수료율';
COMMENT ON COLUMN collection_commission_ledger.commission_amount IS '수금수수료 = premium_amount * commission_rate';
COMMENT ON COLUMN collection_commission_ledger.payment_cycle IS '납입주기';
COMMENT ON COLUMN collection_commission_ledger.paid_at IS '실 보험료 납입일';
CREATE INDEX idx_ccl_agent_month ON collection_commission_ledger(agent_id, settle_month);
CREATE INDEX idx_ccl_contract ON collection_commission_ledger(contract_id);
CREATE INDEX idx_ccl_status ON collection_commission_ledger(status, settle_month);
@@ -0,0 +1,122 @@
-- V42: 갱신수수료 + 부활수수료 (rule + ledger 각 2쌍)
-- 갱신: 자동차/실비/단기보험 갱신 계약 시 정률 또는 정액 수수료
-- 부활: 실효 후 부활 시 수수료 + 기존 환수액 일부 환원
-- ============================================================
-- 1. renewal_commission_rule — 갱신수수료율 마스터
-- ============================================================
CREATE TABLE renewal_commission_rule (
rule_id BIGSERIAL PRIMARY KEY,
product_id BIGINT NOT NULL REFERENCES product(product_id),
pay_type VARCHAR(20) NOT NULL DEFAULT 'RATE',
rate_value NUMERIC(7,4), -- RATE 방식: 갱신 보험료 대비 비율
fixed_amount NUMERIC(15,2), -- FIXED 방식: 건당 고정 금액
effective_from DATE NOT NULL,
effective_to DATE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
UNIQUE (product_id, effective_from),
CHECK (pay_type IN ('RATE','FIXED')),
CHECK (
(pay_type = 'RATE' AND rate_value IS NOT NULL) OR
(pay_type = 'FIXED' AND fixed_amount IS NOT NULL)
)
);
COMMENT ON TABLE renewal_commission_rule IS '갱신수수료율 마스터 — 자동차/실비/단기보험 갱신 대상 상품별 룰';
COMMENT ON COLUMN renewal_commission_rule.pay_type IS 'RATE=갱신보험료 대비 비율 / FIXED=건당 고정금액';
COMMENT ON COLUMN renewal_commission_rule.rate_value IS 'RATE 방식 비율 (소수, 예: 0.02 = 2%)';
COMMENT ON COLUMN renewal_commission_rule.fixed_amount IS 'FIXED 방식 건당 지급액';
CREATE INDEX idx_rcr_product ON renewal_commission_rule(product_id, effective_from);
-- ============================================================
-- 2. renewal_commission_ledger — 갱신수수료 원장
-- ============================================================
CREATE TABLE renewal_commission_ledger (
ledger_id BIGSERIAL PRIMARY KEY,
contract_id BIGINT NOT NULL REFERENCES contract(contract_id),
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
rule_id BIGINT REFERENCES renewal_commission_rule(rule_id),
settle_month CHAR(6) NOT NULL,
renewal_date DATE NOT NULL,
renewal_premium NUMERIC(15,2) NOT NULL,
commission_amount NUMERIC(15,2) NOT NULL,
pay_type VARCHAR(20) NOT NULL DEFAULT 'RATE',
status VARCHAR(20) NOT NULL DEFAULT 'CALCULATED',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP,
CHECK (pay_type IN ('RATE','FIXED')),
CHECK (status IN ('CALCULATED','CONFIRMED','PAID','CANCELLED'))
);
COMMENT ON TABLE renewal_commission_ledger IS '갱신수수료 원장 — 계약 갱신 1회당 발생';
COMMENT ON COLUMN renewal_commission_ledger.renewal_date IS '계약 갱신일';
COMMENT ON COLUMN renewal_commission_ledger.renewal_premium IS '갱신 보험료';
COMMENT ON COLUMN renewal_commission_ledger.commission_amount IS '갱신수수료 (rate*premium 또는 fixed_amount)';
CREATE INDEX idx_rcl_agent_month ON renewal_commission_ledger(agent_id, settle_month);
CREATE INDEX idx_rcl_contract ON renewal_commission_ledger(contract_id);
-- ============================================================
-- 3. reinstatement_commission_rule — 부활수수료율 마스터
-- ============================================================
CREATE TABLE reinstatement_commission_rule (
rule_id BIGSERIAL PRIMARY KEY,
product_id BIGINT NOT NULL REFERENCES product(product_id),
pay_type VARCHAR(20) NOT NULL DEFAULT 'RATE',
rate_value NUMERIC(7,4), -- RATE: 부활 보험료 대비 비율
fixed_amount NUMERIC(15,2), -- FIXED: 건당 고정 금액
chargeback_reversal_rate NUMERIC(7,4) NOT NULL DEFAULT 0,
-- 부활 시 기존 환수액 중 환원 비율 (예: 0.5 = 50% 환원)
effective_from DATE NOT NULL,
effective_to DATE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
UNIQUE (product_id, effective_from),
CHECK (pay_type IN ('RATE','FIXED')),
CHECK (chargeback_reversal_rate >= 0 AND chargeback_reversal_rate <= 1)
);
COMMENT ON TABLE reinstatement_commission_rule IS '부활수수료율 마스터 — 실효 후 부활 시 적용 룰';
COMMENT ON COLUMN reinstatement_commission_rule.chargeback_reversal_rate IS '기존 환수액 중 부활 시 환원 비율 (0~1)';
CREATE INDEX idx_rsr_product ON reinstatement_commission_rule(product_id, effective_from);
-- ============================================================
-- 4. reinstatement_commission_ledger — 부활수수료 원장
-- ============================================================
CREATE TABLE reinstatement_commission_ledger (
ledger_id BIGSERIAL PRIMARY KEY,
contract_id BIGINT NOT NULL REFERENCES contract(contract_id),
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
rule_id BIGINT REFERENCES reinstatement_commission_rule(rule_id),
settle_month CHAR(6) NOT NULL,
reinstatement_date DATE NOT NULL,
reinstatement_premium NUMERIC(15,2) NOT NULL,
commission_amount NUMERIC(15,2) NOT NULL,
chargeback_reversal NUMERIC(15,2) NOT NULL DEFAULT 0,
-- 기존 환수액 중 환원금 (부활 인센티브)
net_commission NUMERIC(15,2) NOT NULL,
-- 최종 수수료 = commission_amount + chargeback_reversal
pay_type VARCHAR(20) NOT NULL DEFAULT 'RATE',
status VARCHAR(20) NOT NULL DEFAULT 'CALCULATED',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP,
CHECK (pay_type IN ('RATE','FIXED')),
CHECK (status IN ('CALCULATED','CONFIRMED','PAID','CANCELLED'))
);
COMMENT ON TABLE reinstatement_commission_ledger IS '부활수수료 원장 — 실효 계약 부활 1건당 발생';
COMMENT ON COLUMN reinstatement_commission_ledger.reinstatement_date IS '계약 부활일';
COMMENT ON COLUMN reinstatement_commission_ledger.chargeback_reversal IS '부활로 인한 환수액 환원금';
COMMENT ON COLUMN reinstatement_commission_ledger.net_commission IS '최종 수수료 = 부활수수료 + 환수환원금';
CREATE INDEX idx_rsl_agent_month ON reinstatement_commission_ledger(agent_id, settle_month);
CREATE INDEX idx_rsl_contract ON reinstatement_commission_ledger(contract_id);
@@ -0,0 +1,74 @@
-- V43: 유지보수수당 (persistency_bonus_rule + persistency_bonus)
-- 13/25/37/49개월 유지율 충족 시 1회 지급하는 유지보수수당.
-- 등급별 정률(RATE) 또는 정액(FIXED) 지급.
-- ============================================================
-- 1. persistency_bonus_rule — 유지보수수당 룰 마스터
-- ============================================================
CREATE TABLE persistency_bonus_rule (
rule_id BIGSERIAL PRIMARY KEY,
grade_id BIGINT REFERENCES grade(grade_id),
-- NULL이면 전 직급 공통 적용
retention_month INT NOT NULL,
-- 유지 기준 개월수 (13/25/37/49)
retention_rate_threshold NUMERIC(5,4) NOT NULL,
-- 유지율 하한 기준 (예: 0.95 = 95% 이상)
bonus_type VARCHAR(10) NOT NULL,
bonus_value NUMERIC(15,4) NOT NULL,
-- RATE: 모집수수료 대비 비율 / FIXED: 지급 고정액
effective_from DATE NOT NULL,
effective_to DATE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
CHECK (retention_month IN (13, 25, 37, 49)),
CHECK (bonus_type IN ('RATE','FIXED')),
CHECK (retention_rate_threshold > 0 AND retention_rate_threshold <= 1)
);
COMMENT ON TABLE persistency_bonus_rule IS '유지보수수당 룰 마스터 — 직급/유지개월/유지율 임계치별 지급 기준';
COMMENT ON COLUMN persistency_bonus_rule.grade_id IS '적용 직급 FK (NULL=전 직급 공통)';
COMMENT ON COLUMN persistency_bonus_rule.retention_month IS '유지 기준 개월수 (13/25/37/49)';
COMMENT ON COLUMN persistency_bonus_rule.retention_rate_threshold IS '유지율 최소 기준 (0초과~1이하, 예: 0.95)';
COMMENT ON COLUMN persistency_bonus_rule.bonus_type IS 'RATE=모집수수료 대비 비율 / FIXED=건당 고정금액';
COMMENT ON COLUMN persistency_bonus_rule.bonus_value IS 'RATE 방식: 비율값 / FIXED 방식: 지급 금액';
CREATE INDEX idx_pbr_grade_month ON persistency_bonus_rule(grade_id, retention_month, effective_from);
-- ============================================================
-- 2. persistency_bonus — 유지보수수당 지급 이력
-- UNIQUE(agent_id, base_month, retention_month) — 동일 기준월·개월수 중복 방지
-- ============================================================
CREATE TABLE persistency_bonus (
bonus_id BIGSERIAL PRIMARY KEY,
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
rule_id BIGINT REFERENCES persistency_bonus_rule(rule_id),
base_month CHAR(6) NOT NULL,
-- 계약 체결 기준 정산월 (YYYYMM)
retention_month INT NOT NULL,
-- 평가 기준 개월수 (13/25/37/49)
settle_month CHAR(6) NOT NULL,
-- 실제 지급 정산월
retention_rate NUMERIC(5,4) NOT NULL,
-- 실제 유지율
bonus_amount NUMERIC(15,2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'CALCULATED',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP,
UNIQUE (agent_id, base_month, retention_month),
CHECK (retention_month IN (13, 25, 37, 49)),
CHECK (status IN ('CALCULATED','CONFIRMED','PAID','CANCELLED'))
);
COMMENT ON TABLE persistency_bonus IS '유지보수수당 지급 이력 — 유지율 충족 확인 후 1회 지급';
COMMENT ON COLUMN persistency_bonus.base_month IS '계약 체결 기준 정산월 (YYYYMM) — 유지율 계산 기산점';
COMMENT ON COLUMN persistency_bonus.retention_month IS '평가 기준 개월수 (13/25/37/49)';
COMMENT ON COLUMN persistency_bonus.settle_month IS '실제 지급 귀속 정산월 (YYYYMM)';
COMMENT ON COLUMN persistency_bonus.retention_rate IS '평가 시점 실제 유지율';
COMMENT ON COLUMN persistency_bonus.bonus_amount IS '지급 유지보수수당 금액';
CREATE INDEX idx_pb_agent_settle ON persistency_bonus(agent_id, settle_month);
CREATE INDEX idx_pb_base_month ON persistency_bonus(base_month, retention_month);
CREATE INDEX idx_pb_status ON persistency_bonus(status, settle_month);
@@ -0,0 +1,77 @@
-- V44: 오버라이드 원장 보강 (override_ledger + override_summary)
-- 기존 V3의 override_rule + V6의 override_settle은 정산 결과 집계용.
-- 이 파일은 calcOverride 결과를 수수료 종류 단위로 추적하는 원장과
-- agent+settle_month 단위 합계 테이블을 추가한다.
-- ============================================================
-- 1. override_ledger — 오버라이드 수수료 상세 원장
-- source_commission_type 단위로 어떤 수수료가 오버라이드 원천인지 추적.
-- ============================================================
CREATE TABLE override_ledger (
ledger_id BIGSERIAL PRIMARY KEY,
from_agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
-- 원천 설계사 (수수료를 발생시킨 하위 설계사)
to_agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
-- 오버라이드 수령 설계사 (상위 직급)
org_id BIGINT REFERENCES organization(org_id),
settle_month CHAR(6) NOT NULL,
source_commission_type VARCHAR(20) NOT NULL,
-- 원천 수수료 종류 (COMMISSION_TYPE 코드)
source_ledger_table VARCHAR(50),
-- 원천 원장 테이블명 (recruit_ledger 등)
source_ledger_id BIGINT,
-- 원천 원장 PK
source_amount NUMERIC(15,2) NOT NULL,
-- 오버라이드 계산 기준 수수료
override_rate NUMERIC(7,4) NOT NULL,
override_amount NUMERIC(15,2) NOT NULL,
-- 최종 오버라이드 금액
status VARCHAR(20) NOT NULL DEFAULT 'CALCULATED',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP,
CHECK (source_commission_type IN ('RECRUIT','MAINTAIN','COLLECT','RENEWAL','REINSTATE','PERSIST','INCENTIVE')),
CHECK (status IN ('CALCULATED','CONFIRMED','PAID','CANCELLED'))
);
COMMENT ON TABLE override_ledger IS '오버라이드 원장 — calcOverride 결과를 수수료 종류 단위로 상세 추적';
COMMENT ON COLUMN override_ledger.from_agent_id IS '원천 설계사 (오버라이드 기준 수수료 발생 설계사)';
COMMENT ON COLUMN override_ledger.to_agent_id IS '오버라이드 수령 설계사 (상위 직급 관리자)';
COMMENT ON COLUMN override_ledger.source_commission_type IS '원천 수수료 종류 (COMMISSION_TYPE 코드)';
COMMENT ON COLUMN override_ledger.source_ledger_table IS '원천 원장 테이블명';
COMMENT ON COLUMN override_ledger.source_amount IS '오버라이드 계산 기준이 된 원천 수수료';
COMMENT ON COLUMN override_ledger.override_rate IS '적용 오버라이드율';
COMMENT ON COLUMN override_ledger.override_amount IS '최종 오버라이드 금액 = source_amount * override_rate';
CREATE INDEX idx_ol_to_agent_month ON override_ledger(to_agent_id, settle_month);
CREATE INDEX idx_ol_from_agent ON override_ledger(from_agent_id, settle_month);
CREATE INDEX idx_ol_org_month ON override_ledger(org_id, settle_month);
CREATE INDEX idx_ol_status ON override_ledger(status, settle_month);
-- ============================================================
-- 2. override_summary — agent+settle_month 단위 오버라이드 합계
-- Materialized View 후보이나 갱신 제어를 위해 일반 테이블로.
-- 배치 완료 후 UPSERT 방식으로 집계.
-- ============================================================
CREATE TABLE override_summary (
summary_id BIGSERIAL PRIMARY KEY,
to_agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
settle_month CHAR(6) NOT NULL,
total_source_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
total_override_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
ledger_count INT NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'CALCULATED',
calc_date TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP,
UNIQUE (to_agent_id, settle_month),
CHECK (status IN ('CALCULATED','CONFIRMED','PAID'))
);
COMMENT ON TABLE override_summary IS '오버라이드 합계 — agent+settle_month 단위 배치 집계 결과';
COMMENT ON COLUMN override_summary.to_agent_id IS '오버라이드 수령 설계사 (상위 직급)';
COMMENT ON COLUMN override_summary.total_source_amount IS '오버라이드 계산 기준 원천 수수료 합계';
COMMENT ON COLUMN override_summary.total_override_amount IS '최종 오버라이드 지급액 합계';
COMMENT ON COLUMN override_summary.ledger_count IS '오버라이드 원장 건수';
CREATE INDEX idx_os_month ON override_summary(settle_month, status);
@@ -0,0 +1,38 @@
-- V45: 위촉계약 (agent_contract)
-- 보험사-설계사 간 위촉계약서. 보험사별 활동 자격 + 위촉수수료 관리.
CREATE TABLE agent_contract (
contract_id BIGSERIAL PRIMARY KEY,
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
carrier_id BIGINT NOT NULL REFERENCES insurance_company(company_id),
contract_no VARCHAR(50) NOT NULL,
signed_date DATE NOT NULL,
effective_from DATE NOT NULL,
effective_to DATE,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
onboarding_bonus_amount NUMERIC(15,2) NOT NULL DEFAULT 0,
-- 위촉수수료 (입사 인센티브 성격)
terminated_at TIMESTAMP,
terminated_reason VARCHAR(500),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
updated_by BIGINT,
UNIQUE (agent_id, carrier_id, contract_no),
CHECK (status IN ('ACTIVE','TERMINATED','SUSPENDED'))
);
COMMENT ON TABLE agent_contract IS '위촉계약 — 보험사-설계사 위촉계약서 + 효력 기간 + 위촉수수료';
COMMENT ON COLUMN agent_contract.agent_id IS '설계사 FK';
COMMENT ON COLUMN agent_contract.carrier_id IS '보험사 FK (insurance_company)';
COMMENT ON COLUMN agent_contract.contract_no IS '위촉계약 번호';
COMMENT ON COLUMN agent_contract.signed_date IS '위촉계약 서명일';
COMMENT ON COLUMN agent_contract.effective_from IS '위촉 효력 시작일';
COMMENT ON COLUMN agent_contract.effective_to IS '위촉 효력 종료일 (NULL=현재 유효)';
COMMENT ON COLUMN agent_contract.status IS 'ACTIVE=유효/TERMINATED=해촉/SUSPENDED=정지';
COMMENT ON COLUMN agent_contract.onboarding_bonus_amount IS '위촉수수료 (위촉 시 지급하는 인센티브)';
COMMENT ON COLUMN agent_contract.terminated_reason IS '해촉 사유';
CREATE INDEX idx_ac_agent ON agent_contract(agent_id, status);
CREATE INDEX idx_ac_carrier ON agent_contract(carrier_id, status);
CREATE INDEX idx_ac_effective ON agent_contract(effective_from, effective_to);
@@ -0,0 +1,35 @@
-- V46: 설계사 자격 관리 (agent_license)
-- 생보/손보/변액 자격증 만료·갱신 이력. 만료 임박 알림용 인덱스 포함.
CREATE TABLE agent_license (
license_id BIGSERIAL PRIMARY KEY,
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
license_type VARCHAR(20) NOT NULL,
license_no VARCHAR(50) NOT NULL,
issued_date DATE NOT NULL,
expires_date DATE NOT NULL,
renewed_from BIGINT REFERENCES agent_license(license_id),
-- 갱신 전 자격증 참조
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
UNIQUE (agent_id, license_type, license_no),
CHECK (license_type IN ('LIFE','NONLIFE','VARIABLE')),
CHECK (status IN ('ACTIVE','EXPIRED','SUSPENDED')),
CHECK (expires_date > issued_date)
);
COMMENT ON TABLE agent_license IS '설계사 자격 관리 — 생보/손보/변액 자격증 만료·갱신 이력';
COMMENT ON COLUMN agent_license.agent_id IS '설계사 FK';
COMMENT ON COLUMN agent_license.license_type IS '자격 종류 (LIFE=생명보험/NONLIFE=손해보험/VARIABLE=변액보험)';
COMMENT ON COLUMN agent_license.license_no IS '자격증 번호';
COMMENT ON COLUMN agent_license.issued_date IS '자격증 발급일';
COMMENT ON COLUMN agent_license.expires_date IS '자격증 만료일 (갱신 주기 5년 기준)';
COMMENT ON COLUMN agent_license.renewed_from IS '갱신 전 자격증 ID (갱신 이력 추적용)';
COMMENT ON COLUMN agent_license.status IS 'ACTIVE=유효/EXPIRED=만료/SUSPENDED=정지';
-- 만료 임박 알림용 인덱스 (expires_date 단독 + 복합)
CREATE INDEX idx_al_agent ON agent_license(agent_id, status);
CREATE INDEX idx_al_expires ON agent_license(expires_date, status);
CREATE INDEX idx_al_type_expires ON agent_license(license_type, expires_date);
@@ -0,0 +1,60 @@
-- V47: 보수교육 (agent_training + training_requirement)
-- 보험업법 시행령 제17조 기준 의무 교육 이수 시간 추적.
-- 연간 12시간 의무 기준은 training_requirement에 외부화.
-- ============================================================
-- 1. training_requirement — 연도별 의무 교육 시간 마스터
-- ============================================================
CREATE TABLE training_requirement (
req_id SERIAL PRIMARY KEY,
target_year INT NOT NULL UNIQUE,
required_hours INT NOT NULL DEFAULT 12,
-- 기본값 12시간/년 (보험업법 시행령 제17조)
description VARCHAR(300),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP
);
COMMENT ON TABLE training_requirement IS '보수교육 의무 시간 마스터 — 연도별 의무 교육 시간 기준';
COMMENT ON COLUMN training_requirement.target_year IS '적용 연도';
COMMENT ON COLUMN training_requirement.required_hours IS '연간 의무 교육 시간 (기본 12시간)';
-- 현재 연도 + 차기 연도 기본값 삽입
INSERT INTO training_requirement (target_year, required_hours, description) VALUES
(2025, 12, '보험업법 시행령 제17조 기준 연간 의무 보수교육'),
(2026, 12, '보험업법 시행령 제17조 기준 연간 의무 보수교육')
ON CONFLICT (target_year) DO NOTHING;
-- ============================================================
-- 2. agent_training — 설계사 보수교육 이수 이력
-- ============================================================
CREATE TABLE agent_training (
training_id BIGSERIAL PRIMARY KEY,
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
course_name VARCHAR(200) NOT NULL,
course_type VARCHAR(20) NOT NULL,
training_date DATE NOT NULL,
hours NUMERIC(5,1) NOT NULL,
certificate_no VARCHAR(100),
instructor VARCHAR(100),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
CHECK (course_type IN ('REGULAR','COMPLIANCE','PRODUCT')),
CHECK (hours > 0)
);
COMMENT ON TABLE agent_training IS '보수교육 이수 이력 — 설계사 연간 의무 교육 이수 시간 추적';
COMMENT ON COLUMN agent_training.agent_id IS '설계사 FK';
COMMENT ON COLUMN agent_training.course_name IS '교육 과목명';
COMMENT ON COLUMN agent_training.course_type IS '교육 유형 (REGULAR=정기보수/COMPLIANCE=컴플라이언스/PRODUCT=상품교육)';
COMMENT ON COLUMN agent_training.training_date IS '교육 이수일';
COMMENT ON COLUMN agent_training.hours IS '이수 시간';
COMMENT ON COLUMN agent_training.certificate_no IS '이수증 번호';
COMMENT ON COLUMN agent_training.instructor IS '강사명 또는 교육기관';
CREATE INDEX idx_at_agent_date ON agent_training(agent_id, training_date);
CREATE INDEX idx_at_course_type ON agent_training(course_type, training_date);
@@ -0,0 +1,40 @@
-- V48: 계약 변경 (contract_endorsement)
-- 보험료/수익자/피보험자/주소 변경 등 계약 변경 이력.
-- requires_commission_recalc=true 이면 배치에서 수수료 재계산 트리거.
CREATE TABLE contract_endorsement (
endorsement_id BIGSERIAL PRIMARY KEY,
contract_id BIGINT NOT NULL REFERENCES contract(contract_id),
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
endorsement_type VARCHAR(30) NOT NULL,
changed_at TIMESTAMP NOT NULL DEFAULT NOW(),
old_value TEXT,
-- 변경 전 값 (JSON 또는 텍스트)
new_value TEXT,
-- 변경 후 값 (JSON 또는 텍스트)
reason VARCHAR(500),
requires_commission_recalc BOOLEAN NOT NULL DEFAULT FALSE,
-- TRUE이면 배치 수수료 재계산 대상
processed BOOLEAN NOT NULL DEFAULT FALSE,
-- 재계산 처리 완료 여부
processed_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
CHECK (endorsement_type IN ('PREMIUM_CHANGE','BENEFICIARY','INSURED','ADDRESS','OTHER'))
);
COMMENT ON TABLE contract_endorsement IS '계약 변경 이력 — 변경 유형별 old/new 값 보관 + 수수료 재계산 트리거';
COMMENT ON COLUMN contract_endorsement.contract_id IS '대상 계약 FK';
COMMENT ON COLUMN contract_endorsement.agent_id IS '담당 설계사 FK';
COMMENT ON COLUMN contract_endorsement.endorsement_type IS '변경 유형 (PREMIUM_CHANGE=보험료변경/BENEFICIARY=수익자/INSURED=피보험자/ADDRESS=주소/OTHER=기타)';
COMMENT ON COLUMN contract_endorsement.old_value IS '변경 전 값';
COMMENT ON COLUMN contract_endorsement.new_value IS '변경 후 값';
COMMENT ON COLUMN contract_endorsement.requires_commission_recalc IS '수수료 재계산 필요 여부 (TRUE이면 배치 재처리 대상)';
COMMENT ON COLUMN contract_endorsement.processed IS '재계산 처리 완료 여부';
CREATE INDEX idx_ce_contract ON contract_endorsement(contract_id, changed_at);
CREATE INDEX idx_ce_agent ON contract_endorsement(agent_id, changed_at);
CREATE INDEX idx_ce_recalc ON contract_endorsement(requires_commission_recalc, processed)
WHERE requires_commission_recalc = TRUE AND processed = FALSE;
-- 재계산 미처리 건 빠른 조회용 부분 인덱스
@@ -0,0 +1,49 @@
-- V49: 민원 관리 (complaint)
-- 고객 민원 접수 → 설계사 책임 추적 → 처리 상태 관리 → 환수 연동.
-- 13M 이내 민원 = 환수 100% 트리거 가정 (chargeback_triggered).
CREATE TABLE complaint (
complaint_id BIGSERIAL PRIMARY KEY,
contract_id BIGINT REFERENCES contract(contract_id),
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
customer_name VARCHAR(100) NOT NULL,
complaint_date DATE NOT NULL,
channel VARCHAR(20) NOT NULL,
complaint_type VARCHAR(30) NOT NULL,
severity VARCHAR(10) NOT NULL DEFAULT 'MID',
status VARCHAR(20) NOT NULL DEFAULT 'OPEN',
resolution TEXT,
-- 처리 결과 기술
chargeback_triggered BOOLEAN NOT NULL DEFAULT FALSE,
-- TRUE이면 환수 자동 생성 대상
settled_at TIMESTAMP,
-- 민원 종결 시각
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
updated_by BIGINT,
CHECK (channel IN ('PHONE','EMAIL','VISIT','ONLINE','FAX')),
CHECK (complaint_type IN ('MIS_SELLING','DELAY','UNFAIR','CANCELLATION','ETC')),
CHECK (severity IN ('LOW','MID','HIGH')),
CHECK (status IN ('OPEN','IN_PROGRESS','CLOSED','WITHDRAWN'))
);
COMMENT ON TABLE complaint IS '민원 관리 — 고객 민원 접수·처리·환수 연동';
COMMENT ON COLUMN complaint.contract_id IS '민원 대상 계약 FK (계약 특정 불가한 경우 NULL 허용)';
COMMENT ON COLUMN complaint.agent_id IS '민원 책임 설계사 FK';
COMMENT ON COLUMN complaint.customer_name IS '민원인 성명';
COMMENT ON COLUMN complaint.complaint_date IS '민원 접수일';
COMMENT ON COLUMN complaint.channel IS '접수 채널 (PHONE/EMAIL/VISIT/ONLINE/FAX)';
COMMENT ON COLUMN complaint.complaint_type IS '민원 유형 (MIS_SELLING=불완전판매/DELAY=처리지연/UNFAIR=불공정/CANCELLATION=해지관련/ETC=기타)';
COMMENT ON COLUMN complaint.severity IS '심각도 (LOW=낮음/MID=보통/HIGH=높음)';
COMMENT ON COLUMN complaint.status IS '처리 상태 (OPEN=접수/IN_PROGRESS=처리중/CLOSED=완료/WITHDRAWN=취하)';
COMMENT ON COLUMN complaint.resolution IS '처리 결과 상세 기술';
COMMENT ON COLUMN complaint.chargeback_triggered IS 'TRUE=환수 트리거 발생 (13M 이내 민원 등)';
COMMENT ON COLUMN complaint.settled_at IS '민원 종결 시각';
CREATE INDEX idx_comp_agent_date ON complaint(agent_id, complaint_date);
CREATE INDEX idx_comp_status ON complaint(status, complaint_date);
CREATE INDEX idx_comp_contract ON complaint(contract_id);
CREATE INDEX idx_comp_chargeback ON complaint(chargeback_triggered, status)
WHERE chargeback_triggered = TRUE;
-- 환수 트리거 미처리 건 조회용 부분 인덱스
@@ -0,0 +1,132 @@
-- V50: 결재 워크플로우 (approval_line + approval_line_step + approval_request + approval_history)
-- 정산/이의신청/시책/출금 결재선 마스터 + 다단 결재 요청·처리 이력.
-- 최대 3단계 결재 (시스템 가정값, approval_line.max_step 으로 외부화).
-- ============================================================
-- 1. approval_line — 결재선 마스터
-- ============================================================
CREATE TABLE approval_line (
line_id BIGSERIAL PRIMARY KEY,
line_code VARCHAR(50) NOT NULL UNIQUE,
line_name VARCHAR(100) NOT NULL,
max_step INT NOT NULL DEFAULT 3,
target_type VARCHAR(20) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
CHECK (target_type IN ('SETTLE','DISPUTE','INCENTIVE','WITHDRAW','COMPLAINT')),
CHECK (max_step BETWEEN 1 AND 5)
);
COMMENT ON TABLE approval_line IS '결재선 마스터 — 업무 유형별 결재 단계 정의';
COMMENT ON COLUMN approval_line.line_code IS '결재선 코드 (예: SETTLE_DEFAULT)';
COMMENT ON COLUMN approval_line.max_step IS '최대 결재 단계 수 (1~5, 기본 3)';
COMMENT ON COLUMN approval_line.target_type IS '적용 업무 유형 (SETTLE=정산/DISPUTE=이의/INCENTIVE=시책/WITHDRAW=출금/COMPLAINT=민원)';
-- 기본 결재선 초기 데이터
INSERT INTO approval_line (line_code, line_name, max_step, target_type, is_active) VALUES
('SETTLE_DEFAULT', '정산 기본 결재선', 2, 'SETTLE', TRUE),
('DISPUTE_DEFAULT', '이의신청 결재선', 2, 'DISPUTE', TRUE),
('INCENTIVE_DEFAULT','시책 결재선', 2, 'INCENTIVE', TRUE),
('WITHDRAW_DEFAULT', '출금신청 결재선', 3, 'WITHDRAW', TRUE),
('COMPLAINT_DEFAULT','민원처리 결재선', 2, 'COMPLAINT', TRUE)
ON CONFLICT (line_code) DO NOTHING;
-- ============================================================
-- 2. approval_line_step — 결재선 단계별 역할 정의
-- ============================================================
CREATE TABLE approval_line_step (
step_id BIGSERIAL PRIMARY KEY,
line_id BIGINT NOT NULL REFERENCES approval_line(line_id),
step_no INT NOT NULL,
role_code VARCHAR(50) NOT NULL,
-- 결재 역할 코드 (role 테이블 참조, 느슨한 참조)
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (line_id, step_no),
CHECK (step_no >= 1)
);
COMMENT ON TABLE approval_line_step IS '결재선 단계 — 결재선별 단계 순서와 결재 역할 매핑';
COMMENT ON COLUMN approval_line_step.step_no IS '결재 단계 번호 (1부터 순서대로)';
COMMENT ON COLUMN approval_line_step.role_code IS '해당 단계 결재 역할 코드';
-- 기본 결재선 단계 삽입
INSERT INTO approval_line_step (line_id, step_no, role_code)
SELECT l.line_id, s.step_no, s.role_code
FROM approval_line l
JOIN (VALUES
('SETTLE_DEFAULT', 1, 'MANAGER'),
('SETTLE_DEFAULT', 2, 'ADMIN'),
('DISPUTE_DEFAULT', 1, 'MANAGER'),
('DISPUTE_DEFAULT', 2, 'ADMIN'),
('INCENTIVE_DEFAULT', 1, 'MANAGER'),
('INCENTIVE_DEFAULT', 2, 'ADMIN'),
('WITHDRAW_DEFAULT', 1, 'MANAGER'),
('WITHDRAW_DEFAULT', 2, 'ADMIN'),
('WITHDRAW_DEFAULT', 3, 'SUPER_ADMIN'),
('COMPLAINT_DEFAULT', 1, 'MANAGER'),
('COMPLAINT_DEFAULT', 2, 'ADMIN')
) AS s(line_code, step_no, role_code) ON l.line_code = s.line_code
ON CONFLICT (line_id, step_no) DO NOTHING;
-- ============================================================
-- 3. approval_request — 결재 요청
-- ============================================================
CREATE TABLE approval_request (
request_id BIGSERIAL PRIMARY KEY,
target_type VARCHAR(20) NOT NULL,
target_id BIGINT NOT NULL,
-- 결재 대상 PK (settle_id / dispute_id 등)
line_id BIGINT NOT NULL REFERENCES approval_line(line_id),
requested_by BIGINT NOT NULL,
-- 요청자 user_id (users 테이블 참조, 느슨한 참조)
requested_at TIMESTAMP NOT NULL DEFAULT NOW(),
current_step INT NOT NULL DEFAULT 1,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
completed_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP,
CHECK (target_type IN ('SETTLE','DISPUTE','INCENTIVE','WITHDRAW','COMPLAINT')),
CHECK (status IN ('PENDING','APPROVED','REJECTED','CANCEL'))
);
COMMENT ON TABLE approval_request IS '결재 요청 — 업무별 결재 요청 생성 및 진행 상태 관리';
COMMENT ON COLUMN approval_request.target_type IS '결재 대상 업무 유형';
COMMENT ON COLUMN approval_request.target_id IS '결재 대상 레코드 PK';
COMMENT ON COLUMN approval_request.current_step IS '현재 결재 단계 번호';
COMMENT ON COLUMN approval_request.status IS 'PENDING=진행중/APPROVED=최종승인/REJECTED=반려/CANCEL=취소';
CREATE INDEX idx_ar_target ON approval_request(target_type, target_id);
CREATE INDEX idx_ar_status ON approval_request(status, requested_at);
CREATE INDEX idx_ar_requester ON approval_request(requested_by, status);
-- ============================================================
-- 4. approval_history — 결재 처리 이력
-- ============================================================
CREATE TABLE approval_history (
history_id BIGSERIAL PRIMARY KEY,
request_id BIGINT NOT NULL REFERENCES approval_request(request_id),
step_no INT NOT NULL,
approver_id BIGINT NOT NULL,
-- 결재자 user_id (느슨한 참조)
action VARCHAR(20) NOT NULL,
comment VARCHAR(1000),
processed_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
CHECK (action IN ('APPROVE','REJECT','RETURN'))
);
COMMENT ON TABLE approval_history IS '결재 처리 이력 — 단계별 결재자 액션 및 코멘트 기록';
COMMENT ON COLUMN approval_history.request_id IS '결재 요청 FK';
COMMENT ON COLUMN approval_history.step_no IS '처리 단계 번호';
COMMENT ON COLUMN approval_history.approver_id IS '결재자 user_id';
COMMENT ON COLUMN approval_history.action IS 'APPROVE=승인/REJECT=반려/RETURN=반송';
COMMENT ON COLUMN approval_history.comment IS '결재 코멘트';
CREATE INDEX idx_ah_request ON approval_history(request_id, step_no);
CREATE INDEX idx_ah_approver ON approval_history(approver_id, processed_at);
@@ -0,0 +1,63 @@
-- V51: 공지사항 + 사용자 알림 (notice + user_notification)
-- 시스템 공지 / 정책 공지 / 이벤트 공지 + 사용자별 개인 알림(읽음 여부).
-- ============================================================
-- 1. notice — 공지사항
-- ============================================================
CREATE TABLE notice (
notice_id BIGSERIAL PRIMARY KEY,
title VARCHAR(300) NOT NULL,
content TEXT NOT NULL,
notice_type VARCHAR(20) NOT NULL DEFAULT 'SYSTEM',
start_date DATE NOT NULL,
end_date DATE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_by BIGINT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP,
updated_by BIGINT,
CHECK (notice_type IN ('SYSTEM','POLICY','EVENT')),
CHECK (end_date IS NULL OR end_date >= start_date)
);
COMMENT ON TABLE notice IS '공지사항 — 시스템/정책/이벤트 공지 게시판';
COMMENT ON COLUMN notice.notice_type IS 'SYSTEM=시스템공지/POLICY=정책공지/EVENT=이벤트';
COMMENT ON COLUMN notice.start_date IS '공지 게시 시작일';
COMMENT ON COLUMN notice.end_date IS '공지 게시 종료일 (NULL=무기한)';
COMMENT ON COLUMN notice.is_active IS '공지 활성 여부';
CREATE INDEX idx_notice_date_type ON notice(start_date, end_date, notice_type, is_active);
-- ============================================================
-- 2. user_notification — 사용자 개인 알림
-- 정산/이의신청/결재 등 이벤트 발생 시 개인 알림 생성.
-- ============================================================
CREATE TABLE user_notification (
notification_id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
-- users 테이블 참조 (느슨한 참조 — 공통모듈 의존 최소화)
title VARCHAR(300) NOT NULL,
content TEXT,
ref_type VARCHAR(30),
-- 참조 업무 유형 (SETTLE/DISPUTE/APPROVAL/COMPLAINT 등)
ref_id BIGINT,
-- 참조 레코드 PK
is_read BOOLEAN NOT NULL DEFAULT FALSE,
read_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
CHECK (ref_type IS NULL OR ref_type IN ('SETTLE','DISPUTE','APPROVAL','COMPLAINT','INCENTIVE','WITHDRAW','NOTICE'))
);
COMMENT ON TABLE user_notification IS '사용자 개인 알림 — 이벤트 발생 시 user_id 단위로 생성';
COMMENT ON COLUMN user_notification.user_id IS '알림 수신 사용자 ID';
COMMENT ON COLUMN user_notification.ref_type IS '참조 업무 유형 (SETTLE/DISPUTE/APPROVAL/COMPLAINT/INCENTIVE/WITHDRAW/NOTICE)';
COMMENT ON COLUMN user_notification.ref_id IS '참조 레코드 PK';
COMMENT ON COLUMN user_notification.is_read IS '읽음 여부';
COMMENT ON COLUMN user_notification.read_at IS '읽은 시각';
-- user_id+is_read 복합 인덱스 — 읽지 않은 알림 빠른 조회
CREATE INDEX idx_un_user_read ON user_notification(user_id, is_read);
CREATE INDEX idx_un_created ON user_notification(created_at DESC);
CREATE INDEX idx_un_ref ON user_notification(ref_type, ref_id);
@@ -0,0 +1,36 @@
-- V52: 첨부파일 (attachment)
-- 도메인 무관 공통 첨부. ref_type + ref_id 조합으로 어느 도메인 레코드에도 첨부 가능.
-- 파일 실체는 스토리지(로컬/S3). file_path에 경로 저장.
CREATE TABLE attachment (
attachment_id BIGSERIAL PRIMARY KEY,
ref_type VARCHAR(50) NOT NULL,
ref_id BIGINT NOT NULL,
file_name VARCHAR(300) NOT NULL,
file_path VARCHAR(1000) NOT NULL,
file_size BIGINT NOT NULL,
-- bytes 단위
mime_type VARCHAR(100),
uploaded_by BIGINT NOT NULL,
-- 업로더 user_id (느슨한 참조)
uploaded_at TIMESTAMP NOT NULL DEFAULT NOW(),
CHECK (ref_type IN (
'AGENT_CONTRACT','COMPLAINT','APPROVAL_REQUEST',
'WITHDRAW_REQUEST','NOTICE','DISPUTE','INCENTIVE',
'AGENT_LICENSE','AGENT_TRAINING'
)),
CHECK (file_size > 0)
);
COMMENT ON TABLE attachment IS '공통 첨부파일 — ref_type+ref_id 조합으로 모든 도메인에 첨부 연결';
COMMENT ON COLUMN attachment.ref_type IS '첨부 대상 도메인 유형 (AGENT_CONTRACT/COMPLAINT/APPROVAL_REQUEST 등)';
COMMENT ON COLUMN attachment.ref_id IS '첨부 대상 레코드 PK';
COMMENT ON COLUMN attachment.file_name IS '원본 파일명';
COMMENT ON COLUMN attachment.file_path IS '저장 경로 (로컬 절대경로 또는 S3 키)';
COMMENT ON COLUMN attachment.file_size IS '파일 크기 (bytes)';
COMMENT ON COLUMN attachment.mime_type IS 'MIME 타입 (예: application/pdf)';
COMMENT ON COLUMN attachment.uploaded_by IS '업로더 user_id';
-- ref_type + ref_id 복합 인덱스 — 도메인별 첨부 목록 조회
CREATE INDEX idx_att_ref ON attachment(ref_type, ref_id);
CREATE INDEX idx_att_uploader ON attachment(uploaded_by, uploaded_at);
@@ -0,0 +1,45 @@
-- V53: 출금 신청 (withdraw_request)
-- 정산 확정 후 설계사 수수료 출금 신청 → 결재 → 펌뱅킹 송신 → 결과 반영.
-- account_no는 AES 암호화 대상 (애플리케이션 EncryptTypeHandler 처리).
CREATE TABLE withdraw_request (
request_id BIGSERIAL PRIMARY KEY,
settle_master_id BIGINT NOT NULL REFERENCES settle_master(settle_id),
agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
amount NUMERIC(15,2) NOT NULL,
requested_by BIGINT NOT NULL,
-- 신청자 user_id (느슨한 참조)
requested_at TIMESTAMP NOT NULL DEFAULT NOW(),
approval_request_id BIGINT REFERENCES approval_request(request_id),
bank_code VARCHAR(10) NOT NULL,
account_no VARCHAR(200) NOT NULL,
-- AES 암호화 대상 (EncryptTypeHandler)
status VARCHAR(20) NOT NULL DEFAULT 'REQUESTED',
sent_at TIMESTAMP,
-- 펌뱅킹 송신 시각
completed_at TIMESTAMP,
-- 입금 완료 시각
fail_reason VARCHAR(500),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP,
CHECK (amount > 0),
CHECK (status IN ('REQUESTED','APPROVED','SENT','COMPLETED','FAILED','CANCELLED'))
);
COMMENT ON TABLE withdraw_request IS '출금 신청 — 정산 확정 후 수수료 출금 결재→펌뱅킹→완료 워크플로우';
COMMENT ON COLUMN withdraw_request.settle_master_id IS '출금 기준 정산 FK (settle_master)';
COMMENT ON COLUMN withdraw_request.agent_id IS '출금 대상 설계사 FK';
COMMENT ON COLUMN withdraw_request.amount IS '출금 신청 금액';
COMMENT ON COLUMN withdraw_request.requested_by IS '신청자 user_id';
COMMENT ON COLUMN withdraw_request.approval_request_id IS '결재 요청 FK (approval_request)';
COMMENT ON COLUMN withdraw_request.bank_code IS '출금 은행 코드';
COMMENT ON COLUMN withdraw_request.account_no IS '출금 계좌번호 (AES 암호화, EncryptTypeHandler 처리)';
COMMENT ON COLUMN withdraw_request.status IS 'REQUESTED=신청/APPROVED=결재완료/SENT=펌뱅킹송신/COMPLETED=입금완료/FAILED=실패/CANCELLED=취소';
COMMENT ON COLUMN withdraw_request.sent_at IS '펌뱅킹 송신 시각';
COMMENT ON COLUMN withdraw_request.completed_at IS '입금 완료 확인 시각';
COMMENT ON COLUMN withdraw_request.fail_reason IS '실패 사유 (FAILED 상태 시)';
CREATE INDEX idx_wr_agent_status ON withdraw_request(agent_id, status);
CREATE INDEX idx_wr_settle ON withdraw_request(settle_master_id);
CREATE INDEX idx_wr_status_date ON withdraw_request(status, requested_at);
CREATE INDEX idx_wr_approval ON withdraw_request(approval_request_id);
@@ -0,0 +1,198 @@
-- V54: P4 도메인 메뉴 일괄 등록 (V40~V53 대응)
--
-- 신규 부모 그룹: GRP_COMMISSION "수수료 마스터" (P4-A 5개 자식)
-- 기존 GRP_ORG 아래: 위촉계약/자격관리/보수교육
-- 기존 GRP_PRD 아래: 계약변경/민원관리 (GRP_CONTRACT_OP 대신 GRP_PRD 활용)
-- 기존 GRP_SYSTEM 아래: 결재/공지/첨부/출금
-- ============================================================
-- 1. GRP_COMMISSION 부모 그룹 메뉴 신설
-- ============================================================
INSERT INTO menu (parent_menu_id, menu_code, menu_name, menu_type, menu_path, menu_icon, menu_level, sort_order, is_visible, is_active, description)
VALUES (NULL, 'GRP_COMMISSION', '수수료 마스터', 'DIRECTORY', NULL, 'dollar', 1, 45, 'Y', 'Y', '수수료 종류별 원장 · 룰 관리')
ON CONFLICT (menu_code) DO NOTHING;
-- ============================================================
-- 2. GRP_COMMISSION 하위 PAGE 5개 (P4-A)
-- ============================================================
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
('COMM_TYPE', '수수료 종류 코드', '/commission/type', 'commission/CommType', 10),
('COMM_MASTER', '수수료 통합 원장', '/commission/master', 'commission/CommMaster', 20),
('COLLECT_COMM', '수금수수료', '/commission/collection', 'commission/CollectComm', 30),
('RENEWAL_COMM', '갱신수수료', '/commission/renewal', 'commission/RenewalComm', 40),
('REINSTATE_COMM', '부활수수료', '/commission/reinstate', 'commission/ReinstateComm', 50),
('PERSIST_BONUS', '유지보수수당', '/commission/persist', 'commission/PersistBonus', 60),
('OVERRIDE_LEDGER', '오버라이드 원장', '/commission/override', 'commission/OverrideLedger', 70)
) AS x(code, name, path, cmpt, sort)
ON p.menu_code = 'GRP_COMMISSION'
WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code);
-- ============================================================
-- 3. GRP_ORG 하위 PAGE 3개 (P4-B 설계사 라이프사이클)
-- ============================================================
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
('AGENT_CONTRACT', '위촉계약', '/org/agent-contract', 'org/AgentContract', 30),
('AGENT_LICENSE', '자격 관리', '/org/agent-license', 'org/AgentLicense', 40),
('AGENT_TRAINING', '보수교육', '/org/agent-training', 'org/AgentTraining', 50)
) AS x(code, name, path, cmpt, sort)
ON p.menu_code = 'GRP_ORG'
WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code);
-- ============================================================
-- 4. GRP_PRD 하위 PAGE 2개 (계약변경 / 민원관리)
-- ============================================================
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
('ENDORSE', '계약 변경', '/contract/endorsement', 'contract/Endorsement', 40),
('COMPLAINT', '민원 관리', '/contract/complaint', 'contract/Complaint', 50)
) AS x(code, name, path, cmpt, sort)
ON p.menu_code = 'GRP_PRD'
WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code);
-- ============================================================
-- 5. GRP_SYSTEM 하위 PAGE 4개 (결재/공지/첨부/출금)
-- ============================================================
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
('APPROVAL', '결재 관리', '/system/approval', 'system/Approval', 70),
('NOTICE', '공지사항', '/system/notice', 'system/Notice', 80),
('ATTACHMENT', '첨부파일', '/system/attachment', 'system/Attachment', 90),
('WITHDRAW', '출금 신청', '/system/withdraw', 'system/Withdraw', 95)
) AS x(code, name, path, cmpt, sort)
ON p.menu_code = 'GRP_SYSTEM'
WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code);
-- ============================================================
-- 6. GRP_COMMISSION 에 READ 권한 등록 (대메뉴 expand 용)
-- ============================================================
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
SELECT menu_id, 'READ', '조회', 1 FROM menu WHERE menu_code = 'GRP_COMMISSION'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
SELECT r.role_id, m.menu_id, 'READ', 'Y'
FROM role r CROSS JOIN menu m
WHERE r.role_code IN ('SUPER_ADMIN','ADMIN','MANAGER','AGENT')
AND m.menu_code = 'GRP_COMMISSION'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- ============================================================
-- 7. 전체 신규 PAGE 메뉴에 권한 항목 등록
-- 수수료 도메인: READ/CREATE/UPDATE/DELETE/EXPORT
-- 관리 도메인(결재/공지/출금): READ/CREATE/UPDATE/APPROVE
-- 조회성(첨부): READ/CREATE/DELETE
-- ============================================================
-- 수수료 + 설계사 라이프사이클 + 계약변경/민원: READ/CREATE/UPDATE/DELETE/EXPORT
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),
('DELETE', '삭제', 40),
('EXPORT', '엑셀', 60)
) AS p(code, name, sort)
WHERE m.menu_code IN (
'COMM_TYPE','COMM_MASTER','COLLECT_COMM','RENEWAL_COMM',
'REINSTATE_COMM','PERSIST_BONUS','OVERRIDE_LEDGER',
'AGENT_CONTRACT','AGENT_LICENSE','AGENT_TRAINING',
'ENDORSE','COMPLAINT'
)
ON CONFLICT (menu_id, perm_code) DO NOTHING;
-- 결재/공지/출금: READ/CREATE/UPDATE/APPROVE
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),
('APPROVE', '결재', 50)
) AS p(code, name, sort)
WHERE m.menu_code IN ('APPROVAL','NOTICE','WITHDRAW')
ON CONFLICT (menu_id, perm_code) DO NOTHING;
-- 첨부파일: READ/CREATE/DELETE
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),
('DELETE', '삭제', 40)
) AS p(code, name, sort)
WHERE m.menu_code = 'ATTACHMENT'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
-- ============================================================
-- 8. 역할별 권한 부여
-- SUPER_ADMIN / ADMIN: 전체 부여
-- MANAGER: READ + CREATE + UPDATE + APPROVE (DELETE 제외)
-- AGENT: 본인 관련 READ 만 (COMM_MASTER/AGENT_CONTRACT/AGENT_LICENSE/AGENT_TRAINING/NOTICE/WITHDRAW)
-- ============================================================
-- SUPER_ADMIN / ADMIN — 모든 신규 메뉴 전체 권한
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 IN (
'COMM_TYPE','COMM_MASTER','COLLECT_COMM','RENEWAL_COMM',
'REINSTATE_COMM','PERSIST_BONUS','OVERRIDE_LEDGER',
'AGENT_CONTRACT','AGENT_LICENSE','AGENT_TRAINING',
'ENDORSE','COMPLAINT',
'APPROVAL','NOTICE','ATTACHMENT','WITHDRAW'
)
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- MANAGER — READ/CREATE/UPDATE/APPROVE (DELETE 제외)
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 IN ('READ','CREATE','UPDATE','APPROVE','EXPORT')
AND m.menu_code IN (
'COMM_TYPE','COMM_MASTER','COLLECT_COMM','RENEWAL_COMM',
'REINSTATE_COMM','PERSIST_BONUS','OVERRIDE_LEDGER',
'AGENT_CONTRACT','AGENT_LICENSE','AGENT_TRAINING',
'ENDORSE','COMPLAINT',
'APPROVAL','NOTICE','ATTACHMENT','WITHDRAW'
)
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- AGENT — 본인 관련 메뉴 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 = 'AGENT'
AND mp.perm_code = 'READ'
AND m.menu_code IN (
'COMM_MASTER','AGENT_CONTRACT','AGENT_LICENSE','AGENT_TRAINING',
'NOTICE','WITHDRAW'
)
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
@@ -0,0 +1,128 @@
-- V55: P4 도메인에서 사용하는 공통코드 그룹/상세 일괄 INSERT
-- core-architect 가 사용한 코드 그룹 누락분 보강.
-- group_code 길이 한도 상향 (기존 VARCHAR(20) → 30) — NOTIFICATION_REF_TYPE / AGENT_CONTRACT_STATUS 등 21자 이상 수용
ALTER TABLE common_code_group ALTER COLUMN group_code TYPE VARCHAR(30);
ALTER TABLE common_code ALTER COLUMN group_code TYPE VARCHAR(30);
-- ===== 그룹 =====
INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
('PAYMENT_CYCLE', '수금주기', 'MONTHLY/QUARTERLY/SEMI/ANNUAL', 'Y', 200),
('BONUS_TYPE', '수당지급유형', 'RATE/FIXED', 'Y', 210),
('RETENTION_MONTH', '유지기준개월', 'M13/M25/M37/M49', 'Y', 220),
('AGENT_CONTRACT_STATUS','위촉계약상태', 'ACTIVE/TERMINATED', 'Y', 230),
('LICENSE_TYPE', '자격증유형', 'LIFE/NONLIFE/VARIABLE', 'Y', 240),
('LICENSE_STATUS', '자격증상태', 'ACTIVE/EXPIRED/SUSPENDED', 'Y', 250),
('COURSE_TYPE', '교육유형', 'REGULAR/COMPLIANCE/PRODUCT', 'Y', 260),
('ENDORSEMENT_TYPE', '계약변경유형', 'PREMIUM/BENEFICIARY/INSURED/ADDRESS', 'Y', 270),
('COMPLAINT_CHANNEL', '민원접수채널', 'PHONE/EMAIL/VISIT/ONLINE', 'Y', 280),
('COMPLAINT_TYPE', '민원유형', 'MIS_SELLING/DELAY/COVERAGE/REFUND/ETC', 'Y', 290),
('COMPLAINT_SEVERITY', '민원심각도', 'LOW/MID/HIGH', 'Y', 300),
('COMPLAINT_STATUS', '민원처리상태', 'OPEN/IN_PROGRESS/CLOSED', 'Y', 310),
('APPROVAL_STATUS', '결재상태', 'PENDING/APPROVED/REJECTED/CANCEL', 'Y', 320),
('APPROVAL_ACTION', '결재처리', 'APPROVE/REJECT', 'Y', 330),
('NOTICE_TYPE', '공지유형', 'SYSTEM/POLICY/EVENT', 'Y', 340),
('NOTIFICATION_REF_TYPE','알림참조유형', 'SETTLE/DISPUTE/INCENTIVE/WITHDRAW/...', 'Y', 350),
('ATTACHMENT_REF_TYPE', '첨부참조유형', 'AGENT_CONTRACT/COMPLAINT/...', 'Y', 360),
('WITHDRAW_STATUS', '출금신청상태', 'REQUESTED/APPROVED/SENT/COMPLETED/FAILED','Y', 370),
('HOLD_REASON', '보류사유', 'NEED_DOC/UNDER_REVIEW/DISPUTE_OPEN/...','Y', 380)
ON CONFLICT (group_code) DO NOTHING;
-- ===== 상세 =====
INSERT INTO common_code (group_code, code, code_name, sort_order) VALUES
-- PAYMENT_CYCLE
('PAYMENT_CYCLE','MONTHLY','월납',10),
('PAYMENT_CYCLE','QUARTERLY','분기납',20),
('PAYMENT_CYCLE','SEMI','반기납',30),
('PAYMENT_CYCLE','ANNUAL','연납',40),
-- BONUS_TYPE
('BONUS_TYPE','RATE','정률',10),
('BONUS_TYPE','FIXED','정액',20),
-- RETENTION_MONTH
('RETENTION_MONTH','M13','13개월',10),
('RETENTION_MONTH','M25','25개월',20),
('RETENTION_MONTH','M37','37개월',30),
('RETENTION_MONTH','M49','49개월',40),
-- AGENT_CONTRACT_STATUS
('AGENT_CONTRACT_STATUS','ACTIVE','유효',10),
('AGENT_CONTRACT_STATUS','TERMINATED','해촉',20),
-- LICENSE_TYPE
('LICENSE_TYPE','LIFE','생명보험',10),
('LICENSE_TYPE','NONLIFE','손해보험',20),
('LICENSE_TYPE','VARIABLE','변액보험',30),
-- LICENSE_STATUS
('LICENSE_STATUS','ACTIVE','유효',10),
('LICENSE_STATUS','EXPIRED','만료',20),
('LICENSE_STATUS','SUSPENDED','정지',30),
-- COURSE_TYPE
('COURSE_TYPE','REGULAR','보수교육',10),
('COURSE_TYPE','COMPLIANCE','준법교육',20),
('COURSE_TYPE','PRODUCT','상품교육',30),
-- ENDORSEMENT_TYPE
('ENDORSEMENT_TYPE','PREMIUM_CHANGE','보험료변경',10),
('ENDORSEMENT_TYPE','BENEFICIARY','수익자변경',20),
('ENDORSEMENT_TYPE','INSURED','피보험자변경',30),
('ENDORSEMENT_TYPE','ADDRESS','주소변경',40),
-- COMPLAINT_CHANNEL
('COMPLAINT_CHANNEL','PHONE','전화',10),
('COMPLAINT_CHANNEL','EMAIL','이메일',20),
('COMPLAINT_CHANNEL','VISIT','방문',30),
('COMPLAINT_CHANNEL','ONLINE','온라인',40),
-- COMPLAINT_TYPE
('COMPLAINT_TYPE','MIS_SELLING','불완전판매',10),
('COMPLAINT_TYPE','DELAY','업무지연',20),
('COMPLAINT_TYPE','COVERAGE','보장설명미흡',30),
('COMPLAINT_TYPE','REFUND','환급요청',40),
('COMPLAINT_TYPE','ETC','기타',90),
-- COMPLAINT_SEVERITY
('COMPLAINT_SEVERITY','LOW','경미',10),
('COMPLAINT_SEVERITY','MID','보통',20),
('COMPLAINT_SEVERITY','HIGH','중대',30),
-- COMPLAINT_STATUS
('COMPLAINT_STATUS','OPEN','접수',10),
('COMPLAINT_STATUS','IN_PROGRESS','처리중',20),
('COMPLAINT_STATUS','CLOSED','처리완료',30),
-- APPROVAL_STATUS
('APPROVAL_STATUS','PENDING','결재대기',10),
('APPROVAL_STATUS','APPROVED','승인',20),
('APPROVAL_STATUS','REJECTED','반려',30),
('APPROVAL_STATUS','CANCEL','취소',40),
-- APPROVAL_ACTION
('APPROVAL_ACTION','APPROVE','승인',10),
('APPROVAL_ACTION','REJECT','반려',20),
-- NOTICE_TYPE
('NOTICE_TYPE','SYSTEM','시스템',10),
('NOTICE_TYPE','POLICY','정책',20),
('NOTICE_TYPE','EVENT','이벤트',30),
-- NOTIFICATION_REF_TYPE
('NOTIFICATION_REF_TYPE','SETTLE','정산',10),
('NOTIFICATION_REF_TYPE','DISPUTE','이의신청',20),
('NOTIFICATION_REF_TYPE','INCENTIVE','시책',30),
('NOTIFICATION_REF_TYPE','WITHDRAW','출금',40),
('NOTIFICATION_REF_TYPE','COMPLAINT','민원',50),
('NOTIFICATION_REF_TYPE','APPROVAL','결재',60),
('NOTIFICATION_REF_TYPE','SYSTEM','시스템',70),
-- ATTACHMENT_REF_TYPE
('ATTACHMENT_REF_TYPE','AGENT_CONTRACT','위촉계약서',10),
('ATTACHMENT_REF_TYPE','COMPLAINT','민원',20),
('ATTACHMENT_REF_TYPE','DISPUTE','이의신청',30),
('ATTACHMENT_REF_TYPE','SETTLE','정산',40),
('ATTACHMENT_REF_TYPE','TRAINING','교육',50),
('ATTACHMENT_REF_TYPE','LICENSE','자격증',60),
('ATTACHMENT_REF_TYPE','ENDORSEMENT','계약변경',70),
('ATTACHMENT_REF_TYPE','NOTICE','공지',80),
('ATTACHMENT_REF_TYPE','ETC','기타',90),
-- WITHDRAW_STATUS
('WITHDRAW_STATUS','REQUESTED','신청',10),
('WITHDRAW_STATUS','APPROVED','승인',20),
('WITHDRAW_STATUS','SENT','전송완료',30),
('WITHDRAW_STATUS','COMPLETED','이체완료',40),
('WITHDRAW_STATUS','FAILED','실패',90),
-- HOLD_REASON
('HOLD_REASON','NEED_DOC','서류미비',10),
('HOLD_REASON','UNDER_REVIEW','검토중',20),
('HOLD_REASON','DISPUTE_OPEN','이의신청중',30),
('HOLD_REASON','LICENSE_EXPIRED','자격증만료',40),
('HOLD_REASON','TRAINING_INSUFFICIENT','교육시간미달',50),
('HOLD_REASON','ETC','기타',90)
ON CONFLICT (group_code, code) DO NOTHING;
@@ -0,0 +1,27 @@
-- V56: agent 테이블 세무 컬럼 누락 보강
-- 운영 DB 의 V23 SQL 과 현재 V23 SQL 의 체크섬 미스매치로 인해
-- agent.tax_type / business_no / vat_type 가 적용되지 않은 상태.
-- AgentMapper.xml 이 위 3개 컬럼을 참조하므로 /api/agents 가 500 에러.
-- IF NOT EXISTS 로 idempotent 추가.
ALTER TABLE agent ADD COLUMN IF NOT EXISTS tax_type VARCHAR(20);
ALTER TABLE agent ADD COLUMN IF NOT EXISTS business_no VARCHAR(20);
ALTER TABLE agent ADD COLUMN IF NOT EXISTS vat_type VARCHAR(20);
COMMENT ON COLUMN agent.tax_type IS '세무유형 (사업소득/기타소득 등 TAX_TYPE 코드)';
COMMENT ON COLUMN agent.business_no IS '사업자등록번호 (사업소득자)';
COMMENT ON COLUMN agent.vat_type IS '부가세유형 (VAT_TYPE 코드)';
-- TAX_TYPE / VAT_TYPE common_code 그룹이 없으면 추가
INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
('TAX_TYPE', '세무유형', 'BUSINESS/OTHER 등', 'Y', 400),
('VAT_TYPE', '부가세유형', 'GENERAL/SIMPLE/EXEMPT', 'Y', 410)
ON CONFLICT (group_code) DO NOTHING;
INSERT INTO common_code (group_code, code, code_name, sort_order) VALUES
('TAX_TYPE','BUSINESS','사업소득',10),
('TAX_TYPE','OTHER','기타소득',20),
('VAT_TYPE','GENERAL','일반과세',10),
('VAT_TYPE','SIMPLE','간이과세',20),
('VAT_TYPE','EXEMPT','면세',30)
ON CONFLICT (group_code, code) DO NOTHING;
@@ -0,0 +1,16 @@
package com.ga.core.mapper.approval;
import com.ga.core.vo.approval.ApprovalHistoryResp;
import com.ga.core.vo.approval.ApprovalHistorySearchParam;
import com.ga.core.vo.approval.ApprovalHistoryVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface ApprovalHistoryMapper {
List<ApprovalHistoryResp> selectByRequest(@Param("requestId") Long requestId);
List<ApprovalHistoryResp> selectList(ApprovalHistorySearchParam param);
int insert(ApprovalHistoryVO vo);
}
@@ -0,0 +1,19 @@
package com.ga.core.mapper.approval;
import com.ga.core.vo.approval.ApprovalLineResp;
import com.ga.core.vo.approval.ApprovalLineSearchParam;
import com.ga.core.vo.approval.ApprovalLineVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface ApprovalLineMapper {
ApprovalLineResp selectById(@Param("lineId") Long lineId);
ApprovalLineVO selectByCode(@Param("lineCode") String lineCode);
List<ApprovalLineResp> selectList(ApprovalLineSearchParam param);
int insert(ApprovalLineVO vo);
int update(ApprovalLineVO vo);
int updateActive(@Param("lineId") Long lineId, @Param("isActive") boolean isActive);
}
@@ -0,0 +1,18 @@
package com.ga.core.mapper.approval;
import com.ga.core.vo.approval.ApprovalLineStepResp;
import com.ga.core.vo.approval.ApprovalLineStepSearchParam;
import com.ga.core.vo.approval.ApprovalLineStepVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface ApprovalLineStepMapper {
ApprovalLineStepVO selectByLineAndStep(@Param("lineId") Long lineId, @Param("stepNo") int stepNo);
List<ApprovalLineStepResp> selectByLine(@Param("lineId") Long lineId);
List<ApprovalLineStepResp> selectList(ApprovalLineStepSearchParam param);
int insert(ApprovalLineStepVO vo);
int delete(@Param("stepId") Long stepId);
}
@@ -0,0 +1,42 @@
package com.ga.core.mapper.approval;
import com.ga.core.vo.approval.ApprovalRequestResp;
import com.ga.core.vo.approval.ApprovalRequestSearchParam;
import com.ga.core.vo.approval.ApprovalRequestVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* approval_request Mapper 결재 요청.
*/
@Mapper
public interface ApprovalRequestMapper {
ApprovalRequestResp selectById(@Param("requestId") Long requestId);
List<ApprovalRequestResp> selectList(ApprovalRequestSearchParam param);
int insert(ApprovalRequestVO vo);
/**
* 결재 단계 진행 approval_history 삽입 + current_step 갱신 + 최종 상태 반영.
* Service에서 트랜잭션으로 묶는 것이 원칙이나,
* 핵심 단계 진행 UPDATE를 단일 메서드로 제공.
*
* @param requestId 결재 요청 PK
* @param approverId 결재자 user_id
* @param action APPROVE / REJECT / RETURN
* @param comment 결재 코멘트
* @return 업데이트
*/
int advanceStep(@Param("requestId") Long requestId,
@Param("approverId") Long approverId,
@Param("action") String action,
@Param("comment") String comment);
int updateStatus(@Param("requestId") Long requestId, @Param("status") String status);
int cancel(@Param("requestId") Long requestId);
}
@@ -0,0 +1,21 @@
package com.ga.core.mapper.attachment;
import com.ga.core.vo.attachment.AttachmentResp;
import com.ga.core.vo.attachment.AttachmentSearchParam;
import com.ga.core.vo.attachment.AttachmentVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface AttachmentMapper {
AttachmentResp selectById(@Param("attachmentId") Long attachmentId);
/** ref_type + ref_id 기준 첨부 목록 — 도메인별 첨부 조회 핵심 메서드 */
List<AttachmentResp> selectByRef(@Param("refType") String refType, @Param("refId") Long refId);
List<AttachmentResp> selectList(AttachmentSearchParam param);
int insert(AttachmentVO vo);
int delete(@Param("attachmentId") Long attachmentId);
/** ref_type + ref_id 전체 삭제 — 레코드 삭제 시 연동 */
int deleteByRef(@Param("refType") String refType, @Param("refId") Long refId);
}
@@ -0,0 +1,31 @@
package com.ga.core.mapper.commission;
import com.ga.core.vo.commission.CollectionCommissionLedgerResp;
import com.ga.core.vo.commission.CollectionCommissionLedgerSearchParam;
import com.ga.core.vo.commission.CollectionCommissionLedgerVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.math.BigDecimal;
import java.util.List;
/**
* collection_commission_ledger Mapper.
*/
@Mapper
public interface CollectionCommissionLedgerMapper {
CollectionCommissionLedgerResp selectById(@Param("ledgerId") Long ledgerId);
List<CollectionCommissionLedgerResp> selectList(CollectionCommissionLedgerSearchParam param);
/** 설계사 + 정산월 합계 — commission_master 집계 배치 용도 */
BigDecimal sumByAgentAndMonth(@Param("agentId") Long agentId,
@Param("settleMonth") String settleMonth);
int insert(CollectionCommissionLedgerVO vo);
int updateStatus(@Param("ledgerId") Long ledgerId, @Param("status") String status);
int delete(@Param("ledgerId") Long ledgerId);
}

Some files were not shown because too many files have changed in this diff Show More