동기화

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 기반 자동 체인으로 진행 중인 작업의 현재 상태와 잔여 작업, 재개 절차를 기록한다.
@@ -64,48 +64,87 @@
- P1-3-b(core) + P1-1-c TODO 교체(api) 2명 동시
- 모듈이 다른 사람끼리 묶어 머지 충돌 없음
## 3. 잔여 작업
## 3. Round 2 완료 사항 (2026-05-14)
### 3-1. P1 정산 통합 (보류 권장)
- ga-batch의 8 Step 정산 잡에 TaxCalculator/DeductionService/RegulatoryLimitChecker/InstallmentService/ChargebackCalculator 통합
- batch-engineer 작업
- 운영 비즈니스 룰 의존이 커 **인프라만 완성하고 통합은 사용자 결정 후** 진행 권장
- 보류 가능 — 현 시점에 인프라/CRUD/API는 모두 동작
### 3-2. 도메인 P2 — 8개 항목 (Wave 방식)
| # | 도메인 | 마이그레이션 | 핵심 |
### 3-1. P2 — 8 도메인 (V29~V36, ~50 VO·Mapper, 18 Service+Controller)
| # | 도메인 | 마이그레이션 | 상태 |
|---|---|---|---|
| P2-1 | 마감 (Period Close) | V29 | period_close 테이블 + 마감 후 수정 차단 AOP |
| P2-2 | 정정 (Correction) | V30 | settle_correction + 원본 참조 + 정정 사유 |
| P2-3 | 이의 신청 (Dispute) | V31 | commission_dispute + OPEN/IN_REVIEW/APPROVED/REJECTED |
| P2-4 | 보류 사유 (Hold Reasons) | V32 | settle_master.hold_reason_code + HOLD_REASON 코드그룹 |
| P2-5 | 시책·인센티브 | V33 | incentive_program + incentive_payment |
| P2-6 | 해촉 정산 (Termination) | V34 | termination_settlement (agent_org_history 연계) |
| P2-7 | 세금계산서 (Tax Invoice) | V35 | tax_invoice 발행 이력 (SUPPLY/PURCHASE) |
| P2-8 | 외부 회신 | V36 | carrier_reconciliation_out (보험사 회신 인터페이스) |
| P2-1 | 마감 (Period Close) | V29 | 완료 |
| P2-2 | 정정 (Correction) | V30 | 완료 |
| P2-3 | 이의신청 (Dispute) | V31 | 완료 |
| P2-4 | 보류사유 (Hold Reasons) | V32 | 완료 |
| P2-5 | 시책·인센티브 | V33 | 완료 |
| P2-6 | 해촉정산 (Termination) | V34 | 완료 |
| P2-7 | 세금계산서 (Tax Invoice) | V35 | 완료 |
| P2-8 | 외부회신 | V36 | 완료 |
**Wave 디스패치 계획**:
- Wave 1 (dba): V29~V36 8개 마이그레이션 한 번에
- Wave 2 (core): 8개 도메인 VO/Mapper 한 번에
- Wave 3 (api): 8개 도메인 Service/Controller 한 번에
- 디스패치 24 → 3으로 축소
화면: ga-frontend Wave 4 에서 P4-B 화면과 함께 작성됨.
### 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 |
| KPI 대시보드 화면 (AG Grid 합계행, 차트) | frontend-developer |
| 회계 전표 자동 분개 | 수수료 지급액 → 분개 엔트리 자동 생성 |
| 원천세 신고 자료 | 분기별 사업소득세 집계 + 국세청 포맷 |
| 부가세 신고 자료 | tax_invoice 기반 매출/매입 집계 |
| 결산 | period_close 와 별개의 월/분기/연 회계 마감 |
| 이상치·예측 KPI | 13M/25M 유지율 이상 탐지 + 차월 예측 |
| 연말 지급명세서 | 설계사 연 사업소득 세무 자료 PDF |
### 3-4. 마무리 통합
| 작업 | 담당 |
### P6 — 외부 연동 (P5 이후)
| 항목 | 설명 |
|---|---|
| ga-batch 8 Step에 도메인 로직 통합 (TaxCalculator/Deduction/Regulatory/Installment/Chargeback) | batch-engineer |
| 통합 테스트 | infra-reviewer |
| README/DEVELOPER_GUIDE 업데이트 | infra-reviewer |
| 펌뱅킹 실연동 | withdraw_request 결재 완료 → 은행 API 직접 호출 |
| SMS/카카오 발송 | user_notification 채널 실연동 |
| 모바일 셀프 조회 | 설계사 전용 모바일 앱 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` (현재 세션과 동일)
2. **Agent Teams 활성화 확인**: `.claude/settings.json` 이미 커밋되어 있음 (`418ff7c`)
@@ -113,12 +152,13 @@
- PL 역할로 `TeamCreate(team_name="ga-pro-team", agent_type="pl", description="...")` 호출
- 7명 팀원 spawn (common/core/api/batch/frontend/dba/infra) — 첫 세션과 동일
4. **HANDOFF.md 읽기**: 본 문서 + 최근 git log로 컨텍스트 확보
5. **권장 시작 지점**: P2 Wave 1 디스패치 (dba-architect에게 V29~V36 8개 마이그레이션)
6. **사용자 결정 필요 항목**:
- P1 정산 통합 진행 여부 (Wave 작업 전 또는 P3 후)
5. **권장 시작 지점**: P5 Wave 1 dba-architect에게 V56~V61 (회계 전표 / 원천세 / 부가세 / 결산 / 이상치 / 연말 지급명세서)
6. **ga-api 재시작 필요**: 운영 DB 에 V18~V55 적용하려면 ga-api 재시작 후 Flyway 자동 실행 확인
7. **사용자 결정 필요 항목**:
- P5 시작 전 ga-api 재시작해서 V18~V55 운영 DB 반영 여부
- P2-1 마감 도메인의 마감 후 수정 차단 AOP 구현 방식 (전역 AOP vs Service-level 가드)
## 5. PL 가이드 (다음 세션 유의사항)
## 6. PL 가이드 (다음 세션 유의사항)
- **시간차 중복 메시지**: 팀원이 SendMessage로 같은 보고를 2~3번 보내는 경우 있음. 짧게 acknowledge만.
- **idle 알림**: 팀원이 작업 끝낼 때마다 idle 알림 보냄. 정상. 액션 필요 없음.
@@ -129,7 +169,7 @@
- 빌드/테스트 실패 시 담당 팀원에 재지시
- 컴파일은 PL이 직접 검증, 테스트는 환경 문제(SqlSessionFactory)로 통과 안 되는 게 알려진 상태
## 6. 표준 가정 (도메인 작업 진행 시 채택한 값)
## 7. 표준 가정 (도메인 작업 진행 시 채택한 값)
도메인 룰은 한국 보험업감독규정 표준 가정을 외부화 가능한 구조로 등록. 실제 회사 정책과 다르면 system_config / regulatory_limit / installment_ratio / chargeback_grade의 데이터만 수정하면 됨.
+108 -18
View File
@@ -40,9 +40,13 @@ ga-commission-system/
### 시나리오 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` 스키마에 적용되어 있습니다.
> **주의**: 운영 DB 에는 현재 V1~V17(또는 V22 까지)만 적용되어 있습니다.
> V18~V55 는 ga-api 를 재시작하면 Flyway 가 자동으로 순서대로 적용합니다.
> 재시작 전까지 P2~P4 신규 도메인 API 는 테이블이 없어 동작하지 않습니다.
#### 1) Backend (ga-api) 실행
```bash
@@ -119,7 +123,7 @@ PW : admin1234!
# 1) PostgreSQL + Redis 기동 (Docker)
docker compose up -d postgres redis
# 2) ga-api 실행 (Flyway 가 자동으로 V1~V17 마이그레이션)
# 2) ga-api 실행 (Flyway 가 자동으로 V1~V55 마이그레이션)
./gradlew :ga-api:bootRun
# 기본 프로파일은 application.yml — localhost:5432/ga 에 연결
@@ -219,21 +223,56 @@ UPDATE ga.users SET password = '$2b$10$...' WHERE login_id = 'admin';
## 📋 주요 기능
### 백엔드
- **인증/권한**: JWT (HS256), 메뉴×권한 RBAC, BCrypt 비밀번호
### 공통 프레임워크 (ga-common)
- **인증/권한**: JWT (HS256 액세스 2h/리프레시 7d), 메뉴×6권한 RBAC, BCrypt 비밀번호
- **공통코드**: Redis 캐시, 시스템코드 잠금, REST CRUD
- **메뉴**: 트리 구조, 사용자별 동적 메뉴 + 권한 매트릭스
- **엑셀**: SXSSF+Cursor 다운로드(100만건), SAX+배치 업로드(70만건)
- **로그**: 로그인/API/데이터변경 자동 기록 (비동기)
- **암호화**: AES-256 CBC (`resident_no`, `account_no` 자동)
- **로그**: 로그인/API/데이터변경 자동 기록 (비동기 `@DataChangeLog` AOP)
- **암호화**: AES-256 CBC (`resident_no`, `account_no` TypeHandler 자동 적용)
- **업로드 템플릿**: 동적 컬럼 매핑 + 변환 규칙 관리
### 도메인
- **조직**: 트리 구조, CTE 재귀, 인사 이력
- **계약**: 보험사/상품/계약 관리
- **수수료 규정**: 보험사율, 지급율, 오버라이드, 환수, 예외코드
- **원장**: 모집/유지/예외 (PageHelper + AG Grid)
- **정산**: 8 Step 배치 (수신→변환→대사→모집→유지→환수/예외→오버라이드→집계)
- **지급**: 이체파일 생성, 상태 추적
### P0/P1 — 핵심 정산 도메인
- **세무 (P0-1)**: TaxCalculator 순수 도메인 — 사업소득 3.3% / 기타소득 8.8% / 지방세 10% / VAT 10%
- **공제 (P0-2)**: agent_deduction 월별 차감 마스터 + payment_deduction_detail 이체 명세
- **명세서 (P0-3)**: commission_statement 발급 마스터 + ExcelStatementGenerator (SXSSF)
- **1200%룰 (P1-1)**: regulatory_limit 마스터 + RegulatoryLimitChecker (효력일 이력 추적)
- **분급 (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
1. **receiveData** — 보험사별 DataReceiver → raw_commission_data 적재
@@ -305,6 +344,47 @@ public class FooController {
| V15 | 통계/리포트 뷰 (v_agent_monthly_summary 등) |
| V16 | 관리자 계정 + 메뉴 상세 + 권한 매트릭스 |
| 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] **ga-common** — 공통 프레임워크 일체
- [x] **ga-core**22개 테이블 VO/Mapper/XML
- [x] **ga-api** — Auth + 14개 도메인 컨트롤러
- [x] **ga-core**65+ 테이블 VO/Mapper/XML (P0~P4 전체)
- [x] **ga-api** — Auth + 50+ 도메인 컨트롤러 (128개 Java 파일)
- [x] **ga-batch** — MonthlySettlementJob 8 Step (실제 동작 로직 포함)
- [x] **Docker** — docker-compose + multi-stage Dockerfile + nginx
- [x] **ga-frontend**13개 페이지 (대시보드/설계사/계약/원장/정산/지급/배치/시스템관리)
- [x] **운영 DB 적용** — 192.168.0.60:55432/trading_ai/ga (V1~V17 + 테스트 데이터)
- [ ] **단위/통합 테스트** — 미작성
- [x] **ga-frontend**50+ 페이지 (대시보드/설계사/계약/원장/정산/지급/배치/KPI/수수료/시스템관리)
- [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 정밀화** — 일부 변환 규칙은 단순화된 형태
- [ ] **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")
@DataChangeLog(menu = "SETTLE_LIST", table = "settle_master")
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();
}
@@ -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
public void hold(Long settleId, String reason) {
public void hold(Long settleId, String reason, String reasonCode) {
SettleMasterVO vo = masterMapper.selectById(settleId);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
if (SettleStatus.PAID.name().equals(vo.getStatus())) {
throw new BizException(ErrorCode.NOT_PERMITTED, "지급완료된 정산은 보류할 수 없습니다");
}
masterMapper.updateHold(settleId, reason);
masterMapper.updateHold(settleId, reason, reasonCode);
}
@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:
key: ${ENCRYPT_KEY:0123456789abcdef0123456789abcdef}
app:
attachment:
path: ${ATTACHMENT_PATH:./uploads}
withdraw:
approval-line-id: ${WITHDRAW_APPROVAL_LINE_ID:1}
logging:
level:
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;
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.core.mapper.ledger.ExceptionLedgerMapper;
import com.ga.core.mapper.ledger.MaintainLedgerMapper;
import com.ga.core.mapper.ledger.RecruitLedgerMapper;
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.CommissionStatementMapper;
import com.ga.core.mapper.settle.OverrideSettleMapper;
import com.ga.core.mapper.settle.SettleMasterMapper;
import com.ga.core.vo.org.AgentResp;
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.StatementType;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.StepContribution;
@@ -23,12 +33,22 @@ import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
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
@Component
@@ -44,16 +64,19 @@ public class AggregateStep implements Tasklet {
private final OverrideSettleMapper overrideMapper;
private final ChargebackMapper chargebackMapper;
private final SettleMasterMapper settleMapper;
private final SystemConfigService configService;
private final AgentDeductionMapper deductionMapper;
private final CommissionStatementMapper statementMapper;
private final BatchTaxCalculator taxCalculator;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
log.info("[Step8 aggregate] settleMonth={}", ctx.getSettleMonth());
Map<Long, BigDecimal> recruitMap = toMap(recruitMapper.aggregateByAgent(ctx.getSettleMonth()));
Map<Long, BigDecimal> maintainMap = toMap(maintainMapper.aggregateByAgent(ctx.getSettleMonth()));
Map<Long, BigDecimal> chargebackMap= toMap(chargebackMapper.aggregateByAgent(ctx.getSettleMonth()));
Map<Long, BigDecimal> overrideMap = toMap(overrideMapper.aggregateByUpper(ctx.getSettleMonth()));
Map<Long, BigDecimal> recruitMap = toMap(recruitMapper.aggregateByAgent(ctx.getSettleMonth()));
Map<Long, BigDecimal> maintainMap = toMap(maintainMapper.aggregateByAgent(ctx.getSettleMonth()));
Map<Long, BigDecimal> chargebackMap = toMap(chargebackMapper.aggregateByAgent(ctx.getSettleMonth()));
Map<Long, BigDecimal> overrideMap = toMap(overrideMapper.aggregateByUpper(ctx.getSettleMonth()));
// 예외는 PLUS/MINUS 분리
Map<Long, BigDecimal> excPlus = new HashMap<>();
Map<Long, BigDecimal> excMinus = new HashMap<>();
@@ -63,10 +86,8 @@ public class AggregateStep implements Tasklet {
excMinus.put(agentId, toBd(row.get("minus")));
}
BigDecimal taxRate = configService.getDecimal("TAX_RATE", new BigDecimal("3.3"));
// 합집합 agent_id
java.util.Set<Long> agentIds = new java.util.HashSet<>();
Set<Long> agentIds = new HashSet<>();
agentIds.addAll(recruitMap.keySet());
agentIds.addAll(maintainMap.keySet());
agentIds.addAll(chargebackMap.keySet());
@@ -76,10 +97,23 @@ public class AggregateStep implements Tasklet {
// 활성 설계사 누락 보충
AgentSearchParam ap = new AgentSearchParam();
ap.setStatus("ACTIVE"); ap.setPageSize(Integer.MAX_VALUE);
for (AgentResp a : agentMapper.selectList(ap)) agentIds.add(a.getAgentId());
ap.setStatus("ACTIVE");
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;
for (Long agentId : agentIds) {
BigDecimal recruit = recruitMap.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)
.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();
vo.setAgentId(agentId);
vo.setSettleMonth(ctx.getSettleMonth());
@@ -103,17 +153,74 @@ public class AggregateStep implements Tasklet {
vo.setOverrideTotal(ov);
vo.setChargebackTotal(cb);
vo.setGrossAmount(gross);
vo.setTaxAmount(tax);
vo.setNetAmount(net);
vo.setTaxAmount(totalTax); // incomeTax + localTax 합산
vo.setNetAmount(netAmount); // gross - incomeTax - localTax + vat - deduction
vo.setStatus("CALCULATED");
settleMapper.upsert(vo);
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);
log.info("[Step8] settle master upserted: {}", count);
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) {
Map<Long, BigDecimal> m = new HashMap<>();
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.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.MoneyUtil;
import com.ga.core.mapper.installment.InstallmentPlanMapper;
import com.ga.core.mapper.ledger.RecruitLedgerMapper;
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.product.ContractResp;
import com.ga.core.vo.product.ContractSearchParam;
@@ -25,6 +30,9 @@ import java.util.List;
* Step 4 calcRecruit: 모집 수수료 계산 (계약 회차)
*
* 원칙: 계약시점의 commission_rate / payout_rule 적용 (effective_from 기준)
*
* 추가: 1200%(BatchRegulatoryLimitChecker) 검증 한도 초과 계약은
* InstallmentPlan(분급 7년) 생성. Step 4.5 역할을 Step 4 내에서 수행.
*/
@Slf4j
@Component
@@ -36,6 +44,9 @@ public class CalcRecruitStep implements Tasklet {
private final ContractMapper contractMapper;
private final RecruitLedgerMapper recruitMapper;
private final CommissionCalculator calculator;
private final BatchRegulatoryLimitChecker regulatoryChecker;
private final BatchInstallmentPlanGenerator installmentGenerator;
private final InstallmentPlanMapper installmentPlanMapper;
private static final int BATCH_SIZE = 1000;
@@ -55,10 +66,33 @@ public class CalcRecruitStep implements Tasklet {
recruitMapper.deleteBySettleMonth(ctx.getSettleMonth());
List<LedgerVO> batch = new ArrayList<>(BATCH_SIZE);
List<InstallmentPlanVO> installmentBatch = new ArrayList<>();
int total = 0;
int violationCount = 0;
for (ContractResp c : contracts) {
LedgerVO led = calculator.calcRecruitLedger(c, ctx.getSettleMonth());
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);
if (batch.size() >= BATCH_SIZE) {
recruitMapper.batchInsert(batch);
@@ -71,8 +105,15 @@ public class CalcRecruitStep implements Tasklet {
total += batch.size();
}
// 분급 계획 일괄 저장
if (!installmentBatch.isEmpty()) {
installmentPlanMapper.insertBatch(installmentBatch);
log.info("[Step4] installment plans created: {} (violations: {})",
installmentBatch.size(), violationCount);
}
ctx.setRecruitCount(total);
log.info("[Step4] recruit ledger inserted: {}", total);
log.info("[Step4] recruit ledger inserted: {}, regulatory violations: {}", total, violationCount);
return RepeatStatus.FINISHED;
}
}
@@ -1,11 +1,11 @@
package com.ga.batch.settlement.step;
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.MoneyUtil;
import com.ga.core.mapper.ledger.RecruitLedgerMapper;
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.vo.product.ContractResp;
import com.ga.core.vo.product.ContractSearchParam;
@@ -26,7 +26,8 @@ import java.util.List;
/**
* Step 6 chargebackException: 환수 자동생성 + 예외금액 자동계산
*
* 환수 대상: 정산월에 LAPSE(실효) 계약 계약일~실효까지 경과월수로 환수율 결정.
* 환수 계산: BatchChargebackCalculator (chargeback_grade 5단계 차등환수) 사용.
* 환수 원금: 해당 계약의 모집 원장 합산 (recruit_ledger 기준).
*/
@Slf4j
@Component
@@ -37,8 +38,8 @@ public class ChargebackExceptionStep implements Tasklet {
private final SettlementContext ctx;
private final ContractMapper contractMapper;
private final RecruitLedgerMapper recruitMapper;
private final RuleMapper ruleMapper;
private final ChargebackMapper chargebackMapper;
private final BatchChargebackCalculator chargebackCalculator;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
@@ -51,30 +52,30 @@ public class ChargebackExceptionStep implements Tasklet {
List<ChargebackVO> chargebacks = new ArrayList<>();
for (ContractResp c : lapsed) {
if (c.getLapseDate() == null) continue;
if (c.getLapseDate() == null || c.getContractDate() == null) continue;
String lapseMonth = DateUtil.getSettleMonth(c.getLapseDate());
if (!ctx.getSettleMonth().equals(lapseMonth)) continue;
if (c.getContractDate() == null) continue;
int monthsActive = DateUtil.calcMonthDiff(
DateUtil.getSettleMonth(c.getContractDate()), lapseMonth);
BigDecimal cbRate = ruleMapper.findChargebackRate(
c.getCompanyCode(), c.getInsuranceType(), monthsActive, c.getLapseDate());
if (cbRate == null || cbRate.signum() <= 0) continue;
// 모집 원장에서 수수료 조회
BigDecimal originalCommission = recruitMapper.sumByContract(c.getContractId());
if (originalCommission == null || originalCommission.signum() <= 0) continue;
// 모집수수료 × 환수
BigDecimal originalAmount = MoneyUtil.zero(c.getPremium())
.multiply(cbRate)
.divide(MoneyUtil.HUNDRED, 2, java.math.RoundingMode.HALF_UP);
// BatchChargebackCalculator: chargeback_grade 5단 차등환수
ChargebackResult result = chargebackCalculator.calculate(
c.getContractDate(), c.getLapseDate(),
originalCommission, c.getInsuranceType());
if (result.chargebackAmount().signum() <= 0) continue;
ChargebackVO cb = new ChargebackVO();
cb.setContractId(c.getContractId());
cb.setAgentId(c.getAgentId());
cb.setPolicyNo(c.getPolicyNo());
cb.setLapseMonth(monthsActive);
cb.setCbRate(cbRate);
cb.setCbAmount(originalAmount);
cb.setRemainAmount(originalAmount);
cb.setLapseMonth(result.monthsElapsed());
cb.setCbRate(result.appliedPercent());
cb.setCbAmount(result.chargebackAmount());
cb.setRemainAmount(result.chargebackAmount());
cb.setSettleMonth(ctx.getSettleMonth());
cb.setStatus("NEW");
chargebacks.add(cb);
@@ -84,7 +85,7 @@ public class ChargebackExceptionStep implements Tasklet {
chargebackMapper.insertBatch(chargebacks);
}
ctx.setChargebackCount(chargebacks.size());
log.info("[Step6] chargeback inserted: {}", chargebacks.size());
log.info("[Step6] chargeback inserted: {} (using BatchChargebackCalculator)", chargebacks.size());
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