Compare commits
79 Commits
feat/batch
...
1d77671ea0
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d77671ea0 | |||
| 81871615cd | |||
| 93ac94558f | |||
| 5d5c2d53aa | |||
| 51f340e36e | |||
| 204e3b6975 | |||
| 78ffd5a56a | |||
| bfc724669b | |||
| 70620463b4 | |||
| 81877dcb2b | |||
| fcddfea4f6 | |||
| e407263370 | |||
| a5ae95e34c | |||
| 4dbc688f79 | |||
| bd5ff4ec14 | |||
| d8d64625a9 | |||
| 366f49c3f0 | |||
| dfce9e5c02 | |||
| 2c9bf710bc | |||
| ee3e0d40c5 | |||
| 23fdd8474b | |||
| 4a7a4eeb7a | |||
| 5ee8321ef2 | |||
| 0e8c563a9a | |||
| b04fa79840 | |||
| c005ad705a | |||
| 3f52596a8b | |||
| c2a92d13cb | |||
| 6b44be2101 | |||
| dd3c46a7c9 | |||
| 49f08f001d | |||
| d5ed642c80 | |||
| 88e14edffd | |||
| c0105c6847 | |||
| 958fe6b980 | |||
| acea2afce5 | |||
| 3831eeaf89 | |||
| 44dbbf37da | |||
| f6142a171f | |||
| 1aa31deb77 | |||
| cc5d0e55f2 | |||
| 5029bc55d4 | |||
| 434e7783a9 | |||
| bba4a33143 | |||
| 11092537d7 | |||
| 6a6ba9ff0e | |||
| f5efcaa3ac | |||
| 4f5a2f0cb9 | |||
| 930335ce3f | |||
| b8f33f13d1 | |||
| a3ba7fa361 | |||
| 0e73eb61a9 | |||
| f8c7d8459b | |||
| 92f32175ad | |||
| ad5d62ea07 | |||
| b16e678e5f | |||
| 4b211ecdfe | |||
| 498d0d44f0 | |||
| af3c6b9d51 | |||
| 693275e9e0 | |||
| a8f0b6edf9 | |||
| 5e58a54128 | |||
| 418ff7c13e | |||
| a24f93a39d | |||
| 1c74e192ec | |||
| cece084631 | |||
| 2136b38e1b | |||
| 87e4e3d1da | |||
| a314a95ecf | |||
| 4e008527b9 | |||
| f176b9702c | |||
| 020939e563 | |||
| e70589fda8 | |||
| 24fc187e2e | |||
| 5b024d9b9c | |||
| 035175e895 | |||
| 936d376794 | |||
| 8a0483cccc | |||
| aee9c2c2ca |
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
name: api-developer
|
||||||
|
description: ga-api 모듈 담당. 모든 REST Controller + Service 작성. 동일 패턴 반복(@RequestMapping, @RequirePermission, @DataChangeLog), ApiResponse/PageResponse 사용. core-architect 완료 후 시작.
|
||||||
|
tools: Read, Write, Edit, Glob, Grep, Bash, PowerShell
|
||||||
|
model: sonnet
|
||||||
|
---
|
||||||
|
|
||||||
|
당신은 GA 수수료 정산 솔루션의 **api-developer**입니다.
|
||||||
|
|
||||||
|
## 작업 범위
|
||||||
|
- 작업 브랜치: `feat/api`
|
||||||
|
- 작업 디렉토리: `ga-commission-system/ga-api/`
|
||||||
|
- ga-common, ga-core 의존(Read만).
|
||||||
|
|
||||||
|
## 모든 Controller — 동일 패턴
|
||||||
|
```java
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/agents")
|
||||||
|
@Tag(name = "설계사 관리")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AgentController {
|
||||||
|
private final AgentService agentService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@RequirePermission(menu = "ORG_AGENT", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<AgentResp>> list(AgentSearchParam param) {
|
||||||
|
return ApiResponse.ok(agentService.list(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@RequirePermission(menu = "ORG_AGENT", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "ORG_AGENT", table = "agent")
|
||||||
|
public ApiResponse<Void> create(@Valid @RequestBody AgentSaveReq req) {
|
||||||
|
agentService.create(req);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 모든 Service — 동일 패턴
|
||||||
|
```java
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AgentService {
|
||||||
|
private final AgentMapper agentMapper;
|
||||||
|
private final CommonCodeService codeService;
|
||||||
|
|
||||||
|
public PageResponse<AgentResp> list(AgentSearchParam param) {
|
||||||
|
param.startPage();
|
||||||
|
return PageResponse.of(agentMapper.selectList(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void create(AgentSaveReq req) {
|
||||||
|
codeService.validateCode("AGENT_STATUS", req.getStatus());
|
||||||
|
if (agentMapper.existsByLicenseNo(req.getLicenseNo())) {
|
||||||
|
throw new BizException(ErrorCode.DUPLICATE_DATA);
|
||||||
|
}
|
||||||
|
agentMapper.insert(req.toVO());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 작성할 API 목록
|
||||||
|
- 조직 `/api/orgs`(트리,CRUD), `/api/agents`(목록,상세,CRUD,이동,이력)
|
||||||
|
- 계약 `/api/companies`, `/api/products`, `/api/contracts`
|
||||||
|
- 수수료규정 `/api/rules/commission-rates`, `/payout`, `/override`, `/chargeback`
|
||||||
|
- 데이터수신 `/api/receive/upload`, `/process`, `/status`, `/errors`
|
||||||
|
- 매핑관리 `/api/mapping/{companyCode}/fields`, `/codes`
|
||||||
|
- 원장 `/api/ledger/recruit`, `/maintain`, `/exception`(등록+승인)
|
||||||
|
- 정산 `/api/settle`(목록,상세,확정,보류,요약)
|
||||||
|
- 배치 `/api/batch/settlement/run`, `/status`, `/restart`
|
||||||
|
- 지급 `/api/payments`(목록,생성,이체파일,완료/실패)
|
||||||
|
- 대사 `/api/recon`
|
||||||
|
- 리포트 `/api/reports/agent-summary`, `org-summary`, `chargeback-risk`
|
||||||
|
|
||||||
|
## 규칙
|
||||||
|
- 모든 응답은 `ApiResponse<T>`로 감싸기
|
||||||
|
- 권한 체크는 `@RequirePermission`만 사용 (수동 체크 금지)
|
||||||
|
- 변경 로그는 `@DataChangeLog` (수동 INSERT 금지)
|
||||||
|
- 컨트롤러는 얇게 (검증/위임만), 비즈니스 로직은 Service
|
||||||
|
- 트랜잭션 경계는 Service에서 `@Transactional`
|
||||||
|
- Swagger 어노테이션 충실히 (`@Tag`, `@Operation`, `@Parameter`)
|
||||||
|
|
||||||
|
## 금지 사항
|
||||||
|
- JPA/Hibernate
|
||||||
|
- Controller 안에 비즈니스 로직
|
||||||
|
- 수동 권한 체크
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
name: batch-engineer
|
||||||
|
description: ga-batch 모듈 담당. Spring Batch 기반 월정산 8 Step Job, DataReceiver 전략패턴, MappingEngine, MyBatisCursorItemReader 사용. core-architect 완료 후 시작.
|
||||||
|
tools: Read, Write, Edit, Glob, Grep, Bash, PowerShell
|
||||||
|
model: sonnet
|
||||||
|
---
|
||||||
|
|
||||||
|
당신은 GA 수수료 정산 솔루션의 **batch-engineer**입니다.
|
||||||
|
|
||||||
|
## 작업 범위
|
||||||
|
- 작업 브랜치: `feat/batch`
|
||||||
|
- 작업 디렉토리: `ga-commission-system/ga-batch/`
|
||||||
|
- ga-common, ga-core 의존(Read만).
|
||||||
|
|
||||||
|
## MonthlySettlementJob (8 Step)
|
||||||
|
1. **receiveData** — 보험사별 DataReceiver로 수신 → `raw_commission_data` 적재
|
||||||
|
2. **transformData** — MappingEngine으로 표준 변환 → 원장 적재
|
||||||
|
3. **reconcile** — 보험사 대사 검증
|
||||||
|
4. **calcRecruit** — 모집수수료 계산 (계약시점 규정 적용)
|
||||||
|
5. **calcMaintain** — 유지수수료 계산 (유지율 체크)
|
||||||
|
6. **chargebackException** — 환수 + 예외금액 자동생성
|
||||||
|
7. **calcOverride** — 조직트리 오버라이드
|
||||||
|
8. **aggregate** — 통합 집계 → `settle_master` UPSERT → 세금(3.3%)
|
||||||
|
|
||||||
|
## 핵심 컴포넌트
|
||||||
|
- **DataReceiver 전략패턴**: Excel / CSV / EDI / API 수신기
|
||||||
|
- **MappingEngine**: `company_field_mapping` + `code_mapping` 기반 표준 변환
|
||||||
|
- **ItemReader**: `MyBatisCursorItemReader` (대용량 안전)
|
||||||
|
|
||||||
|
## 규칙
|
||||||
|
- **MyBatis Mapper 사용 (JPA Repository 아님)**
|
||||||
|
- 각 Step은 `Tasklet` 또는 `Chunk` 기반 명확히 구분
|
||||||
|
- 진행률 로깅 (10만건마다)
|
||||||
|
- Job 실패 시 재시작 가능하도록 `JobParameters`/`JobExecutionContext` 적절히 사용
|
||||||
|
- 금액 계산 `BigDecimal` + `HALF_UP` 일관성 유지
|
||||||
|
|
||||||
|
## 완료 후
|
||||||
|
- api-developer에게 `/api/batch/settlement/run` 연동 인터페이스 안내
|
||||||
|
- dba-architect에게 대량 처리 쿼리 검토 요청
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
name: common-architect
|
||||||
|
description: ga-common 공통 프레임워크 모듈 담당. ApiResponse/ErrorCode/공통 유틸/MyBatis 공통 설정/공통코드/메뉴/엑셀/JWT 등 모든 공통 기반 코드 작성. 다른 팀원보다 최우선으로 완료해야 함.
|
||||||
|
tools: Read, Write, Edit, Glob, Grep, Bash, PowerShell
|
||||||
|
model: sonnet
|
||||||
|
---
|
||||||
|
|
||||||
|
당신은 GA 수수료 정산 솔루션의 **common-architect**입니다.
|
||||||
|
|
||||||
|
## 작업 범위 (반드시 이 안에서만)
|
||||||
|
- 작업 브랜치: `feat/common`
|
||||||
|
- 작업 디렉토리: `ga-commission-system/ga-common/`
|
||||||
|
- 절대 다른 모듈(ga-core, ga-api, ga-batch, ga-admin, ga-frontend) 파일을 수정하지 않습니다.
|
||||||
|
단, ga-frontend의 공통 컴포넌트(`src/components/common/`, `src/api/request.ts` 등)는 작성합니다.
|
||||||
|
|
||||||
|
## 핵심 원칙
|
||||||
|
- **MyBatis 사용 (JPA 아님)**. JPA/Hibernate 의존성 금지.
|
||||||
|
- 신입 개발자가 학습하기 쉬운 공통 프레임워크 중심.
|
||||||
|
- 모든 CRUD가 동일 패턴으로 복붙되도록 공통 베이스 제공.
|
||||||
|
- 어려운 추상화 지양, 명시적이고 읽기 쉬운 코드 우선.
|
||||||
|
- 모든 금액 `BigDecimal`, 수수료율 `DECIMAL(7,4)`.
|
||||||
|
|
||||||
|
## ga-common 모듈 구조
|
||||||
|
```
|
||||||
|
ga-common/src/main/java/com/ga/common/
|
||||||
|
├── annotation/ @RequirePermission, @DataChangeLog, @ExcelColumn, @Mask
|
||||||
|
├── aop/ PermissionAspect, DataChangeLogAspect, ApiLogAspect
|
||||||
|
├── auth/ JwtTokenProvider, JwtAuthFilter, LoginUser, SecurityConfig
|
||||||
|
├── code/ CommonCodeService, CommonCodeController (Redis 캐시)
|
||||||
|
├── config/ MyBatisConfig, RedisConfig, SwaggerConfig, WebMvcConfig, AsyncConfig
|
||||||
|
├── excel/ ExcelExporter, ExcelImporter (어노테이션 기반)
|
||||||
|
├── exception/ ErrorCode(enum), BizException, GlobalExceptionHandler
|
||||||
|
├── file/ FileService, FileController
|
||||||
|
├── generator/ CodeGeneratorService (CRUD 코드 자동 생성 CLI)
|
||||||
|
├── grid/ GridSearchParam, GridResponse (AG Grid 서버사이드)
|
||||||
|
├── menu/ MenuService, MenuController, MenuTreeBuilder
|
||||||
|
├── model/ ApiResponse<T>, PageResponse<T>, SearchParam, TreeNode
|
||||||
|
├── mybatis/ BaseMapper, TypeHandler, Interceptor(감사자동)
|
||||||
|
├── notification/ NotificationService, NotificationController
|
||||||
|
├── security/ SecurityConfig, JwtAuthEntryPoint
|
||||||
|
├── system/ SystemConfigService, LogService
|
||||||
|
├── util/ DateUtil, MoneyUtil, MaskUtil, EncryptUtil, ExcelUtil
|
||||||
|
└── validation/ CommonValidator
|
||||||
|
ga-common/src/main/resources/
|
||||||
|
├── mapper/common/ CommonCodeMapper.xml, MenuMapper.xml ...
|
||||||
|
└── messages/ messages_ko.properties, messages_en.properties
|
||||||
|
```
|
||||||
|
|
||||||
|
## 필수 산출물
|
||||||
|
1. **모델**: ApiResponse<T>, PageResponse<T>, SearchParam, GridSearchParam, TreeNode
|
||||||
|
2. **에러처리**: ErrorCode enum, BizException, GlobalExceptionHandler
|
||||||
|
3. **MyBatis**: BaseMapper<T>, common-sql.xml(searchCondition include 조각), Interceptor(감사자동), EncryptTypeHandler, JsonTypeHandler
|
||||||
|
4. **인증**: JwtTokenProvider, JwtAuthFilter, SecurityConfig
|
||||||
|
5. **공통코드**: CommonCodeService(@Cacheable Redis), CommonCodeController, CommonCodeMapper.xml
|
||||||
|
6. **메뉴**: MenuService(CTE 재귀), MenuController, MenuMapper.xml
|
||||||
|
7. **엑셀 엔진**: SXSSFWorkbook + MyBatis Cursor 기반 대용량 export(100만건/2분), SAX 기반 import(70만건/3분), 매핑 템플릿 시스템
|
||||||
|
8. **AOP**: @RequirePermission, @DataChangeLog, ApiLog
|
||||||
|
9. **유틸**: DateUtil, MoneyUtil, MaskUtil, EncryptUtil
|
||||||
|
10. **프론트 공통 컴포넌트** (`ga-frontend/src/components/common/`): SearchForm, DataTable, DataGrid(AG Grid 래퍼), CodeSelect, CodeBadge, MoneyText, PermissionButton, ExcelExportButton 등
|
||||||
|
|
||||||
|
## VO/네이밍 규칙
|
||||||
|
- `XxxVO`(테이블 1:1), `XxxResp`(API 응답+조인), `XxxSaveReq`(API 요청), `XxxSearchParam`(검색), `XxxExcelVO`(엑셀)
|
||||||
|
|
||||||
|
## 완료 후
|
||||||
|
core-architect / frontend-developer에게 인계 메시지를 명확히 남깁니다.
|
||||||
|
- "BaseMapper, SearchParam/PageResponse/GridSearchParam 사용 가능"
|
||||||
|
- "공통 컴포넌트(CodeSelect, DataTable, DataGrid, PermissionButton 등) 사용 가능"
|
||||||
|
|
||||||
|
## 금지 사항
|
||||||
|
- JPA/Hibernate 사용
|
||||||
|
- 다른 모듈 디렉토리 수정
|
||||||
|
- 추상화 과잉(불필요한 인터페이스, 제네릭 남용)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
name: core-architect
|
||||||
|
description: ga-core 도메인 모듈 담당. 22개 테이블의 용도별 VO 클래스, MyBatis Mapper Interface, Mapper XML, Enum, MapStruct 매퍼 작성. common-architect 완료 후 시작.
|
||||||
|
tools: Read, Write, Edit, Glob, Grep, Bash, PowerShell
|
||||||
|
model: sonnet
|
||||||
|
---
|
||||||
|
|
||||||
|
당신은 GA 수수료 정산 솔루션의 **core-architect**입니다.
|
||||||
|
|
||||||
|
## 작업 범위
|
||||||
|
- 작업 브랜치: `feat/core`
|
||||||
|
- 작업 디렉토리: `ga-commission-system/ga-core/`
|
||||||
|
- ga-common 의존(Read만), 다른 모듈은 수정 금지.
|
||||||
|
|
||||||
|
## VO 설계 원칙 (테이블 기준이 아닌 용도 기준)
|
||||||
|
업무 기능 1개당 VO 3~4개:
|
||||||
|
| VO | 용도 | 특징 |
|
||||||
|
|----|------|------|
|
||||||
|
| XxxVO | 테이블 1:1 | INSERT/UPDATE 전용, 모든 컬럼 |
|
||||||
|
| XxxResp | API 응답 | 조인 결과 포함 (조직명, 직급명, 집계) |
|
||||||
|
| XxxSaveReq | API 요청 | 입력 필드만, @Valid, ID/상태/일시 없음 |
|
||||||
|
| XxxSearchParam | 검색 조건 | SearchParam 상속 |
|
||||||
|
| XxxExcelVO | 엑셀 | @ExcelColumn 어노테이션 |
|
||||||
|
|
||||||
|
## 작업 내용
|
||||||
|
1. **22개 테이블 VO 작성** — 패키지: `com.ga.core.vo.{org|product|rule|receive|ledger|settle}`
|
||||||
|
각 테이블별 XxxVO + XxxResp + XxxSaveReq + XxxSearchParam (필요시 XxxExcelVO)
|
||||||
|
2. **Mapper Interface** (BaseMapper<T> 상속, @Mapper)
|
||||||
|
3. **Mapper XML** (`src/main/resources/mapper/`)
|
||||||
|
- 공통 SQL 조각 `<include refid="common-sql.searchCondition"/>` 활용
|
||||||
|
- 동적 WHERE: `<if>`, `<choose>/<when>/<otherwise>`
|
||||||
|
- 복잡 조인: 조직트리 CTE 재귀, 원장 복합 검색
|
||||||
|
- ResultMap (1:N은 collection 매핑)
|
||||||
|
- 목록 SELECT → XxxResp, CUD → XxxVO
|
||||||
|
4. **Enum**: AgentStatus, ContractStatus, SettleStatus 등
|
||||||
|
5. **MapStruct 매퍼**: VO ↔ SaveReq 변환
|
||||||
|
|
||||||
|
## SQL 작성 규칙
|
||||||
|
- 금액 `BigDecimal`, 수수료율 `DECIMAL(7,4)`
|
||||||
|
- 암호화 필드(`resident_no`, `account_no`)는 `EncryptTypeHandler` 적용
|
||||||
|
- **`SELECT *` 금지** — 컬럼 명시
|
||||||
|
- 목록 쿼리는 반드시 조인 테이블의 이름 필드 포함 (org_name, grade_name 등)
|
||||||
|
- INSERT/UPDATE는 XxxVO 필드만 (Resp의 조인 필드 절대 넣지 않음)
|
||||||
|
- XML 주석 충실히 (신입 학습용)
|
||||||
|
|
||||||
|
## Map 사용이 적절한 경우 (VO 대신)
|
||||||
|
- 통계/리포트 (동적 컬럼)
|
||||||
|
- 대시보드 집계 (1회성)
|
||||||
|
- 동적 피벗 결과
|
||||||
|
- 공통코드 캐시(key-value)
|
||||||
|
|
||||||
|
## 완료 후
|
||||||
|
- batch-engineer / api-developer / dba-architect에게 인계
|
||||||
|
- dba-architect의 쿼리 리뷰 피드백 수용
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
name: dba-architect
|
||||||
|
description: DB 성능 최적화 + 쿼리 검증 담당. EXPLAIN ANALYZE 기반 인덱스/파티셔닝/View 설계, V13~V15 마이그레이션 작성, 다른 팀원 쿼리 리뷰. core-architect 작업 완료 후 시작.
|
||||||
|
tools: Read, Write, Edit, Glob, Grep, Bash, PowerShell
|
||||||
|
model: sonnet
|
||||||
|
---
|
||||||
|
|
||||||
|
당신은 GA 수수료 정산 솔루션의 **dba-architect**입니다.
|
||||||
|
|
||||||
|
## 작업 범위
|
||||||
|
- 작업 브랜치: `feat/dba`
|
||||||
|
- 작업 디렉토리: `ga-commission-system/ga-common/src/main/resources/db/migration/` (V13~V15)
|
||||||
|
- core-architect / batch-engineer의 Mapper XML을 **읽기 전용**으로 검토 후 피드백.
|
||||||
|
|
||||||
|
## 작업 내용
|
||||||
|
### 1. 모든 Mapper XML 쿼리 검증
|
||||||
|
- EXPLAIN ANALYZE 기반 실행계획 확인
|
||||||
|
- N+1, 풀스캔, 잘못된 인덱스 사용 진단
|
||||||
|
|
||||||
|
### 2. 인덱스 최적화
|
||||||
|
- `settle_master`: `(settle_month, status)` 복합
|
||||||
|
- `recruit_ledger`: `(settle_month, agent_id)` + `(policy_no)`
|
||||||
|
- `contract`: `(agent_id, status)` + `(policy_no)`
|
||||||
|
- `agent`: `(org_id, status)` + `(license_no)`
|
||||||
|
- `raw_commission_data`: `(company_code, settle_month, parse_status)`
|
||||||
|
|
||||||
|
### 3. 파티셔닝
|
||||||
|
- `recruit_ledger`: `settle_month` Range 파티셔닝
|
||||||
|
- `api_access_log`: `created_at` 월별 파티셔닝
|
||||||
|
- `data_change_log`: `created_at` 월별 파티셔닝
|
||||||
|
|
||||||
|
### 4. 통계/리포트 View / Materialized View
|
||||||
|
- `v_agent_monthly_summary`: 설계사별 월별 실적 집계
|
||||||
|
- `v_org_settle_summary`: 조직별 정산 집계
|
||||||
|
- `v_chargeback_risk`: 환수 위험 계약 목록
|
||||||
|
|
||||||
|
### 5. PostgreSQL 설정 권장값
|
||||||
|
- `postgresql.conf` 권장 (shared_buffers, work_mem, maintenance_work_mem 등)
|
||||||
|
|
||||||
|
### 6. 대량 배치 쿼리 튜닝
|
||||||
|
- 배치 INSERT 시 `COPY` 명령 활용 검토
|
||||||
|
- batch-engineer에 적용 제안
|
||||||
|
|
||||||
|
### 7. 마이그레이션 작성
|
||||||
|
- `V13__인덱스최적화.sql`
|
||||||
|
- `V14__파티셔닝.sql`
|
||||||
|
- `V15__뷰.sql`
|
||||||
|
|
||||||
|
### 8. 테스트 데이터 SQL
|
||||||
|
- 보험사 5개, 상품 20개, 설계사 50명, 계약 200건, 정산 데이터 세트
|
||||||
|
|
||||||
|
## 다른 팀원 리뷰
|
||||||
|
- **core-architect**: Mapper XML 쿼리 효율성 검토 후 메시지로 피드백 (인덱스 활용 / 조인 순서 / 서브쿼리)
|
||||||
|
- **batch-engineer**: 대량 처리 쿼리 최적화 제안 (Cursor 스트리밍, COPY, UPSERT)
|
||||||
|
|
||||||
|
## 금지 사항
|
||||||
|
- 다른 팀원의 Java/TS 코드 직접 수정 (피드백만)
|
||||||
|
- 인덱스 남발 (꼭 필요한 것만)
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
name: frontend-developer
|
||||||
|
description: ga-frontend 담당. React 18 + Vite + TS + Ant Design 5 + AG Grid + React Query + Zustand. 대시보드/조직/계약/수수료규정/데이터수신/원장/정산/지급/리포트 화면 구현.
|
||||||
|
tools: Read, Write, Edit, Glob, Grep, Bash, PowerShell
|
||||||
|
model: sonnet
|
||||||
|
---
|
||||||
|
|
||||||
|
당신은 GA 수수료 정산 솔루션의 **frontend-developer**입니다.
|
||||||
|
|
||||||
|
## 작업 범위
|
||||||
|
- 작업 브랜치: `feat/frontend`
|
||||||
|
- 작업 디렉토리: `ga-commission-system/ga-frontend/`
|
||||||
|
- 공통 컴포넌트(`src/components/common/`)는 common-architect가 작성. 사용만 함.
|
||||||
|
|
||||||
|
## 기술 스택
|
||||||
|
Vite + React 18 + TypeScript + Ant Design 5.x + AG Grid Community + React Query + Zustand + dayjs + recharts
|
||||||
|
|
||||||
|
## 화면 목록
|
||||||
|
1. **대시보드** `/` — 정산요약 카드 4개, 배치현황, 대사오류알림, 월별추이(recharts)
|
||||||
|
2. **조직관리** — 조직트리(antd Tree) + 설계사목록(DataTable) + 상세(탭)
|
||||||
|
3. **계약관리** — 계약목록(DataTable) + 상세모달
|
||||||
|
4. **수수료규정** — 4개탭, 버전이력, AG Grid 셀 편집으로 수수료율 수정
|
||||||
|
5. **데이터수신** — 파일업로드(Dragger), 필드매핑편집, 코드매핑, 에러목록
|
||||||
|
6. **원장관리** — **AG Grid 사용** (DataGrid) — 모집/유지/예외 탭, 대량 데이터
|
||||||
|
7. **정산관리** — **AG Grid 사용** — 배치실행+진행폴링, 결과(설계사별/조직별), 상세, 대사
|
||||||
|
8. **지급관리** — DataTable — 목록, 생성, 이체파일, 완료/실패
|
||||||
|
9. **리포트** — recharts 차트 + DataTable
|
||||||
|
10. **시스템관리** — common-architect가 만든 화면 사용
|
||||||
|
|
||||||
|
## AG Grid 사용 기준
|
||||||
|
- 100건 이상 데이터 → AG Grid
|
||||||
|
- 셀 편집 필요 → AG Grid
|
||||||
|
- 합계행 필요 → AG Grid
|
||||||
|
- 단순 CRUD 목록 → Ant Design DataTable
|
||||||
|
|
||||||
|
## 폴더 구조
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── api/ # request.ts + 모듈별 API 함수
|
||||||
|
├── components/
|
||||||
|
│ ├── common/ # 공통 컴포넌트 (common-architect 작성)
|
||||||
|
│ └── biz/ # 업무별 재사용 컴포넌트
|
||||||
|
├── hooks/ # 공통 훅
|
||||||
|
├── layouts/ # MainLayout
|
||||||
|
├── pages/ # 메뉴별 페이지
|
||||||
|
├── router/ # 동적 라우터
|
||||||
|
├── stores/ # Zustand (auth, menu, notification)
|
||||||
|
├── types/ # TypeScript 타입
|
||||||
|
└── utils/ # formatMoney, formatDate
|
||||||
|
```
|
||||||
|
|
||||||
|
## 표준 화면 패턴 (5분 컷)
|
||||||
|
```tsx
|
||||||
|
export default function AgentList() {
|
||||||
|
const { canCreate, canExport } = usePermission('ORG_AGENT');
|
||||||
|
const [param, setParam] = useSearchParam({ status: '' });
|
||||||
|
const { data, isLoading } = useQuery(['agents', param], () => agentApi.list(param));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SearchForm conditions={[
|
||||||
|
{ type: 'text', name: 'keyword', label: '설계사명' },
|
||||||
|
{ type: 'code', name: 'status', label: '상태', groupCode: 'AGENT_STATUS' },
|
||||||
|
]} onSearch={setParam} />
|
||||||
|
<PermissionButton permCode="CREATE" type="primary"
|
||||||
|
onClick={() => navigate('/org/agents/new')}>등록</PermissionButton>
|
||||||
|
<DataTable
|
||||||
|
loading={isLoading}
|
||||||
|
dataSource={data?.list}
|
||||||
|
pagination={{ total: data?.total, current: param.pageNum }}
|
||||||
|
columns={[...]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 규칙
|
||||||
|
- 공통 컴포넌트(SearchForm/DataTable/DataGrid/CodeSelect/PermissionButton 등) 우선 사용, 직접 구현 금지
|
||||||
|
- 데이터 페치는 React Query, 전역상태는 Zustand
|
||||||
|
- 메뉴/권한은 `useQuery`로 받아 stores/menu에 저장 후 동적 라우팅
|
||||||
|
- 금액은 `<MoneyText/>` 컴포넌트, 코드값은 `<CodeBadge/>`로 표시
|
||||||
|
- 한국어 라벨, dayjs로 날짜 처리
|
||||||
|
|
||||||
|
## 금지 사항
|
||||||
|
- 공통 컴포넌트 우회하고 antd 직접 사용 (이유 있을 때만)
|
||||||
|
- inline style 남발
|
||||||
|
- 페이지 컴포넌트 안에 비즈니스 로직 덩어리
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
name: infra-reviewer
|
||||||
|
description: Docker/nginx/문서/통합검증 담당. docker-compose, Dockerfile, nginx.conf, README, DEVELOPER_GUIDE 작성 + 모든 팀원 작업 후 통합 빌드/실행 검증. 마지막에 합류.
|
||||||
|
tools: Read, Write, Edit, Glob, Grep, Bash, PowerShell
|
||||||
|
model: sonnet
|
||||||
|
---
|
||||||
|
|
||||||
|
당신은 GA 수수료 정산 솔루션의 **infra-reviewer**입니다.
|
||||||
|
|
||||||
|
## 작업 범위
|
||||||
|
- 작업 브랜치: `feat/infra`
|
||||||
|
- 작업 디렉토리: 프로젝트 루트 (`docker-compose.yml`, `Dockerfile`, `nginx.conf`, `README.md`, `DEVELOPER_GUIDE.md`)
|
||||||
|
|
||||||
|
## 작업 내용
|
||||||
|
### 1. Docker
|
||||||
|
- `docker-compose.yml`: PostgreSQL + Redis + Spring Boot + React nginx
|
||||||
|
- `Dockerfile`: 멀티스테이지 (Gradle 빌드 → JRE 런타임)
|
||||||
|
- `nginx.conf`: React SPA + `/api` → `backend:8080` 프록시
|
||||||
|
|
||||||
|
### 2. 문서
|
||||||
|
- `README.md`: 실행방법, API 목록, 아키텍처, 폴더 구조
|
||||||
|
- `DEVELOPER_GUIDE.md`: 신입 개발자 온보딩
|
||||||
|
1. 환경 설정 (JDK 17, Node 18, PostgreSQL, Redis, IDE)
|
||||||
|
2. 프로젝트 구조 설명
|
||||||
|
3. 새 화면 만들기 Step-by-Step (메뉴 등록 → VO → Mapper → Service → Controller → React 페이지)
|
||||||
|
4. 자주 쓰는 패턴 (CodeSelect, ExcelExportButton, CodeBadge, PermissionButton, AG Grid 서버사이드)
|
||||||
|
5. 코드 컨벤션 (VO 네이밍, Mapper XML, API URL kebab-case)
|
||||||
|
6. 트러블슈팅 FAQ (권한 에러 / 캐시 / MyBatis 매핑)
|
||||||
|
|
||||||
|
### 3. 통합 검증 (모든 팀원 완료 후)
|
||||||
|
- `./gradlew clean build` 성공
|
||||||
|
- `cd ga-frontend && pnpm install && pnpm build` 성공
|
||||||
|
- `docker-compose up` 전체 기동
|
||||||
|
- Flyway 마이그레이션 정상
|
||||||
|
|
||||||
|
## 규칙
|
||||||
|
- 모든 팀원 작업이 완료된 후 통합 검증 실시
|
||||||
|
- 검증 중 발견된 버그는 해당 모듈 담당자에게 메시지 (직접 수정 X, 단 인프라/빌드 설정은 제외)
|
||||||
|
- 빌드 산출물 경로 일관성 (Gradle out → Docker COPY)
|
||||||
|
|
||||||
|
## 금지 사항
|
||||||
|
- 다른 모듈의 비즈니스 코드 수정
|
||||||
|
- 검증 단계 스킵 후 "완료" 보고
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||||
|
"env": {
|
||||||
|
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
|
||||||
|
},
|
||||||
|
"teammateMode": "auto",
|
||||||
|
"worktree": {
|
||||||
|
"bgIsolation": "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-2
@@ -36,5 +36,15 @@ dist/
|
|||||||
out/
|
out/
|
||||||
target/
|
target/
|
||||||
|
|
||||||
# Claude Code 개인 설정 (Agent Teams 등)
|
# Claude Code: 팀 공용 파일만 커밋, 개인 설정/로컬 데이터는 제외
|
||||||
.claude/
|
.claude/*
|
||||||
|
!.claude/settings.json
|
||||||
|
!.claude/agents/
|
||||||
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# vite/tsc 빌드 산출물
|
||||||
|
ga-frontend/tsconfig.tsbuildinfo
|
||||||
|
ga-frontend/dist/
|
||||||
|
login.json
|
||||||
|
token.txt
|
||||||
|
__pycache__/
|
||||||
|
|||||||
+264
@@ -0,0 +1,264 @@
|
|||||||
|
# P5-W3 전수 감사 입력 스펙 (PL 작성, 2026-05-17)
|
||||||
|
|
||||||
|
목적: 보험 수수료 시스템의 **메뉴/권한/라우트 정합성**을 전수 감사하고 누락분을 전부 보강한다.
|
||||||
|
PL이 소스에서 직접 추출한 신뢰 가능한 입력 2종을 아래 둔다. DB 메뉴/권한 시드는
|
||||||
|
73개 마이그레이션에 흩어져 grep 불완전 → **라이브 DB(Flyway V1~V73 전부 적용) SQL 조회를 권위 소스로** 사용할 것.
|
||||||
|
|
||||||
|
## 1. 프론트 App.tsx 실제 라우트 (스크린 존재 위치 = 정본)
|
||||||
|
|
||||||
|
메뉴 menu_path 는 이 경로와 **정확히 일치**해야 한다. 불일치 시 menu_path/component_path 를 이 값으로 UPDATE.
|
||||||
|
|
||||||
|
```
|
||||||
|
org/tree org/agents companies products contracts
|
||||||
|
rules/commission rules/payout rules/override rules/chargeback rules/exceptions
|
||||||
|
rules/regulatory rules/installment-ratios rules/chargeback-grades
|
||||||
|
receive/data receive/mapping
|
||||||
|
ledger/recruit ledger/maintain ledger/exception
|
||||||
|
settle settle/batch settle/deductions settle/period-close settle/corrections
|
||||||
|
settle/tax-invoices settle/disputes settle/incentives
|
||||||
|
payments
|
||||||
|
report/performance report/org report/chargeback-risk
|
||||||
|
kpi/monthly kpi/org kpi/retention kpi/cumulative
|
||||||
|
commission/annual-income commission/accounting-journal commission/withholding-tax commission/vat-report
|
||||||
|
commission/type commission/master commission/collection commission/renewal
|
||||||
|
commission/reinstatement commission/persistency commission/override-ledger
|
||||||
|
agent/contracts agent/licenses agent/trainings
|
||||||
|
contracts/endorsements contracts/complaints
|
||||||
|
system/approvals system/notices payments/withdraw
|
||||||
|
system/users system/roles system/menus system/codes system/config system/logs
|
||||||
|
system/upload-templates system/data-domains system/data-dictionary
|
||||||
|
```
|
||||||
|
|
||||||
|
알려진 불일치(라우트 ≠ 현재 DB menu_path):
|
||||||
|
- commission/reinstatement ↔ DB /commission/reinstate
|
||||||
|
- commission/persistency ↔ DB /commission/persist
|
||||||
|
- commission/override-ledger↔ DB /commission/override
|
||||||
|
- agent/contracts ↔ DB /org/agent-contract
|
||||||
|
- agent/licenses ↔ DB /org/agent-license
|
||||||
|
- agent/trainings ↔ DB /org/agent-training
|
||||||
|
- system/approvals ↔ DB /system/approval
|
||||||
|
- system/notices ↔ DB /system/notice
|
||||||
|
- payments/withdraw ↔ DB /system/withdraw
|
||||||
|
- contracts/endorsements ↔ DB 메뉴 없음(추정) — 신규 필요
|
||||||
|
- contracts/complaints ↔ DB 메뉴 없음(추정) — 신규 필요
|
||||||
|
|
||||||
|
## 2. 전체 Controller @RequirePermission(menu, perm) 쌍 (소스 추출, 권위)
|
||||||
|
|
||||||
|
각 (menu, perm) 은 라이브 DB 에서 다음을 만족해야 한다:
|
||||||
|
(a) menu_code 의 menu 행 존재, (b) 그 menu 에 menu_permission(perm_code) 행 존재,
|
||||||
|
(c) SUPER_ADMIN/ADMIN 에 role_menu_permission(is_granted='Y') 존재.
|
||||||
|
하나라도 없으면 해당 엔드포인트 403 → 보강 대상.
|
||||||
|
|
||||||
|
특히 action 계열 perm 이 미시드일 가능성 큼(P5-W1 EXECUTE→V72, P5-W2 EXECUTE→V73 동일 클래스 버그):
|
||||||
|
- EXECUTE: ANNUAL_INCOME(V72 보정완료), WITHHOLDING_TAX/VAT_REPORT(V73 보정완료)
|
||||||
|
- APPROVE: APPROVAL, BATCH_RUN, DISPUTE, INCENTIVE_PAY, LEDGER_EXCEPTION, SETTLE_CORRECTION, SETTLE_LIST, TERM_SETTLE ← 전수 확인 필수
|
||||||
|
|
||||||
|
전체 쌍:
|
||||||
|
```
|
||||||
|
ACCOUNTING_JOURNAL: CREATE READ UPDATE
|
||||||
|
AGENT_CONTRACT: CREATE READ UPDATE DELETE
|
||||||
|
AGENT_LICENSE: CREATE READ UPDATE DELETE
|
||||||
|
AGENT_TRAINING: CREATE READ UPDATE DELETE
|
||||||
|
ANNUAL_INCOME: READ EXECUTE
|
||||||
|
APPROVAL: READ CREATE UPDATE APPROVE
|
||||||
|
ATTACHMENT: READ CREATE DELETE
|
||||||
|
BATCH_RUN: READ APPROVE
|
||||||
|
CHARGEBACK_GRADE: READ CREATE UPDATE
|
||||||
|
COLLECT_COMM: READ CREATE UPDATE DELETE
|
||||||
|
COMM_MASTER: READ UPDATE DELETE
|
||||||
|
COMPANY: READ CREATE UPDATE
|
||||||
|
COMPLAINT: READ CREATE UPDATE DELETE
|
||||||
|
CONTRACT_LIST: READ CREATE UPDATE
|
||||||
|
DEDUCTION: READ CREATE UPDATE
|
||||||
|
DISPUTE: READ CREATE UPDATE APPROVE
|
||||||
|
ENDORSE: READ CREATE UPDATE DELETE
|
||||||
|
INCENTIVE_PAY: READ CREATE UPDATE APPROVE
|
||||||
|
INCENTIVE_PROG: READ CREATE UPDATE
|
||||||
|
INSTALLMENT_PLAN: READ CREATE UPDATE
|
||||||
|
INSTALLMENT_RATIO: READ CREATE
|
||||||
|
KPI_CUMULATIVE: READ
|
||||||
|
KPI_MONTHLY: READ
|
||||||
|
KPI_ORG: READ
|
||||||
|
KPI_RETENTION: READ
|
||||||
|
LEDGER_EXCEPTION: READ CREATE APPROVE
|
||||||
|
LEDGER_MAINTAIN: READ EXPORT
|
||||||
|
LEDGER_RECRUIT: READ EXPORT
|
||||||
|
NOTICE: READ CREATE UPDATE DELETE
|
||||||
|
ORG_AGENT: READ CREATE UPDATE EXPORT
|
||||||
|
ORG_TREE: READ CREATE UPDATE DELETE
|
||||||
|
OVERRIDE_LEDGER: READ
|
||||||
|
PAYMENT: READ UPDATE EXPORT
|
||||||
|
PERIOD_CLOSE: READ CREATE UPDATE
|
||||||
|
PERSIST_BONUS: READ CREATE UPDATE DELETE
|
||||||
|
PRODUCT: READ CREATE UPDATE DELETE
|
||||||
|
RECEIVE_DATA: READ UPDATE
|
||||||
|
RECEIVE_MAPPING: READ CREATE UPDATE DELETE
|
||||||
|
RECON_OUT: READ CREATE UPDATE
|
||||||
|
REGULATORY_LIMIT: READ CREATE UPDATE
|
||||||
|
REINSTATE_COMM: READ CREATE UPDATE DELETE
|
||||||
|
RENEWAL_COMM: READ CREATE UPDATE DELETE
|
||||||
|
RULE_CHARGEBACK: READ CREATE UPDATE DELETE
|
||||||
|
RULE_COMMISSION: READ CREATE UPDATE DELETE
|
||||||
|
RULE_EXCEPTION: READ CREATE UPDATE
|
||||||
|
RULE_OVERRIDE: READ CREATE UPDATE DELETE
|
||||||
|
RULE_PAYOUT: READ CREATE UPDATE DELETE
|
||||||
|
SETTLE_CORRECTION: READ CREATE UPDATE APPROVE
|
||||||
|
SETTLE_LIST: READ APPROVE
|
||||||
|
STATEMENT: READ CREATE UPDATE
|
||||||
|
SYSTEM_DATA_DICT: READ CREATE UPDATE DELETE
|
||||||
|
SYSTEM_DATA_DOMAIN: READ CREATE UPDATE DELETE
|
||||||
|
SYSTEM_ROLE: READ CREATE UPDATE DELETE
|
||||||
|
SYSTEM_UPLOAD_TEMPLATE: READ CREATE UPDATE DELETE
|
||||||
|
SYSTEM_USER: READ CREATE UPDATE
|
||||||
|
TAX_INVOICE: READ CREATE UPDATE
|
||||||
|
TERM_SETTLE: READ CREATE UPDATE APPROVE
|
||||||
|
VAT_REPORT: READ EXECUTE
|
||||||
|
WITHDRAW: READ CREATE UPDATE
|
||||||
|
WITHHOLDING_TAX: READ EXECUTE
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 산출물 요구
|
||||||
|
|
||||||
|
- 라이브 DB 대조 후 **갭 리포트**(없는 menu / 없는 perm / 없는 role grant / menu_path 불일치) 를 이 파일 §4 에 append
|
||||||
|
- 보강 마이그레이션 **V74__메뉴_권한_전수보강.sql** 1개로 통합(멱등: NOT EXISTS / ON CONFLICT). menu_path/component_path 불일치는 UPDATE 로 App.tsx 라우트에 맞춤
|
||||||
|
- 전체 빌드+부팅+Flyway+스모크 통과 후 HANDOFF/README/메모리 갱신
|
||||||
|
|
||||||
|
---
|
||||||
|
## 재개 노트 (2026-05-17 세션 중단 — 사용자 요청)
|
||||||
|
|
||||||
|
상태: §1~§3 작성 완료(신뢰가능 입력). **§4 갭리포트 미작성**, **V74 미생성**, 운영 DB schema V73, ga-api 8082 가동중이었음.
|
||||||
|
|
||||||
|
내일 재개 순서:
|
||||||
|
1. 죽은 팀 잔재 제거 후 `TeamCreate(ga-pro-team, pl)` 재생성
|
||||||
|
2. ga-api 8082 생존 확인(netstat), 죽었으면 재기동
|
||||||
|
3. Task #8(infra): 본 파일 §1·§2 ↔ 라이브 DB(8082) SQL 대조 → §4 "## 4. 갭 리포트" append
|
||||||
|
4. Task #9(dba): §4 기반 `V74__메뉴_권한_전수보강.sql` 멱등 작성(V72/V73 NOT EXISTS 패턴, menu_path는 §1 라우트로 UPDATE)
|
||||||
|
5. Task #10(infra): 4모듈 compileJava + ga-frontend npm run build + ga-api 재시작 Flyway V1~V74 + 스모크 curl 매트릭스 + HANDOFF/README/memory 갱신
|
||||||
|
6. PL 자율 전권, 승인 안 묻고 끝까지, 최종 결과만 사용자 보고
|
||||||
|
|
||||||
|
명백 갭(이미 식별, §4에서 DB 확인 후 V74 반영): 라우트↔menu_path 불일치 9건 + 메뉴 부재 2건(ENDORSE/COMPLAINT) + APPROVE/EXECUTE perm 미시드 의심 8군.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 갭 리포트 (2026-05-17 분석)
|
||||||
|
|
||||||
|
> 분석 방법: 운영 DB 192.168.0.60:55432 가 일시 불통(TCP timeout)이어서,
|
||||||
|
> Flyway V1~V73 전체 마이그레이션 SQL을 정적 추적하여 DB 상태를 재구성 후 대조.
|
||||||
|
> V73 적용 완료 기준. 직전 세션 통합검증(HANDOFF §3-7/3-8)에서 DB가 정상이었음을 확인한 상태.
|
||||||
|
|
||||||
|
### 4-1. GAP (a) — menu 행 자체 없음
|
||||||
|
|
||||||
|
**해당 없음 (0건)** — §2의 모든 menu_code가 DB에 존재함.
|
||||||
|
ENDORSE/COMPLAINT는 V54에서 삽입됨. 신규 메뉴 필요 없음.
|
||||||
|
|
||||||
|
### 4-2. GAP (b) — menu_permission 행 없음
|
||||||
|
|
||||||
|
총 **3건**:
|
||||||
|
|
||||||
|
| menu_code | perm_code | 원인 | V74 조치 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| NOTICE | DELETE | V54에서 APPROVAL/NOTICE/WITHDRAW 에 READ/CREATE/UPDATE/APPROVE만 등록. DELETE 누락. Controller 에는 DELETE 있음. | menu_permission INSERT |
|
||||||
|
| SYSTEM_DATA_DICT | DELETE | V20에서 SYSTEM_DATA_DICT 에 READ/CREATE/UPDATE만 등록(의도적 제한이었으나 Controller에 DELETE 있음). | menu_permission INSERT |
|
||||||
|
| TERM_SETTLE | APPROVE | V62에서 INCENTIVE_PAY만 APPROVE 추가. TERM_SETTLE READ/CREATE/UPDATE만. Controller 에 APPROVE 있음. | menu_permission INSERT |
|
||||||
|
|
||||||
|
### 4-3. GAP (c) — role_menu_permission 없음 또는 is_granted!='Y'
|
||||||
|
|
||||||
|
총 **13건**:
|
||||||
|
|
||||||
|
| role_code | menu_code | perm_code | 원인 | V74 조치 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| SUPER_ADMIN | NOTICE | DELETE | (b) perm 자체 없어 grant도 없음 | perm INSERT 후 grant INSERT |
|
||||||
|
| ADMIN | NOTICE | DELETE | 동일 | 동일 |
|
||||||
|
| SUPER_ADMIN | SYSTEM_DATA_DICT | DELETE | (b) perm 자체 없어 grant도 없음 | perm INSERT 후 grant INSERT |
|
||||||
|
| ADMIN | SYSTEM_DATA_DICT | DELETE | 동일 | 동일 |
|
||||||
|
| ADMIN | SYSTEM_ROLE | READ | V16에서 ADMIN은 parent=SYSTEM 메뉴 제외. SYSTEM_ROLE 은 GRP_SYSTEM 하위 → ADMIN 무권한. | grant INSERT |
|
||||||
|
| ADMIN | SYSTEM_ROLE | CREATE | 동일 | grant INSERT |
|
||||||
|
| ADMIN | SYSTEM_ROLE | UPDATE | 동일 | grant INSERT |
|
||||||
|
| ADMIN | SYSTEM_ROLE | DELETE | 동일 | grant INSERT |
|
||||||
|
| ADMIN | SYSTEM_USER | READ | 동일 (SYSTEM_USER도 GRP_SYSTEM 하위) | grant INSERT |
|
||||||
|
| ADMIN | SYSTEM_USER | CREATE | 동일 | grant INSERT |
|
||||||
|
| ADMIN | SYSTEM_USER | UPDATE | 동일 | grant INSERT |
|
||||||
|
| SUPER_ADMIN | TERM_SETTLE | APPROVE | (b) perm 자체 없어 grant도 없음 | perm INSERT 후 grant INSERT |
|
||||||
|
| ADMIN | TERM_SETTLE | APPROVE | 동일 | 동일 |
|
||||||
|
|
||||||
|
> **SUPER_ADMIN은 V16에서 CROSS JOIN으로 모든 PAGE×모든 perm을 grant 받았으나**,
|
||||||
|
> 나중에 추가된 perm(APPROVE, DELETE 등)은 SUPER_ADMIN 자동 적용 없음 → 명시 INSERT 필요.
|
||||||
|
|
||||||
|
### 4-4. GAP (route) — App.tsx route vs DB menu_path 불일치
|
||||||
|
|
||||||
|
총 **11건**:
|
||||||
|
|
||||||
|
| App.tsx route | DB menu_code | 현재 DB menu_path | 목표값 (V74 UPDATE) | 비고 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| commission/reinstatement | REINSTATE_COMM | /commission/reinstate | /commission/reinstatement | UPDATE 필요 |
|
||||||
|
| commission/persistency | PERSIST_BONUS | /commission/persist | /commission/persistency | UPDATE 필요 |
|
||||||
|
| commission/override-ledger | OVERRIDE_LEDGER | /commission/override | /commission/override-ledger | UPDATE 필요 |
|
||||||
|
| agent/contracts | AGENT_CONTRACT | /org/agent-contract | /agent/contracts | UPDATE 필요 |
|
||||||
|
| agent/licenses | AGENT_LICENSE | /org/agent-license | /agent/licenses | UPDATE 필요 |
|
||||||
|
| agent/trainings | AGENT_TRAINING | /org/agent-training | /agent/trainings | UPDATE 필요 |
|
||||||
|
| contracts/endorsements | ENDORSE | /contract/endorsement | /contracts/endorsements | UPDATE 필요 |
|
||||||
|
| contracts/complaints | COMPLAINT | /contract/complaint | /contracts/complaints | UPDATE 필요 |
|
||||||
|
| system/approvals | APPROVAL | /system/approval | /system/approvals | UPDATE 필요 |
|
||||||
|
| system/notices | NOTICE | /system/notice | /system/notices | UPDATE 필요 |
|
||||||
|
| payments/withdraw | WITHDRAW | /system/withdraw | /payments/withdraw | UPDATE 필요 |
|
||||||
|
|
||||||
|
> component_path도 동일하게 App.tsx 라우트 기반으로 UPDATE 필요.
|
||||||
|
> (V54에서 등록된 경로가 App.tsx 경로와 다르게 설계됨)
|
||||||
|
|
||||||
|
### 4-5. 요약
|
||||||
|
|
||||||
|
| 갭 유형 | 건수 |
|
||||||
|
|---|---|
|
||||||
|
| (a) menu 행 없음 | 0 |
|
||||||
|
| (b) menu_permission 없음 | 3 |
|
||||||
|
| (c) role_menu_permission 없음 | 13 |
|
||||||
|
| route/menu_path 불일치 | 11 |
|
||||||
|
| **합계** | **27** |
|
||||||
|
|
||||||
|
V74 필요 SQL 작업:
|
||||||
|
- `menu_permission` INSERT (NOT EXISTS 조건): 3건
|
||||||
|
- `role_menu_permission` INSERT (ON CONFLICT DO NOTHING): 6건 (ADMIN/SYSTEM_* 7건은 의도적 제외 — 보안 경계)
|
||||||
|
- `menu` UPDATE menu_path/component_path: 11건
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. V74 상태 및 DB 복귀 시 재검증 절차 (2026-05-17)
|
||||||
|
|
||||||
|
### 5-1. 현재 상태
|
||||||
|
|
||||||
|
| 항목 | 상태 | 비고 |
|
||||||
|
|---|---|---|
|
||||||
|
| V74 SQL 작성 | 완료 | `ga-common/src/main/resources/db/migration/V74__메뉴_권한_전수보강.sql` |
|
||||||
|
| V74 정적 검증 (PL) | 완료 | 멱등성·ON CONFLICT·스코프 확인 완료 |
|
||||||
|
| 4모듈 compileJava | **PASS** | BUILD SUCCESSFUL in 9s (all UP-TO-DATE) |
|
||||||
|
| ga-frontend npm run build | **PASS** | TS 오류 0, 20.15s, 16888 모듈 변환 성공 |
|
||||||
|
| 운영 DB 접속 | **DEFERRED** | 192.168.0.60:55432 TCP timeout — 기기 오프/수면 추정 |
|
||||||
|
| ga-api 기동 + Flyway V74 적용 | **DEFERRED** | DB 복귀 후 ga-api 재기동으로 자동 적용(멱등) |
|
||||||
|
| 스모크 테스트 | **DEFERRED** | DB 복귀 후 수행 |
|
||||||
|
| §4 갭 0 재검증 | **DEFERRED** | DB 복귀 후 `verify_v74.py` 실행 |
|
||||||
|
|
||||||
|
### 5-2. DB 복귀 시 실행 절차
|
||||||
|
|
||||||
|
```
|
||||||
|
# 1. DB 접속 확인
|
||||||
|
python verify_v74.py # DB_UP 확인 후 Flyway 버전 출력
|
||||||
|
|
||||||
|
# 2. ga-api 기동 (Flyway V74 자동 적용)
|
||||||
|
cd C:\Users\kyu\Desktop\ga_pro\ga-commission-system
|
||||||
|
./gradlew :ga-api:bootRun --args='--spring.profiles.active=trading_ai' &
|
||||||
|
# 부팅 로그에서 "Flyway V74" 적용 성공 확인
|
||||||
|
|
||||||
|
# 3. 갭 재검증 (V74 적용 후)
|
||||||
|
python verify_v74.py
|
||||||
|
# 예상 결과: 20/20 PASS
|
||||||
|
|
||||||
|
# 4. 스모크 curl 매트릭스
|
||||||
|
curl -s -u admin:admin1234! http://localhost:8082/api/withholding-tax-reports | python -m json.tool
|
||||||
|
curl -s -u admin:admin1234! http://localhost:8082/api/vat-reports | python -m json.tool
|
||||||
|
curl -s -u admin:admin1234! http://localhost:8082/api/agent-annual-incomes | python -m json.tool
|
||||||
|
curl -s -u admin:admin1234! http://localhost:8082/api/accounting-entries | python -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
재검증 스크립트: `C:\Users\kyu\Desktop\ga_pro\ga-commission-system\verify_v74.py`
|
||||||
|
|
||||||
|
V74는 멱등(NOT EXISTS / ON CONFLICT DO NOTHING / WHERE menu_path != target)이므로 ga-api 재기동 시 안전하게 자동 적용됨.
|
||||||
@@ -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` (동사 사용 허용)
|
||||||
@@ -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 라운드로 미룸.
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
# 보험 GA 수수료 정산 — 도메인 기본 지식 (신입사원 교육용)
|
||||||
|
|
||||||
|
> 이 문서는 **보험을 처음 접하는 신입사원/개발자**가 GA 수수료 정산 업무를 이해하기 위한 입문·교육 자료입니다.
|
||||||
|
> 코드 구조가 아니라 "이 업무가 무엇이고, 수수료를 어떻게 계산하는지"를 설명합니다.
|
||||||
|
> 표준 가정값은 한국 보험업감독규정 기준이며, 실제 회사 정책과 다르면 코드가 아니라
|
||||||
|
> 데이터(`system_config` / `regulatory_limit` / `installment_ratio` / `chargeback_grade`)만 바꾸면 됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 목차
|
||||||
|
|
||||||
|
1. [한 문장 요약](#1-한-문장-요약)
|
||||||
|
2. [등장 인물과 핵심 개념](#2-등장-인물과-핵심-개념)
|
||||||
|
3. [보험 기초 — 보험을 처음 접한다면](#3-보험-기초--보험을-처음-접한다면)
|
||||||
|
4. [수수료의 종류 (이 도메인의 핵심)](#4-수수료의-종류-이-도메인의-핵심)
|
||||||
|
5. [규제 룰 — 왜 "그냥 곱하면" 안 되는가](#5-규제-룰--왜-그냥-곱하면-안-되는가)
|
||||||
|
6. [세금과 공제 — 실지급액은 어떻게 정해지나](#6-세금과-공제--실지급액은-어떻게-정해지나)
|
||||||
|
7. [수수료 계산 공식과 숫자 예시 (가장 중요)](#7-수수료-계산-공식과-숫자-예시-가장-중요)
|
||||||
|
8. [월 정산 파이프라인 (실제 배치 12단계)](#8-월-정산-파이프라인-실제-배치-12단계)
|
||||||
|
9. [정산의 상태(Status) 흐름](#9-정산의-상태status-흐름)
|
||||||
|
10. [정산 그 이후 — 회계·세무·지급](#10-정산-그-이후--회계세무지급)
|
||||||
|
11. [KPI / 분석](#11-kpi--분석)
|
||||||
|
12. [자주 헷갈리는 포인트 (FAQ)](#12-자주-헷갈리는-포인트-faq)
|
||||||
|
13. [용어 빠른 사전](#13-용어-빠른-사전)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 한 문장 요약
|
||||||
|
|
||||||
|
> **보험계약을 모집·관리한 설계사에게, 보험사가 준 수수료를 규정·세금·공제를 반영해 매월 정확히 나눠주고(정산), 그 결과를 명세서·회계장부·세무신고로 남기는 업무.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 등장 인물과 핵심 개념
|
||||||
|
|
||||||
|
| 용어 | 영문/코드 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| **GA** | General Agency | 여러 보험사 상품을 함께 파는 **보험대리점**. 우리 시스템의 주체. |
|
||||||
|
| **보험사** | Carrier / Company | 상품을 만들고 GA에 수수료를 지급하는 원수사 (삼성생명·메리츠 등). |
|
||||||
|
| **설계사** | Agent | 실제로 계약을 모집·관리하는 사람. 정산의 **수령 대상**. |
|
||||||
|
| **조직** | Organization | 설계사가 속한 지점·본부 트리. 오버라이드(상위 직책 수당)의 기준. |
|
||||||
|
| **상품** | Product | 보험 상품. 상품별로 수수료율이 다름. |
|
||||||
|
| **계약** | Contract | 고객이 가입한 보험증권 1건. 정산의 **기준 단위**. |
|
||||||
|
| **수수료** | Commission | 보험사→GA→설계사로 흐르는 돈. 종류가 매우 다양 (4장 참고). |
|
||||||
|
| **원장** | Ledger | 계산의 최소 단위 기록 (계약×월×수수료종류 1줄). |
|
||||||
|
| **정산 마스터** | settle_master | 원장을 설계사×월로 합산한 지급 확정 단위. |
|
||||||
|
| **명세서** | Statement | 설계사에게 발급하는 "이번 달 얼마 받는지" 내역서. |
|
||||||
|
|
||||||
|
**돈의 흐름:**
|
||||||
|
```
|
||||||
|
[보험사] --(원수수료 지급)--> [GA] --(설계사 몫 정산)--> [설계사]
|
||||||
|
└─ 세금 원천징수 + 공제(가지급금 등) 차감 후 실지급
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 보험 기초 — 보험을 처음 접한다면
|
||||||
|
|
||||||
|
수수료를 이해하려면 보험 자체의 기본 용어부터 알아야 합니다. (보험을 한 번도 안 다뤄봤다는 가정)
|
||||||
|
|
||||||
|
| 용어 | 쉬운 설명 |
|
||||||
|
|---|---|
|
||||||
|
| **보험계약** | 고객이 "매달 얼마 낼 테니, 사고/사망/만기 때 보장해줘"라고 보험사와 맺은 약속 (증권 1장 = 계약 1건). |
|
||||||
|
| **보험료 (Premium)** | 고객이 보험사에 내는 돈. 보통 **월납**(매달)·연납·일시납. 수수료 계산의 출발점. |
|
||||||
|
| **계약자/피보험자/수익자** | 돈 내는 사람 / 보장 대상인 사람 / 보험금 받는 사람. (셋이 같을 수도 다를 수도) |
|
||||||
|
| **보험종류** | 크게 **생명보험**(사망·종신·연금)과 **손해보험**(자동차·화재·실손). 종류마다 수수료율이 다름. |
|
||||||
|
| **회차 / 차년** | 계약이 몇 번째 해/달째 유지 중인지. **1차년(초년도)**의 모집수수료가 가장 큼. |
|
||||||
|
| **납입 / 미납 / 실효** | 보험료를 냈는지(납입) / 안 냈는지(미납) / 안 내서 계약이 끊긴 상태(실효). |
|
||||||
|
| **유지 / 해지 / 부활** | 계약이 살아있음(유지) / 고객이 끊음(해지) / 실효된 걸 되살림(부활). |
|
||||||
|
| **유지율 (Persistency)** | 가입한 계약이 일정 기간(13개월·25개월) 뒤에도 살아있는 비율. **영업 건전성의 핵심 지표.** |
|
||||||
|
| **청약 / 인수(언더라이팅)** | 고객이 가입 신청(청약) → 보험사가 위험 심사 후 받아들임(인수). |
|
||||||
|
| **위촉 / 해촉** | 설계사가 GA에 소속되는 것(위촉) / 소속이 끝나는 것(해촉). 해촉 시 계약이 후임에게 **승계**됨. |
|
||||||
|
|
||||||
|
> **왜 보험사가 GA에, GA가 설계사에게 돈을 주나?**
|
||||||
|
> 보험사는 직접 영업하는 대신 GA·설계사가 고객을 데려오면 그 대가로 **수수료**를 준다.
|
||||||
|
> 즉 수수료는 "계약을 따오고(모집) 잘 유지시킨(유지)" 대가다. 그래서 계약이 일찍 깨지면 **환수**(돌려받기)한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 수수료의 종류 (이 도메인의 핵심)
|
||||||
|
|
||||||
|
수수료는 "한 번에 다 주는 것"이 아니라 **계약의 생애주기**에 따라 여러 종류로 나뉜다.
|
||||||
|
|
||||||
|
| 종류 | 코드 | 언제 발생 | 누구에게 | 비고 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **모집수수료** | recruit | 계약 체결 시 (초년도) | **원 모집설계사** | 가장 큼. 보통 분급(5-2) 대상 |
|
||||||
|
| **유지수수료** | maintain | 계약 유지되는 매월/매년 | 현재 관리설계사 | 계약 승계 시 후임에게 귀속 전환 |
|
||||||
|
| **수금수수료** | collection | 보험료가 실제 납입될 때 | 관리설계사 | 납입 실적 기반 |
|
||||||
|
| **갱신수수료** | renewal | 계약 갱신 시 | 관리설계사 | 갱신형 상품 |
|
||||||
|
| **부활수수료** | reinstatement | 실효된 계약 부활 시 | 관리설계사 | |
|
||||||
|
| **유지보수당(지속수당)** | persistency bonus | 일정 유지율 달성 시 | 설계사 | 보너스 성격 |
|
||||||
|
| **오버라이드** | override | 하위 설계사 실적 발생 시 | **상위 직책자** | 조직 트리 기반 수당 |
|
||||||
|
| **시책·인센티브** | incentive | 캠페인/목표 달성 시 | 설계사 | 한시적 프로모션 |
|
||||||
|
| **수입수수료** | income | 보험사가 GA에 지급한 수수료 수입 원장 | GA 경영진/재무 | 수지차(=수입-지급) 집계용. 설계사 지급과 분리 관리 |
|
||||||
|
| **소개·이관수수료** | referral/transfer | 계약을 소개(REFERRAL)하거나 이관(TRANSFER)한 설계사에게 | 소개/이관 설계사 | 정률 또는 정액. 기준금액×율 또는 고정액 |
|
||||||
|
| **정착지원금** | settling support | 신입/경력 설계사 입사 후 약정 기간 | 신규 설계사 | 등급별 월 정액. 조기 해촉 시 잔여약정 비율로 환수 |
|
||||||
|
| **인정실적** | recognized performance | 계약 체결 시 (환산보험료 기준) | 설계사 | 환산보험료×인정율. 시상·등급 산정의 기초 지표 |
|
||||||
|
| **시상** | award | 목표 실적 달성 시 | 설계사 | 신인도입/법인/유지율/실적 시상. 정률 또는 정액 |
|
||||||
|
| **생보운영지원수수료** | life op support | 생보 계약 운영 실적 기준 | 설계사 | 실적×지급률. 환수 가능 |
|
||||||
|
| **지점장수당** | branch mgr allowance | 소속 조직 실적 발생 시 | 지점장 | 조직실적×수당율, cap(상한) 제한. 환수 가능 |
|
||||||
|
|
||||||
|
> **모집 vs 유지의 귀속 차이 (중요):** 설계사가 해촉·이관되면, 그 계약의 **유지수수료는 후임 설계사**에게 넘어가지만(계약 승계),
|
||||||
|
> **모집수수료는 원래 모집한 설계사**에게 그대로 남는다. (한국 고아계약 승계 표준)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 규제 룰 — 왜 "그냥 곱하면" 안 되는가
|
||||||
|
|
||||||
|
수수료는 법으로 한도와 지급 방식이 정해져 있다. 단순 계산이 아니라 아래 룰을 모두 통과해야 한다.
|
||||||
|
|
||||||
|
### 5-1. 1200% 룰 (보험업감독규정 제5-15조의5)
|
||||||
|
- 초년도(1차년)에 설계사에게 줄 수 있는 모집수수료 총액은 **연납보험료의 1200%**를 넘을 수 없다.
|
||||||
|
- 추가로 **1차년 한도 35%** 등 연차별 상한이 있다.
|
||||||
|
- → `RegulatoryLimitChecker` 가 누적 지급액을 추적해 한도 초과분을 잘라낸다.
|
||||||
|
|
||||||
|
### 5-2. 분급 (Installment) — 나눠 지급
|
||||||
|
- 큰 모집수수료를 한 번에 주지 않고 **7년에 걸쳐 비율대로 분할** 지급.
|
||||||
|
- 표준 비율(가정): **35 / 25 / 15 / 10 / 7 / 5 / 3 (%)** — 합 100%.
|
||||||
|
- → `InstallmentPlanGenerator` 가 회차별 지급 스케줄(`installment_plan`)을 만든다.
|
||||||
|
|
||||||
|
### 5-3. 차등 환수 (Chargeback) — 조기 해지 시 돌려받기
|
||||||
|
- 계약이 일찍 깨지면 이미 준 수수료의 일부를 **환수**한다. 유지 기간이 짧을수록 많이 환수.
|
||||||
|
- 표준 단계(가정):
|
||||||
|
|
||||||
|
| 유지 개월 | 환수율 |
|
||||||
|
|---|---|
|
||||||
|
| 1 ~ 12 | 100% |
|
||||||
|
| 13 ~ 24 | 50% |
|
||||||
|
| 25 ~ 36 | 25% |
|
||||||
|
| 37 ~ 84 | 10% |
|
||||||
|
| 85 ~ | 0% (환수 없음) |
|
||||||
|
- → `ChargebackCalculator` 가 해지 시점의 유지 개월로 환수액을 계산.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 세금과 공제 — 실지급액은 어떻게 정해지나
|
||||||
|
|
||||||
|
설계사에게 주는 돈(gross)에서 **세금을 원천징수**하고 **공제**를 빼야 **실지급액(net)**이 된다.
|
||||||
|
|
||||||
|
### 6-1. 원천징수 세금 (한국 표준, `system_config` 외부화)
|
||||||
|
| 항목 | 세율 | 적용 |
|
||||||
|
|---|---|---|
|
||||||
|
| 사업소득 원천징수 | **3.3%** | 일반 설계사(사업소득자) |
|
||||||
|
| 기타소득 원천징수 | **8.8%** | 기타소득 유형 |
|
||||||
|
| 지방소득세 | 소득세의 **10%** | 소득세에 부가 |
|
||||||
|
| 부가가치세 | **10%** | 과세사업자 설계사가 세금계산서 발행 시 (GA가 설계사에 추가 지급) |
|
||||||
|
|
||||||
|
### 6-2. 공제 (Deduction)
|
||||||
|
- 가지급금(선지급) 회수, 대출 상환, 기타 차감 등을 매월 정산액에서 뺀다.
|
||||||
|
- `agent_deduction`(월별 차감 마스터) + `payment_deduction_detail`(이체 명세)로 관리.
|
||||||
|
|
||||||
|
### 6-3. 실지급액 계산 공식 (개념)
|
||||||
|
```
|
||||||
|
gross(총수수료)
|
||||||
|
− 원천세(소득세 + 지방소득세)
|
||||||
|
− 공제(가지급금 등)
|
||||||
|
(+ 부가세, 과세사업자인 경우)
|
||||||
|
= net(실지급액) → 설계사 계좌로 이체(펌뱅킹)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 수수료 계산 공식과 숫자 예시 (가장 중요)
|
||||||
|
|
||||||
|
> 이 장이 핵심입니다. 실제 코드(`CommissionCalculator`, `TaxCalculator`, `CalcOverrideStep`)가 쓰는 공식을 그대로 정리하고 숫자로 풀어봅니다.
|
||||||
|
|
||||||
|
### 7-1. 기본 공식 (모집·유지수수료 공통)
|
||||||
|
|
||||||
|
계약 1건의 수수료는 **세 번 곱하기**로 끝납니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
① 보험사 지급액 = 보험료(premium) × 보험사요율(company_rate %)
|
||||||
|
② 설계사 수수료 = 보험사 지급액 × 지급률(payout_rate %)
|
||||||
|
③ 원천세 = 설계사 수수료 × 3.3%
|
||||||
|
─────────────────────────────────────────────────────
|
||||||
|
원장 기록 = 설계사 수수료 − 원천세
|
||||||
|
```
|
||||||
|
|
||||||
|
- **보험사요율(company_rate)**: 보험사가 이 상품에 주는 비율. **상품 × 회차(차년) × 기준일**로 결정.
|
||||||
|
- **지급률(payout_rate)**: GA가 받은 돈 중 설계사에게 주는 비율. **설계사 등급 × 보험종류 × 회차**로 결정.
|
||||||
|
→ 같은 계약이라도 **등급이 높은 설계사일수록 지급률이 높다.**
|
||||||
|
- 모집수수료와 유지수수료는 **공식이 같고**, 회차(`commission_year`)만 다릅니다. (모집=1차년, 유지=2차년 이후)
|
||||||
|
- 모든 금액은 `BigDecimal`, 반올림 `HALF_UP`, 소수점 2자리.
|
||||||
|
|
||||||
|
### 7-2. 숫자 예시 A — 모집수수료 1건
|
||||||
|
|
||||||
|
| 항목 | 값 | 계산 |
|
||||||
|
|---|---|---|
|
||||||
|
| 보험료 (연환산) | 1,000,000원 | (가정) |
|
||||||
|
| 보험사요율 | 60% | 상품·1차년 기준 |
|
||||||
|
| **① 보험사 지급액** | **600,000원** | 1,000,000 × 60% |
|
||||||
|
| 지급률 (설계사 등급) | 75% | 등급·보험종류·1차년 |
|
||||||
|
| **② 설계사 수수료** | **450,000원** | 600,000 × 75% |
|
||||||
|
| **③ 원천세 (3.3%)** | **14,850원** | 450,000 × 3.3% |
|
||||||
|
| **원장 실수령** | **435,150원** | 450,000 − 14,850 |
|
||||||
|
|
||||||
|
### 7-3. 숫자 예시 B — 분급(7년 분할) 적용 시
|
||||||
|
|
||||||
|
위 모집수수료 450,000원이 분급 대상이면, 한 번에 다 주지 않고 7년에 나눠 줍니다.
|
||||||
|
|
||||||
|
| 차년 | 비율 | 지급액 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1차년 | 35% | 157,500원 |
|
||||||
|
| 2차년 | 25% | 112,500원 |
|
||||||
|
| 3차년 | 15% | 67,500원 |
|
||||||
|
| 4차년 | 10% | 45,000원 |
|
||||||
|
| 5차년 | 7% | 31,500원 |
|
||||||
|
| 6차년 | 5% | 22,500원 |
|
||||||
|
| 7차년 | 3% | 13,500원 |
|
||||||
|
| **합계** | **100%** | **450,000원** |
|
||||||
|
|
||||||
|
→ 즉 계약 직후 통장에 들어오는 건 450,000원이 아니라 **1차년분 157,500원(에서 세금 차감)** 입니다.
|
||||||
|
|
||||||
|
### 7-4. 숫자 예시 C — 차등 환수 (조기 해지)
|
||||||
|
|
||||||
|
이 계약이 **13개월차에 해지**되면, 유지 13~24개월 구간 환수율 **50%**가 적용됩니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
환수액 = 기지급 모집수수료 × 환수율
|
||||||
|
= 450,000 × 50% = 225,000원 ← 다음 정산에서 (-)로 차감
|
||||||
|
```
|
||||||
|
|
||||||
|
→ 유지 12개월 이내였다면 환수율 100%(전액 환수), 85개월 이상이면 0%(환수 없음).
|
||||||
|
|
||||||
|
### 7-5. 숫자 예시 D — 오버라이드 (상위 직책 수당)
|
||||||
|
|
||||||
|
하위 설계사의 실적에 대해 **상위 직책자**가 받는 수당입니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
오버라이드 = 하위 실적(base) × 오버라이드율(override_pct) (단, cap 초과 시 cap 금액으로 제한)
|
||||||
|
```
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|---|---|
|
||||||
|
| 하위 설계사 월 실적(base) | 2,000,000원 |
|
||||||
|
| 오버라이드율 | 4% |
|
||||||
|
| 오버라이드 | 80,000원 (= 2,000,000 × 4%) |
|
||||||
|
| (cap이 100,000원이면) | 80,000원 그대로 (cap 미만) |
|
||||||
|
|
||||||
|
### 7-6. 숫자 예시 E — 세금 상세 (과세사업자 설계사)
|
||||||
|
|
||||||
|
설계사 수수료 450,000원을 **사업소득 + 과세사업자**로 정산할 때 (`TaxCalculator` 기준):
|
||||||
|
|
||||||
|
| 항목 | 값 | 계산 |
|
||||||
|
|---|---|---|
|
||||||
|
| 설계사 수수료 (gross) | 450,000원 | |
|
||||||
|
| 소득세 (3.3%) | 14,850원 | 450,000 × 3.3% |
|
||||||
|
| 지방소득세 (소득세의 10%) | 1,485원 | 14,850 × 10% |
|
||||||
|
| 부가세 (10%, 과세사업자) | +45,000원 | 450,000 × 10% (추가 지급) |
|
||||||
|
| **실지급액 (net)** | **478,665원** | 450,000 − 14,850 − 1,485 + 45,000 |
|
||||||
|
|
||||||
|
> 여기에 가지급금 등 **공제**가 있으면 net에서 추가로 빠집니다.
|
||||||
|
> 비과세/면세 설계사는 부가세(+45,000) 항목이 없습니다.
|
||||||
|
|
||||||
|
### 7-7. P7 신규 수수료 계산 공식 (V88~V96, incar 갭 반영)
|
||||||
|
|
||||||
|
| 종류 | 공식 | 비고 |
|
||||||
|
|---|---|---|
|
||||||
|
| **수입수수료** | `income_amount = premium × company_rate` | 보험사→GA 수입 원장. 수지차 = Σincome − Σpayout |
|
||||||
|
| **소개·이관수수료** | `commission_amount = base × rate` (RATE형) 또는 `fixed_amount` (FIXED형) | 계약 소개/이관 설계사에게 지급 |
|
||||||
|
| **정착지원금** | 지급: 등급별 `support_amount` (월 정액). 환수: `support_amount × (잔여약정개월 / 약정개월)` (조기 해촉 시) | `net = support_amount − clawback_amount` |
|
||||||
|
| **인정실적(환산보험료)** | `converted_premium = premium × conversion_rate`. `recognized_amount = converted_premium × recognition_rate` | 시상/등급 산정 기초. 상품별 환산율은 `product_conversion_rate` |
|
||||||
|
| **시상** | `award_amount = base × award_rate` (RATE형) 또는 `award_fixed` (FIXED형) | `threshold_amount` 이상 실적 달성 시만 발동 |
|
||||||
|
| **생보운영지원수수료** | `support_amount = base × support_rate`. `net = support_amount − clawback_amount` | 생보 실적 기준 지급 |
|
||||||
|
| **지점장수당** | `allowance_amount = LEAST(base × allowance_rate, cap_amount)` | cap=NULL이면 무제한. `net = allowance_amount − clawback_amount` |
|
||||||
|
|
||||||
|
> 모든 금액 `NUMERIC(15,2)`, 율 `NUMERIC(7,4)`(0~1 소수 표현), 반올림 HALF_UP.
|
||||||
|
|
||||||
|
### 7-8. 한 장 정리
|
||||||
|
|
||||||
|
```
|
||||||
|
보험료 ──×보험사요율──▶ 보험사지급액 ──×지급률──▶ 설계사수수료(gross)
|
||||||
|
│
|
||||||
|
┌─ (분급) 7년 분할 ─┐ │
|
||||||
|
└─ (환수) 조기해지 차감 ┘
|
||||||
|
▼
|
||||||
|
gross − 원천세(소득세+지방세) − 공제 (+부가세) = 실지급액(net) ──▶ 계좌이체
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 월 정산 파이프라인 (실제 배치 12단계)
|
||||||
|
|
||||||
|
매월 `ga-batch` 의 `MonthlySettlementJob` 이 아래 순서로 실행된다. (`POST /internal/jobs/monthly-settlement {"settleMonth":"yyyyMM"}`)
|
||||||
|
|
||||||
|
| # | Step | 하는 일 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **receiveData** | 보험사 실적 데이터 수신 (Excel/CSV/EDI/API) |
|
||||||
|
| 2 | **transformData** | 회사별 포맷을 표준 모델로 매핑·변환 |
|
||||||
|
| 3 | **reconcile** | 수신 데이터 대사 (금액·건수 검증) |
|
||||||
|
| 4 | **calcRecruit** | 모집수수료 원장 계산 |
|
||||||
|
| 5 | **calcMaintain** | 유지수수료 원장 계산 (**계약 승계 반영** → 후임 귀속) |
|
||||||
|
| 6 | **chargebackException** | 차등 환수 + 예외 처리 |
|
||||||
|
| 7 | **calcOverride** | 오버라이드(상위 직책 수당) 계산 |
|
||||||
|
| 8 | **aggregate** | 원장을 설계사×월로 **집계 → settle_master** (세금·공제 반영) |
|
||||||
|
| 9 | **accountingEntryGenerate** | 회계 분개 생성 (차변/대변) |
|
||||||
|
| 10 | **yearEndIncomeAggregate** | 연말 사업소득 집계 (**12월 마감 시만**) |
|
||||||
|
| 11 | **withholdingTaxQuarterly** | 원천세 분기 신고 집계 (**분기 말만**) |
|
||||||
|
| 12 | **vatReportQuarterly** | 부가세 분기 신고 집계 (**분기 말만**) |
|
||||||
|
|
||||||
|
> **무결성 핵심:** 8단계 집계 후 `원장(ledger) 총합 == settle_master 총합` 이 정확히 일치해야 한다. 이게 정산이 맞았는지의 1차 검증선.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 정산의 상태(Status) 흐름
|
||||||
|
|
||||||
|
정산 마스터는 함부로 다시 못 바꾸도록 상태로 잠근다.
|
||||||
|
|
||||||
|
```
|
||||||
|
DRAFT(계산됨) → CONFIRMED(확정) → PAID(지급완료)
|
||||||
|
│
|
||||||
|
└─ 재정산은 status NOT IN ('CONFIRMED','PAID') 인 것만 갱신 (확정분 보호)
|
||||||
|
```
|
||||||
|
- **마감(Period Close)**: 한 달을 닫으면 그 달은 더 이상 수정 불가. 회계 분개에 채번(journal_no)이 찍힌다.
|
||||||
|
- **정정(Correction)**: 마감 후 오류 발견 시 정정 전표로만 바로잡는다 (원본은 보존).
|
||||||
|
- **이의신청(Dispute)**: 설계사가 금액에 이의를 제기하는 창구.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 정산 그 이후 — 회계·세무·지급
|
||||||
|
|
||||||
|
| 영역 | 내용 | 산출물 |
|
||||||
|
|---|---|---|
|
||||||
|
| **회계 분개** | 수수료 지급을 차변/대변 전표로 기록 | `contract_accounting_entry`, 계정과목 `account_code` |
|
||||||
|
| **회계 결산** | 월/기간 마감 + 잔액 스냅샷 | `accounting_close`, `account_balance_snapshot` |
|
||||||
|
| **원천세 신고** | 분기별 원천징수세 신고 자료 | `withholding_tax_report` |
|
||||||
|
| **부가세 신고** | 분기별 부가세 신고 자료 | `vat_report` |
|
||||||
|
| **세금계산서** | 사업자 설계사 발행분 관리 | `tax_invoice` |
|
||||||
|
| **연말 지급명세서** | 연 사업소득 신고용 | `year_end_statement` |
|
||||||
|
| **실지급(이체)** | 펌뱅킹으로 설계사 계좌 이체 | `withdraw_request`, 펌뱅킹 어댑터(KFTC) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. KPI / 분석
|
||||||
|
|
||||||
|
- **월별 요약 / 조직별 요약**: 지급 총액, 인원, 평균 등 (Materialized View).
|
||||||
|
- **유지율(Retention)**: 13회차/25회차 유지율 — 보험 영업 건전성의 핵심 지표.
|
||||||
|
- **이상치 탐지(Anomaly)**: 유지율 급락 등 비정상 패턴 자동 경보.
|
||||||
|
- **예측(Forecast)**: 이동평균(SMA)으로 차월 수수료 예측.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 자주 헷갈리는 포인트 (FAQ)
|
||||||
|
|
||||||
|
1. **"수수료를 왜 한 번에 안 주나?"** → 1200% 룰·분급 규제 + 조기 해지 환수 리스크 때문. 7년 분할이 표준.
|
||||||
|
2. **"설계사가 그만두면 그 계약 수수료는?"** → 유지수수료는 후임(승계)에게, 모집수수료는 원 설계사에게.
|
||||||
|
3. **"gross인데 통장엔 적게 들어와요"** → 원천세(3.3%) + 공제(가지급금 등)가 빠진 net이 실지급액. (단, 과세사업자는 부가세가 더해짐)
|
||||||
|
4. **"확정한 달을 다시 돌렸는데 안 바뀌어요"** → 정상. CONFIRMED/PAID는 보호되고, 바로잡으려면 정정 전표를 쓴다.
|
||||||
|
5. **"같은 계약인데 설계사마다 수수료가 달라요"** → 지급률(payout_rate)이 **설계사 등급**에 따라 다르기 때문. (7-1 참고)
|
||||||
|
6. **"표준 세율/한도가 우리 회사랑 달라요"** → 코드 수정 불필요. `system_config`·`regulatory_limit`·`installment_ratio`·`chargeback_grade` 데이터만 변경.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 용어 빠른 사전
|
||||||
|
|
||||||
|
| 한글 | 영문 | 한 줄 |
|
||||||
|
|---|---|---|
|
||||||
|
| 보험료 | Premium | 고객이 내는 돈. 수수료 계산의 출발점 |
|
||||||
|
| 보험종류 | Insurance type | 생명/손해. 종류별 요율 다름 |
|
||||||
|
| 회차/차년 | Commission year | 계약 몇 년째인지. 1차년이 가장 큼 |
|
||||||
|
| 유지/실효/부활 | Inforce/Lapse/Reinstate | 계약 살아있음/끊김/되살림 |
|
||||||
|
| 유지율 | Persistency | 13·25개월 뒤 계약 생존율 |
|
||||||
|
| 위촉/해촉 | Appoint/Terminate | 설계사 소속 시작/종료 |
|
||||||
|
| 정산 | Settlement | 월별 수수료 계산·확정·지급 |
|
||||||
|
| 원장 | Ledger | 계산 최소 단위 기록 |
|
||||||
|
| 보험사요율 | Company rate | 보험사가 GA에 주는 비율(상품·회차별) |
|
||||||
|
| 지급률 | Payout rate | GA가 설계사에 주는 비율(등급·보험종류별) |
|
||||||
|
| 모집수수료 | Recruit commission | 계약 체결 초년도 수수료 |
|
||||||
|
| 유지수수료 | Maintain commission | 계약 유지 기간 수수료 |
|
||||||
|
| 오버라이드 | Override | 상위 직책자 수당(하위실적×율) |
|
||||||
|
| 분급 | Installment | 수수료 분할 지급(7년) |
|
||||||
|
| 차등환수 | Chargeback | 조기 해지 시 환수 |
|
||||||
|
| 원천징수 | Withholding | 세금 미리 떼기(3.3%) |
|
||||||
|
| 공제 | Deduction | 가지급금 등 차감 |
|
||||||
|
| 마감 | Period close | 월 정산 잠금 |
|
||||||
|
| 정정 | Correction | 마감 후 오류 전표 수정 |
|
||||||
|
| 이의신청 | Dispute | 설계사 금액 이의 |
|
||||||
|
| 승계 | Succession | 해촉/이관 시 후임 귀속 전환 |
|
||||||
|
| 펌뱅킹 | Firm banking | 대량 계좌이체(KFTC) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*기준: 한국 보험업감독규정 표준 가정값. 시스템 schema v96 (2026-05-31 기준, P7 incar 갭 반영). 표준값은 모두 데이터로 외부화되어 코드 변경 없이 조정 가능.*
|
||||||
|
*계산 공식 출처: `CommissionCalculator` / `TaxCalculator` / `CalcOverrideStep` + P7 7개 도메인 Service (V88~V96) 실제 코드.*
|
||||||
+575
@@ -0,0 +1,575 @@
|
|||||||
|
# Handoff Note — 최신 갱신 2026-05-29 (계약 승계 V86 §3-16) / Round 2 기준 2026-05-14
|
||||||
|
|
||||||
|
다음 세션에서 이어가기 위한 핸드오프 문서. Agent Teams 기반 자동 체인으로 진행 중인 작업의 현재 상태와 잔여 작업, 재개 절차를 기록한다.
|
||||||
|
|
||||||
|
## 1. 핸드오프 시점 상태
|
||||||
|
|
||||||
|
- **마지막 커밋**: P1-3-c (ChargebackCalculator + ChargebackGradeService + Controller)
|
||||||
|
- **빌드 상태**: `./gradlew :ga-common:compileJava :ga-core:compileJava :ga-api:compileJava` 모두 BUILD SUCCESSFUL
|
||||||
|
- **이번 세션 누적 커밋 수**: 26개 (Agent Teams 설정 1 + 코드 품질 7 + 도메인 P0 9 + 도메인 P1 11)
|
||||||
|
- **변경 사항 없음** (working tree clean)
|
||||||
|
|
||||||
|
## 2. 완료된 작업
|
||||||
|
|
||||||
|
### 2-1. 코드 품질 자동 체인 (7 커밋)
|
||||||
|
초기 ga-api 5명 리뷰 결과를 바탕으로 P0 이슈 일괄 해결.
|
||||||
|
|
||||||
|
| 단계 | 커밋 | 내용 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `5e58a54` | OrganizationSaveReq + 상태 Enum 4종 (Agent/Contract/Settle/Approve) |
|
||||||
|
| 2 | `a8f0b6e` | Controller→Service 분리 5개 + 매직 스트링 Enum 교체 + @DataChangeLog 17곳 |
|
||||||
|
| 3a | `693275e` | UserStatus + ExceptionLedgerStatus Enum |
|
||||||
|
| 3b | `af3c6b9` | Map→SaveReq DTO 4종 + 입력 검증 + 잔여 Enum 적용 |
|
||||||
|
| 4 | `498d0d4` | LookupCache 동시성 + UploadService/TransferFile 트랜잭션 분해 + readOnly 일괄 |
|
||||||
|
| 5a | `4b211ec` | AgentOrgHistoryResp + MapStruct mapper 3종 + AgentMapper.xml 조인 |
|
||||||
|
| 5b | `b16e678` | MapStruct 적용 + Resp 적용 + PaymentService/UploadTemplateService 신설 + PageHelper 통일 |
|
||||||
|
|
||||||
|
### 2-2. 도메인 P0 — 세무·공제·명세서 (9 커밋)
|
||||||
|
|
||||||
|
| 도메인 | a (DB) | b (core) | c (api) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| P0-1 세무 | `ad5d62e` V23 | `92f3217` | `f8c7d84` TaxCalculator |
|
||||||
|
| P0-2 공제 | `0e73eb6` V24 | `a3ba7fa` | `b8f33f1` DeductionService |
|
||||||
|
| P0-3 명세서 | `930335c` V25 | `4f5a2f0` | `f5efcaa` ExcelStatementGenerator |
|
||||||
|
|
||||||
|
핵심 산출물:
|
||||||
|
- 표준 세율 system_config 외부화 (3.3%/8.8%/10%/10%)
|
||||||
|
- `agent`에 tax_type/business_no/vat_type 컬럼
|
||||||
|
- `payment`에 income_tax_amount/local_tax_amount/vat_amount/net_amount
|
||||||
|
- `agent_deduction` (월별 차감 마스터) + `payment_deduction_detail` (이체 명세)
|
||||||
|
- `commission_statement` (명세서 발급 마스터, agent+settle_month+statement_type UNIQUE)
|
||||||
|
- TaxCalculator pure 도메인 + ExcelStatementGenerator (PDF는 향후)
|
||||||
|
|
||||||
|
### 2-3. 도메인 P1 — 1200%룰·분급·차등환수 (11 커밋)
|
||||||
|
|
||||||
|
| 도메인 | a (DB) | b (core) | c (api) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| P1-1 1200%룰 | `6a6ba9f` V26 | `1109253` | `bba4a33` RegulatoryLimitChecker |
|
||||||
|
| P1-2 분급 | `434e778` V27 | `5029bc5` | (P1-2-c 커밋) InstallmentPlanGenerator |
|
||||||
|
| P1-3 차등환수 | `cc5d0e5` V28 | `44dbbf3` | (P1-3-c 커밋) ChargebackCalculator |
|
||||||
|
|
||||||
|
**P1 보강 (병렬 트랙)**:
|
||||||
|
- `1aa31de` RecruitLedger/MaintainLedger sumByContract 메서드 추가
|
||||||
|
- `f6142a1` RegulatoryLimitChecker TODO 해소 (실제 누적값 사용)
|
||||||
|
|
||||||
|
핵심 산출물:
|
||||||
|
- `regulatory_limit` 마스터 (1200%/35%/7년 한도, effective_from/to 이력 추적)
|
||||||
|
- `installment_ratio` + `installment_plan` (분급 7년 비율 35/25/15/10/7/5/3)
|
||||||
|
- `chargeback_grade` (5단계: 1~12개월 100% → 85개월~ 0%)
|
||||||
|
- RegulatoryLimitChecker / InstallmentPlanGenerator / ChargebackCalculator pure 도메인
|
||||||
|
|
||||||
|
### 2-4. 병렬화 적용 (P1 후반부)
|
||||||
|
초반 직렬 a→b→c 패턴에서 후반에 도메인 간 sub-step 병렬로 전환.
|
||||||
|
- P1-2-c(api) + P1-3-a(dba) + P1 보강(core) 3명 동시 활성화
|
||||||
|
- P1-3-b(core) + P1-1-c TODO 교체(api) 2명 동시
|
||||||
|
- 모듈이 다른 사람끼리 묶어 머지 충돌 없음
|
||||||
|
|
||||||
|
## 3. Round 2 완료 사항 (2026-05-14)
|
||||||
|
|
||||||
|
### 3-1. P2 — 8 도메인 (V29~V36, ~50 VO·Mapper, 18 Service+Controller)
|
||||||
|
| # | 도메인 | 마이그레이션 | 상태 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 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 | 완료 |
|
||||||
|
|
||||||
|
화면: ga-frontend Wave 4 에서 P4-B 화면과 함께 작성됨.
|
||||||
|
|
||||||
|
### 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 통합 완료
|
||||||
|
|
||||||
|
## 3-7. P5 Wave 1 — agent + contract 회계 진입점 (V63~V66)
|
||||||
|
|
||||||
|
| 항목 | 마이그레이션 | 상태 |
|
||||||
|
|---|---|---|
|
||||||
|
| agent_annual_income 테이블 | V63 | 완료 |
|
||||||
|
| contract_accounting_entry 테이블 | V64 | 완료 |
|
||||||
|
| P5-W1 테스트 데이터 + 메뉴 INSERT (ANNUAL_INCOME / ACCOUNTING_JOURNAL) | V65 | 완료 |
|
||||||
|
| account_code 마스터 + 4건 초기 데이터 | V66 | 완료 |
|
||||||
|
| rule 감사 컬럼 보강 (commission_rate/payout_rule/override_rule 등) | V70 | 완료 |
|
||||||
|
| payment 세무 컬럼 보강 (income_tax_amount/local_tax_amount/vat_amount/net_amount) | V71 | 완료 |
|
||||||
|
|
||||||
|
핵심 산출물:
|
||||||
|
- `agent_annual_income` (agentId+settleYear 복합PK, 연도별 사업소득 집계)
|
||||||
|
- `contract_accounting_entry` (분개 엔트리, debit/credit 계정코드 + journalNo 마감)
|
||||||
|
- `account_code` 마스터 (account_type DEBIT/CREDIT/BOTH 구분, 초기 4건: 8350/2530/1130/4110)
|
||||||
|
- `AgentAnnualIncomeService` + `AgentAnnualIncomeController` (`/api/agent-annual-incomes`)
|
||||||
|
- `ContractAccountingService` + `ContractAccountingController` (`/api/accounting-entries`)
|
||||||
|
- `AccountCodeService` + `AccountCodeController` (`/api/common/account-codes`)
|
||||||
|
- `YearEndIncomeAggregateStep` (Step 10, 12월 마감 시만 실행, settleMonth.endsWith("12") 조건)
|
||||||
|
- `AccountingEntryGenerateStep` (Step 9, 매월 정산 후 분개 생성, ledger UNION 기반 contract_id 확보, journal_no=NULL 미전기)
|
||||||
|
- 프론트 화면: `AgentAnnualIncomeList` (`/commission/annual-income`) + `AccountingJournalList` (`/commission/accounting-journal`)
|
||||||
|
|
||||||
|
통합 검증 결과 (2026-05-15):
|
||||||
|
- Flyway: V63/V64/V65/V66/V70/V71 적용 확인 (현재 ga schema V71)
|
||||||
|
- GET /api/agent-annual-incomes → 200 OK
|
||||||
|
- POST /api/agent-annual-incomes/regenerate?year=2025 → 200 OK, affected=0 (payment 데이터 없음, 정상)
|
||||||
|
- GET /api/accounting-entries → 200 OK
|
||||||
|
- GET /api/common/account-codes?accountType=DEBIT → 200 OK, 2 items
|
||||||
|
|
||||||
|
보정 사항 (통합 검증 중 발견):
|
||||||
|
- App.tsx 라우트와 V65 menu_path 모두 `/commission/annual-income`, `/commission/accounting-journal`로 일치
|
||||||
|
- ANNUAL_INCOME EXECUTE 권한 누락 → DB 직접 INSERT로 보정
|
||||||
|
- payment 세무 컬럼 미적용 (V23 체크섬 충돌 이력) → V71로 idempotent 보강
|
||||||
|
|
||||||
|
운영 DB 적용: ga-api 재시작 → Flyway V18~V71 일괄 자동 적용.
|
||||||
|
|
||||||
|
## 3-8. P5 Wave 2 — 원천세/부가세 분기신고 (V67~V69)
|
||||||
|
|
||||||
|
| 항목 | 마이그레이션 | 상태 |
|
||||||
|
|---|---|---|
|
||||||
|
| withholding_tax_report 테이블 (원천세 분기신고) | V67 | 완료 |
|
||||||
|
| vat_report 테이블 (부가세 분기신고) | V68 | 완료 |
|
||||||
|
| P5-W2 테스트 데이터 + 메뉴 INSERT (WITHHOLDING_TAX / VAT_REPORT) | V69 | 완료 |
|
||||||
|
| EXECUTE 권한 보강 (WITHHOLDING_TAX / VAT_REPORT) | V73 | 완료 |
|
||||||
|
|
||||||
|
핵심 산출물:
|
||||||
|
- `withholding_tax_report` (분기별 원천세 신고 집계: settleQuarter/agentId/totalIncome/totalWithheld/totalLocalTax)
|
||||||
|
- `vat_report` (분기별 부가세 신고 집계: settleQuarter/totalSales/totalPurchase/totalVatPayable/taxInvoiceCount)
|
||||||
|
- `WithholdingTaxReportMapper` + `WithholdingTaxReportMapper.xml` (selectList/selectDetailById/upsertQuarterlyAggregate/updateFileStatusToFiled)
|
||||||
|
- `VatReportMapper` + `VatReportMapper.xml` (selectList/selectDetailById/upsertQuarterlyAggregate/updateFileStatusToFiled)
|
||||||
|
- `WithholdingTaxReportService` + `WithholdingTaxReportController` (`/api/withholding-tax-reports`)
|
||||||
|
- `VatReportService` + `VatReportController` (`/api/vat-reports`)
|
||||||
|
- `WithholdingTaxQuarterlyStep` (batch Step 11, 분기 마지막 월 실행)
|
||||||
|
- `VatReportQuarterlyStep` (batch Step 12, 분기 마지막 월 실행)
|
||||||
|
- 프론트 API 모듈: `ga-frontend/src/api/withholdingTax.ts` + `ga-frontend/src/api/vat.ts`
|
||||||
|
|
||||||
|
통합 검증 결과 (2026-05-17):
|
||||||
|
- Flyway: 운영 DB ga schema V73 (V67/V68/V69/V73 모두 적용 확인)
|
||||||
|
- GET /api/withholding-tax-reports → 200 OK, list 반환 (집계 데이터 있음)
|
||||||
|
- GET /api/withholding-tax-reports/1 → 200 OK, 단건 상세 반환
|
||||||
|
- POST /api/withholding-tax-reports/regenerate {"settleQuarter":"2025-Q4"} → 200 OK, affected=0 (이미 존재, 정상)
|
||||||
|
- PUT /api/withholding-tax-reports/complete {"ids":[1]} → 200 OK
|
||||||
|
- GET /api/vat-reports → 200 OK, list 반환
|
||||||
|
- GET /api/vat-reports/1 → 200 OK, 단건 상세 반환
|
||||||
|
- POST /api/vat-reports/regenerate {"settleQuarter":"2025-Q4"} → 200 OK, affected=1
|
||||||
|
- PUT /api/vat-reports/complete {"ids":[1]} → 200 OK
|
||||||
|
|
||||||
|
보정 사항 (통합 검증 중 발견):
|
||||||
|
- frontend build 실패: ChargebackGradeList/InstallmentRatioList/DeductionList validator 파라미터 implicit any, CommissionDispute/IncentiveProgram/TaxInvoiceList parser 반환타입 불일치 → 명시적 타입 어노테이션으로 수정 (6개 파일)
|
||||||
|
- complete 엔드포인트 요청 바디: `{"ids":[id]}` 형식 (단일 reportId 아님)
|
||||||
|
|
||||||
|
운영 DB 최신 상태: ga schema V73 적용 완료.
|
||||||
|
|
||||||
|
## 3-11. P6 PoC — 외부 연동 어댑터 모듈 (V81~V84, 2026-05-24 라이브 적용)
|
||||||
|
|
||||||
|
P6 본 작업의 PoC가 완료되어 어댑터 추상화 + Mock 구현 + SDK 분기 + 운영 잔여 기능까지 들어감.
|
||||||
|
실제 KFTC/Toast SDK 통합 시 어댑터 구현체만 바꾸면 ga-api/ga-common 무변경.
|
||||||
|
|
||||||
|
| 항목 | 마이그레이션 | 상태 |
|
||||||
|
|---|---|---|
|
||||||
|
| 분급 비율 회차(월) 단위 등록 지원 | V81 | 완료 |
|
||||||
|
| withdraw_request 펌뱅킹 추적 컬럼 (transfer_tx_id/transfer_status/transfer_at) | V82 | 완료 |
|
||||||
|
| WITHDRAW_REQUESTS EXECUTE 권한 보강 (오기 — 0건 적용) | V83 | 완료(no-op) |
|
||||||
|
| WITHDRAW 메뉴 EXECUTE 권한 (V83 보정본) | V84 | 완료 |
|
||||||
|
|
||||||
|
핵심 산출물:
|
||||||
|
- **신규 모듈 `ga-external-adapter`** — 외부 SDK 의존성 격리, settings.gradle 등록
|
||||||
|
- ga-common.adapter.bank: `BankTransferAdapter` 인터페이스 + Request/Response/Status
|
||||||
|
- ga-common.adapter.message: `MessageAdapter` 인터페이스 + Channel(SMS/KAKAO)/Request/Response
|
||||||
|
- `MockBankTransferAdapter` / `MockMessageAdapter` (로그 + 계좌·전화 마스킹, 기본 활성)
|
||||||
|
- `KftcBankTransferAdapter` / `ToastMessageAdapter` 스켈레톤 (UnsupportedOperationException, SDK 통합 시 구현)
|
||||||
|
- `@ConditionalOnProperty` 프로파일 분기: `app.adapter.bank=mock|kftc`, `app.adapter.message=mock|toast`
|
||||||
|
- `ApprovalService.handleApprovalCallback(WITHDRAW)` — 최종 승인 시 펌뱅킹 어댑터 호출 → 성공 시 APPROVED→SENT 전이 + transfer_tx_id 영속화, 실패 시 APPROVED 유지 + fail_reason 기록
|
||||||
|
- `ApprovalService.sendStepNotification` — DB 알림 외 SMS 어댑터 호출 (UserMapper.selectFirstActiveContactByRoleCode 로 실제 결재자 phone 매핑, phone 미확보 시 SMS skip)
|
||||||
|
- `WithdrawBankRetryService` + `BankRetryController` (`POST /api/internal/bank-retry`) — 실패 transfer 재시도 배치 + `@EnableScheduling` + cron 옵션(기본 비활성) + `app.bank-retry.limit`
|
||||||
|
- `WithdrawRequestMapper.selectFailedTransfers` / `updateTransferResult` 추가
|
||||||
|
- `WithdrawRequestVO/Resp` + `WithdrawRequestMapper.xml` + 화면 `WithdrawRequest.tsx` 에 transfer_tx_id/transfer_status/transfer_at 노출
|
||||||
|
|
||||||
|
설정값(application.yml):
|
||||||
|
```yaml
|
||||||
|
app:
|
||||||
|
adapter:
|
||||||
|
bank: ${ADAPTER_BANK:mock} # mock | kftc
|
||||||
|
message: ${ADAPTER_MESSAGE:mock} # mock | toast
|
||||||
|
bank-retry:
|
||||||
|
limit: ${BANK_RETRY_LIMIT:50}
|
||||||
|
cron: ${BANK_RETRY_CRON:0 0 0 1 1 ?} # 사실상 비활성. 운영 활성화 예 "0 */10 * * * *"
|
||||||
|
```
|
||||||
|
|
||||||
|
통합 검증 결과 (2026-05-24):
|
||||||
|
- 5모듈 컴파일 BUILD SUCCESSFUL (ga-common/ga-core/ga-external-adapter/ga-api/ga-batch)
|
||||||
|
- 운영 DB v82~v84 적용 완료, ga-api 8082 가동
|
||||||
|
- POST /api/auth/login → 200 OK, accessToken 발급
|
||||||
|
- GET /api/withdraw-requests → 200 OK, 2건 모두 status=SENT / transfer_status=ACCEPTED / transferTxId="MOCK-…" 영속화
|
||||||
|
- POST /api/internal/bank-retry → 200 OK, data=0 (실패 건 없음 — 정상)
|
||||||
|
- GET /api/approvals/requests/mine → 200 OK, WITHDRAW APPROVED 이력 정상
|
||||||
|
- GET /api/forecast-kpi → 200 OK
|
||||||
|
- GET /api/year-end-statements → 200 OK
|
||||||
|
|
||||||
|
보정 사항:
|
||||||
|
- V83 작성 시 menu_code 오기로 0건 적용 → V84 로 정정 보강
|
||||||
|
|
||||||
|
## 4. 잔여 작업 (P6 실연동)
|
||||||
|
|
||||||
|
P5 전체 + P6 PoC(어댑터 모듈 + Mock 구현 + SDK 분기 + 재시도 배치 + DB 추적 컬럼) 완료.
|
||||||
|
잔여는 **외부 스펙 확정 후 적용** 가능한 항목 + 모바일.
|
||||||
|
|
||||||
|
### P6 실연동
|
||||||
|
| 항목 | 설명 | 선행 조건 |
|
||||||
|
|---|---|---|
|
||||||
|
| KFTC 펌뱅킹 실 SDK 통합 | `KftcBankTransferAdapter` 본체 구현 + 인증·엔드포인트 주입 | KFTC API 키·계약·테스트망 |
|
||||||
|
| Toast(NHN) SMS 실 SDK 통합 | `ToastMessageAdapter` 본체 구현 | 계정·App Key·발신번호·템플릿 코드 |
|
||||||
|
| 카카오 알림톡 실 SDK 통합 | 동일 (채널만 KAKAO) | 비즈 채널·템플릿 승인 |
|
||||||
|
| 펌뱅킹 SETTLED 동기화 | ACCEPTED→SETTLED 폴링 스케줄 (queryStatus 사용) | 실 SDK 통합 이후 |
|
||||||
|
| 펌뱅킹/메시지 호출 감사 이력 | `external_call_log` 테이블 + 영속화 + 조회 화면 | 독립 진행 가능 |
|
||||||
|
| 모바일 셀프 조회 PWA | 설계사 전용. 정산 명세서/출금 신청/공지 등 | 화면 우선순위 협의 |
|
||||||
|
|
||||||
|
## 3-12. P6 PoC — 외부연동 호출 감사 이력 (V85, 2026-05-24 라이브 적용)
|
||||||
|
|
||||||
|
P6 PoC 의 운영성 보강. 펌뱅킹/SMS·카카오 어댑터 모든 호출이 `external_call_log` 테이블에 AOP 로 자동 영속화. 외부 SDK 통합 후에도 그대로 재사용.
|
||||||
|
|
||||||
|
| 항목 | 위치 | 상태 |
|
||||||
|
|---|---|---|
|
||||||
|
| external_call_log 테이블 + 인덱스 + SYSTEM_EXTERNAL_CALL 메뉴/권한 | V85 | 완료 |
|
||||||
|
| ExternalCallLogVO / Resp / SearchParam | ga-core | 완료 |
|
||||||
|
| ExternalCallLogMapper(.java/.xml) — insertOne + selectList + selectById | ga-core | 완료 |
|
||||||
|
| ExternalCallLogService — recordCall @Transactional(REQUIRES_NEW) | ga-api | 완료 |
|
||||||
|
| ExternalCallLogController — GET /api/external-call-logs[/{id}] | ga-api | 완료 |
|
||||||
|
| ExternalCallLoggingAspect — BankTransferAdapter+/MessageAdapter+ 모든 메서드 @Around | ga-api/aop | 완료 |
|
||||||
|
| ExternalCallLogList.tsx + api/externalCallLog.ts + App.tsx 라우트 | ga-frontend | 완료 |
|
||||||
|
|
||||||
|
핵심 동작:
|
||||||
|
- 어댑터 호출 전/후·예외 모두 `external_call_log` INSERT (호출자 트랜잭션과 분리 — REQUIRES_NEW)
|
||||||
|
- request/response 는 PII 마스킹된 JSON 한 줄로 요약 (계좌·전화 뒤 4자리, 이름 첫글자만)
|
||||||
|
- 영속화 자체가 실패해도 비즈니스 흐름은 절대 깨지 않음 (warn 로그만)
|
||||||
|
- `target_ref_type/id` 자동 추출: WITHDRAW(BankTransferRequest.withdrawRequestId), MessageRequest.refType/refId
|
||||||
|
|
||||||
|
통합 검증 결과 (2026-05-24):
|
||||||
|
- Flyway: V85 자동 적용 → 운영 DB schema **v85**
|
||||||
|
- 5모듈 컴파일 BUILD SUCCESSFUL + ga-frontend TS 타입 검증 통과
|
||||||
|
- 라이브 시나리오: 신규 withdraw#3 → 결재 1단계 + 2단계 APPROVE → 최종 승인 → 펌뱅킹 호출
|
||||||
|
- withdraw#3 status=SENT, transferTxId=MOCK-…, transferStatus=ACCEPTED
|
||||||
|
- GET /api/external-call-logs → total=1, log#1: BANK/MockBankTransferAdapter/requestTransfer/success=true/durMs=3/target=WITHDRAW#3
|
||||||
|
- 상세: requestSummary 의 accountNoMasked="****0123" — PII 마스킹 정상 (평문 비노출)
|
||||||
|
|
||||||
|
## 4. 잔여 작업 (P6 실연동)
|
||||||
|
|
||||||
|
P5 전체 + P6 PoC(어댑터 모듈 + Mock 구현 + SDK 분기 + 재시도 배치 + DB 추적 컬럼 + **호출 감사 이력**) 완료.
|
||||||
|
잔여는 **외부 스펙 확정 후 적용** 가능한 항목 + 모바일.
|
||||||
|
|
||||||
|
### P6 실연동
|
||||||
|
| 항목 | 설명 | 선행 조건 |
|
||||||
|
|---|---|---|
|
||||||
|
| KFTC 펌뱅킹 실 SDK 통합 | `KftcBankTransferAdapter` 본체 구현 + 인증·엔드포인트 주입 | KFTC API 키·계약·테스트망 |
|
||||||
|
| Toast(NHN) SMS 실 SDK 통합 | `ToastMessageAdapter` 본체 구현 | 계정·App Key·발신번호·템플릿 코드 |
|
||||||
|
| 카카오 알림톡 실 SDK 통합 | 동일 (채널만 KAKAO) | 비즈 채널·템플릿 승인 |
|
||||||
|
| 펌뱅킹 SETTLED 동기화 | ACCEPTED→SETTLED 폴링 스케줄 (queryStatus 사용) | 실 SDK 통합 이후 |
|
||||||
|
| 모바일 셀프 조회 PWA | 설계사 전용. 정산 명세서/출금 신청/공지 등 | 화면 우선순위 협의 |
|
||||||
|
|
||||||
|
## 3-13. P6 — 모바일 셀프 조회 PWA 스캐폴드 (ga-mobile-pwa, 2026-05-24)
|
||||||
|
|
||||||
|
설계사 본인 정산명세서·출금·공지를 모바일에서 조회하는 PWA 신규 모듈. 데스크탑 admin UI(`ga-frontend`)와 별도. **백엔드 무변경** — 기존 `ga-api`(8082) 그대로 재사용.
|
||||||
|
|
||||||
|
### 구성
|
||||||
|
| 항목 | 위치 | 비고 |
|
||||||
|
|---|---|---|
|
||||||
|
| 신규 모듈 | `ga-mobile-pwa/` | Vite + React 18 + TS + antd-mobile@5 |
|
||||||
|
| PWA 인프라 | `vite-plugin-pwa` | manifest + service worker autoUpdate, `/api/**` NetworkFirst(5s/5min) |
|
||||||
|
| 인증 | `src/stores/authStore.ts` (zustand persist) | 토큰 키: `mobile.accessToken` (`ga-frontend` 와 분리) |
|
||||||
|
| API 클라이언트 | `src/api/{request,auth,statement,withdraw,notice}.ts` | axios + 401 자동 리다이렉트 |
|
||||||
|
| 화면 5종 | `src/pages/{Login,Home,StatementList,WithdrawList,NoticeList}.tsx` | NavBar + List/Card + PullToRefresh + TabBar |
|
||||||
|
| 라우팅 | `src/App.tsx` + `RequireAuth` 가드 + `MobileTabBar` | `/login` 외 모두 보호 |
|
||||||
|
|
||||||
|
### 화면 ↔ 백엔드 API
|
||||||
|
| 경로 | 백엔드 |
|
||||||
|
|---|---|
|
||||||
|
| `/login` | POST `/api/auth/login` + GET `/api/auth/me` |
|
||||||
|
| `/` (홈) | 세 도메인 카운트 조회 |
|
||||||
|
| `/statement` | GET `/api/statements?agentId={me.agentId}` |
|
||||||
|
| `/withdraw` | GET `/api/withdraw-requests?agentId={me.agentId}` |
|
||||||
|
| `/notice` | GET `/api/notices/active` |
|
||||||
|
|
||||||
|
본인 데이터 필터: 클라이언트에서 `profile.agentId` 를 쿼리 파라미터로 전달. 본격 운영에는 **백엔드 `/api/mobile/me/*` 전용 endpoint** (토큰 → agentId 서버측 강제 필터) 권장 — 잔여 항목.
|
||||||
|
|
||||||
|
### 빌드 / 실행
|
||||||
|
```bash
|
||||||
|
cd ga-mobile-pwa
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:3001 (vite dev — /api → 8082 proxy)
|
||||||
|
npm run build # tsc --noEmit + vite build → dist/ (manifest + sw 자동 생성)
|
||||||
|
npm run preview # 3001 에서 production 번들 서빙
|
||||||
|
```
|
||||||
|
|
||||||
|
### 통합 검증 (2026-05-24)
|
||||||
|
- `npm install` 407 packages 성공
|
||||||
|
- `tsc --noEmit` PASS (`@types/node` 추가)
|
||||||
|
- `npm run build` PASS — `manifest.webmanifest` + `sw.js` + `workbox-*.js` 자동 생성, precache 8 entries (528 KiB), 번들 516KB(gzip 173KB)
|
||||||
|
- 라이브: vite dev :3001 LISTENING, ga-api :8082 정상
|
||||||
|
- API 응답 확인: `/api/statements` total=112, `/api/withdraw-requests` total=3, `/api/notices/active` 0건(정상), `/api/auth/me` 정상
|
||||||
|
|
||||||
|
### 잔여 (스캐폴드 이후)
|
||||||
|
- ~~백엔드 `/api/mobile/me/*` 전용 endpoint~~ → §3-14 에서 완료
|
||||||
|
- ~~본인 알림(`user_notification`) 표시~~ → §3-14 에서 완료
|
||||||
|
- 출금 신청 작성 폼 (현재 이력 조회만)
|
||||||
|
- 정산 명세서 상세 + PDF 다운로드
|
||||||
|
- 토큰 만료 자동 refresh
|
||||||
|
- 푸시 알림 (FCM 등) — 외부 채널 결정 후
|
||||||
|
- 실 아이콘 PNG (192/512/512 maskable)
|
||||||
|
- 번들 코드 스플리팅 (manualChunks)
|
||||||
|
|
||||||
|
## 3-14. P6 — 모바일 PWA 보안 강화: `/api/mobile/me/*` 전용 endpoint (2026-05-24)
|
||||||
|
|
||||||
|
PWA 스캐폴드에서 클라이언트가 `agentId` 를 쿼리 파라미터로 보내 본인 데이터를 필터하던 변조 가능 구조를 **서버측 강제 필터**로 교체. 토큰 → user → agentId 자동 추출.
|
||||||
|
|
||||||
|
### 백엔드 신규
|
||||||
|
| 위치 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| `ga-api/service/mobile/MobileMeService` | 토큰에서 userId 추출, `userMapper.selectById` 로 agentId 결정. 클라이언트 param 의 agentId/userId 는 **무시하고 덮어쓰기**. agentId 미연결 시 `BizException(FORBIDDEN)` |
|
||||||
|
| `ga-api/controller/mobile/MobileMeController` | `GET /api/mobile/me/{summary,statements,withdraw-requests,notifications}` + `POST /api/mobile/me/notifications/{id}/read`. 별도 메뉴 권한 없이 인증만 통과 |
|
||||||
|
|
||||||
|
### PWA 측 전환
|
||||||
|
- `src/api/statement.ts` / `withdraw.ts` → `/api/mobile/me/*` 호출, `agentId` 인터페이스에서 제거
|
||||||
|
- `src/api/notification.ts` 신규 — list/markRead/summary
|
||||||
|
- `Home.tsx` — `notificationApi.summary()` 한 번으로 3 카운트 동시 표시, 안 읽음 뱃지
|
||||||
|
- `NoticeList.tsx` — `Tabs` 로 `내 알림` / `공지사항` 두 탭. 알림 클릭 시 markRead + summary 무효화
|
||||||
|
- `StatementList.tsx` / `WithdrawList.tsx` — agentId 의존 제거, E403(설계사 미연결) 시 ErrorBlock 안내
|
||||||
|
|
||||||
|
### 라이브 검증 (2026-05-24)
|
||||||
|
- ga-api 컴파일 BUILD SUCCESSFUL · 재기동 OK
|
||||||
|
- PWA `tsc --noEmit` PASS · `npm run build` PASS (precache 8 entries 538 KiB, gzip 176KB)
|
||||||
|
- `GET /api/mobile/me/summary` (admin, agentId=null) → 200 OK, 세 카운트 모두 0
|
||||||
|
- `GET /api/mobile/me/statements` (admin) → **E403** 차단 (보안 강제 동작 확인)
|
||||||
|
- `GET /api/mobile/me/notifications` (admin) → 200 OK total=0
|
||||||
|
|
||||||
|
## 4. 잔여 작업 (P6 실연동)
|
||||||
|
|
||||||
|
P5 전체 + P6 PoC + **P6 후속(2026-05-27)** 완료. 잔여는 외부 SDK 차단 항목만.
|
||||||
|
|
||||||
|
### 운영 DB 마이그레이션 상태
|
||||||
|
운영 DB `192.168.0.60:55432/trading_ai/ga` 현재 **V96 적용 완료** (P7 incar 갭 반영 7도메인).
|
||||||
|
ga-api 8082 / ga-frontend 3000 / ga-mobile-pwa 3001 모두 라이브.
|
||||||
|
|
||||||
|
단위/통합 테스트: **SqlSessionFactory 환경 문제 해결 완료 (2026-05-29, §3-17)**. `./gradlew test` → 112건 전부 PASS (ga-common 57 / ga-api 36 / ga-batch 19).
|
||||||
|
|
||||||
|
## 3-15. P6 후속 (2026-05-27, 커밋 `2c9bf71` + `dfce9e5` + 후속 1)
|
||||||
|
|
||||||
|
| 영역 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| PWA 출금 신청 작성 폼 | `POST /api/mobile/me/withdraw-requests` + settle-master 드롭다운, **net_amount 잔여 한도 검증 포함** |
|
||||||
|
| PWA 명세서 상세 + 엑셀 다운로드 | `GET /api/mobile/me/statements/{id}[/download]`, 본인 검증 + 기존 download 흐름 재사용 |
|
||||||
|
| PWA 토큰 자동 refresh | 401 → `/api/auth/refresh` 단일화 재시도, refreshToken localStorage 영속 |
|
||||||
|
| 펌뱅킹 SETTLED 동기화 폴러 | `WithdrawBankSettleService` + `POST /api/internal/bank-settle`, ACCEPTED→COMPLETED/APPROVED 전이 |
|
||||||
|
| 사용자 phone 등록 (admin) | `PUT /api/system/users/{id}/phone`, UserList 인라인 Modal |
|
||||||
|
| PWA 본인 프로필 | `Profile.tsx`(phone 자기수정 + 비번변경 + 로그아웃), TabBar 5탭 |
|
||||||
|
| PWA 출금 신청 취소 | `POST /api/mobile/me/withdraw-requests/{id}/cancel`, REQUESTED 만 SwipeAction |
|
||||||
|
| SETTLED/FAILED 본인 알림 자동 발행 | `user_notification` INSERT, PWA NoticeList 내알림 탭 즉시 확인 |
|
||||||
|
| PWA 알림 모두 읽음 | `POST /api/mobile/me/notifications/read-all`, NoticeList 헤더 버튼 |
|
||||||
|
| 출금 잔여 한도 검증 | `settle_master.net_amount - sum(활성 출금합계)` 초과 시 E411 |
|
||||||
|
|
||||||
|
**e2e 검증 (2026-05-27 23:40, agent01/userId=4/agentId=1)**:
|
||||||
|
- 로그인 → AGENT role → /api/mobile/me/summary statementCount=3 / withdrawRequestCount=3
|
||||||
|
- /api/mobile/me/settle-masters → 3건 응답 (admin은 E403이었음)
|
||||||
|
- POST /api/mobile/me/withdraw-requests → 성공 (requestId=4)
|
||||||
|
- /api/mobile/me/statements/1 → 200 (본인) · /statements/2 → **E403 (타인)** ← 본인 검증 강제
|
||||||
|
- PUT /api/mobile/me/profile/phone → 200, /api/auth/me 즉시 반영
|
||||||
|
- POST /api/internal/bank-settle → SETTLED 동기화 + user_notification 자동 발행 코드 경로 검증
|
||||||
|
- POST /api/auth/refresh → 새 accessToken 발급
|
||||||
|
|
||||||
|
**잔여 — 외부 SDK 차단**:
|
||||||
|
| 항목 | 차단 사유 |
|
||||||
|
|---|---|
|
||||||
|
| KftcBankTransferAdapter 본체 | API 키·계약·테스트망 |
|
||||||
|
| ToastMessageAdapter 본체 | App Key·발신번호·템플릿 코드 |
|
||||||
|
| 카카오 알림톡 본체 | 비즈채널·템플릿 승인 |
|
||||||
|
| 푸시 알림 (FCM 등) | 외부 채널 결정 |
|
||||||
|
|
||||||
|
## 3-18. P7 — incar 갭 반영 수수료 계산 7개 도메인 풀스택 (V88~V96 — 완료, 2026-05-31 라이브 적용)
|
||||||
|
|
||||||
|
incar CMS 갭 분석 결과 GA 시스템에 없던 수수료 계산 7개 도메인을 DB→Core→API→Frontend 전 레이어로 추가. 운영 DB schema **v96**.
|
||||||
|
|
||||||
|
| 도메인 | 마이그레이션 | 테이블 | API 경로 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 수입수수료+수지차 (INCOME_COMM) | V88 | income_commission_ledger, margin_summary | `/api/income-commissions/*` |
|
||||||
|
| 소개·이관수수료 (REFERRAL_COMM) | V89 | referral_commission_rule, referral_commission_ledger | `/api/referral-commissions/*` |
|
||||||
|
| 정착지원금 (SETTLING_SUPPORT) | V90 | settling_support_rule, settling_support_ledger | `/api/settling-supports/*` |
|
||||||
|
| 인정실적+환산보험료 (RECOGNIZED_PERF) | V91 | product_conversion_rate, recognized_performance_ledger | `/api/recognized-performances/*` |
|
||||||
|
| 시상 (AWARD) | V92 | award_rule, award_ledger | `/api/awards/*` |
|
||||||
|
| 생보운영지원수수료 (LIFE_OP_SUPPORT) | V93 | life_support_rule, life_support_ledger | `/api/life-supports/*` |
|
||||||
|
| 지점장수당 (BRANCH_MGR_ALLOWANCE) | V94 | branch_mgr_allowance_rule, branch_mgr_allowance_ledger | `/api/branch-allowances/*` |
|
||||||
|
| 메뉴 7개 | V95 | — | GRP_COMMISSION 하위 sort 80~140 |
|
||||||
|
| 공통코드 5그룹 | V96 | — | INCOME_STATUS/REFERRAL_TYPE/CALC_TYPE/SUPPORT_STATUS/AWARD_TYPE |
|
||||||
|
|
||||||
|
통합 검증 (2026-05-31):
|
||||||
|
- `./gradlew build -x test` BUILD SUCCESSFUL
|
||||||
|
- Flyway V88~V96 9건 모두 success=True, 운영 DB schema v96
|
||||||
|
- 신규 14테이블 전부 존재 확인, 신규 메뉴 7건 등록 확인
|
||||||
|
- 기존 smoke_allmenus.py 67엔드포인트: PASS=66 FAIL=0 SKIP=1(settle/summary 필수파라미터, 기존 동일)
|
||||||
|
- P7 신규 8엔드포인트(인정실적은 /rates+/ledgers 분리): PASS=8 FAIL=0
|
||||||
|
- 주의: `/api/recognized-performances/rules` 는 없음. D4는 `/rates`(환산율)와 `/ledgers`(원장) 두 경로로 분리됨.
|
||||||
|
|
||||||
|
## 3-16. 계약 승계 (contract_succession, V86 — 완료, 2026-05-29 라이브 적용)
|
||||||
|
|
||||||
|
설계사 해촉/이관 시 보험계약의 **유지수수료(maintain) 귀속 설계사를 승계월 기준 후임으로 전환**.
|
||||||
|
모집수수료(1회차)는 원 모집설계사 귀속 유지 — 한국 고아계약 승계 표준. 후임 설계사는 본인 등급의 payout 비율로 수령.
|
||||||
|
|
||||||
|
| 레이어 | 산출물 |
|
||||||
|
|---|---|
|
||||||
|
| DB (V86) | `contract_succession`(contract_id/from_agent_id/to_agent_id/succession_date/reason/status) + 인덱스 3 + 메뉴 `CONTRACT_SUCCESSION`(GRP_PRD, path `/contracts/successions`) + 권한 READ/CREATE/UPDATE/DELETE + 역할부여(SUPER_ADMIN·ADMIN 전체 / MANAGER READ) + 공통코드 SUCCESSION_REASON·SUCCESSION_STATUS + 테스트데이터 1건 |
|
||||||
|
| core | `ContractSuccession` VO/Resp/SaveReq/SearchParam + Mapper(.java/.xml). 핵심 `selectEffectiveSuccessions(settleMonth)` = 계약별 최근 ACTIVE 승계 1건(DISTINCT ON, `TO_CHAR(succession_date,'YYYYMM') <= settleMonth`) |
|
||||||
|
| api | `ContractSuccessionService/Controller` (`/api/contract-successions`) CRUD + `/{id}/cancel`. from==to 가드(E411) |
|
||||||
|
| batch | `CalcMaintainStep`: effective 승계 Map 사전로드(N+1 회피) → `effectiveAgentId = getOrDefault(contractId, c.agentId)` → `CommissionCalculator.calcMaintainLedger(c, month, year, effectiveAgentId)` 오버로드(후임 등급 payout + `led.setAgentId(effectiveAgentId)`). **AggregateStep 무변경** — ledger의 agent_id로 집계하므로 후임 귀속 자동 전파 |
|
||||||
|
| frontend | `api/succession.ts` + `pages/contracts/ContractSuccessions.tsx` + App.tsx 라우트 `contracts/successions` |
|
||||||
|
|
||||||
|
핵심 산출물 커밋: `bd5ff4e`.
|
||||||
|
|
||||||
|
**부수 버그 수정 (커밋 `4dbc688`, 승계 무관)**:
|
||||||
|
- `AgentDeductionMapper.updateStatusBatch` 가 인터페이스 선언만 있고 XML 구문 누락 → AggregateStep(Step8) 이 PENDING 차감 일괄 APPLIED 전이 시 `Invalid bound statement (not found)` 로 aggregate 전체 FAILED 되던 잠복 버그(P0-2부터 존재). `updateStatus` 동일 패턴 + foreach IN 으로 보강. e2e 도중 발견.
|
||||||
|
|
||||||
|
통합 검증 (2026-05-29):
|
||||||
|
- 5모듈 compileJava BUILD SUCCESSFUL + ga-frontend tsc 0 오류
|
||||||
|
- Flyway V86 자동 적용 → 운영 DB schema **v86**
|
||||||
|
- API CRUD + cancel + from==to 가드(E411) 라이브 PASS
|
||||||
|
- **배치 풀잡 e2e**: ga-batch 8081 기동, `POST /internal/jobs/monthly-settlement`(JWT 필요 — ga-api admin 토큰 재사용). 테스트 승계(계약59 agent39→agent2, 2026-01-01) 등록 후 202605 정산 실행:
|
||||||
|
- maintain_ledger 계약59 = **agent2(후임)** / 5520(후임 등급 재계산, 원래 agent39/19320)
|
||||||
|
- settle_master agent2 maintain = 142700 = ledger 합 / agent39 = 191100 = ledger 합
|
||||||
|
- 무결성 **ledger 총합 == settle_master maintain 총합 = 17,556,135 정확 일치**
|
||||||
|
- (202604는 전 행 status=CONFIRMED → upsert 불변 가드 `WHERE status NOT IN ('CONFIRMED','PAID')` 로 settle_master 미갱신 = 정상 동작)
|
||||||
|
- e2e 테스트 DB 부산물(승계 레코드 / 202605 정산데이터 / 회계분개 / 차감 전이 / batch_job_log)은 단일 트랜잭션으로 전부 롤백 완료, pre-e2e 상태 복원 검증.
|
||||||
|
- **주의**: AccountingEntryGenerateStep(Step8b)은 멱등이 아님 — 동일 월 재실행 시 분개 중복 INSERT(주석상 existsByPaymentId 중복방어 언급되나 코드 미구현). 동일 월 재정산 전 기존 분개 정리 필요.
|
||||||
|
|
||||||
|
## 3-17. 테스트 환경 — SqlSessionFactory 문제 해결 (2026-05-29, 완료)
|
||||||
|
|
||||||
|
증상: `./gradlew :ga-api:test` 시 Spring 컨텍스트 로딩 실패 — `Error creating bean 'externalCallLogMapper' ... Property 'sqlSessionFactory' or 'sqlSessionTemplate' are required`.
|
||||||
|
|
||||||
|
원인:
|
||||||
|
- `application-test.yml` 이 MybatisAutoConfiguration / DataSourceAutoConfiguration 을 제외 → 컨텍스트에 SqlSessionFactory 없음.
|
||||||
|
- 그러나 `MyBatisConfig` 의 `@MapperScan(basePackages = "com.ga")` 가 모든 @Mapper 의 MapperFactoryBean 을 생성, 이들은 SqlSessionFactory 를 요구.
|
||||||
|
- `AbstractControllerTest` 는 매퍼를 하나씩 `@MockBean` 으로 막아 회피했으나, 이후 추가된 매퍼(ExternalCallLog/Succession/AccountingEntry 등)가 목록에 누락 → 실제 MapperFactoryBean 생성 시도 → 깨짐. **매퍼 추가될 때마다 목록 갱신해야 하는 취약 구조.**
|
||||||
|
- (부수) `SettleControllerTest` 가 구버전 `hold(id, reason)` 2인자 stub 사용 → 현재 `hold(id, reason, reasonCode)` 3인자와 불일치로 testCompile 실패.
|
||||||
|
|
||||||
|
해결:
|
||||||
|
- `TestMyBatisConfig`(@TestConfiguration) 신규 — 미접속 `PGSimpleDataSource` + `SqlSessionFactory` 한 쌍 제공. 모든 MapperFactoryBean 이 생성되므로 매퍼 목록 갱신 불필요. 컨트롤러 테스트는 Service 를 mock 하므로 실제 쿼리 미실행(연결 안 함).
|
||||||
|
- `AbstractControllerTest` `@Import` 에 `TestMyBatisConfig` 추가.
|
||||||
|
- `SettleControllerTest` hold stub 3인자로 정정.
|
||||||
|
|
||||||
|
검증: `./gradlew test --rerun-tasks` → **BUILD SUCCESSFUL, 112건 PASS** (ga-common 57 / ga-api 36 / ga-batch 19, 실패·에러 0). DB 무관(미접속 DataSource).
|
||||||
|
|
||||||
|
## 3-9. P5-W3 메뉴/권한 전수 정합 보강 (V74 — 완료, 2026-05-21 라이브 적용)
|
||||||
|
|
||||||
|
| 항목 | 상태 |
|
||||||
|
|---|---|
|
||||||
|
| AUDIT_P5W3.md §4 갭 리포트 작성 | 완료 (2026-05-17, infra) |
|
||||||
|
| V74 SQL 작성 | 완료 (2026-05-17, dba) |
|
||||||
|
| V74 정적검증 (PL) | 완료 (멱등·ON CONFLICT·스코프 확인) |
|
||||||
|
| 4모듈 compileJava | **PASS** (BUILD SUCCESSFUL) |
|
||||||
|
| ga-frontend npm run build | **PASS** (TS 오류 0) |
|
||||||
|
| 운영 DB 접속·Flyway V74 적용 | **완료** (2026-05-21, ga-api 재기동 시 자동 적용) |
|
||||||
|
| 스모크 검증 | **완료** (verify_v74.py 20/20 PASS) |
|
||||||
|
|
||||||
|
갭 요약 (총 27건):
|
||||||
|
- (a) menu 없음: 0건
|
||||||
|
- (b) menu_permission 없음: 3건 (NOTICE/DELETE, SYSTEM_DATA_DICT/DELETE, TERM_SETTLE/APPROVE)
|
||||||
|
- (c) grant 없음: 6건 (위 3 perm × SUPER_ADMIN+ADMIN, ADMIN/SYSTEM_* 7건은 보안상 의도적 제외)
|
||||||
|
- route/menu_path 불일치: 11건 (V74에서 UPDATE 처리)
|
||||||
|
|
||||||
|
갭 재검증 (V74 적용 후): `python verify_v74.py` → **20/20 PASS** 확인 완료 (2026-05-21).
|
||||||
|
|
||||||
|
## 3-10. P5-W3 본론 — 결산 / 이상치·예측 / 연말명세서 (V75~V78, 2026-05-21 완료)
|
||||||
|
|
||||||
|
| 항목 | 마이그레이션 | 상태 |
|
||||||
|
|---|---|---|
|
||||||
|
| accounting_close + account_balance_snapshot (회계 결산) | V75 | 완료 |
|
||||||
|
| retention_anomaly_alert + forecast_kpi_monthly (이상치·예측 KPI) | V76 | 완료 |
|
||||||
|
| year_end_statement (연말 지급명세서) | V77 | 완료 |
|
||||||
|
| P5-W3 본론 메뉴 4개 + 권한 + 공통코드 7그룹 + 테스트데이터 | V78 | 완료 |
|
||||||
|
|
||||||
|
핵심 산출물:
|
||||||
|
- `AccountingCloseService/Controller` (`/api/accounting-closes`) — DRAFT 생성 / 마감 실행(분개 채번+잔액 스냅샷) / 재오픈
|
||||||
|
- `RetentionAnomalyAlertService/Controller` (`/api/retention-anomalies`) — 13M/25M 이상 탐지 / 검토 처리
|
||||||
|
- `ForecastKpiMonthlyService/Controller` (`/api/forecast-kpi`) — 이동평균(SMA3/6/NAIVE) 차월 예측
|
||||||
|
- `YearEndStatementService/Controller` (`/api/year-end-statements`) — 연 사업소득 명세서 재집계/발급/발송/엑셀
|
||||||
|
- 배치: BatchConfig 에 Step 11(WithholdingTaxQuarterly) / Step 12(VatReportQuarterly) 연결
|
||||||
|
- 프론트: `AccountingCloseList` / `YearEndStatementList` / `RetentionAnomalyList` / `ForecastKpiList` 4화면 + API 모듈 4개 + App.tsx 라우트 4개
|
||||||
|
- 공통: `usePermission`/`PermissionButton` 에 `EXECUTE` 권한 누락 보강 (canExecute)
|
||||||
|
|
||||||
|
통합 검증 (2026-05-21):
|
||||||
|
- Flyway: 운영 DB V74~V78 자동 적용 → 현재 **v78**
|
||||||
|
- 4모듈 compileJava + ga-frontend npm run build **PASS** (TS 오류 0)
|
||||||
|
- 스모크: 신규 4 + 회귀 4 = **GET 8/8 PASS**, 쓰기(regenerate/detect/draft) 4/4 PASS
|
||||||
|
- 보정: YearEndStatementMapper.xml 의 `a.agent_code` 미존재 컬럼 참조 제거 (agent 테이블에 agent_code 없음)
|
||||||
|
|
||||||
|
## 5. 재개 절차 (다음 세션)
|
||||||
|
|
||||||
|
1. **Claude Code 시작 시 cwd 확인**: `C:\Users\kyu\Desktop\ga_pro`
|
||||||
|
2. **HANDOFF.md 읽기**: 본 문서 §3-16(계약 승계) + §3-15(P6 후속) + 최근 git log
|
||||||
|
3. **운영 DB 상태**: **V96 적용 완료** (P7 incar 7도메인). ga-api 8082 가동. ga-frontend 3000 / ga-mobile-pwa 3001 은 필요 시 기동. origin/main 동기화 완료(HEAD `4dbc688`, 2026-05-29 push). P7 코드(V88~V96)는 로컬 커밋 아직 없음.
|
||||||
|
4. **재기동이 필요한 경우**:
|
||||||
|
- `cd ga-commission-system && nohup ./gradlew :ga-api:bootRun --args='--spring.profiles.active=trading_ai' > ../ga-api-bootrun.log 2>&1 & disown`
|
||||||
|
- `cd ga-commission-system/ga-frontend && npm run dev` (3000)
|
||||||
|
- `cd ga-commission-system/ga-mobile-pwa && npm run dev` (3001)
|
||||||
|
- 정산 배치: `nohup ./gradlew :ga-batch:bootRun --args='--spring.profiles.active=trading_ai' > ../ga-batch-bootrun.log 2>&1 &` (8081). 트리거 `POST :8081/internal/jobs/monthly-settlement {"settleMonth":"yyyyMM"}` — **JWT 필요(ga-api `/api/auth/login` admin 토큰 재사용)**. 동기 실행.
|
||||||
|
5. **테스트 계정 / DB**: `admin`/`admin1234!`(SUPER_ADMIN), `agent01`/`agent1234!`(userId=4·agentId=1·AGENT). 로그인 필드명 `loginId`. DB: psql17 `192.168.0.60:55432/trading_ai` user `kyu`/pw `7895123`, `SET search_path TO ga`.
|
||||||
|
6. **사용자 결정 필요 항목 (외부 SDK 차단 사유)**:
|
||||||
|
- KFTC 펌뱅킹 API 계약·인증 정보 → `KftcBankTransferAdapter` 본체 구현
|
||||||
|
- NHN Toast / 카카오 비즈채널 계정 + 템플릿 코드 → `ToastMessageAdapter` 본체
|
||||||
|
- 푸시 알림 채널 (FCM 등) 결정 → PWA 통합
|
||||||
|
|
||||||
|
## 6. PL 가이드 (다음 세션 유의사항)
|
||||||
|
|
||||||
|
- **시간차 중복 메시지**: 팀원이 SendMessage로 같은 보고를 2~3번 보내는 경우 있음. 짧게 acknowledge만.
|
||||||
|
- **idle 알림**: 팀원이 작업 끝낼 때마다 idle 알림 보냄. 정상. 액션 필요 없음.
|
||||||
|
- **책임 경계**: dba=Flyway/SQL, core=VO/Mapper/Enum, api=Service/Controller. api-developer가 가끔 ga-core Mapper에 메서드 추가하는데 작은 변경이면 통과 가능, 큰 변경은 core-architect에 위임.
|
||||||
|
- **TODO 잔존**: api-developer가 부족한 ga-core 메서드 발견 시 TODO로 남기고 보고 → PL이 후속 보강 디스패치. 같은 단계에서 멈추지 말고 흐름 유지.
|
||||||
|
- **자동 정책**:
|
||||||
|
- 각 단계 완료 후 자동 커밋 (push 안 함)
|
||||||
|
- 빌드/테스트 실패 시 담당 팀원에 재지시
|
||||||
|
- 컴파일은 PL이 직접 검증. 테스트는 SqlSessionFactory 환경 문제 해결 후 `./gradlew test` 112건 PASS (§3-17)
|
||||||
|
|
||||||
|
## 7. 표준 가정 (도메인 작업 진행 시 채택한 값)
|
||||||
|
|
||||||
|
도메인 룰은 한국 보험업감독규정 표준 가정을 외부화 가능한 구조로 등록. 실제 회사 정책과 다르면 system_config / regulatory_limit / installment_ratio / chargeback_grade의 데이터만 수정하면 됨.
|
||||||
|
|
||||||
|
| 항목 | 표준값 | 근거 |
|
||||||
|
|---|---|---|
|
||||||
|
| 사업소득 원천징수 | 3.3% | 한국 표준 |
|
||||||
|
| 기타소득 원천징수 | 8.8% | 한국 표준 |
|
||||||
|
| 지방소득세 | 원천세의 10% | 한국 표준 |
|
||||||
|
| 부가세 | 10% | 한국 표준 |
|
||||||
|
| 1200% 룰 총 한도 | 1200% | 보험업감독규정 제5-15조의5 |
|
||||||
|
| 1차년 한도 | 35% | 2024년 기준 |
|
||||||
|
| 분급 연한 | 7년 | 2024년 기준 |
|
||||||
|
| 분급 비율 | 35/25/15/10/7/5/3 (합 100%) | 가정값 |
|
||||||
|
| 차등 환수 | 1~12: 100%, 13~24: 50%, 25~36: 25%, 37~84: 10%, 85~: 0% | 가정값 |
|
||||||
|
|
||||||
|
가정값과 실제가 다르면 운영 데이터 수정으로 해결 가능. 코드 변경 불필요.
|
||||||
@@ -40,9 +40,13 @@ ga-commission-system/
|
|||||||
|
|
||||||
### 시나리오 A — 운영 DB(trading_ai/ga) 에 연결해서 실행 ⭐ 권장
|
### 시나리오 A — 운영 DB(trading_ai/ga) 에 연결해서 실행 ⭐ 권장
|
||||||
|
|
||||||
이미 V1~V17 마이그레이션 + 테스트 데이터(보험사 5 / 상품 20 / 설계사 50 / 계약 200)가
|
V1~V17 마이그레이션 + 테스트 데이터(보험사 5 / 상품 20 / 설계사 50 / 계약 200)가
|
||||||
`192.168.0.60:55432/trading_ai/ga` 스키마에 적용되어 있습니다.
|
`192.168.0.60:55432/trading_ai/ga` 스키마에 적용되어 있습니다.
|
||||||
|
|
||||||
|
> **운영 DB 상태 (2026-05-17 기준)**: V73 적용 완료. V74(메뉴/권한 전수보강) 작성 완료, 대기 중.
|
||||||
|
> ga-api 를 재시작하면 Flyway 가 V74 를 자동 적용합니다(멱등).
|
||||||
|
> DB 복귀 후 `python verify_v74.py` 로 보강 결과를 검증하세요.
|
||||||
|
|
||||||
#### 1) Backend (ga-api) 실행
|
#### 1) Backend (ga-api) 실행
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -107,6 +111,10 @@ PW : admin1234!
|
|||||||
| 정산 결과 | `/settle` | 월 요약 + 확정/보류 |
|
| 정산 결과 | `/settle` | 월 요약 + 확정/보류 |
|
||||||
| 정산 배치 | `/settle/batch` | 배치 실행 + 진행률 폴링 |
|
| 정산 배치 | `/settle/batch` | 배치 실행 + 진행률 폴링 |
|
||||||
| 지급 관리 | `/payments` | 지급 상태 추적 |
|
| 지급 관리 | `/payments` | 지급 상태 추적 |
|
||||||
|
| 연말 사업소득 명세서 | `/settle/annual-income` | 설계사별 연간 사업소득 집계 + 재집계 (P5-W1) |
|
||||||
|
| 분개장 | `/settle/accounting-journal` | 회계 자동분개 엔트리 + 마감 처리 (P5-W1) |
|
||||||
|
| 원천세 신고 | `/tax/withholding-tax` | 분기별 원천세 신고 집계 + 재집계 + 제출완료 처리 (P5-W2) |
|
||||||
|
| 부가세 신고 | `/tax/vat` | 분기별 부가세 신고 집계 + 재집계 + 제출완료 처리 (P5-W2) |
|
||||||
| 사용자 관리 | `/system/users` | 비번 초기화/잠금 해제 |
|
| 사용자 관리 | `/system/users` | 비번 초기화/잠금 해제 |
|
||||||
| 역할/권한 | `/system/roles` | 권한 매트릭스 (메뉴 × 6권한) |
|
| 역할/권한 | `/system/roles` | 권한 매트릭스 (메뉴 × 6권한) |
|
||||||
| 공통코드 | `/system/codes` | 그룹별 코드 조회 |
|
| 공통코드 | `/system/codes` | 그룹별 코드 조회 |
|
||||||
@@ -119,7 +127,7 @@ PW : admin1234!
|
|||||||
# 1) PostgreSQL + Redis 기동 (Docker)
|
# 1) PostgreSQL + Redis 기동 (Docker)
|
||||||
docker compose up -d postgres redis
|
docker compose up -d postgres redis
|
||||||
|
|
||||||
# 2) ga-api 실행 (Flyway 가 자동으로 V1~V17 마이그레이션)
|
# 2) ga-api 실행 (Flyway 가 자동으로 V1~V55 마이그레이션)
|
||||||
./gradlew :ga-api:bootRun
|
./gradlew :ga-api:bootRun
|
||||||
# 기본 프로파일은 application.yml — localhost:5432/ga 에 연결
|
# 기본 프로파일은 application.yml — localhost:5432/ga 에 연결
|
||||||
|
|
||||||
@@ -219,23 +227,86 @@ UPDATE ga.users SET password = '$2b$10$...' WHERE login_id = 'admin';
|
|||||||
|
|
||||||
## 📋 주요 기능
|
## 📋 주요 기능
|
||||||
|
|
||||||
### 백엔드
|
### 공통 프레임워크 (ga-common)
|
||||||
- **인증/권한**: JWT (HS256), 메뉴×권한 RBAC, BCrypt 비밀번호
|
- **인증/권한**: JWT (HS256 액세스 2h/리프레시 7d), 메뉴×6권한 RBAC, BCrypt 비밀번호
|
||||||
- **공통코드**: Redis 캐시, 시스템코드 잠금, REST CRUD
|
- **공통코드**: Redis 캐시, 시스템코드 잠금, REST CRUD
|
||||||
- **메뉴**: 트리 구조, 사용자별 동적 메뉴 + 권한 매트릭스
|
- **메뉴**: 트리 구조, 사용자별 동적 메뉴 + 권한 매트릭스
|
||||||
- **엑셀**: SXSSF+Cursor 다운로드(100만건), SAX+배치 업로드(70만건)
|
- **엑셀**: SXSSF+Cursor 다운로드(100만건), SAX+배치 업로드(70만건)
|
||||||
- **로그**: 로그인/API/데이터변경 자동 기록 (비동기)
|
- **로그**: 로그인/API/데이터변경 자동 기록 (비동기 `@DataChangeLog` AOP)
|
||||||
- **암호화**: AES-256 CBC (`resident_no`, `account_no` 자동)
|
- **암호화**: AES-256 CBC (`resident_no`, `account_no` TypeHandler 자동 적용)
|
||||||
|
- **업로드 템플릿**: 동적 컬럼 매핑 + 변환 규칙 관리
|
||||||
|
|
||||||
### 도메인
|
### P0/P1 — 핵심 정산 도메인
|
||||||
- **조직**: 트리 구조, CTE 재귀, 인사 이력
|
- **세무 (P0-1)**: TaxCalculator 순수 도메인 — 사업소득 3.3% / 기타소득 8.8% / 지방세 10% / VAT 10%
|
||||||
- **계약**: 보험사/상품/계약 관리
|
- **공제 (P0-2)**: agent_deduction 월별 차감 마스터 + payment_deduction_detail 이체 명세
|
||||||
- **수수료 규정**: 보험사율, 지급율, 오버라이드, 환수, 예외코드
|
- **명세서 (P0-3)**: commission_statement 발급 마스터 + ExcelStatementGenerator (SXSSF)
|
||||||
- **원장**: 모집/유지/예외 (PageHelper + AG Grid)
|
- **1200%룰 (P1-1)**: regulatory_limit 마스터 + RegulatoryLimitChecker (효력일 이력 추적)
|
||||||
- **정산**: 8 Step 배치 (수신→변환→대사→모집→유지→환수/예외→오버라이드→집계)
|
- **분급 (P1-2)**: installment_plan 7년 (35/25/15/10/7/5/3%) + InstallmentPlanGenerator
|
||||||
- **지급**: 이체파일 생성, 상태 추적
|
- **차등환수 (P1-3)**: chargeback_grade 5단계 + ChargebackCalculator
|
||||||
|
|
||||||
### 정산 배치 8 Step
|
### 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 — 결재 + 펌뱅킹 송신 + 결과 반영
|
||||||
|
|
||||||
|
### P5 Wave 2 — 원천세/부가세 분기신고 (V67~V69)
|
||||||
|
- **원천세 신고** (`/tax/withholding-tax`): 분기별 설계사 사업소득 원천세 신고 집계, 재집계 + 제출완료 처리
|
||||||
|
- `GET /api/withholding-tax-reports` — 목록 조회 (settleQuarter/agentId/orgId/fileStatus 필터)
|
||||||
|
- `GET /api/withholding-tax-reports/{id}` — 단건 상세
|
||||||
|
- `POST /api/withholding-tax-reports/regenerate {"settleQuarter":"YYYY-Qn"}` — 분기 재집계 (EXECUTE 권한)
|
||||||
|
- `PUT /api/withholding-tax-reports/complete {"ids":[...]}` — 제출완료 처리 (EXECUTE 권한)
|
||||||
|
- **부가세 신고** (`/tax/vat`): tax_invoice 기반 분기 매출/매입 VAT 집계, 재집계 + 제출완료 처리
|
||||||
|
- `GET /api/vat-reports` — 목록 조회 (settleQuarter/fileStatus 필터)
|
||||||
|
- `GET /api/vat-reports/{id}` — 단건 상세
|
||||||
|
- `POST /api/vat-reports/regenerate {"settleQuarter":"YYYY-Qn"}` — 분기 재집계 (EXECUTE 권한)
|
||||||
|
- `PUT /api/vat-reports/complete {"ids":[...]}` — 제출완료 처리 (EXECUTE 권한)
|
||||||
|
|
||||||
|
### P5 Wave 1 — 회계 진입점 (V63~V66)
|
||||||
|
- **연말 사업소득 명세서** (`/settle/annual-income`): 설계사별 귀속연도 사업소득 집계, 재집계 버튼
|
||||||
|
- `GET /api/agent-annual-incomes` — 목록 조회 (settleYear/agentId/orgId 필터)
|
||||||
|
- `GET /api/agent-annual-incomes/{agentId}/{settleYear}` — 단건 상세
|
||||||
|
- `POST /api/agent-annual-incomes/regenerate?year=YYYY` — 연도 전체 재집계 (EXECUTE 권한)
|
||||||
|
- **분개장** (`/settle/accounting-journal`): 수수료 지급 자동분개 엔트리, 마감(journalNo 채번) 기능
|
||||||
|
- `GET /api/accounting-entries` — 분개 목록 (settleMonth/journalNo/status 필터)
|
||||||
|
- `POST /api/accounting-entries/generate` — 정산월 기준 분개 자동생성 (settleMonth or paymentIds)
|
||||||
|
- `PUT /api/accounting-entries/close` — 분개 마감 처리 (entryIds + journalNo)
|
||||||
|
- **계정코드 마스터** (공통 드롭다운용)
|
||||||
|
- `GET /api/common/account-codes?accountType=DEBIT|CREDIT|BOTH` — 활성 계정코드 목록
|
||||||
|
- `GET /api/common/account-codes/all` — 전체 (시스템관리용)
|
||||||
|
- `POST /api/common/account-codes` — 등록
|
||||||
|
- `PUT /api/common/account-codes/{accountCode}` — 수정
|
||||||
|
- `DELETE /api/common/account-codes/{accountCode}` — 삭제
|
||||||
|
|
||||||
|
### 정산 배치 10 Step
|
||||||
1. **receiveData** — 보험사별 DataReceiver → raw_commission_data 적재
|
1. **receiveData** — 보험사별 DataReceiver → raw_commission_data 적재
|
||||||
2. **transformData** — MappingEngine 으로 raw → 표준 원장 (TRIM/UPPER/DATE/DIVIDE/CODE_MAP)
|
2. **transformData** — MappingEngine 으로 raw → 표준 원장 (TRIM/UPPER/DATE/DIVIDE/CODE_MAP)
|
||||||
3. **reconcile** — 보험사 통보액 vs 시스템 계산액 대사
|
3. **reconcile** — 보험사 통보액 vs 시스템 계산액 대사
|
||||||
@@ -244,6 +315,8 @@ UPDATE ga.users SET password = '$2b$10$...' WHERE login_id = 'admin';
|
|||||||
6. **chargebackException** — 실효 계약 환수 자동생성
|
6. **chargebackException** — 실효 계약 환수 자동생성
|
||||||
7. **calcOverride** — 조직 트리 상향 탐색 → 상위 직급에 분배
|
7. **calcOverride** — 조직 트리 상향 탐색 → 상위 직급에 분배
|
||||||
8. **aggregate** — 설계사별 통합 집계 → settle_master UPSERT (3.3% 세금)
|
8. **aggregate** — 설계사별 통합 집계 → settle_master UPSERT (3.3% 세금)
|
||||||
|
9. **accountingEntryGenerate** — recruit_ledger+maintain_ledger → contract_accounting_entry INSERT
|
||||||
|
10. **yearEndIncomeAggregate** — 12월 마감 시만 실행: payment 합산 → agent_annual_income UPSERT
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -305,6 +378,65 @@ public class FooController {
|
|||||||
| V15 | 통계/리포트 뷰 (v_agent_monthly_summary 등) |
|
| V15 | 통계/리포트 뷰 (v_agent_monthly_summary 등) |
|
||||||
| V16 | 관리자 계정 + 메뉴 상세 + 권한 매트릭스 |
|
| V16 | 관리자 계정 + 메뉴 상세 + 권한 매트릭스 |
|
||||||
| V17 | 테스트 데이터 (보험사 5 / 상품 20 / 설계사 50 / 계약 200 + 수수료 규정) |
|
| V17 | 테스트 데이터 (보험사 5 / 상품 20 / 설계사 50 / 계약 200 + 수수료 규정) |
|
||||||
|
| V18 | 메뉴 트리 구조 재정렬 (동적 사이드바용) |
|
||||||
|
| V19 | 업로드 템플릿 마스터 (upload_template / upload_column) |
|
||||||
|
| V20 | 데이터 사전 (data_domain / data_dictionary) |
|
||||||
|
| V21 | 프로필·이력서 도메인 (agent_profile) |
|
||||||
|
| V22 | 비즈니스 도메인 확장 (계약·정산 보조 컬럼) |
|
||||||
|
| V23 | 세무 처리 — agent.tax_type/business_no/vat_type, payment 세금 컬럼 |
|
||||||
|
| V24 | 공제차감 — agent_deduction + payment_deduction_detail |
|
||||||
|
| V25 | 수수료 명세서 — commission_statement |
|
||||||
|
| V26 | 규제한도 마스터 — regulatory_limit (1200%룰) |
|
||||||
|
| V27 | 분급 계획 — installment_ratio + installment_plan |
|
||||||
|
| V28 | 차등환수 룰 — chargeback_grade |
|
||||||
|
| V29 | 마감 마스터 — period_close |
|
||||||
|
| V30 | 정정 — settle_correction |
|
||||||
|
| V31 | 이의신청 — commission_dispute |
|
||||||
|
| V32 | 보류사유 코드 — HOLD_REASON 공통코드 그룹 |
|
||||||
|
| V33 | 시책·인센티브 — incentive_program + incentive_payment |
|
||||||
|
| V34 | 해촉정산 — termination_settlement |
|
||||||
|
| V35 | 세금계산서 — tax_invoice |
|
||||||
|
| V36 | 보험사 회신 인터페이스 — carrier_reconciliation_out |
|
||||||
|
| V37 | KPI Materialized View 4종 (월별/조직별/유지율/누계) |
|
||||||
|
| V38 | 리포트 메뉴 INSERT |
|
||||||
|
| V39 | KPI 메뉴 INSERT |
|
||||||
|
| V40 | 수수료종류 코드 + commission_master 통합 마스터 |
|
||||||
|
| V41 | 수금수수료 — collection_commission_rule + collection_commission_ledger |
|
||||||
|
| V42 | 갱신·부활수수료 — renewal/reinstatement commission rule+ledger |
|
||||||
|
| V43 | 유지보수수당 — persistency_bonus_rule + persistency_bonus |
|
||||||
|
| V44 | 오버라이드 원장 — override_ledger + override_summary |
|
||||||
|
| V45 | 위촉계약 — agent_contract |
|
||||||
|
| V46 | 자격관리 — agent_license |
|
||||||
|
| V47 | 보수교육 — agent_training + training_requirement |
|
||||||
|
| V48 | 계약변경 — contract_endorsement |
|
||||||
|
| V49 | 민원관리 — complaint |
|
||||||
|
| V50 | 결재 워크플로우 — approval_line + approval_line_step + approval_request + approval_history |
|
||||||
|
| V51 | 공지·알림 — notice + user_notification |
|
||||||
|
| V52 | 첨부파일 — attachment |
|
||||||
|
| V53 | 출금신청 — withdraw_request |
|
||||||
|
| V54 | P4 메뉴 INSERT (P4-A + P4-B 14개 메뉴) |
|
||||||
|
| V55 | P4 공통코드 추가 (approval_type, complaint_type, license_type 등) |
|
||||||
|
| V56 | agent 세무 컬럼 보강 (tax_type/business_no/vat_type — V23 체크섬 충돌 보정) |
|
||||||
|
| V57 | admin 계정 초기화 |
|
||||||
|
| V58 | 1200%룰 메뉴 권한 |
|
||||||
|
| V59 | 신규 메뉴 권한 |
|
||||||
|
| V60 | statement 메뉴 권한 |
|
||||||
|
| V61 | installment_plan 메뉴 권한 |
|
||||||
|
| V62 | 추가 메뉴 권한 |
|
||||||
|
| V63 | P5-W1: agent_annual_income 테이블 (설계사 연말 사업소득 집계) |
|
||||||
|
| V64 | P5-W1: contract_accounting_entry 테이블 (회계 분개 엔트리) |
|
||||||
|
| V65 | P5-W1: 테스트 데이터 + 메뉴 INSERT (ANNUAL_INCOME / ACCOUNTING_JOURNAL) |
|
||||||
|
| V66 | P5-W1: account_code 마스터 + 초기 4건 (8350/2530/1130/4110) |
|
||||||
|
| V67 | P5-W2: withholding_tax_report 테이블 (원천세 분기 신고) |
|
||||||
|
| V68 | P5-W2: vat_report 테이블 (부가세 분기 신고) |
|
||||||
|
| V69 | P5-W2: 테스트 데이터 + 메뉴 INSERT (원천세/부가세 신고) |
|
||||||
|
| V70 | rule 도메인 감사 컬럼 보강 (commission_rate/payout_rule/override_rule 등) |
|
||||||
|
| V71 | payment 세무 컬럼 보강 (income_tax_amount/local_tax_amount/vat_amount/net_amount) |
|
||||||
|
| V72 | P5-W1 EXECUTE 권한 보강 (ANNUAL_INCOME / ACCOUNTING_JOURNAL) |
|
||||||
|
| V73 | P5-W2 EXECUTE 권한 보강 (WITHHOLDING_TAX / VAT_REPORT) |
|
||||||
|
|
||||||
|
> 운영 DB (192.168.0.60) 현재 V73 까지 적용 완료.
|
||||||
|
> **ga-api 재시작 시** Flyway 가 신규 마이그레이션을 자동으로 순차 적용합니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -312,14 +444,26 @@ public class FooController {
|
|||||||
|
|
||||||
- [x] 1단계 — 프로젝트 뼈대 + DB (V1~V17)
|
- [x] 1단계 — 프로젝트 뼈대 + DB (V1~V17)
|
||||||
- [x] **ga-common** — 공통 프레임워크 일체
|
- [x] **ga-common** — 공통 프레임워크 일체
|
||||||
- [x] **ga-core** — 22개 테이블 VO/Mapper/XML
|
- [x] **ga-core** — 65+ 테이블 VO/Mapper/XML (P0~P4 전체)
|
||||||
- [x] **ga-api** — Auth + 14개 도메인 컨트롤러
|
- [x] **ga-api** — Auth + 50+ 도메인 컨트롤러 (128개 Java 파일)
|
||||||
- [x] **ga-batch** — MonthlySettlementJob 8 Step (실제 동작 로직 포함)
|
- [x] **ga-batch** — MonthlySettlementJob 8 Step (실제 동작 로직 포함)
|
||||||
- [x] **Docker** — docker-compose + multi-stage Dockerfile + nginx
|
- [x] **Docker** — docker-compose + multi-stage Dockerfile + nginx
|
||||||
- [x] **ga-frontend** — 13개 페이지 (대시보드/설계사/계약/원장/정산/지급/배치/시스템관리)
|
- [x] **ga-frontend** — 50+ 페이지 (대시보드/설계사/계약/원장/정산/지급/배치/KPI/수수료/시스템관리)
|
||||||
- [x] **운영 DB 적용** — 192.168.0.60:55432/trading_ai/ga (V1~V17 + 테스트 데이터)
|
- [x] **DB 마이그레이션** — V1~V71 (71개 파일, 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] **P5-W1 회계 진입점** — agent_annual_income / contract_accounting_entry / account_code (V63~V66, 통합검증 완료)
|
||||||
|
- [x] **P5-W2 원천세·부가세 분기신고** — withholding_tax_report / vat_report (V67~V69, 통합검증 완료 2026-05-17)
|
||||||
|
- [x] **운영 DB** — 192.168.0.60:55432/trading_ai/ga (V73 까지 적용 완료)
|
||||||
|
- [ ] **단위/통합 테스트** — 미작성 (SqlSessionFactory 환경 문제로 보류)
|
||||||
- [ ] **MappingEngine 정밀화** — 일부 변환 규칙은 단순화된 형태
|
- [ ] **MappingEngine 정밀화** — 일부 변환 규칙은 단순화된 형태
|
||||||
|
- [ ] **P5-W3 결산 / 이상치 / 연말 지급명세서** — 미착수
|
||||||
|
- [ ] **P6 외부 연동** — 펌뱅킹 실연동 / SMS·카카오 / 모바일 셀프 조회
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -46,7 +46,7 @@ services:
|
|||||||
JWT_SECRET: ${JWT_SECRET:-please-change-this-jwt-secret-must-be-256-bits}
|
JWT_SECRET: ${JWT_SECRET:-please-change-this-jwt-secret-must-be-256-bits}
|
||||||
ENCRYPT_KEY: ${ENCRYPT_KEY:-please-change-this-32-byte-key!}
|
ENCRYPT_KEY: ${ENCRYPT_KEY:-please-change-this-32-byte-key!}
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8082:8082"
|
||||||
|
|
||||||
batch:
|
batch:
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# 00_PROMPTS — Claude Code 실행 프롬프트 모음
|
||||||
|
|
||||||
|
## 사전 준비
|
||||||
|
|
||||||
|
### 1) Agent Teams 활성화 (`~/.claude/settings.json`)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2) Git Worktree 격리 (선택)
|
||||||
|
```bash
|
||||||
|
git init ga-commission-system && cd ga-commission-system
|
||||||
|
git commit --allow-empty -m "init"
|
||||||
|
git worktree add ../ga-wt-common -b feat/common
|
||||||
|
git worktree add ../ga-wt-core -b feat/core
|
||||||
|
git worktree add ../ga-wt-batch -b feat/batch
|
||||||
|
git worktree add ../ga-wt-api -b feat/api
|
||||||
|
git worktree add ../ga-wt-front -b feat/frontend
|
||||||
|
git worktree add ../ga-wt-infra -b feat/infra
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3) 본 docs/ 폴더의 모든 파일을 프로젝트 루트에 배치
|
||||||
|
- `docs/01_DB_SCHEMA.md` ~ `docs/08_DBA_SPEC.md` (8 spec)
|
||||||
|
- `docs/00_PROMPTS.md` (this file)
|
||||||
|
|
||||||
|
> 각 프롬프트 안에 "docs/0N_*.md 읽어줘" 지시가 들어 있어, Claude Code 가
|
||||||
|
> 자동으로 해당 spec 을 읽고 그대로 구현합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 프롬프트 1: 리더 단독 — 프로젝트 뼈대 + DB
|
||||||
|
|
||||||
|
```
|
||||||
|
GA(법인보험대리점) 수수료 정산 솔루션 프로젝트를 만들어줘.
|
||||||
|
|
||||||
|
docs/01_DB_SCHEMA.md 를 읽고 그대로 구현해줘.
|
||||||
|
|
||||||
|
핵심 요약:
|
||||||
|
- Java 17 + Spring Boot 3.2 + MyBatis 3 (JPA 아님!) + PageHelper
|
||||||
|
- PostgreSQL + Redis + Flyway
|
||||||
|
- React 18 + Vite + Ant Design 5 + AG Grid Community
|
||||||
|
- Gradle 멀티모듈: ga-common / ga-core / ga-batch / ga-api / ga-admin / ga-frontend
|
||||||
|
|
||||||
|
작업:
|
||||||
|
1. settings.gradle / build.gradle / 모듈별 build.gradle
|
||||||
|
2. application.yml (mybatis-config 포함, mapUnderscoreToCamelCase=true)
|
||||||
|
3. Flyway V1~V17 SQL 전부 작성 (docs/01 의 스키마/초기데이터/인덱스/파티션/뷰/관리자/테스트데이터)
|
||||||
|
4. 모듈별 빈 패키지 + 진입점 (GaApiApplication 등)
|
||||||
|
|
||||||
|
완료 후 ./gradlew build 가 통과해야 함.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 프롬프트 2: 리더 단독 — ga-common 공통 프레임워크
|
||||||
|
|
||||||
|
```
|
||||||
|
docs/02_COMMON_SPEC.md 를 읽고 ga-common 모듈을 그대로 구현해줘.
|
||||||
|
|
||||||
|
핵심:
|
||||||
|
- 신입 개발자가 학습하기 쉬운 단순/명시적 코드 우선
|
||||||
|
- 패키지 구조: model / exception / util / annotation / aop / auth / security /
|
||||||
|
config / mybatis / grid / code / menu / excel / file / system / notification
|
||||||
|
- 모든 Controller 가 ApiResponse<T> 형태로 응답
|
||||||
|
- 검색 파라미터는 SearchParam 상속, 페이징은 PageHelper + PageResponse.of()
|
||||||
|
- @RequirePermission / @DataChangeLog / @ExcelColumn / @Mask 어노테이션 + AOP
|
||||||
|
- JWT (HS256) + BCrypt + AES-256 CBC 암호화 (EncryptTypeHandler)
|
||||||
|
- Excel SXSSF + xlsx-streamer 으로 100만건/70만건 처리
|
||||||
|
- 공통코드 Redis 캐시 (@Cacheable)
|
||||||
|
- 메뉴 트리 빌더 + 권한 체커
|
||||||
|
- ga-frontend 의 공통 컴포넌트 (CodeSelect / CodeBadge / SearchForm /
|
||||||
|
DataTable / DataGrid / PermissionButton / ExcelExportButton 등) 도 함께 작성
|
||||||
|
|
||||||
|
완료 후 ./gradlew :ga-common:build 통과 + 다른 팀원이 ga-common 만 의존하면 즉시 시작 가능해야 함.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 프롬프트 3: Agent Teams 5명 병렬 스폰
|
||||||
|
|
||||||
|
```
|
||||||
|
GA 수수료 정산 솔루션의 뼈대 + ga-common 이 완성됐어.
|
||||||
|
Agent Teams 5명을 병렬로 스폰해줘.
|
||||||
|
|
||||||
|
각 팀원은 자기 담당 docs 파일을 읽고 그대로 구현한다:
|
||||||
|
|
||||||
|
1) core-architect → docs/03_CORE_SPEC.md → ga-core (VO/Mapper/XML)
|
||||||
|
2) batch-engineer → docs/04_BATCH_SPEC.md → ga-batch (8 Step)
|
||||||
|
3) api-developer → docs/05_API_SPEC.md → ga-api (Controller/Service)
|
||||||
|
4) frontend-developer → docs/06_FRONTEND_SPEC.md
|
||||||
|
+ docs/09_UI_DESIGN.md → ga-frontend (13 화면 + 디자인 시스템)
|
||||||
|
5) dba-architect → docs/08_DBA_SPEC.md → V13~V15 + 인덱스/파티션/뷰
|
||||||
|
|
||||||
|
의존성:
|
||||||
|
- core-architect 가 가장 먼저 (다른 팀원이 VO/Mapper 사용)
|
||||||
|
- batch / api / frontend 는 core 완료 후 병렬 시작
|
||||||
|
- dba 는 core 의 Mapper XML 검증 후 인덱스/파티셔닝 적용
|
||||||
|
|
||||||
|
각 팀원 작업 규칙:
|
||||||
|
- 담당 모듈만 수정 (다른 팀 코드 건드리지 말기)
|
||||||
|
- ga-common 의 표준 패턴(ApiResponse, PageResponse, SearchParam, @RequirePermission,
|
||||||
|
@DataChangeLog) 을 반드시 따른다
|
||||||
|
- Mapper XML 은 SELECT * 금지 (필드 명시), 목록은 XxxResp 에 매핑
|
||||||
|
- 신입이 복붙 후 수정만으로 새 화면 만들 수 있도록 동일 패턴 유지
|
||||||
|
- 완료 시 리더에게 "[팀명] 완료" 메시지
|
||||||
|
|
||||||
|
팀을 만들고 작업을 시작해줘.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 프롬프트 4: 리더 단독 — 인프라 + 통합 검증
|
||||||
|
|
||||||
|
```
|
||||||
|
모든 팀원 작업 완료. docs/07_INFRA_SPEC.md 를 읽고 마지막 작업을 해줘.
|
||||||
|
|
||||||
|
작업:
|
||||||
|
1. docker-compose.yml: postgres / redis / api / batch / frontend
|
||||||
|
2. Dockerfile: 멀티스테이지 (Gradle 빌드 → JRE 17 실행)
|
||||||
|
3. nginx.conf: React SPA + /api → backend:8080 프록시
|
||||||
|
4. README.md: 시나리오 A(운영 DB) / B(로컬) / C(Docker) 3 가지 실행 가이드
|
||||||
|
5. DEVELOPER_GUIDE.md: 신입 개발자 온보딩 (새 화면 만들기 step-by-step)
|
||||||
|
|
||||||
|
통합 검증:
|
||||||
|
- ./gradlew clean build 성공
|
||||||
|
- cd ga-frontend && npm install && npm run build 성공
|
||||||
|
- docker-compose up -d --build 전체 기동 후 http://localhost:3000 로그인 동작
|
||||||
|
- Flyway V1~V17 자동 적용 확인
|
||||||
|
- 단위 테스트 ./gradlew test 통과
|
||||||
|
|
||||||
|
문제 있으면 수정 후 v1.0.0-alpha 태그.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 프롬프트 5: 리더 단독 — 단위 테스트 (선택)
|
||||||
|
|
||||||
|
```
|
||||||
|
docs/02_COMMON_SPEC.md / 04_BATCH_SPEC.md 를 참고해 단위 테스트를 추가해줘.
|
||||||
|
|
||||||
|
대상:
|
||||||
|
- ga-common 유틸: MoneyUtilTest / DateUtilTest / MaskUtilTest / EncryptUtilTest
|
||||||
|
- ga-common 서비스: CommonCodeServiceTest / MenuTreeBuilderTest / PermissionCheckerTest
|
||||||
|
- ga-batch 핵심 로직: CommissionCalculatorTest / MappingEngineTest
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
- JUnit 5 + Mockito + AssertJ (spring-boot-starter-test 에 포함)
|
||||||
|
- @Cacheable / @CacheEvict 는 단위 테스트에서 동작 안 함 → 비즈니스 로직만 검증
|
||||||
|
- Mockito strict stubbing 위반 주의 (@BeforeEach 의 stub 은 모든 테스트가 사용해야 함)
|
||||||
|
- 모든 테스트 통과 후 커밋
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 프롬프트 6: 리더 단독 — 운영 DB 배포 (선택)
|
||||||
|
|
||||||
|
```
|
||||||
|
운영 DB(192.168.0.60:55432/trading_ai/ga) 에 V1~V17 마이그레이션 + 테스트 데이터를 적용해줘.
|
||||||
|
|
||||||
|
방법:
|
||||||
|
- Python + pg8000 으로 migrate.py 작성 (저장소 외부에 둔다)
|
||||||
|
- ga 스키마 존재 여부 확인 후 idempotent guard
|
||||||
|
- application-trading_ai.yml 프로파일 추가 (currentSchema=ga)
|
||||||
|
- 실행: ./gradlew :ga-api:bootRun --args='--spring.profiles.active=trading_ai'
|
||||||
|
|
||||||
|
확인:
|
||||||
|
- /api/health 200
|
||||||
|
- /login admin / admin1234! 로 토큰 발급
|
||||||
|
- 메뉴 트리 / 공통코드 / 설계사 50명 / 계약 200건 조회 OK
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 비용 절약 팁
|
||||||
|
- 리더: Opus / 팀원: Sonnet (하이브리드 모델)
|
||||||
|
- 5명이 적정. 그 이상은 조율 오버헤드 증가
|
||||||
|
- 작업 범위 명확 + 서로 독립적일수록 효과적
|
||||||
|
- `--dangerously-skip-permissions` 사용 시 모든 팀원 동일 권한
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
# 01_DB_SCHEMA — DB 스키마 + 프로젝트 뼈대
|
||||||
|
|
||||||
|
## 1. 기술 스택
|
||||||
|
|
||||||
|
| 영역 | 기술 |
|
||||||
|
|---|---|
|
||||||
|
| Backend | Java 17, Spring Boot 3.2.5, Spring Security, Spring Batch |
|
||||||
|
| ORM | **MyBatis 3** + mybatis-spring-boot-starter:3.0.3 (JPA 아님) |
|
||||||
|
| 페이징 | pagehelper-spring-boot-starter:2.1.0 |
|
||||||
|
| DB | PostgreSQL 16 |
|
||||||
|
| Cache | Redis 7 (선택) |
|
||||||
|
| Frontend | React 18 + Vite + TS + Ant Design 5 + AG Grid Community + React Query + Zustand |
|
||||||
|
| Build | Gradle 멀티모듈 (Java 17 toolchain) |
|
||||||
|
| 기타 | Lombok, MapStruct 1.5.5, Flyway, Springdoc OpenAPI 2.3 |
|
||||||
|
|
||||||
|
## 2. 프로젝트 구조 (멀티모듈)
|
||||||
|
|
||||||
|
```
|
||||||
|
ga-commission-system/
|
||||||
|
├── settings.gradle # 모듈 7개 (ga-common/core/api/batch/admin/frontend)
|
||||||
|
├── build.gradle # subprojects { Lombok, MapStruct, JUnit5 }
|
||||||
|
├── gradle.properties
|
||||||
|
├── ga-common/ # 공통 프레임워크 (라이브러리, bootJar disabled)
|
||||||
|
├── ga-core/ # 도메인 코어 (VO/Mapper/XML, ga-common 의존)
|
||||||
|
├── ga-api/ # REST API (executable jar, 8080)
|
||||||
|
├── ga-batch/ # Spring Batch (executable jar, 8081)
|
||||||
|
├── ga-admin/ # (현재 빈 모듈, 향후 관리자 전용)
|
||||||
|
├── ga-frontend/ # Vite 빌드, dist/ → nginx
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── Dockerfile
|
||||||
|
└── docs/ # 본 폴더
|
||||||
|
```
|
||||||
|
|
||||||
|
### 모듈 의존성
|
||||||
|
- `ga-core` → `ga-common`
|
||||||
|
- `ga-api` → `ga-core` (transitively `ga-common`)
|
||||||
|
- `ga-batch`→ `ga-core`
|
||||||
|
- `ga-admin`→ `ga-core` (현재 미사용)
|
||||||
|
|
||||||
|
### 핵심 build.gradle 설정
|
||||||
|
```groovy
|
||||||
|
// 루트 build.gradle subprojects
|
||||||
|
plugins {
|
||||||
|
id 'java'
|
||||||
|
id 'org.springframework.boot' version '3.2.5' apply false
|
||||||
|
id 'io.spring.dependency-management' version '1.1.4' apply false
|
||||||
|
}
|
||||||
|
java { toolchain { languageVersion = JavaLanguageVersion.of(17) } }
|
||||||
|
compileJava { options.compilerArgs << '-parameters' } // MyBatis 파라미터 이름
|
||||||
|
|
||||||
|
// ga-common: 라이브러리 (bootJar disabled, jar enabled)
|
||||||
|
// ga-api / ga-batch: bootJar enabled, jar disabled
|
||||||
|
```
|
||||||
|
|
||||||
|
### MyBatis 설정 (`ga-common/src/main/resources/mybatis/mybatis-config.xml`)
|
||||||
|
```xml
|
||||||
|
<configuration>
|
||||||
|
<settings>
|
||||||
|
<setting name="mapUnderscoreToCamelCase" value="true"/>
|
||||||
|
<setting name="cacheEnabled" value="false"/>
|
||||||
|
<setting name="callSettersOnNulls" value="true"/>
|
||||||
|
<setting name="defaultStatementTimeout" value="60"/>
|
||||||
|
<setting name="jdbcTypeForNull" value="NULL"/>
|
||||||
|
</settings>
|
||||||
|
<typeHandlers>
|
||||||
|
<!-- JsonTypeHandler 만 글로벌. EncryptTypeHandler 는 컬럼별 명시 -->
|
||||||
|
<typeHandler handler="com.ga.common.mybatis.JsonTypeHandler"/>
|
||||||
|
</typeHandlers>
|
||||||
|
</configuration>
|
||||||
|
```
|
||||||
|
|
||||||
|
### application.yml (ga-api)
|
||||||
|
```yaml
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:postgresql://localhost:5432/ga
|
||||||
|
username: ga
|
||||||
|
password: ga
|
||||||
|
hikari: { maximum-pool-size: 20 }
|
||||||
|
flyway:
|
||||||
|
locations: classpath:db/migration # ga-common 의 V1~V17
|
||||||
|
baseline-on-migrate: true
|
||||||
|
mybatis:
|
||||||
|
config-location: classpath:mybatis/mybatis-config.xml
|
||||||
|
mapper-locations: classpath*:mapper/**/*.xml
|
||||||
|
ga:
|
||||||
|
jwt:
|
||||||
|
secret: ${JWT_SECRET:replace-me-256-bit-or-longer-secret-key}
|
||||||
|
access-ttl-min: 120
|
||||||
|
refresh-ttl-day: 7
|
||||||
|
encrypt:
|
||||||
|
key: ${ENCRYPT_KEY:32byte-key-for-aes-256-cbc-aaaa} # 32 byte
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Flyway 마이그레이션 V1~V17
|
||||||
|
|
||||||
|
위치: `ga-common/src/main/resources/db/migration/`
|
||||||
|
|
||||||
|
**모든 금액 `DECIMAL(15,2)`, 모든 수수료율 `DECIMAL(7,4)`. 모든 테이블에 인덱스 부여.**
|
||||||
|
|
||||||
|
### V1__조직_인사.sql (4 테이블)
|
||||||
|
```sql
|
||||||
|
grade (grade_id PK, grade_name, grade_level, grade_group, description, is_active, sort_order)
|
||||||
|
organization (org_id PK, parent_org_id FK self, org_name, org_type[HQ/BR/TM], org_level, region,
|
||||||
|
effective_from, effective_to, is_active, created_at, updated_at, created_by)
|
||||||
|
agent (agent_id PK, org_id FK, grade_id FK, agent_name, resident_no(AES), license_no,
|
||||||
|
phone, email, bank_code, account_no(AES), status[ACTIVE/LEAVE/SUSPEND],
|
||||||
|
join_date, leave_date, created_at, updated_at)
|
||||||
|
agent_org_history(history_id PK, agent_id FK, from_org_id, to_org_id, from_grade_id, to_grade_id,
|
||||||
|
change_type[TRANSFER/PROMOTE/DEMOTE/JOIN/LEAVE], change_date, change_reason)
|
||||||
|
```
|
||||||
|
인덱스: `idx_org_parent`, `idx_org_type(org_type,is_active)`, `idx_agent_org(org_id,status)`,
|
||||||
|
`idx_agent_lic(license_no)`, `idx_agent_status`.
|
||||||
|
|
||||||
|
### V2__상품_계약.sql (3 테이블)
|
||||||
|
```sql
|
||||||
|
insurance_company(company_id PK, company_code UQ, company_name, company_type, biz_no,
|
||||||
|
contact_name, contact_phone, contact_email, is_active)
|
||||||
|
product (product_id PK, company_id FK, product_code, product_name, insurance_type,
|
||||||
|
product_group, pay_period_type, is_active, launch_date, end_date)
|
||||||
|
contract (contract_id PK, agent_id FK, product_id FK, policy_no UQ, contractor_name,
|
||||||
|
insured_name, premium DECIMAL(15,2), pay_cycle, pay_period, coverage_period,
|
||||||
|
status, contract_date, effective_date, lapse_date, revival_date, created_at, updated_at)
|
||||||
|
```
|
||||||
|
|
||||||
|
### V3__수수료규정.sql (5 테이블)
|
||||||
|
```sql
|
||||||
|
commission_rate (rate_id PK, product_id FK, commission_year, rate_pct DECIMAL(7,4),
|
||||||
|
pay_method_cond, min_premium, effective_from, effective_to, version)
|
||||||
|
payout_rule (rule_id PK, grade_id FK, insurance_type, commission_year, payout_pct DECIMAL(7,4),
|
||||||
|
pay_method_cond, performance_grade, effective_from, effective_to, version)
|
||||||
|
override_rule (override_id PK, from_grade, to_grade, override_pct DECIMAL(7,4),
|
||||||
|
calc_type, insurance_type_cond, cap_amount, effective_from, effective_to)
|
||||||
|
chargeback_rule (cb_rule_id PK, company_code, insurance_type, lapse_month_from, lapse_month_to,
|
||||||
|
cb_rate DECIMAL(7,4), include_override, installment_allowed, max_installments,
|
||||||
|
effective_from, effective_to)
|
||||||
|
exception_type_code (exception_code PK, exception_name, category, direction[PLUS/MINUS],
|
||||||
|
auto_calc_yn, approval_required, tax_included, description, is_active)
|
||||||
|
```
|
||||||
|
|
||||||
|
### V4__데이터수신.sql (5 테이블)
|
||||||
|
```sql
|
||||||
|
company_profile (company_code PK FK, data_format, receive_method, file_encoding, delimiter,
|
||||||
|
header_row, data_start_row, sheet_name, date_format, amount_format,
|
||||||
|
api_url, ftp_host, ftp_path, receive_schedule, is_active)
|
||||||
|
company_field_mapping (mapping_id PK, company_code FK, source_field, source_col_index,
|
||||||
|
target_table, target_field, data_type, transform_rule, default_value,
|
||||||
|
is_required, sort_order, version, effective_from)
|
||||||
|
code_mapping (code_id PK, company_code, code_type, source_code, source_name,
|
||||||
|
target_code, target_name, is_active)
|
||||||
|
raw_commission_data (raw_id PK, company_code, batch_id, settle_month, data_type,
|
||||||
|
raw_content TEXT, parse_status[PENDING/OK/ERROR], mapped_ledger_id,
|
||||||
|
mapped_table, file_name, row_number, received_at, parsed_at)
|
||||||
|
parse_error_log (error_id PK, raw_id FK, company_code, error_type, error_field,
|
||||||
|
error_value, error_message, resolve_status[OPEN/RESOLVED/IGNORED],
|
||||||
|
resolved_by, resolve_note, resolved_at, created_at)
|
||||||
|
```
|
||||||
|
|
||||||
|
### V5__원장관리.sql (3 테이블, 동일 컬럼)
|
||||||
|
```sql
|
||||||
|
recruit_ledger / maintain_ledger / exception_ledger
|
||||||
|
공통 컬럼:
|
||||||
|
ledger_id PK, contract_id FK, agent_id FK, policy_no, company_code, product_code,
|
||||||
|
insurance_type, commission_year, premium DECIMAL(15,2), pay_method,
|
||||||
|
company_rate DECIMAL(7,4), company_amount DECIMAL(15,2),
|
||||||
|
payout_rate DECIMAL(7,4), agent_amount DECIMAL(15,2),
|
||||||
|
tax_amount DECIMAL(15,2),
|
||||||
|
reconcile_status[PENDING/MATCHED/DIFF], reconcile_diff,
|
||||||
|
advance_yn, advance_amount, chargeback_yn, settle_month, status[CALCULATED/CONFIRMED/HOLD],
|
||||||
|
version, created_at, updated_at
|
||||||
|
|
||||||
|
exception_ledger 추가:
|
||||||
|
exception_code, approve_status, approver_id, approve_date,
|
||||||
|
recurring_yn, recurring_until, recurring_parent_id
|
||||||
|
```
|
||||||
|
|
||||||
|
### V6__정산_지급.sql (4 테이블)
|
||||||
|
```sql
|
||||||
|
settle_master (settle_id PK, agent_id FK, settle_month,
|
||||||
|
recruit_total, maintain_total, exception_plus, exception_minus,
|
||||||
|
override_total, chargeback_total, gross_amount, tax_amount, net_amount,
|
||||||
|
status[DRAFT/CONFIRMED/HOLD/PAID], calc_date, confirmed_by, confirmed_at,
|
||||||
|
hold_reason, UNIQUE(agent_id, settle_month))
|
||||||
|
override_settle(os_id PK, settle_id FK, from_agent_id, to_agent_id, base_amount,
|
||||||
|
override_pct, override_amount, calc_basis)
|
||||||
|
chargeback (cb_id PK, contract_id FK, agent_id FK, lapse_month_diff, base_amount,
|
||||||
|
cb_rate, cb_amount, installment_seq, installment_total, status, created_at)
|
||||||
|
payment (payment_id PK, settle_id FK, agent_id FK, bank_code, account_no(AES),
|
||||||
|
amount, transfer_date, status[PENDING/SENT/SUCCESS/FAIL], result_code, result_msg)
|
||||||
|
```
|
||||||
|
|
||||||
|
### V7__배치_감사.sql (4 테이블)
|
||||||
|
```sql
|
||||||
|
batch_job_log (job_log_id PK, job_name, job_params, settle_month, status[REQUESTED/RUNNING/
|
||||||
|
COMPLETED/FAILED], step_name, total_count, success_count, error_count,
|
||||||
|
error_message, started_at, ended_at, duration_ms)
|
||||||
|
reconciliation (recon_id PK, company_code, settle_month, expected_amount, actual_amount,
|
||||||
|
diff_amount, recon_status, resolved_by, resolved_at)
|
||||||
|
audit_log (audit_id PK, user_id, action_type, target_table, target_id,
|
||||||
|
before_data JSONB, after_data JSONB, ip_address, user_agent, created_at)
|
||||||
|
users (user_id PK, login_id UQ, password(BCrypt), user_name, agent_id FK nullable,
|
||||||
|
email, phone, status[ACTIVE/LOCKED/EXPIRED], last_login_at,
|
||||||
|
fail_count, password_changed_at, created_at, updated_at)
|
||||||
|
```
|
||||||
|
|
||||||
|
### V8__초기데이터.sql
|
||||||
|
- grade 6 건 (FC/SFC/SMGR/DM/GM/SVP)
|
||||||
|
- exception_type_code 10 건 (ADJUST_PLUS / ADJUST_MINUS / BONUS / PENALTY / ...)
|
||||||
|
|
||||||
|
### V9__공통코드.sql (2 테이블)
|
||||||
|
```sql
|
||||||
|
common_code_group (group_code VARCHAR(20) PK, group_name, description, is_system CHAR(1),
|
||||||
|
is_active, sort_order, created_at, created_by, updated_at, updated_by)
|
||||||
|
common_code (code_id PK, group_code FK, code VARCHAR(30), code_name, code_name_en,
|
||||||
|
code_desc, attr1, attr2, attr3, ref_code, sort_order, is_active,
|
||||||
|
created_at, created_by, updated_at, updated_by,
|
||||||
|
UNIQUE(group_code, code))
|
||||||
|
```
|
||||||
|
|
||||||
|
### V10__메뉴관리.sql (5 테이블)
|
||||||
|
```sql
|
||||||
|
menu (menu_id PK, parent_menu_id FK self, menu_code UQ, menu_name,
|
||||||
|
menu_type[DIRECTORY/PAGE/LINK], menu_path, menu_icon, component_path,
|
||||||
|
menu_level, sort_order, is_visible, is_active, description)
|
||||||
|
menu_permission (perm_id PK, menu_id FK, perm_code[READ/CREATE/UPDATE/DELETE/APPROVE/
|
||||||
|
EXPORT/PRINT], perm_name, sort_order, UNIQUE(menu_id, perm_code))
|
||||||
|
role (role_id PK, role_code UQ, role_name, role_desc, role_level,
|
||||||
|
is_system, is_active)
|
||||||
|
role_menu_permission (rmp_id PK, role_id FK, menu_id FK, perm_code, is_granted,
|
||||||
|
UNIQUE(role_id, menu_id, perm_code))
|
||||||
|
user_role (ur_id PK, user_id FK, role_id FK, UNIQUE(user_id, role_id))
|
||||||
|
```
|
||||||
|
|
||||||
|
### V11__시스템관리.sql (6 테이블)
|
||||||
|
```sql
|
||||||
|
login_log (log_id PK, user_id, login_id, login_type[PWD/SSO], result[SUCCESS/FAIL],
|
||||||
|
fail_reason, ip_address, user_agent, login_at)
|
||||||
|
api_access_log (access_id PK, user_id, request_uri, http_method, params, response_code,
|
||||||
|
response_time_ms, ip_address, created_at) -- 비동기 저장
|
||||||
|
data_change_log (change_id PK, user_id, menu_code, table_name, record_id,
|
||||||
|
before_data JSONB, after_data JSONB, action[CREATE/UPDATE/DELETE], created_at)
|
||||||
|
file_storage (file_id PK, group_id, original_name, stored_name, file_path, file_size,
|
||||||
|
content_type, download_count, is_deleted, created_by, created_at)
|
||||||
|
notification (noti_id PK, user_id, noti_type, title, content, link_url, is_read, read_at, created_at)
|
||||||
|
system_config (config_key VARCHAR(50) PK, config_value, config_group, description,
|
||||||
|
is_encrypted, updated_by, updated_at)
|
||||||
|
```
|
||||||
|
|
||||||
|
### V12__공통_초기데이터.sql
|
||||||
|
- 공통코드 18 그룹 + 상세 42 건
|
||||||
|
(AGENT_STATUS / CONTRACT_STATUS / SETTLE_STATUS / PAY_CYCLE / INSURANCE_TYPE /
|
||||||
|
ORG_TYPE / GRADE_GROUP / RECONCILE_STATUS / PARSE_STATUS / EXCEPTION_DIRECTION /
|
||||||
|
APPROVE_STATUS / PAYMENT_STATUS / NOTI_TYPE / FILE_TYPE / DATA_FORMAT /
|
||||||
|
RECEIVE_METHOD / TRANSFORM_RULE / CHANGE_TYPE)
|
||||||
|
- 역할 4 건 (`SUPER_ADMIN` / `ADMIN` / `MANAGER` / `AGENT`)
|
||||||
|
- 대메뉴 10 건 + 시스템설정 13 건
|
||||||
|
|
||||||
|
### V13__인덱스최적화.sql
|
||||||
|
- `recruit_ledger(settle_month, agent_id)`, `recruit_ledger(policy_no)`
|
||||||
|
- `maintain_ledger(settle_month, agent_id)`, `maintain_ledger(policy_no)`
|
||||||
|
- `exception_ledger(settle_month, approve_status)`
|
||||||
|
- `settle_master(settle_month, status)`
|
||||||
|
- `contract(agent_id, status)`, `contract(policy_no)`
|
||||||
|
- `raw_commission_data(company_code, settle_month, parse_status)`
|
||||||
|
- `api_access_log(created_at)`, `data_change_log(created_at, table_name)`
|
||||||
|
|
||||||
|
### V14__파티셔닝.sql
|
||||||
|
- 헬퍼 함수 `create_monthly_partition(table_name, year_month)` 정의
|
||||||
|
- `recruit_ledger / maintain_ledger / api_access_log / data_change_log` 를
|
||||||
|
`settle_month` 또는 `created_at` 기준 RANGE 파티션으로 변환 (PG 13+)
|
||||||
|
|
||||||
|
### V15__뷰.sql
|
||||||
|
```sql
|
||||||
|
v_agent_monthly_summary (agent_id, agent_name, org_name, settle_month,
|
||||||
|
recruit_amt, maintain_amt, exception_plus, exception_minus,
|
||||||
|
override_amt, chargeback_amt, gross, tax, net, status)
|
||||||
|
v_org_settle_summary (org_id, org_name, settle_month, agent_count, total_gross, total_net)
|
||||||
|
v_chargeback_risk (contract_id, policy_no, agent_name, lapse_month_diff,
|
||||||
|
predicted_cb_amount, risk_grade)
|
||||||
|
```
|
||||||
|
|
||||||
|
### V16__관리자_초기데이터.sql
|
||||||
|
- `users` 에 `admin / admin1234!` (BCrypt) + `SUPER_ADMIN` 역할 매핑
|
||||||
|
- 메뉴 33건 + 메뉴별 권한 150건 + 역할-메뉴-권한 매트릭스 323건
|
||||||
|
|
||||||
|
### V17__테스트데이터.sql
|
||||||
|
- 보험사 5 (SAMSUNG / HANWHA / KYOBO / DB / KB)
|
||||||
|
- 상품 20 (보험사당 4)
|
||||||
|
- 조직 7 (본부 1 / 지점 3 / 팀 3) + 설계사 50
|
||||||
|
- 계약 200 (ACTIVE 190 / LAPSE 10)
|
||||||
|
- 수수료 규정: commission_rate 100 / payout_rule 120 / override_rule 10 / chargeback_rule 4
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 환경변수 / 시크릿
|
||||||
|
|
||||||
|
| 키 | 기본값 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| `JWT_SECRET` | `replace-me-...` | HS256 256bit+ |
|
||||||
|
| `ENCRYPT_KEY` | `32byte-key-...` | AES-256 키 (32 byte) |
|
||||||
|
| `DB_HOST` / `DB_PORT` / `DB_NAME` / `DB_USERNAME` / `DB_PASSWORD` | localhost/5432/ga/ga/ga | DataSource |
|
||||||
|
| `REDIS_HOST` / `REDIS_PORT` | localhost / 6379 | 선택 (캐시) |
|
||||||
|
|
||||||
|
운영 DB 프로파일 (`application-trading_ai.yml`):
|
||||||
|
```yaml
|
||||||
|
spring.datasource:
|
||||||
|
url: jdbc:postgresql://192.168.0.60:55432/trading_ai?currentSchema=ga
|
||||||
|
username: kyu
|
||||||
|
password: ${DB_PASSWORD}
|
||||||
|
spring.flyway.schemas: ga
|
||||||
|
```
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
# 02_COMMON_SPEC — ga-common 공통 프레임워크
|
||||||
|
|
||||||
|
> 모든 다른 모듈이 의존하는 핵심. 신입 개발자가 학습하기 쉬운 단순/명시적 코드 우선.
|
||||||
|
|
||||||
|
## 패키지 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
ga-common/src/main/java/com/ga/common/
|
||||||
|
├── annotation/ # @RequirePermission, @DataChangeLog, @ExcelColumn, @Mask
|
||||||
|
├── aop/ # PermissionAspect, DataChangeLogAspect, ApiLogAspect
|
||||||
|
├── auth/ # JwtTokenProvider, JwtAuthFilter, LoginUser
|
||||||
|
├── code/ # CommonCodeService/Controller/Mapper (Redis 캐시)
|
||||||
|
├── config/ # RedisConfig, SwaggerConfig, WebMvcConfig, AsyncConfig
|
||||||
|
├── exception/ # ErrorCode (enum), BizException, GlobalExceptionHandler
|
||||||
|
├── excel/ # ExcelService (Export 100만, Import 70만)
|
||||||
|
├── file/ # FileService/Controller (multipart 업로드/다운로드)
|
||||||
|
├── grid/ # GridSearchParam, GridResponse (AG Grid 서버사이드)
|
||||||
|
├── menu/ # MenuService/Controller, MenuTreeBuilder, PermissionChecker
|
||||||
|
├── model/ # ApiResponse<T>, PageResponse<T>, SearchParam, TreeNode
|
||||||
|
├── mybatis/ # BaseMapper, JsonTypeHandler, EncryptTypeHandler(+Initializer), AuditInterceptor
|
||||||
|
├── notification/ # NotificationService/Controller
|
||||||
|
├── security/ # SecurityConfig, JwtAuthEntryPoint, AccessDeniedHandlerImpl
|
||||||
|
├── system/ # SystemConfigService, DataChangeLogService, ApiAccessLogService, SystemLogController
|
||||||
|
└── util/ # DateUtil, MoneyUtil, MaskUtil, EncryptUtil, SecurityUtil
|
||||||
|
|
||||||
|
ga-common/src/main/resources/
|
||||||
|
├── mybatis/mybatis-config.xml
|
||||||
|
└── mapper/common/ # CommonCodeMapper.xml, MenuMapper.xml, FileMapper.xml, SystemConfigMapper.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
build.gradle 핵심: `api 'org.springframework.boot:spring-boot-starter-{web,security,aop,validation,data-redis,cache}'`,
|
||||||
|
`api 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'`,
|
||||||
|
`api 'com.github.pagehelper:pagehelper-spring-boot-starter:2.1.0'`,
|
||||||
|
`api 'io.jsonwebtoken:jjwt-api:0.12.5'`, `api 'org.apache.poi:poi-ooxml:5.2.5'`,
|
||||||
|
`api 'com.monitorjbl:xlsx-streamer:2.2.0'`, `api 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 공통 모델 (`model/`)
|
||||||
|
|
||||||
|
### `ApiResponse<T>` — 모든 API 응답 표준
|
||||||
|
```java
|
||||||
|
public class ApiResponse<T> {
|
||||||
|
private boolean success;
|
||||||
|
private String code; // "0000" 성공
|
||||||
|
private String message;
|
||||||
|
private T data;
|
||||||
|
private LocalDateTime timestamp;
|
||||||
|
|
||||||
|
public static <T> ApiResponse<T> ok(T data) { ... }
|
||||||
|
public static <T> ApiResponse<T> ok(T data, String msg) { ... }
|
||||||
|
public static ApiResponse<Void> ok() { ... }
|
||||||
|
public static <T> ApiResponse<T> fail(ErrorCode e) { ... }
|
||||||
|
public static <T> ApiResponse<T> fail(String code, String msg) { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `PageResponse<T>` — PageHelper 연동
|
||||||
|
```java
|
||||||
|
public class PageResponse<T> {
|
||||||
|
private List<T> list;
|
||||||
|
private int pageNum, pageSize, pages;
|
||||||
|
private long total;
|
||||||
|
public static <T> PageResponse<T> of(List<T> list) {
|
||||||
|
PageInfo<T> p = new PageInfo<>(list);
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `SearchParam` — 공통 검색 파라미터
|
||||||
|
```java
|
||||||
|
public class SearchParam {
|
||||||
|
private int pageNum = 1, pageSize = 20;
|
||||||
|
private String searchType, searchKeyword;
|
||||||
|
private String startDate, endDate;
|
||||||
|
private String status;
|
||||||
|
private String sortField, sortOrder; // ASC / DESC
|
||||||
|
|
||||||
|
public void startPage() {
|
||||||
|
PageHelper.startPage(pageNum, pageSize);
|
||||||
|
if (sortField != null) PageHelper.orderBy(sortField + " " + sortOrder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `TreeNode` — 트리 응답 공통 (메뉴/조직)
|
||||||
|
```java
|
||||||
|
public class TreeNode {
|
||||||
|
private Object id, parentId;
|
||||||
|
private String name, type;
|
||||||
|
private int level, sortOrder;
|
||||||
|
private List<TreeNode> children = new ArrayList<>();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GridSearchParam extends SearchParam` — AG Grid 서버사이드용
|
||||||
|
```java
|
||||||
|
public class GridSearchParam extends SearchParam {
|
||||||
|
private List<Map<String,Object>> filterModel; // AG Grid 필터
|
||||||
|
private List<Map<String,String>> sortModel; // AG Grid 정렬
|
||||||
|
public String buildFilterSql() { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 예외 처리 (`exception/`)
|
||||||
|
|
||||||
|
### `ErrorCode` enum
|
||||||
|
```java
|
||||||
|
SUCCESS("0000", 200), BAD_REQUEST("E400", 400),
|
||||||
|
UNAUTHORIZED("E401", 401), FORBIDDEN("E403", 403),
|
||||||
|
NOT_FOUND("E404", 404), INVALID_PARAMETER("E411", 400),
|
||||||
|
INTERNAL_ERROR("E500", 500),
|
||||||
|
|
||||||
|
TOKEN_EXPIRED("A001", 401), LOGIN_FAIL("A003", 401), ACCOUNT_LOCKED("A004", 403),
|
||||||
|
|
||||||
|
ALREADY_CONFIRMED("B001", 400), RULE_VERSION_CONFLICT("B002", 409),
|
||||||
|
APPROVAL_REQUIRED("B003", 400), SYSTEM_CODE_LOCKED("B008", 400),
|
||||||
|
DUPLICATE_DATA("B009", 409), BATCH_RUNNING("B010", 409),
|
||||||
|
|
||||||
|
FILE_NOT_FOUND("F001", 404), FILE_UPLOAD_FAIL("F002", 500);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `BizException`
|
||||||
|
```java
|
||||||
|
@Getter
|
||||||
|
public class BizException extends RuntimeException {
|
||||||
|
private final ErrorCode errorCode;
|
||||||
|
private final String customMessage;
|
||||||
|
public BizException(ErrorCode e) { ... }
|
||||||
|
public BizException(ErrorCode e, String msg) { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GlobalExceptionHandler`
|
||||||
|
- `BizException` → `ApiResponse.fail(errorCode)`
|
||||||
|
- `MethodArgumentNotValidException` → 필드별 에러 목록
|
||||||
|
- `AccessDeniedException` → 403
|
||||||
|
- `DuplicateKeyException` → "중복된 데이터입니다"
|
||||||
|
- `Exception` → 500 + log.error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. MyBatis 공통 (`mybatis/`)
|
||||||
|
|
||||||
|
### `BaseMapper<T>` (선택적 상속)
|
||||||
|
```java
|
||||||
|
public interface BaseMapper<T> {
|
||||||
|
T selectById(Long id);
|
||||||
|
List<T> selectList(SearchParam param);
|
||||||
|
int insert(T entity);
|
||||||
|
int update(T entity);
|
||||||
|
int deleteById(Long id);
|
||||||
|
int countByCondition(SearchParam param);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `JsonTypeHandler` — JSONB ↔ JsonNode
|
||||||
|
```java
|
||||||
|
@MappedTypes(JsonNode.class)
|
||||||
|
public class JsonTypeHandler extends BaseTypeHandler<JsonNode> { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### `EncryptTypeHandler` — AES-256 컬럼 암호화
|
||||||
|
- **중요**: `@Component` 등록하면 mybatis-spring-boot-starter 가
|
||||||
|
자동으로 모든 String 컬럼에 적용해버림 → 일반 클래스로 둠.
|
||||||
|
- 사용처: `<result property="residentNo" column="resident_no"
|
||||||
|
typeHandler="com.ga.common.mybatis.EncryptTypeHandler"/>` 형태로 컬럼별 명시.
|
||||||
|
- `EncryptTypeHandlerInitializer` (`@Configuration`) 가 부팅 시
|
||||||
|
`EncryptUtil` 을 정적 주입.
|
||||||
|
- 적용 컬럼: `agent.resident_no`, `agent.account_no`, `payment.account_no`.
|
||||||
|
|
||||||
|
### `AuditInterceptor` (Plugin)
|
||||||
|
- `INSERT` 시 `created_at`, `created_by` 자동
|
||||||
|
- `UPDATE` 시 `updated_at`, `updated_by` 자동
|
||||||
|
- 현재 로그인 사용자: `SecurityUtil.currentUserId()`
|
||||||
|
|
||||||
|
### 공통 SQL 조각 (`mapper/common/common-sql.xml`)
|
||||||
|
```xml
|
||||||
|
<sql id="searchCondition">
|
||||||
|
<if test="searchKeyword != null and searchKeyword != ''">
|
||||||
|
<choose>
|
||||||
|
<when test="searchType == 'name'">AND name LIKE CONCAT('%', #{searchKeyword}, '%')</when>
|
||||||
|
<when test="searchType == 'code'">AND code = #{searchKeyword}</when>
|
||||||
|
</choose>
|
||||||
|
</if>
|
||||||
|
<if test="startDate != null">AND created_at >= #{startDate}::timestamp</if>
|
||||||
|
<if test="endDate != null">AND created_at <= #{endDate}::timestamp + interval '1 day'</if>
|
||||||
|
<if test="status != null and status != ''">AND status = #{status}</if>
|
||||||
|
</sql>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 인증/권한 (`auth/`, `security/`, `aop/`)
|
||||||
|
|
||||||
|
### JWT
|
||||||
|
- `JwtTokenProvider`: HS256, 액세스 2 시간 / 리프레시 7 일
|
||||||
|
- `JwtAuthFilter`: `Authorization: Bearer <token>` → SecurityContext 에 `LoginUser` 주입
|
||||||
|
- `LoginUser`: `userId`, `loginId`, `userName`, `roleCodes`, `agentId`
|
||||||
|
|
||||||
|
### `SecurityConfig`
|
||||||
|
- 기본 `permitAll`: `/api/auth/**`, `/swagger-ui/**`, `/v3/api-docs/**`, `/api/health`
|
||||||
|
- 그 외 `authenticated()` + JwtAuthFilter 적용
|
||||||
|
- 세션 STATELESS, CSRF disable, CORS allow `*` (개발), method-level `@PreAuthorize` 미사용 — `@RequirePermission` 으로 통일
|
||||||
|
|
||||||
|
### 어노테이션 + AOP
|
||||||
|
|
||||||
|
**`@RequirePermission(menu = "ORG_AGENT", perm = "READ")`**
|
||||||
|
- `PermissionAspect` 가 `PermissionChecker.hasPermission(userId, menuCode, permCode)` 호출
|
||||||
|
- `false` → `BizException(ErrorCode.FORBIDDEN)`
|
||||||
|
|
||||||
|
**`@DataChangeLog(menu = "RULE", table = "payout_rule")`**
|
||||||
|
- `DataChangeLogAspect` 가 메서드 실행 전 PK 로 before 조회 → 실행 → after 조회
|
||||||
|
- `data_change_log` 에 `before_data`/`after_data` JSONB 저장 (비동기)
|
||||||
|
|
||||||
|
**`@Mask(MaskType.RRN)` (응답 필드용 어노테이션)**
|
||||||
|
- `JsonSerializer` 또는 `@JsonComponent` 로 직렬화 시 마스킹
|
||||||
|
|
||||||
|
**`ApiLogAspect`**
|
||||||
|
- 모든 `@RestController` 자동 적용
|
||||||
|
- `request_uri`, `params` (민감정보 마스킹), `response_code`, `response_time_ms`
|
||||||
|
- `@Async` 비동기 저장 (`api_access_log`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 유틸리티 (`util/`)
|
||||||
|
|
||||||
|
### `DateUtil`
|
||||||
|
- `today()` → `"2026-05-10"`
|
||||||
|
- `getSettleMonth()` → `"202605"`, `getSettleMonth(LocalDate)` 동일
|
||||||
|
- `getMonthRange("202605")` → `["2026-05-01", "2026-05-31"]`
|
||||||
|
- `calcMonthDiff(from, to)`, `addMonth("202401", 3)` → `"202404"`
|
||||||
|
- `parseDate(s, "yyyy-MM-dd")`, `format(date, pattern)`
|
||||||
|
|
||||||
|
### `MoneyUtil` (BigDecimal HALF_UP, 소수점 2)
|
||||||
|
- `add/sub/mul/div`, null 안전
|
||||||
|
- `pct(amount, ratePct)` → `amount × ratePct / 100`
|
||||||
|
- `tax(amount)` → 기본 3.3%, `tax(amount, rate)` 사용자 지정
|
||||||
|
- `fmt`/`fmtDec`/`fmtSign` (콤마 포맷)
|
||||||
|
- `isZero/isPositive/isNegative`
|
||||||
|
|
||||||
|
### `MaskUtil`
|
||||||
|
- `mask("홍길동", NAME)` → `"홍*동"`
|
||||||
|
- `mask("010-1234-5678", PHONE)` → `"010-****-5678"` (11자리/10자리 분기, 9자리 이하 원본)
|
||||||
|
- `mask("880101-1234567", RRN)` → `"880101-*******"`
|
||||||
|
- `mask("110-123-456789", ACCOUNT)` → `"110-***-******"`
|
||||||
|
- `mask("aaa@bbb.com", EMAIL)` → `"a**@bbb.com"`
|
||||||
|
|
||||||
|
### `EncryptUtil` (`@Component`)
|
||||||
|
- AES-256/CBC/PKCS5Padding, IV = key 의 앞 16 byte (운영은 별도 관리 권장)
|
||||||
|
- 키 32 byte 미만이면 0 패딩
|
||||||
|
- `encrypt(plain)` / `decrypt(b64)` round-trip
|
||||||
|
- 실패 시 `BizException(ErrorCode.INTERNAL_ERROR)`
|
||||||
|
|
||||||
|
### `SecurityUtil`
|
||||||
|
- `currentUserId()`, `currentLoginUser()`, `hasRole(roleCode)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 공통코드 (`code/`) — Redis 캐시
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Service @RequiredArgsConstructor
|
||||||
|
public class CommonCodeService {
|
||||||
|
@Cacheable(value = "commonCode", key = "#groupCode")
|
||||||
|
public List<CommonCodeVO> getCodesByGroup(String groupCode) {...}
|
||||||
|
|
||||||
|
public String getCodeName(String groupCode, String code) {...}
|
||||||
|
public void validateCode(String groupCode, String code) // 미존재 시 INVALID_PARAMETER
|
||||||
|
|
||||||
|
@CacheEvict(value = "commonCode", key = "#groupCode")
|
||||||
|
public void refreshCache(String groupCode) {}
|
||||||
|
|
||||||
|
@CacheEvict(value = "commonCode", allEntries = true)
|
||||||
|
public void refreshAll() {}
|
||||||
|
|
||||||
|
// 그룹 CRUD: 시스템 그룹(is_system='Y')은 update/delete 시 SYSTEM_CODE_LOCKED
|
||||||
|
// 상세 CRUD: 동일 (group, code) 중복 시 DUPLICATE_DATA
|
||||||
|
// 시스템 그룹 코드 update 시 code/groupCode 변경 무시
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
REST: `GET /api/common/codes/{groupCode}` (캐시 적중), `POST /api/common/codes/groups`,
|
||||||
|
`PUT/DELETE /api/common/codes/groups/{groupCode}`, 상세 코드 동일.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 메뉴 / 권한 (`menu/`)
|
||||||
|
|
||||||
|
### `MenuTreeBuilder` (정적 유틸)
|
||||||
|
```java
|
||||||
|
public static List<MenuResp> build(List<MenuVO> flat,
|
||||||
|
Map<Long, List<String>> permissionsByMenuId)
|
||||||
|
// - parent_menu_id null → root
|
||||||
|
// - 부모 미존재 (고아) → root 로 끌어올림
|
||||||
|
// - sortOrder 로 재귀 정렬
|
||||||
|
// - permissionsByMenuId null 이면 권한 주입 스킵
|
||||||
|
```
|
||||||
|
|
||||||
|
### `PermissionChecker`
|
||||||
|
```java
|
||||||
|
@Component @RequiredArgsConstructor
|
||||||
|
public class PermissionChecker {
|
||||||
|
@Cacheable(value = "userPerm",
|
||||||
|
key = "T(java.lang.String).format('%d_%s_%s', #userId, #menuCode, #permCode)",
|
||||||
|
unless = "#result == false")
|
||||||
|
public boolean hasPermission(Long userId, String menuCode, String permCode) {
|
||||||
|
if (userId == null) return false;
|
||||||
|
return menuMapper.hasPermission(userId, menuCode, permCode) > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `MenuService`
|
||||||
|
- `getMyMenus(userId)` → 사용자 권한 기반 메뉴 트리 (CTE 재귀)
|
||||||
|
- `getMenuTree()` → 전체 메뉴 트리 (관리자용)
|
||||||
|
|
||||||
|
REST: `GET /api/menus/my`, `GET /api/menus/tree`, 메뉴 CRUD + 권한 매트릭스.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 엑셀 엔진 (`excel/`)
|
||||||
|
|
||||||
|
성능 목표: **다운로드 100만건 < 2분 / 업로드 70만건 < 3분**
|
||||||
|
|
||||||
|
### `@ExcelColumn` 어노테이션
|
||||||
|
```java
|
||||||
|
@Target(FIELD) @Retention(RUNTIME)
|
||||||
|
public @interface ExcelColumn {
|
||||||
|
String header();
|
||||||
|
int order();
|
||||||
|
int width() default 15;
|
||||||
|
String format() default ""; // "#,##0", "yyyy-MM-dd"
|
||||||
|
String codeGroup() default ""; // 자동 코드명 변환
|
||||||
|
boolean masked() default false; // 다운로드 시 마스킹
|
||||||
|
String defaultValue() default "";
|
||||||
|
boolean required() default false;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `ExcelService`
|
||||||
|
**Export — `SXSSFWorkbook(100)` + `MyBatis Cursor`**
|
||||||
|
```java
|
||||||
|
public <T> void exportLargeExcel(String fileName, Class<T> clazz,
|
||||||
|
Supplier<Cursor<T>> cursorSupplier, HttpServletResponse response) {
|
||||||
|
// 1) SXSSFWorkbook (메모리 100행만 유지, gzip 임시파일)
|
||||||
|
// 2) ExcelColumnParser.parse(clazz) → 헤더 자동 생성
|
||||||
|
// 3) Cursor 로 1건씩 읽어 Row 생성
|
||||||
|
// 4) 10만건마다 progress 로그
|
||||||
|
// 5) workbook.write(response.getOutputStream()), workbook.dispose()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Import — `xlsx-streamer (StreamingReader)` + 배치 INSERT**
|
||||||
|
```java
|
||||||
|
public <T> ExcelImportResult importLargeExcel(MultipartFile file, Class<T> clazz,
|
||||||
|
int batchSize, Consumer<List<T>> batchConsumer) {
|
||||||
|
// 1) StreamingReader (rowCacheSize 100, bufferSize 4KB)
|
||||||
|
// 2) 1행 헤더 스킵
|
||||||
|
// 3) ExcelRowParser.parse(row, clazz) → VO
|
||||||
|
// 4) Bean Validation → 실패 시 ExcelErrorRow 누적
|
||||||
|
// 5) batchSize(1000) 마다 batchConsumer.accept(batch)
|
||||||
|
// 6) 잔여분 flush
|
||||||
|
// 7) 에러 있으면 에러 엑셀 생성 → result.errorFileId
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`ExcelImportResult`**: `totalCount`, `successCount`, `errorCount`, `errorFileId`,
|
||||||
|
`List<ExcelErrorRow>` (처음 100건).
|
||||||
|
|
||||||
|
**Template Download**:
|
||||||
|
`exportTemplate(fileName, clazz, response)` — 빈 헤더 + `codeGroup` 컬럼에
|
||||||
|
드롭다운 유효성검사 + `required=true` 헤더 빨간색.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 파일 / 알림 / 시스템관리
|
||||||
|
|
||||||
|
### `FileService`
|
||||||
|
- 업로드: `MultipartFile` → `file_storage` 적재, `groupId` 로 그룹화, 최대 100MB
|
||||||
|
- 다운로드: `download_count` 증가, `Content-Disposition` 한글 파일명 인코딩
|
||||||
|
- 삭제: 논리 삭제 (`is_deleted='Y'`)
|
||||||
|
|
||||||
|
REST: `POST /api/files/upload` (`groupId` param), `GET /api/files/{fileId}`,
|
||||||
|
`DELETE /api/files/{fileId}`, `GET /api/files?groupId=...`
|
||||||
|
|
||||||
|
### `NotificationService`
|
||||||
|
- 생성/조회/읽음 처리, 미읽음 카운트 (`/api/notifications/unread-count`)
|
||||||
|
- 발송은 비동기 (Redis pub/sub 또는 `@Async`)
|
||||||
|
|
||||||
|
### `SystemConfigService`
|
||||||
|
- key-value 설정. `is_encrypted='Y'` 인 값은 응답 시 마스킹.
|
||||||
|
- Cache 적용 가능 (`commonCode` 와 동일 패턴)
|
||||||
|
|
||||||
|
### 로그 서비스 (비동기)
|
||||||
|
- `ApiAccessLogService.save(...)` — `api_access_log` 적재
|
||||||
|
- `DataChangeLogService.save(...)` — `data_change_log` JSONB 적재
|
||||||
|
- 둘 다 `@Async("taskExecutor")` (`AsyncConfig`)
|
||||||
|
|
||||||
|
REST: `/api/system/logs/login`, `/api/system/logs/api`, `/api/system/logs/changes`
|
||||||
|
(`/api/system/config`, `/api/system/config/{key}` PUT)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. ga-frontend 공통 (함께 작성)
|
||||||
|
|
||||||
|
### 폴더 구조
|
||||||
|
```
|
||||||
|
ga-frontend/src/
|
||||||
|
├── api/ # request.ts (axios) + 도메인별 함수
|
||||||
|
├── components/
|
||||||
|
│ ├── common/ # 본 spec 의 공통 컴포넌트
|
||||||
|
│ └── biz/ # 업무 재사용
|
||||||
|
├── hooks/ # useCommonCodes, useMenuTree, usePermission, useSearchParam
|
||||||
|
├── layouts/ # MainLayout
|
||||||
|
├── pages/ # 화면 (06_FRONTEND_SPEC.md)
|
||||||
|
├── router/ # 동적 라우터
|
||||||
|
├── stores/ # Zustand (auth, menu, notification)
|
||||||
|
├── types/ # TS 타입
|
||||||
|
└── utils/ # formatMoney, formatDate
|
||||||
|
```
|
||||||
|
|
||||||
|
### `api/request.ts`
|
||||||
|
```typescript
|
||||||
|
const api = axios.create({ baseURL: import.meta.env.VITE_API_URL ?? '' });
|
||||||
|
api.interceptors.request.use(c => {
|
||||||
|
const t = localStorage.getItem('accessToken');
|
||||||
|
if (t) c.headers.Authorization = `Bearer ${t}`;
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
api.interceptors.response.use(r => r, err => {
|
||||||
|
if (err.response?.status === 401) { localStorage.removeItem('accessToken'); location.href = '/login'; }
|
||||||
|
if (err.response?.status === 403) message.error('권한이 없습니다');
|
||||||
|
return Promise.reject(err);
|
||||||
|
});
|
||||||
|
export type ApiResponse<T> = { success: boolean; code: string; message: string; data: T; };
|
||||||
|
export type PageResponse<T> = { list: T[]; pageNum: number; pageSize: number; total: number; pages: number; };
|
||||||
|
```
|
||||||
|
|
||||||
|
### 공통 훅 (`hooks/`)
|
||||||
|
- `useCommonCodes(groupCode)` — React Query 캐시 (`staleTime: 10분`)
|
||||||
|
- `useMenuTree()` — 사용자 메뉴 트리
|
||||||
|
- `usePermission(menuCode)` — `{ canRead, canCreate, canUpdate, canDelete, canApprove, canExport }`
|
||||||
|
- `useSearchParam(defaults)` — URL 동기화 + 페이징
|
||||||
|
- `useGridApi()` — AG Grid 서버사이드 datasource
|
||||||
|
|
||||||
|
### 공통 컴포넌트 (`components/common/`)
|
||||||
|
|
||||||
|
**데이터 표시**
|
||||||
|
- `<CodeSelect groupCode="AGENT_STATUS" />` — Ant Select + 공통코드
|
||||||
|
- `<CodeRadio groupCode="PAY_CYCLE" />`
|
||||||
|
- `<CodeBadge groupCode="SETTLE_STATUS" value="CONFIRMED" />` — 색 자동
|
||||||
|
- 초록: CONFIRMED/APPROVED/ACTIVE/PAID
|
||||||
|
- 파랑: PENDING/CALCULATED
|
||||||
|
- 빨강: HOLD/SUSPEND/REJECTED
|
||||||
|
- 회색: DRAFT/NEW
|
||||||
|
- `<MonthPicker />` — 정산월
|
||||||
|
- `<MoneyText value={amt} />` — 양수 검정 / 음수 빨강 / 콤마 / null → "-"
|
||||||
|
|
||||||
|
**검색 / 목록**
|
||||||
|
- `<SearchForm conditions={[ {type:'text'|'code'|'dateRange'|'month', name, label, ...} ]} onSearch onReset />`
|
||||||
|
- `<DataTable>` — Ant Design Table 래퍼 (단순 목록, 페이징, 행 클릭)
|
||||||
|
- `<DataGrid>` — AG Grid Community 래퍼 (서버사이드 페이징/정렬/필터, 셀 편집,
|
||||||
|
컬럼 고정, 합계행 `pinnedBottomRow`, 엑셀 내보내기)
|
||||||
|
|
||||||
|
**액션**
|
||||||
|
- `<PermissionButton permCode="CREATE">등록</PermissionButton>`
|
||||||
|
- `<ExcelExportButton url="/api/.../export" params fileName showProgress maxRows={1000000} />`
|
||||||
|
- `<ExcelImportButton url=... templateUrl=... onComplete maxSize={100} />`
|
||||||
|
- `<FileUpload groupId="settle-202605" maxCount={5} />`
|
||||||
|
- `<AuditTrail table="payout_rule" recordId={id} />` — 변경이력 타임라인
|
||||||
|
|
||||||
|
### 동적 라우터 (`router/`)
|
||||||
|
- 로그인 → `GET /api/menus/my` → 메뉴 트리
|
||||||
|
- `menu.component_path` 기반 `React.lazy()` 동적 import
|
||||||
|
- 좌측 Sider 자동 렌더링, 권한 없는 URL → 403 페이지
|
||||||
|
|
||||||
|
### 레이아웃 (`layouts/MainLayout.tsx`)
|
||||||
|
- Ant Design Layout (Sider + Header + Content)
|
||||||
|
- Header: 알림 벨 (숫자 뱃지), 사용자 드롭다운, 정산월 표시
|
||||||
|
- Sider: 동적 메뉴 트리 (접기/펼치기, 아이콘)
|
||||||
|
- Content: Breadcrumb + 페이지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 단위 테스트 가이드 (선택)
|
||||||
|
|
||||||
|
`spring-boot-starter-test` 가 JUnit5 + Mockito + AssertJ 모두 포함.
|
||||||
|
|
||||||
|
대상:
|
||||||
|
- `MoneyUtilTest` (10) — pct/tax/mul/div HALF_UP, null 안전
|
||||||
|
- `DateUtilTest` (10) — 정산월 범위, 윤년/평년, 월 차이/가산
|
||||||
|
- `MaskUtilTest` (7) — NAME/PHONE/RRN/ACCOUNT/EMAIL
|
||||||
|
- `EncryptUtilTest` (6) — round-trip, 한글, 짧은 키 패딩
|
||||||
|
- `MenuTreeBuilderTest` (6) — 트리 빌드, 정렬, 고아 노드
|
||||||
|
- `PermissionCheckerTest` (4) — userId null 단락, mapper 위임
|
||||||
|
- `CommonCodeServiceTest` (14) — 그룹·상세 CRUD, 시스템 코드 잠금
|
||||||
|
|
||||||
|
주의: `@Cacheable`/`@CacheEvict` 는 단위 테스트(직접 인스턴스 호출)에서 동작하지 않음 — 비즈니스 로직만 검증.
|
||||||
|
Mockito strict stubbing → `@BeforeEach` 의 stub 은 모든 테스트가 사용해야 함.
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
# 03_CORE_SPEC — ga-core (도메인 VO + Mapper + SQL)
|
||||||
|
|
||||||
|
> 22 개 테이블의 용도별 VO + Mapper Interface + Mapper XML.
|
||||||
|
> ga-common 의 표준 패턴(`SearchParam`, `PageResponse`, `BaseMapper`) 을 사용한다.
|
||||||
|
|
||||||
|
## 패키지 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
ga-core/src/main/java/com/ga/core/
|
||||||
|
├── enums/ # AgentStatus, ContractStatus, SettleStatus 등
|
||||||
|
├── mapper/
|
||||||
|
│ ├── org/ # GradeMapper, AgentMapper
|
||||||
|
│ ├── product/ # InsuranceCompanyMapper, ProductMapper, ContractMapper
|
||||||
|
│ ├── rule/ # RuleMapper (5 테이블 통합)
|
||||||
|
│ ├── receive/ # ReceiveMapper (5 테이블 통합)
|
||||||
|
│ ├── ledger/ # RecruitLedgerMapper, MaintainLedgerMapper, ExceptionLedgerMapper
|
||||||
|
│ ├── settle/ # SettleMasterMapper, OverrideSettleMapper, ChargebackMapper, PaymentMapper
|
||||||
|
│ ├── batch/ # BatchJobLogMapper, ReconciliationMapper
|
||||||
|
│ └── user/ # UserMapper, RoleMapper
|
||||||
|
├── vo/
|
||||||
|
│ ├── org/ # GradeVO, OrganizationVO/Resp, AgentVO/Resp/SaveReq/SearchParam/ExcelVO, AgentOrgHistoryVO
|
||||||
|
│ ├── product/ # InsuranceCompanyVO, ProductVO/Resp, ContractVO/Resp/SaveReq/SearchParam
|
||||||
|
│ ├── rule/ # CommissionRateVO, PayoutRuleVO, OverrideRuleVO, ChargebackRuleVO, ExceptionTypeCodeVO
|
||||||
|
│ ├── receive/ # CompanyProfileVO, CompanyFieldMappingVO, CodeMappingVO, RawCommissionDataVO, ParseErrorLogVO
|
||||||
|
│ ├── ledger/ # LedgerVO, LedgerResp, LedgerSearchParam, LedgerExcelVO (3 테이블 공유)
|
||||||
|
│ ├── settle/ # SettleMasterVO/Resp/SearchParam, OverrideSettleVO, ChargebackVO, PaymentVO
|
||||||
|
│ ├── batch/ # BatchJobLogVO, ReconciliationVO
|
||||||
|
│ └── user/ # UserVO/Resp/SaveReq/SearchParam, RoleVO
|
||||||
|
└── mapstruct/ # AgentMapstruct, ContractMapstruct (SaveReq ↔ VO)
|
||||||
|
|
||||||
|
ga-core/src/main/resources/mapper/
|
||||||
|
├── org/ # GradeMapper.xml, AgentMapper.xml
|
||||||
|
├── product/ # ContractMapper.xml, ProductMapper.xml, InsuranceCompanyMapper.xml
|
||||||
|
├── rule/ # RuleMapper.xml
|
||||||
|
├── receive/ # ReceiveMapper.xml
|
||||||
|
├── ledger/ # RecruitLedgerMapper.xml, MaintainLedgerMapper.xml, ExceptionLedgerMapper.xml
|
||||||
|
├── settle/ # SettleMasterMapper.xml, PaymentMapper.xml, OverrideSettleMapper.xml, ChargebackMapper.xml
|
||||||
|
├── batch/ # BatchJobLogMapper.xml
|
||||||
|
└── user/ # UserMapper.xml, RoleMapper.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
build.gradle: `implementation project(':ga-common')`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. VO 설계 원칙 ★ 핵심
|
||||||
|
|
||||||
|
> **VO 는 테이블 1:1 이 아니라, 하나의 업무 기능당 용도별 3~4 개를 만든다.**
|
||||||
|
|
||||||
|
| VO 종류 | 용도 | 네이밍 예 | 특징 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `XxxVO` | 테이블 1:1 매핑 | `AgentVO` | INSERT/UPDATE 전용, 모든 컬럼 |
|
||||||
|
| `XxxResp` | API 응답 | `AgentResp` | 조인 결과 포함 (조직명/직급명/집계) — 화면 표시 전용 |
|
||||||
|
| `XxxSaveReq` | API 요청 | `AgentSaveReq` | 사용자 입력 필드만 + `@Valid` (ID/상태/일시 없음) |
|
||||||
|
| `XxxSearchParam` | 검색 조건 | `AgentSearchParam` | `SearchParam` 상속, 화면별 필터 |
|
||||||
|
| `XxxExcelVO` | 엑셀 전용 | `AgentExcelVO` | `@ExcelColumn` 어노테이션, 목록과 다른 구성 |
|
||||||
|
|
||||||
|
### 예시: 설계사 (`org/`)
|
||||||
|
```java
|
||||||
|
// AgentVO — 테이블 1:1
|
||||||
|
@Data public class AgentVO {
|
||||||
|
private Long agentId, orgId, gradeId;
|
||||||
|
private String agentName, residentNo, licenseNo, phone, email, bankCode, accountNo, status;
|
||||||
|
private LocalDate joinDate, leaveDate;
|
||||||
|
private LocalDateTime createdAt, updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentResp — 조인 결과 (화면 표시용)
|
||||||
|
@Data public class AgentResp {
|
||||||
|
private Long agentId, orgId, gradeId;
|
||||||
|
private String agentName, status;
|
||||||
|
private String statusName; // 공통코드 변환
|
||||||
|
private String orgName, gradeName;// 조인
|
||||||
|
private int contractCount; // 집계
|
||||||
|
private BigDecimal totalAmt; // settle_master 집계
|
||||||
|
private String phone; // 마스킹
|
||||||
|
private String licenseNo;
|
||||||
|
private LocalDate joinDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentSaveReq — 등록/수정 입력
|
||||||
|
@Data public class AgentSaveReq {
|
||||||
|
@NotBlank private String agentName;
|
||||||
|
@NotNull private Long orgId;
|
||||||
|
@NotNull private Long gradeId;
|
||||||
|
private String licenseNo, phone, email, bankCode;
|
||||||
|
private String residentNo, accountNo; // 서버에서 EncryptTypeHandler 로 암호화
|
||||||
|
private LocalDate joinDate;
|
||||||
|
public AgentVO toVO() { ... MapStruct ... }
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentSearchParam — 검색
|
||||||
|
@Data
|
||||||
|
public class AgentSearchParam extends SearchParam {
|
||||||
|
private Long orgId, gradeId;
|
||||||
|
private String licenseNo;
|
||||||
|
// status 는 SearchParam 에 이미 있음
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentExcelVO — 엑셀 다운로드 전용
|
||||||
|
public class AgentExcelVO {
|
||||||
|
@ExcelColumn(header = "설계사번호", order = 1) private Long agentId;
|
||||||
|
@ExcelColumn(header = "성명", order = 2) private String agentName;
|
||||||
|
@ExcelColumn(header = "조직", order = 3) private String orgName;
|
||||||
|
@ExcelColumn(header = "직급", order = 4) private String gradeName;
|
||||||
|
@ExcelColumn(header = "상태", order = 5, codeGroup = "AGENT_STATUS") private String status;
|
||||||
|
@ExcelColumn(header = "주민번호", order = 6, masked = true) private String residentNo;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Map 사용이 적절한 경우 (VO 대신)
|
||||||
|
- 통계/리포트 (동적 컬럼)
|
||||||
|
- 대시보드 집계 (1회성)
|
||||||
|
- 동적 피벗 결과
|
||||||
|
- 공통코드 캐시 (key-value)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Mapper Interface 패턴
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Mapper
|
||||||
|
public interface AgentMapper {
|
||||||
|
// 조회
|
||||||
|
AgentResp selectDetailById(@Param("agentId") Long agentId);
|
||||||
|
AgentVO selectById(@Param("agentId") Long agentId);
|
||||||
|
List<AgentResp> selectList(AgentSearchParam param);
|
||||||
|
Cursor<AgentExcelVO> selectCursorForExcel(AgentSearchParam param); // ★ 엑셀 100만건
|
||||||
|
|
||||||
|
// 검증
|
||||||
|
int existsByLicenseNo(@Param("licenseNo") String licenseNo);
|
||||||
|
|
||||||
|
// CUD
|
||||||
|
int insert(AgentVO vo);
|
||||||
|
int update(AgentVO vo);
|
||||||
|
int updateStatus(@Param("agentId") Long id, @Param("status") String status);
|
||||||
|
int updateOrgGrade(@Param("agentId") Long id, @Param("orgId") Long o, @Param("gradeId") Long g);
|
||||||
|
|
||||||
|
// 이력
|
||||||
|
List<AgentOrgHistoryVO> selectHistory(@Param("agentId") Long agentId);
|
||||||
|
int insertHistory(AgentOrgHistoryVO vo);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `BaseMapper<T>` 상속 (선택)
|
||||||
|
- 단순 CRUD 만 필요한 Mapper 는 `extends BaseMapper<XxxVO>` 로 짧게.
|
||||||
|
- 복잡한 조인/집계 가 필요하면 직접 정의.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Mapper XML 패턴 (필수 규칙)
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.ga.core.mapper.org.AgentMapper">
|
||||||
|
|
||||||
|
<!-- 컬럼 암호화는 명시적으로 -->
|
||||||
|
<resultMap id="AgentVoMap" type="com.ga.core.vo.org.AgentVO">
|
||||||
|
<id property="agentId" column="agent_id"/>
|
||||||
|
<result property="orgId" column="org_id"/>
|
||||||
|
<result property="gradeId" column="grade_id"/>
|
||||||
|
<result property="agentName" column="agent_name"/>
|
||||||
|
<result property="residentNo" column="resident_no"
|
||||||
|
typeHandler="com.ga.common.mybatis.EncryptTypeHandler"/>
|
||||||
|
<result property="accountNo" column="account_no"
|
||||||
|
typeHandler="com.ga.common.mybatis.EncryptTypeHandler"/>
|
||||||
|
<!-- 나머지는 mapUnderscoreToCamelCase 자동 -->
|
||||||
|
</resultMap>
|
||||||
|
|
||||||
|
<resultMap id="AgentRespMap" type="com.ga.core.vo.org.AgentResp">
|
||||||
|
<id property="agentId" column="agent_id"/>
|
||||||
|
<result property="orgName" column="org_name"/>
|
||||||
|
<result property="gradeName" column="grade_name"/>
|
||||||
|
<result property="residentNo" column="resident_no"
|
||||||
|
typeHandler="com.ga.common.mybatis.EncryptTypeHandler"/>
|
||||||
|
</resultMap>
|
||||||
|
|
||||||
|
<!-- 목록: Resp 매핑, 조인 포함 -->
|
||||||
|
<select id="selectList" parameterType="com.ga.core.vo.org.AgentSearchParam"
|
||||||
|
resultMap="AgentRespMap">
|
||||||
|
SELECT a.agent_id, a.agent_name, a.status, a.phone, a.license_no, a.join_date,
|
||||||
|
o.org_name, g.grade_name,
|
||||||
|
(SELECT COUNT(*) FROM contract c WHERE c.agent_id = a.agent_id) AS contract_count
|
||||||
|
FROM agent a
|
||||||
|
JOIN organization o ON a.org_id = o.org_id
|
||||||
|
JOIN grade g ON a.grade_id = g.grade_id
|
||||||
|
WHERE 1=1
|
||||||
|
<if test="orgId != null"> AND a.org_id = #{orgId}</if>
|
||||||
|
<if test="gradeId != null"> AND a.grade_id = #{gradeId}</if>
|
||||||
|
<if test="status != null"> AND a.status = #{status}</if>
|
||||||
|
<if test="licenseNo != null"> AND a.license_no = #{licenseNo}</if>
|
||||||
|
<if test="searchKeyword != null and searchKeyword != ''">
|
||||||
|
AND a.agent_name LIKE CONCAT('%', #{searchKeyword}, '%')
|
||||||
|
</if>
|
||||||
|
ORDER BY a.agent_id DESC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- INSERT: VO 만 받음, 조인 필드는 절대 사용 X -->
|
||||||
|
<insert id="insert" parameterType="com.ga.core.vo.org.AgentVO"
|
||||||
|
useGeneratedKeys="true" keyProperty="agentId">
|
||||||
|
INSERT INTO agent (org_id, grade_id, agent_name, resident_no, license_no, phone, email,
|
||||||
|
bank_code, account_no, status, join_date)
|
||||||
|
VALUES (#{orgId}, #{gradeId}, #{agentName},
|
||||||
|
#{residentNo,typeHandler=com.ga.common.mybatis.EncryptTypeHandler},
|
||||||
|
#{licenseNo}, #{phone}, #{email}, #{bankCode},
|
||||||
|
#{accountNo,typeHandler=com.ga.common.mybatis.EncryptTypeHandler},
|
||||||
|
#{status}, #{joinDate})
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- 엑셀용 Cursor (절대 List 로 받지 말 것) -->
|
||||||
|
<select id="selectCursorForExcel" resultType="com.ga.core.vo.org.AgentExcelVO">
|
||||||
|
SELECT a.agent_id, a.agent_name, o.org_name, g.grade_name, a.status,
|
||||||
|
a.resident_no
|
||||||
|
FROM agent a JOIN organization o ON a.org_id = o.org_id
|
||||||
|
JOIN grade g ON a.grade_id = g.grade_id
|
||||||
|
ORDER BY a.agent_id
|
||||||
|
</select>
|
||||||
|
</mapper>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 필수 규칙
|
||||||
|
1. **`SELECT *` 금지** — 모든 컬럼 명시
|
||||||
|
2. **목록 SELECT 는 XxxResp 에 매핑** (조인 포함)
|
||||||
|
3. **INSERT/UPDATE 는 XxxVO 의 필드만** 사용
|
||||||
|
4. **암호화 컬럼은 `typeHandler` 명시** (resident_no, account_no, payment.account_no)
|
||||||
|
5. **동적 WHERE** — `<if>` / `<choose>` 적극 사용
|
||||||
|
6. **신입 학습용 주석** — 복잡한 쿼리에 한글 주석
|
||||||
|
7. **CTE 재귀** — 조직 트리 (organization), 메뉴 트리 (menu)
|
||||||
|
|
||||||
|
### CTE 재귀 예시 (organization)
|
||||||
|
```xml
|
||||||
|
<select id="selectTree" resultType="com.ga.core.vo.org.OrganizationResp">
|
||||||
|
WITH RECURSIVE org_tree AS (
|
||||||
|
SELECT org_id, parent_org_id, org_name, org_type, org_level, 1 AS depth
|
||||||
|
FROM organization WHERE parent_org_id IS NULL AND is_active = 'Y'
|
||||||
|
UNION ALL
|
||||||
|
SELECT o.org_id, o.parent_org_id, o.org_name, o.org_type, o.org_level, t.depth + 1
|
||||||
|
FROM organization o JOIN org_tree t ON o.parent_org_id = t.org_id
|
||||||
|
WHERE o.is_active = 'Y'
|
||||||
|
)
|
||||||
|
SELECT org_id, parent_org_id, org_name, org_type, org_level
|
||||||
|
FROM org_tree ORDER BY org_level, org_name
|
||||||
|
</select>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 모듈별 Mapper 요약
|
||||||
|
|
||||||
|
### `org/`
|
||||||
|
- **`GradeMapper`** : selectAll, selectById, insert/update/delete
|
||||||
|
- **`AgentMapper`** : (위 예시 — 22 개 메서드)
|
||||||
|
|
||||||
|
### `product/`
|
||||||
|
- **`InsuranceCompanyMapper`** : 5 개 보험사 CRUD
|
||||||
|
- **`ProductMapper`** : 보험사별 상품, insurance_type 별 통계
|
||||||
|
- **`ContractMapper`** : 검색 (agent/product/status), `existsByPolicyNo`, `countByAgent(agentId, status)`
|
||||||
|
|
||||||
|
### `rule/` — `RuleMapper` (단일 인터페이스, 5 테이블 통합)
|
||||||
|
```java
|
||||||
|
// 핵심 조회 (배치 계산에서 호출)
|
||||||
|
BigDecimal findCompanyRate(@Param("productId") Long, @Param("year") Integer,
|
||||||
|
@Param("baseDate") LocalDate);
|
||||||
|
BigDecimal findPayoutRate(@Param("gradeId") Long, @Param("insuranceType") String,
|
||||||
|
@Param("year") Integer, @Param("baseDate") LocalDate);
|
||||||
|
BigDecimal findChargebackRate(@Param("companyCode") String, @Param("insuranceType") String,
|
||||||
|
@Param("lapseMonth") Integer, @Param("baseDate") LocalDate);
|
||||||
|
List<OverrideRuleVO> selectOverrideRules();
|
||||||
|
List<ExceptionTypeCodeVO> selectExceptionCodes();
|
||||||
|
// + 5 개 테이블 각각의 CRUD
|
||||||
|
```
|
||||||
|
|
||||||
|
`effective_from <= baseDate < effective_to` 조건 (NULL = 무한). 같은 base 에 여러 행이면 `version DESC` 최신.
|
||||||
|
|
||||||
|
### `receive/` — `ReceiveMapper` (5 테이블 통합)
|
||||||
|
```java
|
||||||
|
List<CompanyFieldMappingVO> selectFieldMappings(companyCode, targetTable);
|
||||||
|
String findTargetCode(companyCode, codeType, sourceCode);
|
||||||
|
int insertRaw(RawCommissionDataVO);
|
||||||
|
int insertRawBatch(List<RawCommissionDataVO>);
|
||||||
|
int updateRawStatus(rawId, parseStatus, mappedLedgerId, mappedTable);
|
||||||
|
int insertError(ParseErrorLogVO);
|
||||||
|
int resolveError(errorId, status, note, userId);
|
||||||
|
```
|
||||||
|
|
||||||
|
### `ledger/` — 3 개 Mapper (구조 동일)
|
||||||
|
- `RecruitLedgerMapper`, `MaintainLedgerMapper`, `ExceptionLedgerMapper`
|
||||||
|
- 모두 `LedgerVO` 사용 (테이블 컬럼 동일)
|
||||||
|
- 핵심: `selectList(LedgerSearchParam)`, `selectCursorForExcel(...)`,
|
||||||
|
`batchInsert(List<LedgerVO>)`, `aggregateByAgent(settleMonth)`,
|
||||||
|
`sumByAgentMonth(agentId, settleMonth)`, `deleteBySettleMonth(settleMonth)`
|
||||||
|
- `ExceptionLedgerMapper` 추가: `approve(ledgerId, approverId)`, `reject(ledgerId, reason)`
|
||||||
|
|
||||||
|
### `settle/`
|
||||||
|
- **`SettleMasterMapper`** : `upsert(SettleMasterVO)` (PG `ON CONFLICT(agent_id, settle_month) DO UPDATE`),
|
||||||
|
`confirm(settleId, userId)`, `hold(settleId, reason)`, `selectMonthlySummary(month)`
|
||||||
|
- **`OverrideSettleMapper`** : 정산별 오버라이드 내역
|
||||||
|
- **`ChargebackMapper`** : 환수 내역, 분할 회차 (`installment_seq`)
|
||||||
|
- **`PaymentMapper`** : 이체 상태 추적, `account_no` AES 암호화
|
||||||
|
|
||||||
|
### `batch/`
|
||||||
|
- **`BatchJobLogMapper`** : `request(jobName, settleMonth)`, `start/complete/fail`,
|
||||||
|
`selectLatest(jobName)`, `existsRunning(jobName)`
|
||||||
|
|
||||||
|
### `user/`
|
||||||
|
- **`UserMapper`** : `selectByLoginId`, `selectMyMenus`, BCrypt 비밀번호 검증, 잠금/실패 카운트
|
||||||
|
- **`RoleMapper`** : 역할 목록, 사용자-역할 매핑, 역할-메뉴-권한 매트릭스
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Enum + MapStruct
|
||||||
|
|
||||||
|
### Enum (코드그룹 대응)
|
||||||
|
```java
|
||||||
|
public enum AgentStatus { ACTIVE, LEAVE, SUSPEND;
|
||||||
|
public boolean isActive() { return this == ACTIVE; }
|
||||||
|
}
|
||||||
|
public enum ContractStatus { ACTIVE, LAPSE, REVIVED, EXPIRED, CANCELED; }
|
||||||
|
public enum SettleStatus { DRAFT, CONFIRMED, HOLD, PAID; }
|
||||||
|
public enum ParseStatus { PENDING, OK, ERROR; }
|
||||||
|
public enum ApproveStatus { PENDING, APPROVED, REJECTED; }
|
||||||
|
```
|
||||||
|
|
||||||
|
VO 의 String 필드는 enum 으로 받지 말고 **String 그대로** 둔다 (DB 와 1:1, MyBatis 호환성).
|
||||||
|
서비스 레이어에서 enum 으로 변환해서 사용.
|
||||||
|
|
||||||
|
### MapStruct (`mapstruct/`)
|
||||||
|
```java
|
||||||
|
@Mapper(componentModel = "spring")
|
||||||
|
public interface AgentMapstruct {
|
||||||
|
AgentVO toVO(AgentSaveReq req);
|
||||||
|
@Mapping(target = "createdAt", ignore = true)
|
||||||
|
void update(@MappingTarget AgentVO target, AgentSaveReq req);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`SaveReq → VO` 변환 정도만 사용. `Resp` 는 SQL ResultMap 이 채우므로 MapStruct 불필요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 테스트 시나리오 (선택)
|
||||||
|
|
||||||
|
`ga-core` 의 Mapper XML 검증은 통상 H2 호환 안되어 **Testcontainers PostgreSQL** 또는
|
||||||
|
운영 DB 의 read-only 트랜잭션으로 통합 테스트. (현재 미구현, 향후 작업.)
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
# 04_BATCH_SPEC — ga-batch (정산 배치 8 Step)
|
||||||
|
|
||||||
|
> Spring Batch 기반 월별 정산 잡. ga-core 의 Mapper 를 사용. 별도 실행 모듈 (포트 8081).
|
||||||
|
|
||||||
|
## 패키지 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
ga-batch/src/main/java/com/ga/batch/
|
||||||
|
├── GaBatchApplication.java # @SpringBootApplication, scanBasePackages = "com.ga"
|
||||||
|
├── config/
|
||||||
|
│ └── BatchConfig.java # MonthlySettlementJob 정의
|
||||||
|
├── controller/
|
||||||
|
│ └── JobLauncherController.java # POST /run, GET /status
|
||||||
|
└── settlement/
|
||||||
|
├── SettlementContext.java # @JobScope 공유 컨텍스트
|
||||||
|
├── calc/
|
||||||
|
│ └── CommissionCalculator.java # 모집/유지 수수료 계산기
|
||||||
|
├── receive/
|
||||||
|
│ ├── DataReceiver.java # 전략 패턴 인터페이스
|
||||||
|
│ └── ManualReceiver.java # 기본 구현 (수동 업로드)
|
||||||
|
├── transform/
|
||||||
|
│ └── MappingEngine.java # raw → 표준 원장
|
||||||
|
└── step/
|
||||||
|
├── ReceiveDataStep.java # Step 1
|
||||||
|
├── TransformDataStep.java # Step 2
|
||||||
|
├── ReconcileStep.java # Step 3
|
||||||
|
├── CalcRecruitStep.java # Step 4
|
||||||
|
├── CalcMaintainStep.java # Step 5
|
||||||
|
├── ChargebackExceptionStep.java # Step 6
|
||||||
|
├── CalcOverrideStep.java # Step 7
|
||||||
|
└── AggregateStep.java # Step 8
|
||||||
|
```
|
||||||
|
|
||||||
|
build.gradle:
|
||||||
|
```groovy
|
||||||
|
apply plugin: 'org.springframework.boot'
|
||||||
|
bootJar { archiveFileName = 'ga-batch.jar'; mainClass = 'com.ga.batch.GaBatchApplication' }
|
||||||
|
jar.enabled = false
|
||||||
|
dependencies {
|
||||||
|
implementation project(':ga-core')
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-batch'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`application.yml`:
|
||||||
|
```yaml
|
||||||
|
server.port: 8081
|
||||||
|
spring:
|
||||||
|
batch:
|
||||||
|
jdbc:
|
||||||
|
initialize-schema: always # BATCH_JOB_* 메타테이블
|
||||||
|
job: { enabled: false } # 자동 실행 끄기, 컨트롤러로만 트리거
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. SettlementContext (`@JobScope`)
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Component @JobScope @Getter @Setter
|
||||||
|
public class SettlementContext {
|
||||||
|
@Value("#{jobParameters['settleMonth']}") private String settleMonth; // "202605"
|
||||||
|
@Value("#{jobParameters['companyCode']}") private String companyCode; // 선택 (없으면 전체)
|
||||||
|
@Value("#{jobParameters['jobLogId']}") private Long jobLogId;
|
||||||
|
|
||||||
|
// Step 간 공유 카운트
|
||||||
|
private int receiveCount, transformOk, transformErr, reconcileOk, reconcileDiff;
|
||||||
|
private int recruitCount, maintainCount, chargebackCount, exceptionCount, overrideCount;
|
||||||
|
private int aggregateCount;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Job 정의 (`BatchConfig`)
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Configuration @RequiredArgsConstructor
|
||||||
|
public class BatchConfig {
|
||||||
|
private final JobRepository jobRepo;
|
||||||
|
private final PlatformTransactionManager txMgr;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Job monthlySettlementJob(
|
||||||
|
ReceiveDataStep s1, TransformDataStep s2, ReconcileStep s3,
|
||||||
|
CalcRecruitStep s4, CalcMaintainStep s5,
|
||||||
|
ChargebackExceptionStep s6, CalcOverrideStep s7, AggregateStep s8) {
|
||||||
|
return new JobBuilder("monthlySettlementJob", jobRepo)
|
||||||
|
.start(toStep("receive", s1))
|
||||||
|
.next(toStep("transform", s2))
|
||||||
|
.next(toStep("reconcile", s3))
|
||||||
|
.next(toStep("calcRecruit", s4))
|
||||||
|
.next(toStep("calcMaintain", s5))
|
||||||
|
.next(toStep("chargeback", s6))
|
||||||
|
.next(toStep("calcOverride", s7))
|
||||||
|
.next(toStep("aggregate", s8))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Step toStep(String name, Tasklet t) {
|
||||||
|
return new StepBuilder(name, jobRepo).tasklet(t, txMgr).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
각 Step 은 `Tasklet` (단순 단계) 또는 `Chunk` 기반.
|
||||||
|
대용량 (Step 4, 5) 은 `MyBatisCursorItemReader` + `ItemWriter` 청크 처리.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 8 Step 명세
|
||||||
|
|
||||||
|
### Step 1 — `receiveData`
|
||||||
|
- 입력: `companyCode` (또는 전체 활성 보험사)
|
||||||
|
- 처리: `DataReceiver` 전략 패턴
|
||||||
|
- `Excel/CSV` (수동 업로드 — 기본): `ManualReceiver`
|
||||||
|
- `EDI` (FTP), `API` (HTTP) 는 향후 확장
|
||||||
|
- 각 receiver 가 `raw_commission_data` 에 1 행씩 (`raw_content` JSON) 저장
|
||||||
|
- 우선 구현: `ManualReceiver` 만. 다른 receiver 는 인터페이스만 두고 stub.
|
||||||
|
- 출력: `ctx.receiveCount`
|
||||||
|
|
||||||
|
```java
|
||||||
|
public interface DataReceiver {
|
||||||
|
String getMethod(); // EXCEL / CSV / EDI / API / MANUAL
|
||||||
|
int receive(String companyCode, String settleMonth);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2 — `transformData` (★ 핵심)
|
||||||
|
- 입력: `raw_commission_data` 에서 `parse_status='PENDING'` 인 행
|
||||||
|
- 처리: `MappingEngine.transform(raw)` 호출
|
||||||
|
- `company_field_mapping` 로 source → target 매핑
|
||||||
|
- 변환 규칙: `TRIM / UPPER / LOWER / DATE / DIVIDE / MULTIPLY / CODE_MAP`
|
||||||
|
- `policy_no` 로 `contract` 매칭 → `contract_id`, `agent_id` 채움
|
||||||
|
- `data_type` 에 따라 `recruit_ledger` / `maintain_ledger` 에 INSERT
|
||||||
|
- 실패 시 `parse_error_log` 에 적재
|
||||||
|
- 9 가지 에러 유형:
|
||||||
|
`INVALID_INPUT / UNKNOWN_TYPE / NO_MAPPING / JSON_PARSE / REQUIRED /
|
||||||
|
TRANSFORM_FAIL / NO_POLICY / CONTRACT_NOT_FOUND / CODE_MAP_FAIL`
|
||||||
|
- 출력: `ctx.transformOk`, `ctx.transformErr`
|
||||||
|
|
||||||
|
`MappingEngine` 의 핵심 메서드:
|
||||||
|
```java
|
||||||
|
@Transactional
|
||||||
|
public Long transform(RawCommissionDataVO raw) {
|
||||||
|
// 1) companyCode/rawContent null check
|
||||||
|
// 2) data_type → target_table (RECRUIT/MAINTAIN)
|
||||||
|
// 3) selectFieldMappings(companyCode, target_table)
|
||||||
|
// 4) JSON 파싱 (ObjectMapper)
|
||||||
|
// 5) 각 매핑별 applyTransform(value, mapping, companyCode) → mapped Map
|
||||||
|
// 6) policy_no 로 contract 매칭
|
||||||
|
// 7) buildLedger(mapped, contract, raw) → recruit/maintain mapper insert
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3 — `reconcile`
|
||||||
|
- 입력: 보험사 통보 합계 vs 시스템 계산 합계 (`raw_commission_data` 기준)
|
||||||
|
- 처리: `reconciliation` 테이블에 `expected/actual/diff` 적재
|
||||||
|
- 차이 임계값(시스템설정 `RECON_DIFF_THRESHOLD`) 초과 시 `recon_status='DIFF'`
|
||||||
|
- 출력: `ctx.reconcileOk`, `ctx.reconcileDiff`
|
||||||
|
|
||||||
|
### Step 4 — `calcRecruit`
|
||||||
|
- 입력: `recruit_ledger` 의 status='CALCULATED' 또는 신규 계약 (계약일이 정산월 이내)
|
||||||
|
- 처리: `CommissionCalculator.calcRecruitLedger(contract, settleMonth)`
|
||||||
|
- `contractDate` 기준 `commission_rate` (commission_year=1) → `companyAmount`
|
||||||
|
- `gradeId` + `insuranceType` + `commission_year=1` 기준 `payout_rate` → `agentAmount`
|
||||||
|
- `tax = agentAmount × 3.3%` (HALF_UP)
|
||||||
|
- 출력: `ctx.recruitCount`
|
||||||
|
|
||||||
|
```java
|
||||||
|
public LedgerVO calcRecruitLedger(ContractResp c, String settleMonth) {
|
||||||
|
AgentVO agent = agentMapper.selectById(c.getAgentId());
|
||||||
|
if (agent == null) return null;
|
||||||
|
LocalDate base = c.getContractDate() != null ? c.getContractDate() : LocalDate.now();
|
||||||
|
|
||||||
|
BigDecimal companyRate = ruleMapper.findCompanyRate(c.getProductId(), 1, base);
|
||||||
|
if (companyRate == null) companyRate = BigDecimal.ZERO;
|
||||||
|
BigDecimal payoutRate = ruleMapper.findPayoutRate(agent.getGradeId(),
|
||||||
|
c.getInsuranceType(), 1, base);
|
||||||
|
if (payoutRate == null) payoutRate = BigDecimal.ZERO;
|
||||||
|
|
||||||
|
BigDecimal premium = MoneyUtil.zero(c.getPremium());
|
||||||
|
BigDecimal companyAmt = MoneyUtil.pct(premium, companyRate);
|
||||||
|
BigDecimal agentAmt = MoneyUtil.pct(companyAmt, payoutRate);
|
||||||
|
BigDecimal tax = MoneyUtil.tax(agentAmt);
|
||||||
|
// LedgerVO 필드 채워서 반환
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5 — `calcMaintain`
|
||||||
|
- 입력: 유지 계약 (`status='ACTIVE'`, `effective_date` 가 1~5 년차 도래)
|
||||||
|
- 처리: `CommissionCalculator.calcMaintainLedger(contract, settleMonth, year)`
|
||||||
|
- `commission_year` 만 1 → year (2~5) 로 다름
|
||||||
|
- 같은 공식 적용
|
||||||
|
- 출력: `ctx.maintainCount`
|
||||||
|
|
||||||
|
### Step 6 — `chargebackException`
|
||||||
|
- 입력: 정산월 직전 실효 계약 (`status='LAPSE'`, `lapse_date` 가 정산월 범위)
|
||||||
|
- 처리:
|
||||||
|
- `ruleMapper.findChargebackRate(companyCode, insuranceType, lapseMonthDiff, base)`
|
||||||
|
- 환수 계산: `cb_amount = base × cb_rate`
|
||||||
|
- 분할 회차(`installment_seq`) 정의 시 N 회로 분할
|
||||||
|
- 결과를 `chargeback` 에 적재
|
||||||
|
- 예외 자동 생성: `exception_type_code` 의 `auto_calc_yn='Y'` 인 코드별로
|
||||||
|
`exception_ledger` 에 기록 (수동 승인 대기)
|
||||||
|
- 출력: `ctx.chargebackCount`, `ctx.exceptionCount`
|
||||||
|
|
||||||
|
### Step 7 — `calcOverride`
|
||||||
|
- 입력: Step 4/5 에서 생성된 모집/유지 수수료 합계 (per agent)
|
||||||
|
- 처리:
|
||||||
|
- 조직 트리 상향 탐색 (CTE): 설계사 → 팀장 → 지점장 → 본부장
|
||||||
|
- 각 단계마다 `override_rule` (from_grade → to_grade) 의 `override_pct` 적용
|
||||||
|
- `cap_amount` 가 있으면 상한 적용
|
||||||
|
- `override_settle` 에 (from_agent, to_agent, base, pct, amount) 적재
|
||||||
|
- 출력: `ctx.overrideCount`
|
||||||
|
|
||||||
|
### Step 8 — `aggregate`
|
||||||
|
- 입력: 모든 원장의 설계사별 합계 + 환수 + 오버라이드 + 예외(PLUS/MINUS)
|
||||||
|
- 처리:
|
||||||
|
```
|
||||||
|
gross = recruit + maintain + override + excPlus - chargeback - excMinus
|
||||||
|
tax = gross × TAX_RATE (system_config 의 TAX_RATE, 기본 3.3%)
|
||||||
|
net = gross - tax
|
||||||
|
```
|
||||||
|
- `settle_master` 에 `(agent_id, settle_month)` UPSERT (`ON CONFLICT DO UPDATE`)
|
||||||
|
- `status = 'CALCULATED'` (확정은 별도 API)
|
||||||
|
- 활성 설계사 누락 보충: `agentMapper.selectList(status=ACTIVE)` 로 0 행도 빈 settle 생성
|
||||||
|
- 출력: `ctx.aggregateCount`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. JobLauncherController
|
||||||
|
|
||||||
|
```java
|
||||||
|
@RestController @RequestMapping("/api/batch/settlement")
|
||||||
|
public class JobLauncherController {
|
||||||
|
private final JobLauncher launcher;
|
||||||
|
private final Job monthlySettlementJob;
|
||||||
|
private final BatchJobLogMapper logMapper;
|
||||||
|
|
||||||
|
@PostMapping("/run")
|
||||||
|
public ApiResponse<Long> run(@RequestParam String settleMonth,
|
||||||
|
@RequestParam(required=false) String companyCode) {
|
||||||
|
if (logMapper.existsRunning("monthlySettlementJob") > 0)
|
||||||
|
throw new BizException(ErrorCode.BATCH_RUNNING);
|
||||||
|
Long jobLogId = logMapper.request("monthlySettlementJob", settleMonth);
|
||||||
|
JobParameters p = new JobParametersBuilder()
|
||||||
|
.addString("settleMonth", settleMonth)
|
||||||
|
.addString("companyCode", companyCode)
|
||||||
|
.addLong ("jobLogId", jobLogId)
|
||||||
|
.addLong ("ts", System.currentTimeMillis()) // 동일 파라미터 재실행
|
||||||
|
.toJobParameters();
|
||||||
|
new Thread(() -> launcher.run(monthlySettlementJob, p)).start();
|
||||||
|
return ApiResponse.ok(jobLogId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/status")
|
||||||
|
public ApiResponse<BatchJobLogVO> status(@RequestParam Long jobLogId) {
|
||||||
|
return ApiResponse.ok(logMapper.selectById(jobLogId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
ga-api 도 동일 엔드포인트 노출 가능 — 배치 서버가 따로 떠 있을 때는
|
||||||
|
ga-api 가 `batch_job_log.status='REQUESTED'` 로 적재만 하고, ga-batch 가 폴링/직접 호출.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 단위 테스트 (현재 작성됨)
|
||||||
|
|
||||||
|
`ga-batch/src/test/java/com/ga/batch/`:
|
||||||
|
- `settlement/calc/CommissionCalculatorTest` (6) — 모집/유지/폴백/agent 누락
|
||||||
|
- `settlement/transform/MappingEngineTest` (13) — RECRUIT/MAINTAIN 라우팅,
|
||||||
|
TRIM/UPPER/LOWER/DATE/DIVIDE/MULTIPLY/CODE_MAP, 9 가지 에러
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 향후 확장 (현재 단순화 부분)
|
||||||
|
|
||||||
|
1. **DataReceiver**: EDI / FTP / API 구현 (현재 ManualReceiver 만)
|
||||||
|
2. **MappingEngine**: REPLACE / SUBSTRING / CONCAT / LOOKUP 등 추가 변환 규칙
|
||||||
|
3. **이체파일**: 은행별 EBCDIC / CSV 출력 어댑터 (Step 9 으로 분리)
|
||||||
|
4. **재실행**: 특정 Step 부터 재실행 (`/api/batch/settlement/restart?fromStep=4`)
|
||||||
|
5. **병렬 처리**: Step 4/5 를 `partitioning` 으로 멀티스레드화
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
# 05_API_SPEC — ga-api (REST API)
|
||||||
|
|
||||||
|
> 모든 Controller / Service 가 동일한 패턴으로 작성된다.
|
||||||
|
> 신입이 복붙 후 테이블명/필드명만 바꾸면 새 화면이 완성되도록.
|
||||||
|
|
||||||
|
## 패키지 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
ga-api/src/main/java/com/ga/api/
|
||||||
|
├── GaApiApplication.java # @SpringBootApplication(scanBasePackages = "com.ga")
|
||||||
|
├── auth/
|
||||||
|
│ ├── AuthController.java # /api/auth/*
|
||||||
|
│ └── AuthService.java
|
||||||
|
├── controller/
|
||||||
|
│ ├── batch/ BatchTriggerController.java
|
||||||
|
│ ├── ledger/ LedgerController.java # 모집/유지/예외 통합
|
||||||
|
│ ├── org/ OrganizationController.java, AgentController.java
|
||||||
|
│ ├── product/ CompanyController.java, ProductController.java, ContractController.java
|
||||||
|
│ ├── receive/ ReceiveController.java
|
||||||
|
│ ├── rule/ RuleController.java
|
||||||
|
│ ├── settle/ SettleController.java, PaymentController.java
|
||||||
|
│ └── system/ UserController.java, RoleController.java
|
||||||
|
└── service/
|
||||||
|
└── (controller 미러 구조 — XxxService.java)
|
||||||
|
```
|
||||||
|
|
||||||
|
build.gradle:
|
||||||
|
```groovy
|
||||||
|
apply plugin: 'org.springframework.boot'
|
||||||
|
bootJar { archiveFileName = 'ga-api.jar'; mainClass = 'com.ga.api.GaApiApplication' }
|
||||||
|
jar.enabled = false
|
||||||
|
dependencies {
|
||||||
|
implementation project(':ga-core')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`application.yml` 은 `01_DB_SCHEMA.md` 의 yml + `server.port: 8080`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 표준 Controller 패턴
|
||||||
|
|
||||||
|
```java
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/agents")
|
||||||
|
@Tag(name = "설계사 관리")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AgentController {
|
||||||
|
|
||||||
|
private final AgentService agentService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@RequirePermission(menu = "ORG_AGENT", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<AgentResp>> list(AgentSearchParam param) {
|
||||||
|
return ApiResponse.ok(agentService.list(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@RequirePermission(menu = "ORG_AGENT", perm = "READ")
|
||||||
|
public ApiResponse<AgentResp> detail(@PathVariable Long id) {
|
||||||
|
return ApiResponse.ok(agentService.detail(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@RequirePermission(menu = "ORG_AGENT", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "ORG_AGENT", table = "agent")
|
||||||
|
public ApiResponse<Long> create(@Valid @RequestBody AgentSaveReq req) {
|
||||||
|
return ApiResponse.ok(agentService.create(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
@RequirePermission(menu = "ORG_AGENT", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "ORG_AGENT", table = "agent")
|
||||||
|
public ApiResponse<Void> update(@PathVariable Long id, @Valid @RequestBody AgentSaveReq req) {
|
||||||
|
agentService.update(id, req); return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{id}/status")
|
||||||
|
@RequirePermission(menu = "ORG_AGENT", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "ORG_AGENT", table = "agent")
|
||||||
|
public ApiResponse<Void> updateStatus(@PathVariable Long id, @RequestParam String status) {
|
||||||
|
agentService.updateStatus(id, status); return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/export")
|
||||||
|
@RequirePermission(menu = "ORG_AGENT", perm = "EXPORT")
|
||||||
|
public void export(AgentSearchParam p, HttpServletResponse res) {
|
||||||
|
agentService.exportExcel(p, res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
1. **클래스 어노테이션**: `@RestController @RequestMapping @Tag @RequiredArgsConstructor`
|
||||||
|
2. **메서드 어노테이션**: `@RequirePermission` + (CUD 일 때) `@DataChangeLog`
|
||||||
|
3. **응답**: `ApiResponse<T>` / `ApiResponse<PageResponse<T>>` / `ApiResponse<Void>`
|
||||||
|
4. **요청 바디**: `@Valid @RequestBody XxxSaveReq`
|
||||||
|
5. **검색**: `XxxSearchParam` (쿼리 파라미터로 자동 바인딩)
|
||||||
|
6. **PathVariable**: id 는 `Long`
|
||||||
|
7. **엑셀 export 는 `void` + `HttpServletResponse` 직접 사용**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 표준 Service 패턴
|
||||||
|
|
||||||
|
```java
|
||||||
|
@Service @RequiredArgsConstructor
|
||||||
|
public class AgentService {
|
||||||
|
private final AgentMapper mapper;
|
||||||
|
private final CommonCodeService codeService;
|
||||||
|
private final AgentMapstruct mapstruct;
|
||||||
|
private final ExcelService excelService;
|
||||||
|
|
||||||
|
public PageResponse<AgentResp> list(AgentSearchParam param) {
|
||||||
|
param.startPage();
|
||||||
|
return PageResponse.of(mapper.selectList(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
public AgentResp detail(Long id) {
|
||||||
|
AgentResp r = mapper.selectDetailById(id);
|
||||||
|
if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Long create(AgentSaveReq req) {
|
||||||
|
if (req.getLicenseNo() != null && mapper.existsByLicenseNo(req.getLicenseNo()) > 0)
|
||||||
|
throw new BizException(ErrorCode.DUPLICATE_DATA);
|
||||||
|
AgentVO vo = mapstruct.toVO(req);
|
||||||
|
vo.setStatus("ACTIVE");
|
||||||
|
mapper.insert(vo);
|
||||||
|
return vo.getAgentId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void update(Long id, AgentSaveReq req) {
|
||||||
|
AgentVO vo = mapper.selectById(id);
|
||||||
|
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
|
||||||
|
mapstruct.update(vo, req);
|
||||||
|
mapper.update(vo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void updateStatus(Long id, String status) {
|
||||||
|
codeService.validateCode("AGENT_STATUS", status);
|
||||||
|
mapper.updateStatus(id, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void exportExcel(AgentSearchParam p, HttpServletResponse res) {
|
||||||
|
excelService.exportLargeExcel("설계사목록", AgentExcelVO.class,
|
||||||
|
() -> mapper.selectCursorForExcel(p), res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
규칙:
|
||||||
|
1. **Service 는 Mapper + 다른 Service 만 의존** (Controller 직접 호출 X)
|
||||||
|
2. **CUD 메서드는 `@Transactional`**
|
||||||
|
3. **공통코드 검증은 `codeService.validateCode(group, code)`**
|
||||||
|
4. **중복 검증 → `DUPLICATE_DATA`, 미존재 → `NOT_FOUND`**
|
||||||
|
5. **엑셀은 `excelService.exportLargeExcel(fileName, ExcelVO.class, cursorSupplier, response)`**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. API 엔드포인트 목록 (현재 구현된 것)
|
||||||
|
|
||||||
|
### 인증 (`AuthController` — `/api/auth`)
|
||||||
|
- `POST /login` — `{loginId, password}` → `{accessToken, refreshToken, user}` + `login_log` 적재
|
||||||
|
- `POST /refresh` — refresh 토큰으로 access 갱신
|
||||||
|
- `POST /logout` — 클라이언트 토큰 폐기 (서버는 로그만)
|
||||||
|
- `GET /me` — 현재 사용자 + 역할 + 메뉴 트리
|
||||||
|
|
||||||
|
### 조직 / 설계사
|
||||||
|
- `OrganizationController /api/orgs`
|
||||||
|
- `GET /tree` — 트리 (CTE 재귀)
|
||||||
|
- `GET /{id}` / `POST` / `PUT /{id}` / `DELETE /{id}`
|
||||||
|
- `AgentController /api/agents`
|
||||||
|
- `GET` (검색+페이징) / `GET /{id}` / `POST` / `PUT /{id}`
|
||||||
|
- `PATCH /{id}/status` / `PATCH /{id}/transfer` (조직/직급 변경)
|
||||||
|
- `GET /{id}/history` (조직/직급 이력)
|
||||||
|
- `GET /export` (엑셀 다운로드)
|
||||||
|
|
||||||
|
### 보험상품 / 계약
|
||||||
|
- `CompanyController /api/companies` — 보험사 5 건 CRUD
|
||||||
|
- `ProductController /api/products` — 보험사 × 상품
|
||||||
|
- `ContractController /api/contracts` — 검색/CRUD/엑셀, `policy_no` 중복검증
|
||||||
|
|
||||||
|
### 수수료 규정
|
||||||
|
- `RuleController /api/rules`
|
||||||
|
- `GET /commission-rates`, `POST`, `PUT /{id}`, `DELETE /{id}`
|
||||||
|
- `GET /payout-rules`, ... 동일 패턴
|
||||||
|
- `GET /override-rules`, `GET /chargeback-rules`, `GET /exception-codes`
|
||||||
|
|
||||||
|
### 데이터 수신 / 매핑
|
||||||
|
- `ReceiveController /api/receive`
|
||||||
|
- `POST /upload?companyCode=&dataType=` — multipart 업로드 → `raw_commission_data` 적재
|
||||||
|
- `POST /process?companyCode=&settleMonth=` — `MappingEngine` 으로 변환 호출
|
||||||
|
- `GET /status?companyCode=&settleMonth=` — 진행 현황
|
||||||
|
- `GET /errors?companyCode=&resolveStatus=` — 에러 목록
|
||||||
|
- `PATCH /errors/{errorId}/resolve` — 에러 해결 처리
|
||||||
|
- 매핑 관리: `GET /mapping/{companyCode}/fields`, `POST/PUT/DELETE`
|
||||||
|
|
||||||
|
### 원장 (`LedgerController /api/ledger` — 통합)
|
||||||
|
- `GET /recruit` (검색+페이징) / `GET /recruit/export` (엑셀)
|
||||||
|
- `GET /maintain`, `GET /exception` — 동일 패턴
|
||||||
|
- `POST /exception` — 예외금액 등록 (수동)
|
||||||
|
- `PATCH /exception/{id}/approve` — 승인 (`@RequirePermission perm=APPROVE`)
|
||||||
|
- `PATCH /exception/{id}/reject` — 반려
|
||||||
|
|
||||||
|
### 정산
|
||||||
|
- `SettleController /api/settle`
|
||||||
|
- `GET` (월별 검색) / `GET /{id}` / `GET /summary?settleMonth=`
|
||||||
|
- `PATCH /{id}/confirm` (`@RequirePermission APPROVE`) — `@Transactional`,
|
||||||
|
`version` 체크 → `RULE_VERSION_CONFLICT` 가능
|
||||||
|
- `PATCH /{id}/hold` `{reason}`
|
||||||
|
- `PaymentController /api/payments`
|
||||||
|
- `GET` 검색 / `POST` 이체 생성 (`settle_master.status='CONFIRMED'` 인 것만)
|
||||||
|
- `POST /{id}/transfer-file` 이체파일 생성
|
||||||
|
- `PATCH /{id}/success`, `PATCH /{id}/fail`
|
||||||
|
|
||||||
|
### 배치 트리거 (`BatchTriggerController /api/batch`)
|
||||||
|
- `POST /settlement/run?settleMonth=&companyCode=` — `batch_job_log` 적재 + ga-batch 트리거
|
||||||
|
- `GET /settlement/status?jobLogId=` — 진행 상태
|
||||||
|
- `POST /settlement/restart?jobLogId=&fromStep=` — 재실행 (선택 구현)
|
||||||
|
|
||||||
|
### 시스템관리
|
||||||
|
- `UserController /api/system/users`
|
||||||
|
- `GET` / `POST` / `PUT /{id}` / `DELETE /{id}`
|
||||||
|
- `PATCH /{id}/reset-password` — 임시 비밀번호 발급 (이메일/알림)
|
||||||
|
- `PATCH /{id}/unlock` — 잠금 해제
|
||||||
|
- `PUT /{id}/roles` — 역할 매핑
|
||||||
|
- `RoleController /api/system/roles`
|
||||||
|
- 역할 CRUD + `GET /{id}/permissions` (메뉴 × 권한 매트릭스)
|
||||||
|
- `PUT /{id}/permissions` (전체 매트릭스 갱신)
|
||||||
|
|
||||||
|
### 공통 (ga-common 에 위치하지만 ga-api 가 노출)
|
||||||
|
- `MenuController /api/menus` — `GET /my`, `GET /tree`, CRUD
|
||||||
|
- `CommonCodeController /api/common/codes` — 그룹/상세 CRUD
|
||||||
|
- `FileController /api/files` — 업로드/다운로드/목록
|
||||||
|
- `NotificationController /api/notifications` — 목록/읽음
|
||||||
|
- `SystemConfigController /api/system/config` — 시스템 설정
|
||||||
|
- `SystemLogController /api/system/logs` — 로그인/API/변경 이력
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 인증 / 권한 흐름
|
||||||
|
|
||||||
|
1. **로그인**: `POST /api/auth/login` → JWT 발급
|
||||||
|
2. **헤더**: 모든 요청에 `Authorization: Bearer <token>`
|
||||||
|
3. **`JwtAuthFilter`**: SecurityContext 에 `LoginUser` 주입
|
||||||
|
4. **`@RequirePermission`**: `PermissionAspect` 가 `role_menu_permission` 조회 → 없으면 `FORBIDDEN`
|
||||||
|
5. **`@DataChangeLog`**: 변경 전/후 데이터 비교 후 `data_change_log` JSONB 비동기 적재
|
||||||
|
6. **`ApiLogAspect`**: 모든 요청 `api_access_log` 비동기 적재 (민감정보 마스킹)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 응답 표준
|
||||||
|
|
||||||
|
### 성공
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true, "code": "0000", "message": "OK",
|
||||||
|
"data": { ... }, "timestamp": "2026-05-10T12:34:56"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 페이징
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true, "code": "0000",
|
||||||
|
"data": {
|
||||||
|
"list": [...], "pageNum": 1, "pageSize": 20, "total": 123, "pages": 7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실패
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false, "code": "B009", "message": "중복된 데이터입니다",
|
||||||
|
"data": null, "timestamp": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 테스트 (선택)
|
||||||
|
|
||||||
|
- MockMvc 기반 Controller 테스트
|
||||||
|
- `@WebMvcTest(AgentController.class)` + `@MockBean AgentService`
|
||||||
|
- 인증/권한 검증 (403 응답)
|
||||||
|
- 요청 바디 검증 (`@Valid` 실패 시 400)
|
||||||
|
- WireMock 으로 ga-batch 호출 stub
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
# 06_FRONTEND_SPEC — ga-frontend (React 18)
|
||||||
|
|
||||||
|
> Vite + React 18 + TypeScript + Ant Design 5 + AG Grid Community + React Query + Zustand.
|
||||||
|
> ga-common 에서 작성한 공통 컴포넌트(02_COMMON_SPEC §10)를 반드시 재사용.
|
||||||
|
> **디자인 시스템**: `docs/09_UI_DESIGN.md` 함께 참조 (테마 토큰, 레이아웃 규격, 화면별 와이어프레임)
|
||||||
|
|
||||||
|
## 패키지 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
ga-frontend/
|
||||||
|
├── package.json # AG Grid Community ^31.3, antd ^5.18, react-query ^5.40
|
||||||
|
├── vite.config.ts # /api → http://localhost:8080 proxy
|
||||||
|
├── tsconfig.json # strict: true
|
||||||
|
├── index.html
|
||||||
|
└── src/
|
||||||
|
├── main.tsx # ReactDOM.createRoot, QueryClientProvider, ConfigProvider(ko)
|
||||||
|
├── App.tsx # 라우터 + 전역 메시지/모달
|
||||||
|
├── api/
|
||||||
|
│ ├── request.ts # axios + 인터셉터 + ApiResponse 타입
|
||||||
|
│ ├── auth.ts, agent.ts, contract.ts, ledger.ts, settle.ts, payment.ts,
|
||||||
|
│ ├── system.ts, menu.ts, code.ts, file.ts, batch.ts
|
||||||
|
├── components/
|
||||||
|
│ └── common/
|
||||||
|
│ ├── CodeSelect.tsx, CodeBadge.tsx, MoneyText.tsx
|
||||||
|
│ ├── SearchForm.tsx, DataGrid.tsx
|
||||||
|
│ ├── PermissionButton.tsx, ExcelExportButton.tsx, PageContainer.tsx
|
||||||
|
├── hooks/
|
||||||
|
│ ├── useCommonCodes.ts, useMenuTree.ts, usePermission.ts, useSearchParam.ts
|
||||||
|
├── layouts/
|
||||||
|
│ └── MainLayout.tsx # Sider + Header + Content + Breadcrumb
|
||||||
|
├── pages/
|
||||||
|
│ ├── Dashboard.tsx
|
||||||
|
│ ├── LoginPage.tsx
|
||||||
|
│ ├── org/ # AgentList / AgentDetail / AgentForm
|
||||||
|
│ ├── product/ # ContractList
|
||||||
|
│ ├── ledger/ # RecruitLedger / ExceptionLedger
|
||||||
|
│ ├── settle/ # SettleList / BatchRun / PaymentList
|
||||||
|
│ └── system/ # UserList / RoleList / CodeList
|
||||||
|
├── router/
|
||||||
|
│ └── DynamicRouter.tsx # /api/menus/my → React.lazy 매핑
|
||||||
|
├── stores/
|
||||||
|
│ ├── authStore.ts (Zustand) # accessToken, user, login/logout
|
||||||
|
│ ├── menuStore.ts # 메뉴 트리 캐시
|
||||||
|
│ └── notificationStore.ts # 미읽음 카운트
|
||||||
|
├── types/
|
||||||
|
│ └── api.ts, domain.ts
|
||||||
|
└── utils/
|
||||||
|
└── formatMoney.ts, formatDate.ts, downloadFile.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
`vite.config.ts`:
|
||||||
|
```typescript
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 공통 컴포넌트 / 훅
|
||||||
|
|
||||||
|
상세 시그니처는 `02_COMMON_SPEC.md §10` 참고. 핵심:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 공통코드 셀렉트
|
||||||
|
<CodeSelect groupCode="AGENT_STATUS" allowClear />
|
||||||
|
<CodeBadge groupCode="SETTLE_STATUS" value="CONFIRMED" />
|
||||||
|
<MoneyText value={123456} sign /> // +123,456 (음수 빨강)
|
||||||
|
<MonthPicker value={month} onChange={setMonth} />
|
||||||
|
|
||||||
|
// 검색폼 — conditions 배열로 자동 렌더링
|
||||||
|
<SearchForm
|
||||||
|
conditions={[
|
||||||
|
{ type: 'text', name: 'searchKeyword', label: '검색어' },
|
||||||
|
{ type: 'code', name: 'status', label: '상태', groupCode: 'AGENT_STATUS' },
|
||||||
|
{ type: 'dateRange', name: 'dateRange', label: '기간' },
|
||||||
|
{ type: 'month', name: 'settleMonth', label: '정산월' },
|
||||||
|
]}
|
||||||
|
onSearch={setParam} onReset={resetParam}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// 권한 버튼 — perm 없으면 안 보임
|
||||||
|
<PermissionButton menuCode="ORG_AGENT" permCode="CREATE" type="primary"
|
||||||
|
onClick={() => navigate('new')}>등록</PermissionButton>
|
||||||
|
|
||||||
|
// 엑셀 다운로드 — 100만건 진행률 표시
|
||||||
|
<ExcelExportButton url="/api/agents/export" params={searchParam}
|
||||||
|
fileName="설계사목록" showProgress />
|
||||||
|
|
||||||
|
// AG Grid 서버사이드
|
||||||
|
<DataGrid columnDefs={cols} dataSource={(p) => agentApi.list(p)}
|
||||||
|
totalRow rowSelection="multiple" />
|
||||||
|
```
|
||||||
|
|
||||||
|
훅:
|
||||||
|
```tsx
|
||||||
|
const { data: codes } = useCommonCodes('AGENT_STATUS'); // React Query staleTime 10분
|
||||||
|
const { canCreate, canExport, canApprove } = usePermission('ORG_AGENT');
|
||||||
|
const [param, setParam] = useSearchParam({ status: '' }); // URL 동기화
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. AG Grid vs DataTable 사용 기준
|
||||||
|
|
||||||
|
| 화면 | 컴포넌트 | 이유 |
|
||||||
|
|---|---|---|
|
||||||
|
| 설계사 목록, 사용자 목록 | Ant DataTable | 단순 CRUD, < 100 행 |
|
||||||
|
| 계약 목록 | Ant DataTable | 검색 위주 |
|
||||||
|
| **모집/유지/예외 원장** | **AG Grid** | 1만~100만 행, 합계행, 셀 편집 |
|
||||||
|
| **정산 결과** | **AG Grid** | 설계사 × 항목, 합계행 |
|
||||||
|
| 수수료율 편집 | AG Grid | 인라인 셀 편집 |
|
||||||
|
| 지급 관리 | Ant DataTable | 단순 상태 추적 |
|
||||||
|
|
||||||
|
판단 규칙:
|
||||||
|
- 100 건 이상 → AG Grid
|
||||||
|
- 셀 편집 필요 → AG Grid
|
||||||
|
- 합계행 (`pinnedBottomRowData`) → AG Grid
|
||||||
|
- 그 외 단순 목록 → Ant DataTable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 동적 라우터 (`router/DynamicRouter.tsx`)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export function DynamicRouter() {
|
||||||
|
const { menuTree } = useMenuTree(); // /api/menus/my
|
||||||
|
const flat = flatten(menuTree).filter(m => m.menuType === 'PAGE' && m.componentPath);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route element={<MainLayout />}>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
{flat.map(m => {
|
||||||
|
const Page = lazy(() => import(`../pages/${m.componentPath}.tsx`));
|
||||||
|
return (
|
||||||
|
<Route key={m.menuId} path={m.menuPath}
|
||||||
|
element={<Suspense fallback={<Spin />}><Page /></Suspense>} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Route path="*" element={<NotFound />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 로그인 → `/api/menus/my` 호출 → Sider 메뉴 + 라우터 동적 등록
|
||||||
|
- `menu.component_path` 가 `org/AgentList` 면 `pages/org/AgentList.tsx` 로 lazy import
|
||||||
|
- 권한 없는 URL 진입 시 403
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 화면 13개 (현재 구현됨)
|
||||||
|
|
||||||
|
### 1) `Dashboard.tsx` (`/`)
|
||||||
|
- 정산 요약 카드 4개: 모집 / 유지 / 예외 / 환수 (월간 합계)
|
||||||
|
- 배치 진행 상황 (최근 monthlySettlementJob)
|
||||||
|
- 미해결 대사 오류 알림
|
||||||
|
- 월별 추이 (recharts `LineChart`)
|
||||||
|
|
||||||
|
### 2) `LoginPage.tsx` (`/login`)
|
||||||
|
- 로그인 폼 (`loginId`, `password`)
|
||||||
|
- 성공 시 토큰 저장 + 메뉴 트리 fetch + `/` 리다이렉트
|
||||||
|
|
||||||
|
### 3~5) `org/AgentList.tsx`, `AgentDetail.tsx`, `AgentForm.tsx` (`/org/agents`)
|
||||||
|
- 목록 (DataTable, 50명 더미 표시)
|
||||||
|
- SearchForm: 이름/상태/조직
|
||||||
|
- 상세 모달 또는 페이지 (탭: 기본정보/조직이력/계약/정산이력)
|
||||||
|
- 등록/수정 폼 (`AgentSaveReq`, Ant Form + `<Form.Item rules>`)
|
||||||
|
- 엑셀 다운로드 (`ExcelExportButton`)
|
||||||
|
|
||||||
|
### 6) `product/ContractList.tsx` (`/contracts`)
|
||||||
|
- 200건 더미 표시
|
||||||
|
- 필터: 상태(ACTIVE/LAPSE), 보험사, 보험종류, 기간
|
||||||
|
|
||||||
|
### 7) `ledger/RecruitLedger.tsx` (`/ledger/recruit`)
|
||||||
|
- AG Grid (서버사이드 페이징/정렬/필터)
|
||||||
|
- 합계행 (`pinnedBottomRowData`)
|
||||||
|
- 컬럼: 정산월, 증권번호, 설계사, 보험사, 보험료, 수수료율, 수수료, 세액
|
||||||
|
- 엑셀 다운로드
|
||||||
|
|
||||||
|
### 8) `ledger/ExceptionLedger.tsx` (`/ledger/exception`)
|
||||||
|
- AG Grid + 등록 모달 (수동 등록)
|
||||||
|
- 행 액션: 승인 / 반려 (`@RequirePermission perm=APPROVE`)
|
||||||
|
|
||||||
|
### 9) `settle/SettleList.tsx` (`/settle`)
|
||||||
|
- 월별 정산 요약
|
||||||
|
- 설계사별 카드 / AG Grid (모집/유지/예외/오버라이드/환수/총액/세금/실지급)
|
||||||
|
- 행 액션: 확정 / 보류
|
||||||
|
|
||||||
|
### 10) `settle/BatchRun.tsx` (`/settle/batch`)
|
||||||
|
- 정산월 선택 → 배치 실행 버튼
|
||||||
|
- 진행률 폴링 (5초 간격, `GET /api/batch/settlement/status`)
|
||||||
|
- 8 Step 별 진행 카운트 표시
|
||||||
|
|
||||||
|
### 11) `settle/PaymentList.tsx` (`/payments`)
|
||||||
|
- 지급 상태 (PENDING / SENT / SUCCESS / FAIL)
|
||||||
|
- 이체파일 생성 / 결과 등록
|
||||||
|
|
||||||
|
### 12) `system/UserList.tsx` (`/system/users`)
|
||||||
|
- 사용자 CRUD, 역할 할당 체크박스
|
||||||
|
- 비밀번호 초기화 / 잠금 해제 액션
|
||||||
|
|
||||||
|
### 13) `system/RoleList.tsx` (`/system/roles`)
|
||||||
|
- 역할 CRUD + 권한 매트릭스 (메뉴 × 6 권한 체크박스 그리드)
|
||||||
|
|
||||||
|
### `system/CodeList.tsx` (`/system/codes`)
|
||||||
|
- 좌측: 그룹 리스트 (DataTable)
|
||||||
|
- 우측: 선택한 그룹의 상세 코드 (DataTable)
|
||||||
|
- `is_system='Y'` 인 그룹은 수정/삭제 disable
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 색상 / 디자인 규칙
|
||||||
|
|
||||||
|
- **상태 뱃지 색** (`CodeBadge` 자동):
|
||||||
|
- 초록: CONFIRMED / APPROVED / ACTIVE / PAID / SUCCESS
|
||||||
|
- 파랑: PENDING / CALCULATED / PROCESSING / SENT
|
||||||
|
- 빨강: HOLD / SUSPEND / REJECTED / FAIL / LAPSE / ERROR
|
||||||
|
- 회색: DRAFT / NEW / OPEN / IGNORED
|
||||||
|
- **금액**: `MoneyText` 사용 (양수 검정 / 음수 빨강 / null `-`)
|
||||||
|
- **마스킹**: 주민번호/계좌번호는 응답에서 마스킹돼 옴 (`@Mask` 어노테이션)
|
||||||
|
- **빈 상태**: `<Empty />` (Ant Design)
|
||||||
|
- **로딩**: `<Spin />`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Zustand Store 패턴
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AuthState {
|
||||||
|
accessToken: string | null;
|
||||||
|
user: { userId: number; loginId: string; userName: string; roleCodes: string[] } | null;
|
||||||
|
login: (token: string, user: any) => void;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
|
accessToken: localStorage.getItem('accessToken'),
|
||||||
|
user: JSON.parse(localStorage.getItem('user') ?? 'null'),
|
||||||
|
login: (token, user) => {
|
||||||
|
localStorage.setItem('accessToken', token);
|
||||||
|
localStorage.setItem('user', JSON.stringify(user));
|
||||||
|
set({ accessToken: token, user });
|
||||||
|
},
|
||||||
|
logout: () => {
|
||||||
|
localStorage.removeItem('accessToken');
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
set({ accessToken: null, user: null });
|
||||||
|
location.href = '/login';
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 신입 개발자 5분 컷 패턴
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default function AgentList() {
|
||||||
|
const { canCreate, canExport } = usePermission('ORG_AGENT');
|
||||||
|
const [param, setParam] = useSearchParam({ status: '' });
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['agents', param],
|
||||||
|
queryFn: () => agentApi.list(param),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer title="설계사 관리">
|
||||||
|
<SearchForm conditions={[
|
||||||
|
{ type: 'text', name: 'searchKeyword', label: '설계사명' },
|
||||||
|
{ type: 'code', name: 'status', label: '상태', groupCode: 'AGENT_STATUS' },
|
||||||
|
]} onSearch={setParam} />
|
||||||
|
|
||||||
|
<Space style={{ marginBottom: 16, justifyContent: 'flex-end' }}>
|
||||||
|
<PermissionButton menuCode="ORG_AGENT" permCode="CREATE" type="primary"
|
||||||
|
onClick={() => navigate('/org/agents/new')}>등록</PermissionButton>
|
||||||
|
{canExport && <ExcelExportButton url="/api/agents/export" params={param}
|
||||||
|
fileName="설계사목록" showProgress />}
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
loading={isLoading}
|
||||||
|
dataSource={data?.list ?? []}
|
||||||
|
rowKey="agentId"
|
||||||
|
pagination={{ total: data?.total ?? 0, current: param.pageNum, pageSize: param.pageSize }}
|
||||||
|
columns={[
|
||||||
|
{ title: '설계사명', dataIndex: 'agentName' },
|
||||||
|
{ title: '소속', dataIndex: 'orgName' },
|
||||||
|
{ title: '직급', dataIndex: 'gradeName' },
|
||||||
|
{ title: '상태', dataIndex: 'status',
|
||||||
|
render: v => <CodeBadge groupCode="AGENT_STATUS" value={v} /> },
|
||||||
|
{ title: '계약수', dataIndex: 'contractCount', align: 'right' },
|
||||||
|
{ title: '수수료', dataIndex: 'totalAmt',
|
||||||
|
render: v => <MoneyText value={v} />, align: 'right' },
|
||||||
|
]}
|
||||||
|
onRow={(r) => ({ onClick: () => navigate(`/org/agents/${r.agentId}`) })}
|
||||||
|
/>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
# 07_INFRA_SPEC — Docker / Nginx / 문서
|
||||||
|
|
||||||
|
## 1. docker-compose.yml
|
||||||
|
|
||||||
|
루트에 위치. 5 서비스: `postgres / redis / api / batch / frontend`.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ga
|
||||||
|
POSTGRES_USER: ga
|
||||||
|
POSTGRES_PASSWORD: ga
|
||||||
|
TZ: Asia/Seoul
|
||||||
|
ports: ["5432:5432"]
|
||||||
|
volumes: [ga-postgres-data:/var/lib/postgresql/data]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ga -d ga"]
|
||||||
|
interval: 10s
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports: ["6379:6379"]
|
||||||
|
volumes: [ga-redis-data:/data]
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args: { MODULE: ga-api }
|
||||||
|
depends_on:
|
||||||
|
postgres: { condition: service_healthy }
|
||||||
|
redis: { condition: service_started }
|
||||||
|
environment:
|
||||||
|
SPRING_PROFILES_ACTIVE: docker
|
||||||
|
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/ga
|
||||||
|
SPRING_DATASOURCE_USERNAME: ga
|
||||||
|
SPRING_DATASOURCE_PASSWORD: ga
|
||||||
|
SPRING_DATA_REDIS_HOST: redis
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-please-change-this-jwt-secret-must-be-256-bits}
|
||||||
|
ENCRYPT_KEY: ${ENCRYPT_KEY:-please-change-this-32-byte-key!}
|
||||||
|
ports: ["8080:8080"]
|
||||||
|
|
||||||
|
batch:
|
||||||
|
build: { context: ., dockerfile: Dockerfile, args: { MODULE: ga-batch } }
|
||||||
|
depends_on:
|
||||||
|
postgres: { condition: service_healthy }
|
||||||
|
redis: { condition: service_started }
|
||||||
|
api: { condition: service_started }
|
||||||
|
environment: # api 와 동일
|
||||||
|
...
|
||||||
|
ports: ["8081:8081"]
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: { context: ./ga-frontend, dockerfile: Dockerfile }
|
||||||
|
depends_on: [api]
|
||||||
|
ports: ["3000:80"]
|
||||||
|
|
||||||
|
volumes: { ga-postgres-data: , ga-redis-data: }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Dockerfile (루트, ga-api/ga-batch 공용)
|
||||||
|
|
||||||
|
멀티스테이지: Gradle 빌드 → 슬림 JRE.
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# stage 1: builder
|
||||||
|
FROM gradle:8.6-jdk17 AS builder
|
||||||
|
WORKDIR /workspace
|
||||||
|
COPY settings.gradle build.gradle gradle.properties ./
|
||||||
|
COPY ga-common/build.gradle ga-common/
|
||||||
|
COPY ga-core/build.gradle ga-core/
|
||||||
|
COPY ga-api/build.gradle ga-api/
|
||||||
|
COPY ga-batch/build.gradle ga-batch/
|
||||||
|
COPY ga-admin/build.gradle ga-admin/
|
||||||
|
RUN gradle dependencies --no-daemon || true
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
ARG MODULE=ga-api
|
||||||
|
RUN gradle :${MODULE}:bootJar --no-daemon -x test
|
||||||
|
RUN cp /workspace/${MODULE}/build/libs/${MODULE}.jar /workspace/app.jar
|
||||||
|
|
||||||
|
# stage 2: runtime
|
||||||
|
FROM eclipse-temurin:17-jre-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apk add --no-cache tzdata && cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime
|
||||||
|
COPY --from=builder /workspace/app.jar /app/app.jar
|
||||||
|
EXPOSE 8080 8081
|
||||||
|
ENTRYPOINT ["java","-XX:+UseContainerSupport","-XX:MaxRAMPercentage=75","-jar","/app/app.jar"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. ga-frontend/Dockerfile + nginx.conf
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# ga-frontend/Dockerfile
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci || npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build # → /app/dist
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
```
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# ga-frontend/nginx.conf
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# SPA fallback
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API 프록시
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
client_max_body_size 100m;
|
||||||
|
proxy_read_timeout 300s; # 엑셀 대용량 다운로드 대비
|
||||||
|
}
|
||||||
|
|
||||||
|
# 정적 자원 캐시
|
||||||
|
location ~* \.(?:css|js|woff2?|ttf|svg|png|jpg|jpeg|gif|ico)$ {
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, max-age=604800, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. README.md (루트)
|
||||||
|
|
||||||
|
작성 항목 (현재 README.md 가 이미 갖춘 형태):
|
||||||
|
|
||||||
|
- 기술 스택 요약 표
|
||||||
|
- 모듈 구조 다이어그램
|
||||||
|
- **시나리오 A — 운영 DB(`192.168.0.60:55432/trading_ai/ga`) 연결**
|
||||||
|
- Gradle wrapper 생성 → `bootJar` → `bootRun --args='--spring.profiles.active=trading_ai'`
|
||||||
|
- 환경변수 `DB_PASSWORD`, `REDIS_HOST` 오버라이드
|
||||||
|
- **시나리오 B — 로컬 PostgreSQL** (개발용)
|
||||||
|
- `docker compose up -d postgres redis` → `bootRun`
|
||||||
|
- **시나리오 C — Docker Compose 전체** (운영 흐름)
|
||||||
|
- `docker compose up -d --build`
|
||||||
|
- DB 마이그레이션 직접 실행 (Python `migrate.py`)
|
||||||
|
- 데이터 확인 표 (보험사 5 / 상품 20 / 설계사 50 / 계약 200 / ...)
|
||||||
|
- 트러블슈팅 FAQ
|
||||||
|
- `gradle wrapper --gradle-version 8.6`
|
||||||
|
- `Failed to determine a suitable driver class` → 프로파일 확인
|
||||||
|
- `NoSuchKey: ga` → `currentSchema=ga`
|
||||||
|
- 401 토큰 만료 → 재로그인
|
||||||
|
- AG Grid 빈 화면 → CSS import 확인
|
||||||
|
- 주요 기능 (백엔드/도메인/배치 8 Step 요약)
|
||||||
|
- 보안 (JWT/AES/RBAC/감사로그)
|
||||||
|
- 진행 상황 체크리스트
|
||||||
|
- 향후 확장
|
||||||
|
- 외부 시스템 연결 정보
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. DEVELOPER_GUIDE.md (신입 개발자 온보딩)
|
||||||
|
|
||||||
|
### 1. 환경 설정
|
||||||
|
- JDK 17 (`java -version` 확인)
|
||||||
|
- Node 20 + npm
|
||||||
|
- PostgreSQL 14+ (운영 또는 Docker)
|
||||||
|
- Redis 7+ (선택)
|
||||||
|
- IntelliJ IDEA Community + Lombok 플러그인 (자동 설치 권장)
|
||||||
|
|
||||||
|
### 2. 프로젝트 구조 한 장 다이어그램
|
||||||
|
```
|
||||||
|
ga-common ─┬─> ga-core ─┬─> ga-api (REST API, 8080)
|
||||||
|
│ ├─> ga-batch (Spring Batch, 8081)
|
||||||
|
│ └─> ga-admin (예약 모듈)
|
||||||
|
└─> 다른 모듈 모두 의존 (web/security/aop/cache/mybatis/excel...)
|
||||||
|
|
||||||
|
ga-frontend (독립 빌드, dist → nginx)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 새 화면 만들기 Step-by-Step (예: "공지사항")
|
||||||
|
|
||||||
|
**Step 1 — DB**: `notice` 테이블 + 메뉴/권한 등록 (V18 마이그레이션)
|
||||||
|
|
||||||
|
**Step 2 — VO** (`ga-core/vo/notice/`):
|
||||||
|
- `NoticeVO.java` (테이블 1:1)
|
||||||
|
- `NoticeResp.java` (조인 결과)
|
||||||
|
- `NoticeSaveReq.java` (`@Valid` 검증)
|
||||||
|
- `NoticeSearchParam.java extends SearchParam`
|
||||||
|
|
||||||
|
**Step 3 — Mapper**:
|
||||||
|
- `mapper/notice/NoticeMapper.java` (`@Mapper`)
|
||||||
|
- `resources/mapper/notice/NoticeMapper.xml` (기존 AgentMapper.xml 복사 후 수정)
|
||||||
|
|
||||||
|
**Step 4 — Service** (`ga-api/service/notice/`):
|
||||||
|
- `NoticeService.java` (단순 CRUD 패턴 + `@Transactional`)
|
||||||
|
|
||||||
|
**Step 5 — Controller**:
|
||||||
|
- `NoticeController.java` — `@RequirePermission(menu="NOTICE", perm="...")`
|
||||||
|
- CUD 메서드에 `@DataChangeLog`
|
||||||
|
|
||||||
|
**Step 6 — 프론트** (`ga-frontend/src/`):
|
||||||
|
- `api/notice.ts` — 함수 5개 (list/detail/create/update/delete)
|
||||||
|
- `pages/notice/NoticeList.tsx` — `06_FRONTEND_SPEC.md §7` 패턴
|
||||||
|
- `pages/notice/NoticeForm.tsx` — Ant Form
|
||||||
|
|
||||||
|
**Step 7 — 메뉴 등록**:
|
||||||
|
- `system/menus` 화면에서 메뉴/권한 등록 → 사용자 역할에 권한 부여
|
||||||
|
|
||||||
|
### 4. 자주 쓰는 패턴
|
||||||
|
- **공통코드 드롭다운**: `<CodeSelect groupCode="..." />`
|
||||||
|
- **엑셀 다운로드**: `<ExcelExportButton url={...} fileName={...} showProgress />`
|
||||||
|
- **상태 뱃지**: `<CodeBadge groupCode="..." value={status} />`
|
||||||
|
- **권한 버튼**: `<PermissionButton menuCode permCode>...</PermissionButton>`
|
||||||
|
- **AG Grid 서버사이드**: `<DataGrid dataSource={p => api.list(p)} />`
|
||||||
|
|
||||||
|
### 5. 코드 컨벤션
|
||||||
|
- VO 네이밍: `XxxVO / XxxSearchParam / XxxSaveReq / XxxResp / XxxExcelVO`
|
||||||
|
- Mapper XML: `XxxMapper.xml` (resources/mapper/{도메인}/)
|
||||||
|
- API URL: `/api/{리소스}s` (복수형, kebab-case)
|
||||||
|
- 금액: 항상 `BigDecimal` + `MoneyUtil` 헬퍼
|
||||||
|
- 날짜: `LocalDate / LocalDateTime` (java.util.Date 금지)
|
||||||
|
- 예외: `BizException(ErrorCode.XXX)` (RuntimeException 직접 throw 금지)
|
||||||
|
- 로그: `lombok @Slf4j` + `log.info("msg, key={}", value)` (한글 메시지 OK)
|
||||||
|
|
||||||
|
### 6. 트러블슈팅 FAQ
|
||||||
|
- **권한 에러**: `@RequirePermission` 의 menuCode 가 `menu_permission` 에 등록되어 있는지 + 사용자 역할에 권한 부여됐는지
|
||||||
|
- **공통코드 캐시 안 맞음**: `CommonCodeService.refreshCache(groupCode)` 또는 Redis FLUSH
|
||||||
|
- **MyBatis 매핑 에러**: ResultMap 의 column / property 명 + `mapUnderscoreToCamelCase` 확인
|
||||||
|
- **암호화 컬럼 평문 노출**: `EncryptTypeHandler` 가 컬럼별 명시(typeHandler 속성)되어 있는지
|
||||||
|
- **JSONB null**: `JsonTypeHandler` 가 `@MappedTypes(JsonNode.class)` 로 등록됐는지
|
||||||
|
- **PageHelper 안 먹음**: `param.startPage()` 호출 후 SELECT 1회만 + 같은 트랜잭션
|
||||||
|
- **AG Grid 빈 화면**: `@ag-grid-community/styles/ag-theme-quartz.css` import 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 통합 검증 체크리스트
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) 빌드
|
||||||
|
./gradlew clean build # 모든 모듈 컴파일 + 단위 테스트 통과
|
||||||
|
cd ga-frontend && npm install && npm run build # → dist/
|
||||||
|
|
||||||
|
# 2) Docker 전체 기동
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# 3) Smoke
|
||||||
|
curl http://localhost:8080/api/health # {"status":"UP"}
|
||||||
|
curl http://localhost:3000/ # nginx 200
|
||||||
|
# 브라우저: admin / admin1234! 로그인 → 메뉴 트리 보임 → 설계사 50 행 표시
|
||||||
|
|
||||||
|
# 4) 정산 배치
|
||||||
|
curl -X POST -H "Authorization: Bearer $TOKEN" \
|
||||||
|
"http://localhost:8081/api/batch/settlement/run?settleMonth=202605"
|
||||||
|
|
||||||
|
# 5) 태그
|
||||||
|
git tag v1.0.0-alpha
|
||||||
|
```
|
||||||
|
|
||||||
|
기대 결과:
|
||||||
|
- Flyway V1~V17 자동 적용
|
||||||
|
- ga-api 8080 / ga-batch 8081 / frontend 3000 모두 OK
|
||||||
|
- 단위테스트 76+ 건 통과
|
||||||
|
- 운영 DB 시나리오 A 도 동일 동작 (`--spring.profiles.active=trading_ai`)
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
# 08_DBA_SPEC — DB 성능 (인덱스 / 파티셔닝 / 뷰)
|
||||||
|
|
||||||
|
> core-architect 의 Mapper XML 작성 완료 후, EXPLAIN ANALYZE 로 검증하고
|
||||||
|
> V13~V15 마이그레이션을 추가한다.
|
||||||
|
|
||||||
|
## 1. 검토 절차
|
||||||
|
|
||||||
|
1. core-architect 가 작성한 모든 Mapper XML 의 SELECT 쿼리를 추출
|
||||||
|
2. 운영 DB 또는 테스트 DB 에서 `EXPLAIN ANALYZE` 실행
|
||||||
|
3. `Seq Scan` 발견 시 인덱스 후보 정리
|
||||||
|
4. 통계/리포트 쿼리는 View 또는 Materialized View 로 분리
|
||||||
|
5. 대용량 테이블은 파티셔닝 검토 (recruit_ledger, api_access_log, data_change_log)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. V13__인덱스최적화.sql
|
||||||
|
|
||||||
|
이미 생성된 V1~V12 의 인덱스 외에 추가:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- settle_master: 정산월 기준 빈번한 조회
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sm_month_status ON settle_master(settle_month, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sm_agent_month ON settle_master(agent_id, settle_month);
|
||||||
|
|
||||||
|
-- recruit_ledger / maintain_ledger: 정산월 + 설계사
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rl_month_agent ON recruit_ledger(settle_month, agent_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ml_month_agent ON maintain_ledger(settle_month, agent_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rl_company ON recruit_ledger(company_code, settle_month);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ml_company ON maintain_ledger(company_code, settle_month);
|
||||||
|
|
||||||
|
-- exception_ledger: 정산월 + 승인상태
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_el_month_approve ON exception_ledger(settle_month, approve_status);
|
||||||
|
|
||||||
|
-- contract: 만기/실효 처리용 (부분 인덱스)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contract_lapse ON contract(lapse_date) WHERE lapse_date IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contract_status_agent ON contract(status, agent_id);
|
||||||
|
|
||||||
|
-- chargeback / payment
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cb_month ON chargeback(settle_month, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pay_month ON payment(pay_date, pay_status);
|
||||||
|
|
||||||
|
-- raw / 에러 / 매핑 (부분 인덱스 적극 사용)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_raw_status ON raw_commission_data(parse_status, settle_month);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pe_company ON parse_error_log(company_code, resolve_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_codemap_lookup ON code_mapping(company_code, code_type, source_code)
|
||||||
|
WHERE is_active = 'Y';
|
||||||
|
|
||||||
|
-- 로그 테이블 (DESC 인덱스로 최신순 조회 최적화)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dcl_table_recent ON data_change_log(table_name, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_aal_url_recent ON api_access_log(url, created_at DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 인덱스 설계 원칙
|
||||||
|
- **WHERE 자주 쓰이는 컬럼 + ORDER BY 컬럼** 을 복합 인덱스로
|
||||||
|
- **선택도(cardinality) 낮은 컬럼은 후순위** (`status` 보다 `agent_id` 가 앞)
|
||||||
|
- **부분 인덱스**(`WHERE`) — 활성 데이터만 인덱싱하여 크기 축소
|
||||||
|
- **DESC 인덱스** — 최신순 조회가 빈번한 로그 테이블
|
||||||
|
- **외래키는 자동 인덱스 X** — PG 는 FK 컬럼에 자동 인덱스 안 만듦. 직접 추가.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. V14__파티셔닝.sql
|
||||||
|
|
||||||
|
대용량/시계열 테이블을 RANGE 파티셔닝으로 변환.
|
||||||
|
|
||||||
|
### 헬퍼 함수
|
||||||
|
```sql
|
||||||
|
CREATE OR REPLACE FUNCTION create_monthly_partition(
|
||||||
|
parent_table TEXT,
|
||||||
|
year_month TEXT -- 'YYYYMM'
|
||||||
|
) RETURNS VOID AS $$
|
||||||
|
DECLARE
|
||||||
|
partition_name TEXT;
|
||||||
|
start_date DATE;
|
||||||
|
end_date DATE;
|
||||||
|
BEGIN
|
||||||
|
partition_name := parent_table || '_p' || year_month;
|
||||||
|
start_date := to_date(year_month || '01', 'YYYYMMDD');
|
||||||
|
end_date := start_date + INTERVAL '1 month';
|
||||||
|
|
||||||
|
EXECUTE format(
|
||||||
|
'CREATE TABLE IF NOT EXISTS %I PARTITION OF %I FOR VALUES FROM (%L) TO (%L)',
|
||||||
|
partition_name, parent_table, start_date, end_date
|
||||||
|
);
|
||||||
|
END $$ LANGUAGE plpgsql;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 적용 테이블
|
||||||
|
- **`recruit_ledger`, `maintain_ledger`**: `settle_month` 기준 (VARCHAR 'YYYYMM')
|
||||||
|
- 변환 절차: 임시 테이블로 데이터 이전 → 파티션 마스터 생성 → 월별 파티션 생성
|
||||||
|
- 신규 시스템이면 처음부터 파티션 테이블로 정의 (V5 변경)
|
||||||
|
- **`api_access_log`, `data_change_log`**: `created_at` (TIMESTAMP) 기준 월별
|
||||||
|
- 90일 이상 자료는 자동 분리 (별도 cron 으로 detach)
|
||||||
|
|
||||||
|
### 자동 파티션 생성 (예약)
|
||||||
|
```sql
|
||||||
|
-- 매월 1일 차월 파티션 생성 (pg_cron 또는 외부 스케줄러)
|
||||||
|
SELECT create_monthly_partition('recruit_ledger', to_char(now() + interval '1 month', 'YYYYMM'));
|
||||||
|
SELECT create_monthly_partition('api_access_log', to_char(now() + interval '1 month', 'YYYYMM'));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 주의사항
|
||||||
|
- 파티션 테이블은 글로벌 UNIQUE INDEX 못 만듦 → 파티션 키 포함 UNIQUE 만 가능
|
||||||
|
- ON CONFLICT 사용 시 모든 파티션에 동일 인덱스 필요
|
||||||
|
- Flyway 적용 시 운영 데이터가 있으면 별도 데이터 이관 스크립트 필요 (개발 환경에서는 빈 테이블 가정)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. V15__뷰.sql
|
||||||
|
|
||||||
|
### `v_agent_monthly_summary` — 설계사별 월 실적
|
||||||
|
```sql
|
||||||
|
CREATE OR REPLACE VIEW v_agent_monthly_summary AS
|
||||||
|
SELECT sm.agent_id, a.agent_name, o.org_id, o.org_name, g.grade_name,
|
||||||
|
sm.settle_month,
|
||||||
|
sm.recruit_total, sm.maintain_total,
|
||||||
|
sm.exception_plus, sm.exception_minus,
|
||||||
|
sm.override_total, sm.chargeback_total,
|
||||||
|
sm.gross_amount, sm.tax_amount, sm.net_amount, sm.status
|
||||||
|
FROM settle_master sm
|
||||||
|
JOIN agent a ON a.agent_id = sm.agent_id
|
||||||
|
LEFT JOIN organization o ON o.org_id = a.org_id
|
||||||
|
LEFT JOIN grade g ON g.grade_id = a.grade_id;
|
||||||
|
```
|
||||||
|
|
||||||
|
### `v_org_settle_summary` — 조직별 월 합계
|
||||||
|
```sql
|
||||||
|
CREATE OR REPLACE VIEW v_org_settle_summary AS
|
||||||
|
SELECT a.org_id, o.org_name, o.org_type, sm.settle_month,
|
||||||
|
COUNT(sm.settle_id) AS agent_count,
|
||||||
|
COALESCE(SUM(sm.recruit_total), 0) AS recruit_total,
|
||||||
|
COALESCE(SUM(sm.maintain_total), 0) AS maintain_total,
|
||||||
|
COALESCE(SUM(sm.gross_amount), 0) AS gross_amount,
|
||||||
|
COALESCE(SUM(sm.tax_amount), 0) AS tax_amount,
|
||||||
|
COALESCE(SUM(sm.net_amount), 0) AS net_amount
|
||||||
|
FROM settle_master sm
|
||||||
|
JOIN agent a ON a.agent_id = sm.agent_id
|
||||||
|
JOIN organization o ON o.org_id = a.org_id
|
||||||
|
GROUP BY a.org_id, o.org_name, o.org_type, sm.settle_month;
|
||||||
|
```
|
||||||
|
|
||||||
|
### `v_chargeback_risk` — 환수 위험 계약
|
||||||
|
```sql
|
||||||
|
CREATE OR REPLACE VIEW v_chargeback_risk AS
|
||||||
|
SELECT cb.cb_id, cb.contract_id, cb.agent_id, a.agent_name, o.org_name,
|
||||||
|
cb.policy_no, cb.lapse_month, cb.cb_rate, cb.cb_amount,
|
||||||
|
cb.paid_amount, cb.remain_amount, cb.settle_month, cb.status
|
||||||
|
FROM chargeback cb
|
||||||
|
JOIN agent a ON a.agent_id = cb.agent_id
|
||||||
|
LEFT JOIN organization o ON o.org_id = a.org_id;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Materialized View 후보 (대시보드 차트)
|
||||||
|
- `mv_monthly_total` — 월별 전사 합계 (`REFRESH MATERIALIZED VIEW` 일 1회)
|
||||||
|
- `mv_agent_top10` — 설계사 실적 상위 10
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 테스트 데이터 (V17 — `infra-reviewer` 가 아닌 dba-architect 가 생성)
|
||||||
|
|
||||||
|
| 테이블 | 건수 | 설명 |
|
||||||
|
|---|---:|---|
|
||||||
|
| insurance_company | 5 | SAMSUNG / HANWHA / KYOBO / DB / KB |
|
||||||
|
| product | 20 | 보험사당 4 (생명/건강/저축/연금) |
|
||||||
|
| organization | 7 | 본부 1 / 지점 3 / 팀 3 |
|
||||||
|
| agent | 50 | GM 3 / DM 3 / SMGR 6 / SFC 12 / FC 26 |
|
||||||
|
| contract | 200| ACTIVE 190 / LAPSE 10 |
|
||||||
|
| commission_rate | 100| 상품 × 1~5년차 |
|
||||||
|
| payout_rule | 120| grade × insurance_type × year |
|
||||||
|
| override_rule | 10 | grade pair |
|
||||||
|
| chargeback_rule | 4 | 보험종 × 실효 월수 |
|
||||||
|
|
||||||
|
생성 SQL 패턴:
|
||||||
|
```sql
|
||||||
|
-- 50명 설계사 자동 생성
|
||||||
|
INSERT INTO agent (org_id, grade_id, agent_name, license_no, phone, status, join_date)
|
||||||
|
SELECT
|
||||||
|
-- org/grade 분배
|
||||||
|
CASE WHEN n <= 3 THEN 1 WHEN n <= 6 THEN 2 ... END,
|
||||||
|
CASE WHEN n <= 3 THEN 5 WHEN n <= 6 THEN 4 ... END,
|
||||||
|
'설계사_' || LPAD(n::text, 3, '0'),
|
||||||
|
'L' || LPAD(n::text, 5, '0'),
|
||||||
|
'010-' || LPAD((1000 + n)::text, 4, '0') || '-' || LPAD(n::text, 4, '0'),
|
||||||
|
'ACTIVE',
|
||||||
|
CURRENT_DATE - (n * 30)
|
||||||
|
FROM generate_series(1, 50) n;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. PostgreSQL 설정 권장값 (`postgresql.conf`)
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# 메모리 (8GB 서버 기준)
|
||||||
|
shared_buffers = 2GB # 25%
|
||||||
|
effective_cache_size = 6GB # 75%
|
||||||
|
work_mem = 32MB
|
||||||
|
maintenance_work_mem = 512MB
|
||||||
|
|
||||||
|
# WAL / 체크포인트 (대량 INSERT 대비)
|
||||||
|
wal_buffers = 16MB
|
||||||
|
checkpoint_completion_target = 0.9
|
||||||
|
max_wal_size = 4GB
|
||||||
|
|
||||||
|
# 병렬
|
||||||
|
max_parallel_workers_per_gather = 4
|
||||||
|
max_worker_processes = 8
|
||||||
|
|
||||||
|
# 통계 (쿼리 플래너 정확도)
|
||||||
|
default_statistics_target = 100
|
||||||
|
|
||||||
|
# autovacuum (대용량 로그 테이블 대비)
|
||||||
|
autovacuum_vacuum_scale_factor = 0.05
|
||||||
|
autovacuum_analyze_scale_factor = 0.025
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 대량 배치 쿼리 튜닝
|
||||||
|
|
||||||
|
### MyBatis 배치 INSERT (현재 구현)
|
||||||
|
```xml
|
||||||
|
<insert id="batchInsert" parameterType="list">
|
||||||
|
INSERT INTO recruit_ledger (contract_id, agent_id, policy_no, ...)
|
||||||
|
VALUES
|
||||||
|
<foreach collection="list" item="item" separator=",">
|
||||||
|
(#{item.contractId}, #{item.agentId}, #{item.policyNo}, ...)
|
||||||
|
</foreach>
|
||||||
|
</insert>
|
||||||
|
```
|
||||||
|
- 1000 건씩 끊어서 호출 (ExcelService.importLargeExcel 의 batchSize)
|
||||||
|
- 70만건 INSERT: 약 700 회 호출 → 약 1~2 분 소요
|
||||||
|
|
||||||
|
### COPY 명령 활용 (10만건+ 시 10배 빠름)
|
||||||
|
```java
|
||||||
|
// JDBC CopyManager 직접 사용
|
||||||
|
CopyManager copy = new CopyManager((BaseConnection) conn);
|
||||||
|
copy.copyIn("COPY recruit_ledger FROM STDIN WITH (FORMAT csv)", reader);
|
||||||
|
```
|
||||||
|
- 트랜잭션 격리 / 트리거 / 제약 조건 주의
|
||||||
|
- 향후 70만건 이상 단발성 적재용 옵션으로 추가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 검토 체크리스트 (core-architect 와 협업)
|
||||||
|
|
||||||
|
- [ ] 모든 SELECT 쿼리에 `ORDER BY` + `LIMIT/OFFSET`(또는 PageHelper) 적용
|
||||||
|
- [ ] `LIKE '%키워드%'` 가 자주 쓰이는 컬럼은 `pg_trgm` GIN 인덱스 검토
|
||||||
|
- [ ] JSONB 컬럼 검색은 `jsonb_path_ops` GIN 인덱스
|
||||||
|
- [ ] 날짜 범위 검색은 BTREE 인덱스 + WHERE `created_at >= ...`
|
||||||
|
- [ ] N+1 SELECT 가 발생하지 않도록 ResultMap 의 `<collection>` 검토
|
||||||
|
- [ ] 정산 배치 Step 4/5 의 SELECT 가 `idx_rl_month_agent` 를 쓰는지 EXPLAIN
|
||||||
@@ -0,0 +1,625 @@
|
|||||||
|
# UI 디자인 시스템 (UI_DESIGN.md)
|
||||||
|
|
||||||
|
> GA 수수료 정산 솔루션의 UI 표준. 모든 화면은 이 규칙을 따른다.
|
||||||
|
> 기술: React 18 + Ant Design 5.x + AG Grid Community
|
||||||
|
> 신입 개발자가 복붙으로 일관된 UI를 만들 수 있도록 설계.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ant Design 테마 커스터마이징
|
||||||
|
|
||||||
|
### ConfigProvider 설정 (App.tsx)
|
||||||
|
```tsx
|
||||||
|
import { ConfigProvider } from 'antd';
|
||||||
|
import koKR from 'antd/locale/ko_KR';
|
||||||
|
|
||||||
|
const theme = {
|
||||||
|
token: {
|
||||||
|
// 브랜드 컬러
|
||||||
|
colorPrimary: '#1677FF',
|
||||||
|
colorSuccess: '#0F6E56',
|
||||||
|
colorWarning: '#BA7517',
|
||||||
|
colorError: '#E24B4A',
|
||||||
|
colorInfo: '#1677FF',
|
||||||
|
|
||||||
|
// 폰트
|
||||||
|
fontFamily: "'Pretendard', -apple-system, sans-serif",
|
||||||
|
fontSize: 13,
|
||||||
|
fontSizeHeading1: 22,
|
||||||
|
fontSizeHeading2: 18,
|
||||||
|
fontSizeHeading3: 16,
|
||||||
|
|
||||||
|
// 라운딩
|
||||||
|
borderRadius: 6,
|
||||||
|
borderRadiusLG: 8,
|
||||||
|
|
||||||
|
// 간격
|
||||||
|
padding: 16,
|
||||||
|
paddingLG: 24,
|
||||||
|
margin: 16,
|
||||||
|
|
||||||
|
// 배경
|
||||||
|
colorBgContainer: '#ffffff',
|
||||||
|
colorBgLayout: '#f5f5f5',
|
||||||
|
colorBgElevated: '#ffffff',
|
||||||
|
|
||||||
|
// 테이블
|
||||||
|
tablePaddingHorizontal: 12,
|
||||||
|
tablePaddingVertical: 10,
|
||||||
|
tableHeaderBg: '#fafafa',
|
||||||
|
tableRowHoverBg: '#f5f7fa',
|
||||||
|
tableBorderColor: '#f0f0f0',
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
// 사이드 메뉴
|
||||||
|
Menu: {
|
||||||
|
itemHeight: 40,
|
||||||
|
itemMarginInline: 8,
|
||||||
|
itemBorderRadius: 6,
|
||||||
|
subMenuItemBg: 'transparent',
|
||||||
|
itemSelectedBg: '#e6f1fb',
|
||||||
|
itemSelectedColor: '#185fa5',
|
||||||
|
},
|
||||||
|
// 테이블
|
||||||
|
Table: {
|
||||||
|
headerBg: '#fafafa',
|
||||||
|
rowHoverBg: '#f5f7fa',
|
||||||
|
cellPaddingBlock: 10,
|
||||||
|
cellPaddingInline: 12,
|
||||||
|
headerSplitColor: '#f0f0f0',
|
||||||
|
},
|
||||||
|
// 버튼
|
||||||
|
Button: {
|
||||||
|
controlHeight: 32,
|
||||||
|
controlHeightSM: 28,
|
||||||
|
paddingInline: 16,
|
||||||
|
},
|
||||||
|
// 입력
|
||||||
|
Input: {
|
||||||
|
controlHeight: 32,
|
||||||
|
paddingInline: 10,
|
||||||
|
},
|
||||||
|
Select: {
|
||||||
|
controlHeight: 32,
|
||||||
|
},
|
||||||
|
// 카드
|
||||||
|
Card: {
|
||||||
|
paddingLG: 16,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
<ConfigProvider theme={theme} locale={koKR}>
|
||||||
|
<App />
|
||||||
|
</ConfigProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 폰트 설치 (index.html)
|
||||||
|
```html
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### 아이콘: Tabler Icons
|
||||||
|
```bash
|
||||||
|
pnpm add @tabler/icons-react
|
||||||
|
```
|
||||||
|
```tsx
|
||||||
|
import { IconUsers, IconCalculator, IconDownload } from '@tabler/icons-react';
|
||||||
|
<IconUsers size={16} />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 글로벌 CSS 변수 (styles/variables.css)
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
/* 브랜드 */
|
||||||
|
--ga-primary: #1677FF;
|
||||||
|
--ga-primary-light: #E6F1FB;
|
||||||
|
--ga-primary-dark: #0C447C;
|
||||||
|
|
||||||
|
/* 상태 색상 (CodeBadge 등) */
|
||||||
|
--ga-success: #0F6E56;
|
||||||
|
--ga-success-bg: #E1F5EE;
|
||||||
|
--ga-warning: #BA7517;
|
||||||
|
--ga-warning-bg: #FAEEDA;
|
||||||
|
--ga-danger: #E24B4A;
|
||||||
|
--ga-danger-bg: #FCEBEB;
|
||||||
|
--ga-info: #185FA5;
|
||||||
|
--ga-info-bg: #E6F1FB;
|
||||||
|
|
||||||
|
/* 금액 */
|
||||||
|
--ga-money-plus: #0F6E56;
|
||||||
|
--ga-money-minus: #E24B4A;
|
||||||
|
|
||||||
|
/* 레이아웃 */
|
||||||
|
--ga-sider-width: 220px;
|
||||||
|
--ga-sider-collapsed-width: 64px;
|
||||||
|
--ga-header-height: 48px;
|
||||||
|
--ga-content-padding: 20px;
|
||||||
|
--ga-page-max-width: 1400px;
|
||||||
|
|
||||||
|
/* 간격 단위 */
|
||||||
|
--ga-spacing-xs: 4px;
|
||||||
|
--ga-spacing-sm: 8px;
|
||||||
|
--ga-spacing-md: 12px;
|
||||||
|
--ga-spacing-lg: 16px;
|
||||||
|
--ga-spacing-xl: 24px;
|
||||||
|
|
||||||
|
/* 그림자 (최소한만 사용) */
|
||||||
|
--ga-shadow-card: 0 1px 2px rgba(0,0,0,0.03);
|
||||||
|
--ga-shadow-dropdown: 0 4px 12px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 레이아웃 규칙
|
||||||
|
|
||||||
|
### MainLayout 구조
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ Header (48px) │
|
||||||
|
│ [☰] GA 정산시스템 [2026-05 ▼] [🔔3] [관리자]│
|
||||||
|
├──────┬──────────────────────────────────────┤
|
||||||
|
│Sider │ Content │
|
||||||
|
│220px │ Breadcrumb │
|
||||||
|
│ │ ┌─────────────────────────────────┐ │
|
||||||
|
│ 메뉴 │ │ 페이지 내용 │ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ │ └─────────────────────────────────┘ │
|
||||||
|
└──────┴──────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Header
|
||||||
|
- 높이: 48px, 배경: 흰색, 하단 border
|
||||||
|
- 왼쪽: 햄버거 메뉴(접기/펼치기) + 로고 "GA 정산시스템"
|
||||||
|
- 오른쪽: 정산월 MonthPicker + 알림벨(미읽음 숫자뱃지) + 사용자 Dropdown
|
||||||
|
|
||||||
|
### Sider (사이드 메뉴)
|
||||||
|
- 너비: 220px (접으면 64px, 아이콘만)
|
||||||
|
- 배경: 흰색
|
||||||
|
- 메뉴 아이템: 높이 40px, 좌측 아이콘 16px + 텍스트 13px
|
||||||
|
- 활성 메뉴: 배경 #E6F1FB, 텍스트 #185FA5, font-weight 500
|
||||||
|
- 서브메뉴: 좌측 30px 인덴트
|
||||||
|
- 메뉴 데이터: API에서 동적 로드 (GET /api/menus/my)
|
||||||
|
|
||||||
|
### Content
|
||||||
|
- Breadcrumb: 상단 (홈 > 조직관리 > 설계사목록)
|
||||||
|
- 패딩: 20px
|
||||||
|
- 최대 너비: 1400px (중앙 정렬)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 페이지 표준 레이아웃 (3가지)
|
||||||
|
|
||||||
|
### 타입 A: 목록 페이지 (가장 많이 사용)
|
||||||
|
```
|
||||||
|
┌──── 검색 폼 (SearchForm) ─────────────────┐
|
||||||
|
│ [설계사명 ___] [상태 ▼전체] [기간 ___~___] [조회] [초기화] │
|
||||||
|
└────────────────────────────────────────────┘
|
||||||
|
┌──── 버튼 영역 ─────────────────────────────┐
|
||||||
|
│ [+ 등록] [⬇ 엑셀] │
|
||||||
|
└────────────────────────────────────────────┘
|
||||||
|
┌──── 데이터 테이블 ─────────────────────────┐
|
||||||
|
│ □ 설계사명 소속 직급 수수료합계 상태│
|
||||||
|
│ □ 김영수 서울1팀 수석 12,450,000 위촉│
|
||||||
|
│ □ 이미영 경기2팀 설계사 8,320,000 위촉│
|
||||||
|
├────────────────────────────────────────────┤
|
||||||
|
│ 총 1,234건 ‹ 1 2 3 ... › │
|
||||||
|
└────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
- 검색 폼: 회색 배경(#fafafa), border-radius 8px, padding 12px 16px
|
||||||
|
- 검색 조건: 한 줄에 3~4개, 버튼은 우측
|
||||||
|
- 버튼 영역: 우측 정렬, 테이블 바로 위 margin-bottom 12px
|
||||||
|
- 등록 버튼: Primary(파란색), 엑셀: Default(흰색)
|
||||||
|
- 테이블: antd Table 또는 AG Grid
|
||||||
|
|
||||||
|
### 타입 B: 상세 페이지
|
||||||
|
```
|
||||||
|
┌──── 기본 정보 카드 ─────────────────────────┐
|
||||||
|
│ 설계사명: 김영수 소속: 서울1팀 상태: [위촉] │
|
||||||
|
│ 전화번호: 010-****-5678 이메일: kim@ga.com │
|
||||||
|
│ [수정] [삭제] │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
┌──── 탭 영역 ────────────────────────────────┐
|
||||||
|
│ [기본정보] [실적현황] [소속이력] [변경이력] │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ 탭 내용... │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
- 상단 카드: 흰색 배경, border, border-radius 8px
|
||||||
|
- 탭: antd Tabs, 카드 안에 포함
|
||||||
|
- 버튼: 카드 우상단
|
||||||
|
|
||||||
|
### 타입 C: 대시보드
|
||||||
|
```
|
||||||
|
┌──── 지표 카드 (4열) ───────────────────────┐
|
||||||
|
│ [모집 2.4억] [유지 8,200만] [환수 1,350만] [순 3.1억]│
|
||||||
|
└────────────────────────────────────────────┘
|
||||||
|
┌─── 차트 (2:1) ──────┬─── 현황 ────────────┐
|
||||||
|
│ 월별 추이 (recharts) │ 배치 진행 상태 │
|
||||||
|
│ ▆▆▆▇█ │ ● 수신 완료 │
|
||||||
|
│ │ ● 계산 진행중... │
|
||||||
|
└─────────────────────┴──────────────────────┘
|
||||||
|
┌──── 알림/오류 ──────────────────────────────┐
|
||||||
|
│ [대사불일치] 삼성생명 5월 3건 차이 10분 전 │
|
||||||
|
│ [파싱에러] 한화생명 12행 형식 오류 1시간 전 │
|
||||||
|
└────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
- 지표 카드: 배경 #fafafa, border-radius 8px, padding 12px
|
||||||
|
- 숫자: 20~22px, weight 500
|
||||||
|
- 증감: ▲ 초록(양수), ▼ 빨강(음수)
|
||||||
|
- 차트: recharts, 높이 200~240px
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 공통 컴포넌트 스타일 규칙
|
||||||
|
|
||||||
|
### CodeBadge (상태 뱃지)
|
||||||
|
```tsx
|
||||||
|
// 상태값 → 색상 자동 매핑
|
||||||
|
const COLOR_MAP = {
|
||||||
|
// 초록 (긍정적 완료 상태)
|
||||||
|
CONFIRMED: 'success', APPROVED: 'success', ACTIVE: 'success',
|
||||||
|
PAID: 'success', COMPLETED: 'success', MATCH: 'success',
|
||||||
|
|
||||||
|
// 파랑 (진행 중)
|
||||||
|
CALCULATED: 'info', PENDING: 'info', PROCESSING: 'info',
|
||||||
|
REVIEWED: 'info', NEW: 'info',
|
||||||
|
|
||||||
|
// 주황 (주의)
|
||||||
|
HOLD: 'warning', SUSPEND: 'warning', MISMATCH: 'warning',
|
||||||
|
PARTIAL: 'warning',
|
||||||
|
|
||||||
|
// 빨강 (부정적)
|
||||||
|
REJECTED: 'danger', LEAVE: 'danger', CANCEL: 'danger',
|
||||||
|
FAILED: 'danger', LAPSED: 'danger',
|
||||||
|
|
||||||
|
// 회색 (기본)
|
||||||
|
DRAFT: 'default', READY: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 렌더링
|
||||||
|
<Tag color={COLOR_MAP[value] || 'default'}>{codeName}</Tag>
|
||||||
|
```
|
||||||
|
|
||||||
|
### MoneyText (금액 표시)
|
||||||
|
```tsx
|
||||||
|
// 양수: 초록, 음수: 빨강, 0: 회색
|
||||||
|
// 항상 천 단위 콤마, 정수 표시
|
||||||
|
<span style={{ color: amount > 0 ? '#0F6E56' : amount < 0 ? '#E24B4A' : '#888' }}>
|
||||||
|
{amount >= 0 ? '' : '-'}{Math.abs(amount).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
### SearchForm (검색 폼)
|
||||||
|
```tsx
|
||||||
|
// 스타일 규칙
|
||||||
|
// 배경: #fafafa, border-radius: 8px, padding: 12px 16px
|
||||||
|
// 조건: Row + Col (span 기준)
|
||||||
|
// - 텍스트 입력: span=6 (4개/행)
|
||||||
|
// - Select: span=4 (6개/행까지)
|
||||||
|
// - DateRange: span=8
|
||||||
|
// - 버튼: 우측 정렬
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
background: '#fafafa',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '12px 16px',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}>
|
||||||
|
<Row gutter={[12, 12]} align="bottom">
|
||||||
|
<Col span={6}>
|
||||||
|
<div style={{ fontSize: 12, color: '#999', marginBottom: 4 }}>설계사명</div>
|
||||||
|
<Input placeholder="이름 검색" />
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<div style={{ fontSize: 12, color: '#999', marginBottom: 4 }}>상태</div>
|
||||||
|
<CodeSelect groupCode="AGENT_STATUS" allowClear placeholder="전체" />
|
||||||
|
</Col>
|
||||||
|
<Col span={4} style={{ marginLeft: 'auto' }}>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" icon={<IconSearch size={14}/>}>조회</Button>
|
||||||
|
<Button type="link">초기화</Button>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### DataTable (목록 테이블)
|
||||||
|
```tsx
|
||||||
|
// 스타일 규칙
|
||||||
|
// 행 높이: 42px (compact)
|
||||||
|
// 헤더: 배경 #fafafa, font-weight 500, font-size 12px
|
||||||
|
// 본문: font-size 13px
|
||||||
|
// hover: 배경 #f5f7fa
|
||||||
|
// 금액 컬럼: 우측 정렬, MoneyText
|
||||||
|
// 상태 컬럼: 중앙 정렬, CodeBadge
|
||||||
|
// 이름 컬럼: font-weight 500, 클릭 시 상세 이동
|
||||||
|
// 페이지네이션: 우측, "총 N건" 좌측
|
||||||
|
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowSelection={{ type: 'checkbox' }}
|
||||||
|
pagination={{
|
||||||
|
showTotal: (total) => `총 ${total.toLocaleString()}건`,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: [20, 50, 100],
|
||||||
|
defaultPageSize: 20,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### DataGrid (AG Grid 래퍼)
|
||||||
|
```tsx
|
||||||
|
// AG Grid 공통 설정
|
||||||
|
const defaultColDef = {
|
||||||
|
sortable: true,
|
||||||
|
resizable: true,
|
||||||
|
filter: true,
|
||||||
|
minWidth: 80,
|
||||||
|
};
|
||||||
|
|
||||||
|
const gridOptions = {
|
||||||
|
rowHeight: 36,
|
||||||
|
headerHeight: 38,
|
||||||
|
// 한글 로케일
|
||||||
|
localeText: AG_GRID_LOCALE_KO,
|
||||||
|
// 합계행
|
||||||
|
pinnedBottomRowData: [summaryRow],
|
||||||
|
// 셀 스타일
|
||||||
|
getRowStyle: (params) => {
|
||||||
|
if (params.node.rowPinned) return { background: '#fafafa', fontWeight: 500 };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// AG Grid 사용 화면: 원장관리, 정산결과, 수수료율편집, 대사현황
|
||||||
|
// 나머지는 Ant Design Table
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 등록/수정 폼 규칙
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 폼 레이아웃 규칙
|
||||||
|
// 2열 배치 (Row > Col span=12)
|
||||||
|
// 레이블: 12px, color #999, 필수 표시 빨간 *
|
||||||
|
// 입력: height 32px
|
||||||
|
// Select: 공통코드는 CodeSelect 사용
|
||||||
|
// 하단 버튼: 우측 정렬 [취소] [저장]
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<Form layout="vertical" form={form}>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item label="설계사명" name="agentName" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item label="소속" name="orgId" rules={[{ required: true }]}>
|
||||||
|
<TreeSelect treeData={orgTree} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item label="직급" name="gradeId">
|
||||||
|
<CodeSelect groupCode="GRADE" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item label="상태" name="status">
|
||||||
|
<CodeSelect groupCode="AGENT_STATUS" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<div style={{ textAlign: 'right', marginTop: 16 }}>
|
||||||
|
<Space>
|
||||||
|
<Button onClick={onCancel}>취소</Button>
|
||||||
|
<Button type="primary" onClick={onSubmit}>저장</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 모달/드로워 규칙
|
||||||
|
|
||||||
|
```
|
||||||
|
// 등록/수정 폼 → Drawer (우측에서 슬라이드)
|
||||||
|
// 너비: 600px (2열 폼), 400px (단순 폼)
|
||||||
|
// 제목: "설계사 등록" / "설계사 수정"
|
||||||
|
// 하단: 취소 + 저장 버튼
|
||||||
|
|
||||||
|
// 상세 보기 → Modal
|
||||||
|
// 너비: 720px
|
||||||
|
// Descriptions 컴포넌트로 상세 표시
|
||||||
|
|
||||||
|
// 확인/경고 → Modal.confirm()
|
||||||
|
// "정산을 확정하시겠습니까?" [취소] [확정]
|
||||||
|
|
||||||
|
// 삭제 → Modal.confirm({ type: 'warning' })
|
||||||
|
// "삭제하면 복구할 수 없습니다" [취소] [삭제]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 화면별 상세 와이어프레임
|
||||||
|
|
||||||
|
### 8.1 대시보드 (/)
|
||||||
|
```
|
||||||
|
레이아웃: 타입 C (대시보드)
|
||||||
|
├── 지표 카드 4열: 모집수수료, 유지수수료, 환수금, 순지급액
|
||||||
|
│ - 이번 달 금액 (억/만 단위 자동)
|
||||||
|
│ - 전월 대비 증감률 (▲초록/▼빨강)
|
||||||
|
│ - 카드 클릭 → 해당 원장 페이지 이동
|
||||||
|
├── 2열 영역
|
||||||
|
│ ├── 왼쪽(60%): 월별 추이 차트 (recharts BarChart)
|
||||||
|
│ │ - X축: 최근 6개월, Y축: 금액
|
||||||
|
│ │ - 시리즈: 모집(파랑), 유지(초록), 환수(빨강)
|
||||||
|
│ └── 오른쪽(40%): 배치 현황 카드
|
||||||
|
│ - Step별 상태: ● 완료(초록), ● 진행중(파랑+pulse), ○ 대기(회색)
|
||||||
|
│ - 각 Step: 이름 + 완료시간/진행시간
|
||||||
|
│ - 에러 시: ● 빨강 + 에러 메시지
|
||||||
|
└── 알림/오류 패널
|
||||||
|
- 대사 불일치, 파싱 에러, 승인 대기 등
|
||||||
|
- 뱃지 색상: 에러=빨강, 경고=주황, 정보=파랑
|
||||||
|
- 클릭 → 해당 화면 이동
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 조직관리 (/org)
|
||||||
|
```
|
||||||
|
레이아웃: 2열 (좌 300px 트리 + 우 나머지 목록)
|
||||||
|
├── 좌측: 조직 트리 (antd Tree)
|
||||||
|
│ - 본사 > 지역본부 > 지점 > 팀
|
||||||
|
│ - 드래그앤드롭 정렬 (관리자만)
|
||||||
|
│ - 노드 클릭 → 우측 설계사 목록 필터링
|
||||||
|
│ - 우클릭 → 추가/수정/삭제 컨텍스트메뉴
|
||||||
|
└── 우측: 설계사 목록 (타입 A)
|
||||||
|
- 검색: 이름, 상태, 직급
|
||||||
|
- 테이블 컬럼: 설계사명, 직급, 상태, 위촉일, 수수료합계
|
||||||
|
- 행 클릭 → 상세 (타입 B, 탭: 기본정보/실적/이력)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 원장관리 (/ledger)
|
||||||
|
```
|
||||||
|
레이아웃: 탭 3개 (모집/유지/예외)
|
||||||
|
├── 공통 검색: 정산월, 보험사, 설계사, 상태
|
||||||
|
├── AG Grid (DataGrid) 사용 ← 대량 데이터
|
||||||
|
│ - 서버사이드 페이징/정렬/필터
|
||||||
|
│ - 컬럼 고정: 왼쪽(증권번호, 설계사명), 오른쪽(수수료합계)
|
||||||
|
│ - 합계행: 하단 고정 (pinnedBottomRow)
|
||||||
|
│ - 금액 셀: 우측 정렬, MoneyText 스타일
|
||||||
|
│ - 상태 셀: CodeBadge
|
||||||
|
├── 예외금액 탭 추가 기능:
|
||||||
|
│ - [+ 등록] → Drawer (예외유형, 금액, 방향, 설명)
|
||||||
|
│ - 승인 워크플로우: 작성→대기→승인/반려
|
||||||
|
│ - 승인 버튼: PermissionButton(permCode="APPROVE")
|
||||||
|
└── 엑셀 다운로드: ExcelExportButton (100만건, 진행률)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.4 정산관리 (/settle)
|
||||||
|
```
|
||||||
|
레이아웃: 탭 4개 (배치실행/정산결과/정산상세/대사)
|
||||||
|
|
||||||
|
[배치실행 탭]
|
||||||
|
├── 정산월 선택 + [정산실행] 버튼
|
||||||
|
├── 진행 상태: Steps 컴포넌트 (8단계)
|
||||||
|
│ - 각 Step: 이름 + 처리건수 + 소요시간 + 상태아이콘
|
||||||
|
│ - 진행 중: 폴링으로 실시간 업데이트 (3초 간격)
|
||||||
|
│ - 에러: Step 클릭 → 에러 상세 모달
|
||||||
|
└── 완료 시: 결과 요약 카드 (총건수, 성공, 에러, 소요시간)
|
||||||
|
|
||||||
|
[정산결과 탭]
|
||||||
|
├── AG Grid: 설계사별 정산 결과
|
||||||
|
│ - 컬럼: 설계사명, 소속, 모집, 유지, 예외가산, 예외차감, 오버라이드, 환수, 세전, 세금, 순지급
|
||||||
|
│ - 합계행
|
||||||
|
│ - 상태별 필터: 계산완료/검토/확정/지급/보류
|
||||||
|
│ - 행 클릭 → 정산상세 탭으로 이동
|
||||||
|
└── 버튼: [일괄확정] [엑셀다운로드]
|
||||||
|
|
||||||
|
[정산상세 탭]
|
||||||
|
├── 설계사 선택 (상단)
|
||||||
|
├── 요약 카드 4열: 모집합계, 유지합계, 예외합계, 순지급액
|
||||||
|
└── 탭 5개: 모집수수료 / 유지수수료 / 예외금액 / 오버라이드 / 환수
|
||||||
|
- 각 탭 AG Grid로 상세 내역 표시
|
||||||
|
|
||||||
|
[대사 탭]
|
||||||
|
├── 보험사별 대사 결과 테이블
|
||||||
|
│ - 보험사, 보험사건수, 시스템건수, 차이, 보험사금액, 시스템금액, 차이, 상태
|
||||||
|
│ - 상태: MATCH(초록), MISMATCH(빨강)
|
||||||
|
└── 불일치 행 클릭 → 상세 모달 (건별 차이 목록)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.5 수수료규정 (/rules)
|
||||||
|
```
|
||||||
|
레이아웃: 탭 4개 (수수료율/지급률/오버라이드/환수)
|
||||||
|
|
||||||
|
각 탭 공통:
|
||||||
|
├── 검색: 보험사/보험종류/적용기간
|
||||||
|
├── AG Grid: 셀 편집 가능 (인라인 수정)
|
||||||
|
│ - 수정된 셀: 노란색 배경 강조
|
||||||
|
│ - 하단: [저장] [취소] 버튼
|
||||||
|
├── 버전 관리:
|
||||||
|
│ - 규정 수정 시 → version 자동 증가 + 기존 규정 effective_to 설정
|
||||||
|
│ - [이력보기] 버튼 → 버전별 변경 내역 타임라인
|
||||||
|
└── 신규 등록: [+ 규정 등록] → Drawer
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.6 시스템관리 (/system)
|
||||||
|
|
||||||
|
#### 역할관리 (/system/roles)
|
||||||
|
```
|
||||||
|
├── 역할 목록 테이블
|
||||||
|
└── 역할 클릭 → 권한 매트릭스
|
||||||
|
┌──────────┬──────┬──────┬──────┬──────┬──────┬──────┐
|
||||||
|
│ 메뉴 │ 조회 │ 등록 │ 수정 │ 삭제 │ 승인 │ 엑셀 │
|
||||||
|
├──────────┼──────┼──────┼──────┼──────┼──────┼──────┤
|
||||||
|
│ 조직관리 │ ☑ │ ☑ │ ☑ │ ☐ │ ☐ │ ☑ │
|
||||||
|
│ 정산관리 │ ☑ │ ☐ │ ☐ │ ☐ │ ☑ │ ☑ │
|
||||||
|
│ 시스템관리 │ ☑ │ ☑ │ ☑ │ ☑ │ ☐ │ ☐ │
|
||||||
|
└──────────┴──────┴──────┴──────┴──────┴──────┴──────┘
|
||||||
|
[전체선택/해제 토글] [저장]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 공통코드관리 (/system/codes)
|
||||||
|
```
|
||||||
|
2열 레이아웃:
|
||||||
|
├── 좌측 (300px): 코드 그룹 리스트
|
||||||
|
│ - 선택 시 우측에 상세 코드 표시
|
||||||
|
│ - 시스템 코드: 🔒 아이콘 + 수정 불가
|
||||||
|
└── 우측: 상세 코드 테이블
|
||||||
|
- 코드값, 코드명, 영문명, 정렬순서, 활성여부
|
||||||
|
- 인라인 편집 가능 (시스템코드 제외)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 업로드 템플릿 관리 (/system/upload-templates)
|
||||||
|
```
|
||||||
|
2열 레이아웃:
|
||||||
|
├── 좌측 (300px): 템플릿 목록 (카테고리별 필터)
|
||||||
|
└── 우측: 컬럼 매핑 편집기
|
||||||
|
- 각 행: [엑셀 컬럼 입력] → [DB 필드 Select] → [변환규칙 Select] → [파라미터]
|
||||||
|
- 드래그앤드롭 순서 변경
|
||||||
|
- [+ 행 추가] [미리보기] [저장]
|
||||||
|
- 미리보기: 샘플 엑셀 업로드 → 첫 5행 매핑 결과 테이블
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 반응형 규칙
|
||||||
|
|
||||||
|
```
|
||||||
|
// 이 솔루션은 데스크톱 전용 (1280px 이상)
|
||||||
|
// 모바일 대응 불필요 (사내 관리 시스템)
|
||||||
|
// 최소 해상도: 1280 x 720
|
||||||
|
// 권장 해상도: 1920 x 1080
|
||||||
|
|
||||||
|
// 1280px 미만: Sider 자동 접힘 (아이콘만)
|
||||||
|
// 1920px 이상: Content 최대 너비 1400px 중앙 정렬
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 신입 개발자 UI 체크리스트
|
||||||
|
|
||||||
|
새 화면 만들 때 아래 항목 확인:
|
||||||
|
- [ ] 검색 폼: SearchForm 컴포넌트 사용, 배경 #fafafa
|
||||||
|
- [ ] 목록: 단순이면 DataTable, 대량/편집이면 DataGrid
|
||||||
|
- [ ] 상태 컬럼: CodeBadge 사용 (하드코딩 색상 금지)
|
||||||
|
- [ ] 금액 컬럼: MoneyText 사용 (양수 초록, 음수 빨강)
|
||||||
|
- [ ] Select: CodeSelect 사용 (공통코드 연동)
|
||||||
|
- [ ] 버튼: PermissionButton 사용 (권한 제어)
|
||||||
|
- [ ] 엑셀: ExcelExportButton 사용
|
||||||
|
- [ ] 등록/수정: Drawer 사용 (모달 아님)
|
||||||
|
- [ ] 삭제: Modal.confirm 사용
|
||||||
|
- [ ] 폼: antd Form + 2열(Col span=12) + layout="vertical"
|
||||||
|
- [ ] 금액 입력: InputNumber + formatter (천단위 콤마)
|
||||||
|
- [ ] 날짜: DatePicker + dayjs
|
||||||
|
- [ ] 정산월: MonthPicker 공통 컴포넌트
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
# P7 — incar 갭 반영: 수수료 계산 7개 도메인
|
||||||
|
|
||||||
|
작성: PL (자율 모드), 2026-05-31
|
||||||
|
근거: incar CMS 116메뉴 vs ga-pro 96메뉴 갭 분석. 사용자 지시 "필요한 메뉴 적용 + 수수료 공식 다 적용".
|
||||||
|
범위: 🅰 수수료 계산 7개 도메인 풀스택 (DB→Core→API→Frontend→Infra).
|
||||||
|
|
||||||
|
기존 패턴 100% 준수 (template = 수금수수료 V41 / CollectionCommission*). 모든 금액 `NUMERIC(15,2)`, 율 `NUMERIC(7,4)`(소수, 0~1), 반올림 HALF_UP.
|
||||||
|
|
||||||
|
Flyway: **V87이 최신 → V88부터.** 각 도메인은 rule(+) ledger 2테이블 패턴. 메뉴는 GRP_COMMISSION 하위.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 도메인별 설계
|
||||||
|
|
||||||
|
### D1. 수입수수료 + 수지차 (INCOME_COMM) — V88
|
||||||
|
보험사가 GA에 지급한 **수입** 측. 기존 원장(LEDGER_*)은 설계사 **지급** 측. 수지차 = 수입 − 지급.
|
||||||
|
|
||||||
|
**income_commission_ledger** (수입수수료 원장)
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| income_id | BIGSERIAL PK | |
|
||||||
|
| contract_id | BIGINT NN FK contract | |
|
||||||
|
| carrier_id | BIGINT NN FK insurance_company | 보험사 |
|
||||||
|
| settle_month | CHAR(6) NN | |
|
||||||
|
| premium_amount | NUMERIC(15,2) NN | 보험료 |
|
||||||
|
| company_rate | NUMERIC(7,4) NN | 보험사요율 |
|
||||||
|
| income_amount | NUMERIC(15,2) NN | **= premium_amount × company_rate** |
|
||||||
|
| commission_type | VARCHAR(20) NN | RECRUIT/MAINTAIN 등 |
|
||||||
|
| status | VARCHAR(20) NN DEFAULT 'CALCULATED' | CALCULATED/CONFIRMED/CANCELLED |
|
||||||
|
| created_at/updated_at | | |
|
||||||
|
idx: (carrier_id, settle_month), (contract_id), (status, settle_month)
|
||||||
|
|
||||||
|
**margin_summary** (수지차 집계 — recalc 액션으로 채움)
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| margin_id | BIGSERIAL PK | |
|
||||||
|
| settle_month | CHAR(6) NN | |
|
||||||
|
| agent_id | BIGINT FK agent (NULL이면 전체) | |
|
||||||
|
| income_total | NUMERIC(15,2) NN DEFAULT 0 | 수입 합 |
|
||||||
|
| payout_total | NUMERIC(15,2) NN DEFAULT 0 | 지급 합(settle_master 기준) |
|
||||||
|
| margin_amount | NUMERIC(15,2) NN DEFAULT 0 | **= income_total − payout_total** |
|
||||||
|
| margin_rate | NUMERIC(7,4) | = margin/income (income>0) |
|
||||||
|
| created_at | | |
|
||||||
|
UNIQUE(settle_month, agent_id)
|
||||||
|
|
||||||
|
**공식**: income_amount = premium × company_rate. margin = Σincome − Σpayout.
|
||||||
|
|
||||||
|
### D2. 소개·이관수수료 (REFERRAL_COMM) — V89
|
||||||
|
계약을 소개(REFERRAL)하거나 이관(TRANSFER)한 설계사에게 주는 수수료.
|
||||||
|
|
||||||
|
**referral_commission_rule**
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| rule_id | BIGSERIAL PK | |
|
||||||
|
| referral_type | VARCHAR(20) NN | REFERRAL/TRANSFER |
|
||||||
|
| calc_type | VARCHAR(10) NN | RATE/FIXED |
|
||||||
|
| commission_rate | NUMERIC(7,4) | calc_type=RATE 일 때 |
|
||||||
|
| fixed_amount | NUMERIC(15,2) | calc_type=FIXED 일 때 |
|
||||||
|
| effective_from DATE NN / effective_to DATE | | |
|
||||||
|
CHECK referral_type IN ('REFERRAL','TRANSFER'); CHECK calc_type IN ('RATE','FIXED')
|
||||||
|
|
||||||
|
**referral_commission_ledger**
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| ledger_id | BIGSERIAL PK | |
|
||||||
|
| contract_id | BIGINT NN FK | |
|
||||||
|
| referrer_agent_id | BIGINT NN FK agent | 소개/이관한 설계사 |
|
||||||
|
| settle_month | CHAR(6) NN | |
|
||||||
|
| referral_type | VARCHAR(20) NN | |
|
||||||
|
| base_amount | NUMERIC(15,2) NN | 기준금액(수수료/보험료) |
|
||||||
|
| commission_rate | NUMERIC(7,4) | |
|
||||||
|
| commission_amount | NUMERIC(15,2) NN | **= base×rate (RATE) 또는 fixed_amount (FIXED)** |
|
||||||
|
| status | VARCHAR(20) NN DEFAULT 'CALCULATED' | |
|
||||||
|
idx: (referrer_agent_id, settle_month),(contract_id)
|
||||||
|
|
||||||
|
### D3. 정착지원금 (SETTLING_SUPPORT) — V90
|
||||||
|
신입/경력 설계사 정착 지원금. 등급별 정액 지급 + 조기 해촉 시 환수.
|
||||||
|
|
||||||
|
**settling_support_rule**
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| rule_id BIGSERIAL PK | | |
|
||||||
|
| agent_grade | VARCHAR(20) NN | 설계사 등급 |
|
||||||
|
| support_amount | NUMERIC(15,2) NN | 월 정액 지원금 |
|
||||||
|
| duration_months | INT NN | 약정 지원 개월수 |
|
||||||
|
| clawback_months | INT NN | 이 개월 내 해촉 시 환수 대상 |
|
||||||
|
| effective_from DATE NN / effective_to DATE | | |
|
||||||
|
|
||||||
|
**settling_support_ledger**
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| ledger_id BIGSERIAL PK | | |
|
||||||
|
| agent_id BIGINT NN FK agent | | |
|
||||||
|
| settle_month CHAR(6) NN | | |
|
||||||
|
| support_amount NUMERIC(15,2) NN | 지급액 | |
|
||||||
|
| clawback_amount NUMERIC(15,2) NN DEFAULT 0 | 환수액 | |
|
||||||
|
| net_amount NUMERIC(15,2) NN | **= support_amount − clawback_amount** | |
|
||||||
|
| status VARCHAR(20) NN DEFAULT 'CALCULATED' | PAID/CLAWED_BACK 포함 | |
|
||||||
|
idx: (agent_id, settle_month)
|
||||||
|
|
||||||
|
**공식**: 지급 = 등급별 support_amount. 환수 = 조기해촉시 support_amount × (잔여약정개월/약정개월) (clawback service 계산).
|
||||||
|
|
||||||
|
### D4. 인정실적 + 환산보험료 (RECOGNIZED_PERF) — V91
|
||||||
|
환산보험료 = 보험료 × 상품별환산율. 인정실적 = 환산보험료 × 인정율. 시상/등급의 기초.
|
||||||
|
|
||||||
|
**product_conversion_rate** (상품별 환산율)
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| rate_id BIGSERIAL PK | | |
|
||||||
|
| product_id BIGINT NN FK product | | |
|
||||||
|
| conversion_rate NUMERIC(7,4) NN | 환산율 (예: 0.5=50%) | |
|
||||||
|
| effective_from DATE NN / effective_to DATE | | |
|
||||||
|
UNIQUE(product_id, effective_from)
|
||||||
|
|
||||||
|
**recognized_performance_ledger**
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| ledger_id BIGSERIAL PK | | |
|
||||||
|
| agent_id BIGINT NN FK agent | | |
|
||||||
|
| contract_id BIGINT NN FK contract | | |
|
||||||
|
| settle_month CHAR(6) NN | | |
|
||||||
|
| premium_amount NUMERIC(15,2) NN | 보험료 | |
|
||||||
|
| conversion_rate NUMERIC(7,4) NN | 적용 환산율 | |
|
||||||
|
| converted_premium NUMERIC(15,2) NN | **= premium × conversion_rate** | |
|
||||||
|
| recognition_rate NUMERIC(7,4) NN | 인정율 | |
|
||||||
|
| recognized_amount NUMERIC(15,2) NN | **= converted_premium × recognition_rate** | |
|
||||||
|
| status VARCHAR(20) NN DEFAULT 'CALCULATED' | | |
|
||||||
|
idx: (agent_id, settle_month),(contract_id)
|
||||||
|
|
||||||
|
### D5. 시상 (AWARD) — V92
|
||||||
|
신인도입/법인/유지율 등 시상. 조건 충족 시 정액 또는 정률.
|
||||||
|
|
||||||
|
**award_rule**
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| rule_id BIGSERIAL PK | | |
|
||||||
|
| award_type VARCHAR(20) NN | NEW_RECRUIT/CORPORATE/RETENTION/PERFORMANCE | |
|
||||||
|
| calc_type VARCHAR(10) NN | RATE/FIXED | |
|
||||||
|
| threshold_amount NUMERIC(15,2) | 충족 기준 실적(이상) | |
|
||||||
|
| award_rate NUMERIC(7,4) | RATE | |
|
||||||
|
| award_fixed NUMERIC(15,2) | FIXED | |
|
||||||
|
| effective_from DATE NN / effective_to DATE | | |
|
||||||
|
CHECK award_type IN (...); CHECK calc_type IN ('RATE','FIXED')
|
||||||
|
|
||||||
|
**award_ledger**
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| ledger_id BIGSERIAL PK | | |
|
||||||
|
| agent_id BIGINT NN FK agent | | |
|
||||||
|
| settle_month CHAR(6) NN | | |
|
||||||
|
| award_type VARCHAR(20) NN | | |
|
||||||
|
| base_amount NUMERIC(15,2) NN | 기준 실적 | |
|
||||||
|
| award_amount NUMERIC(15,2) NN | **= base×award_rate (RATE) 또는 award_fixed (FIXED)** | |
|
||||||
|
| status VARCHAR(20) NN DEFAULT 'CALCULATED' | | |
|
||||||
|
idx: (agent_id, settle_month),(award_type, settle_month)
|
||||||
|
|
||||||
|
### D6. 생보운영지원수수료 (LIFE_OP_SUPPORT) — V93
|
||||||
|
생보 계약 운영지원 수수료. 실적 기준 지급률 + 환수.
|
||||||
|
|
||||||
|
**life_support_rule**
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| rule_id BIGSERIAL PK | | |
|
||||||
|
| agent_grade VARCHAR(20) NN | | |
|
||||||
|
| support_rate NUMERIC(7,4) NN | 지급률 | |
|
||||||
|
| effective_from DATE NN / effective_to DATE | | |
|
||||||
|
|
||||||
|
**life_support_ledger**
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| ledger_id BIGSERIAL PK | | |
|
||||||
|
| agent_id BIGINT NN FK agent | | |
|
||||||
|
| settle_month CHAR(6) NN | | |
|
||||||
|
| base_amount NUMERIC(15,2) NN | 생보 실적 | |
|
||||||
|
| support_rate NUMERIC(7,4) NN | | |
|
||||||
|
| support_amount NUMERIC(15,2) NN | **= base × support_rate** | |
|
||||||
|
| clawback_amount NUMERIC(15,2) NN DEFAULT 0 | 환수 | |
|
||||||
|
| net_amount NUMERIC(15,2) NN | = support − clawback | |
|
||||||
|
| status VARCHAR(20) NN DEFAULT 'CALCULATED' | | |
|
||||||
|
idx: (agent_id, settle_month)
|
||||||
|
|
||||||
|
### D7. 지점장수당 (BRANCH_MGR_ALLOWANCE) — V94
|
||||||
|
지점장이 소속 조직 실적에 대해 받는 수당. cap 제한 + 환수.
|
||||||
|
|
||||||
|
**branch_mgr_allowance_rule**
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| rule_id BIGSERIAL PK | | |
|
||||||
|
| position_code VARCHAR(20) NN | 직책(지점장 등) | |
|
||||||
|
| allowance_rate NUMERIC(7,4) NN | 수당율 | |
|
||||||
|
| cap_amount NUMERIC(15,2) | 상한(NULL=무제한) | |
|
||||||
|
| effective_from DATE NN / effective_to DATE | | |
|
||||||
|
|
||||||
|
**branch_mgr_allowance_ledger**
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| ledger_id BIGSERIAL PK | | |
|
||||||
|
| manager_agent_id BIGINT NN FK agent | 지점장 | |
|
||||||
|
| org_id BIGINT NN FK organization | 대상 조직 | |
|
||||||
|
| settle_month CHAR(6) NN | | |
|
||||||
|
| base_amount NUMERIC(15,2) NN | 조직 실적 | |
|
||||||
|
| allowance_rate NUMERIC(7,4) NN | | |
|
||||||
|
| allowance_amount NUMERIC(15,2) NN | **= LEAST(base×rate, cap)** | |
|
||||||
|
| clawback_amount NUMERIC(15,2) NN DEFAULT 0 | | |
|
||||||
|
| status VARCHAR(20) NN DEFAULT 'CALCULATED' | | |
|
||||||
|
idx: (manager_agent_id, settle_month),(org_id, settle_month)
|
||||||
|
|
||||||
|
> **확정 FK (DB 실검증):** contract(contract_id) · agent(agent_id) · product(product_id) · organization(org_id) · **insurance_company(company_id)** · settle_master. 보험사 FK는 `carrier_id BIGINT REFERENCES insurance_company(company_id)`. 수지차 payout은 settle_master 집계로 산출(dba가 컬럼 확인).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 메뉴 (V95) — GRP_COMMISSION 하위 7개 PAGE
|
||||||
|
| menu_code | menu_name | menu_path | component_path | sort |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| INCOME_COMM | 수입수수료/수지차 | /commission/income | commission/IncomeCommission | 80 |
|
||||||
|
| REFERRAL_COMM | 소개·이관수수료 | /commission/referral | commission/ReferralCommission | 90 |
|
||||||
|
| SETTLING_SUPPORT | 정착지원금 | /commission/settling-support | commission/SettlingSupport | 100 |
|
||||||
|
| RECOGNIZED_PERF | 인정실적 | /commission/recognized | commission/RecognizedPerformance | 110 |
|
||||||
|
| AWARD | 시상 | /commission/award | commission/Award | 120 |
|
||||||
|
| LIFE_OP_SUPPORT | 생보운영지원수수료 | /commission/life-support | commission/LifeSupport | 130 |
|
||||||
|
| BRANCH_MGR_ALLOWANCE | 지점장수당 | /commission/branch-allowance | commission/BranchAllowance | 140 |
|
||||||
|
|
||||||
|
권한: READ/CREATE/UPDATE/DELETE/EXPORT. 역할부여: SUPER_ADMIN/ADMIN 전체, MANAGER READ/CREATE/UPDATE/EXPORT, AGENT READ(INCOME_COMM 제외 — 본인 관련만). V54 패턴 그대로.
|
||||||
|
|
||||||
|
## 공통코드 (V96)
|
||||||
|
| group_code | 코드 |
|
||||||
|
|---|---|
|
||||||
|
| INCOME_STATUS | CALCULATED/CONFIRMED/CANCELLED |
|
||||||
|
| REFERRAL_TYPE | REFERRAL=소개/TRANSFER=이관 |
|
||||||
|
| CALC_TYPE | RATE=정률/FIXED=정액 |
|
||||||
|
| SUPPORT_STATUS | CALCULATED/PAID/CLAWED_BACK |
|
||||||
|
| AWARD_TYPE | NEW_RECRUIT=신인도입/CORPORATE=법인/RETENTION=유지율/PERFORMANCE=실적 |
|
||||||
|
|
||||||
|
CommissionType enum 확장: INCOME/REFERRAL/TRANSFER/SETTLING/RECOGNIZED/AWARD/LIFE_SUPPORT/BRANCH_ALLOWANCE 추가 + V40 COMMISSION_TYPE 공통코드 동기화.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wave 실행
|
||||||
|
| Wave | 담당 | 산출물 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | dba-architect | V88~V96 (도메인 7 + 메뉴 + 공통코드). FK 테이블명 실검증. |
|
||||||
|
| 2 | core-architect | 14테이블 VO/Resp/SaveReq/SearchParam + Mapper IF+XML + CommissionType enum 확장 |
|
||||||
|
| 3 | api-developer | 7 Service + 7 Controller (@RequirePermission/@DataChangeLog, 공식 계산 Service 내장) |
|
||||||
|
| 4 | frontend-developer | 7 페이지 + api/commission.ts 확장 + App.tsx 라우트 |
|
||||||
|
| 5 | infra-reviewer | `./gradlew build` + 프론트 빌드 + smoke. README/HANDOFF/DOMAIN_KNOWLEDGE 갱신 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 완료 상태 (2026-05-31)
|
||||||
|
schema **v96** 적용(Flyway V88~V96 success), 전체 `./gradlew build` SUCCESSFUL, 프론트 빌드 OK, ga-api 8082 라이브. P7 14개 GET + 수지차 recalc POST 전수 호출 5xx 0건. 수입수수료(보험료×요율=6,000,000)·수지차(수입−지급) 공식 런타임 실측 검증 후 테스트데이터 정리.
|
||||||
|
|
||||||
|
**PL 통합검증에서 잡은 버그 3건(팀원 GET-only smoke가 놓침) — 수정 완료:**
|
||||||
|
1. 프론트 인정실적 경로 `/rules` → 백엔드 `/rates` 불일치 → `api/commission.ts` 수정.
|
||||||
|
2. 프론트 수지차 경로 `/margin` → 백엔드 `/margins` 불일치 → 수정.
|
||||||
|
3. 수지차 recalc 3중 결함: 컨트롤러 `@RequestBody`(프론트는 쿼리파라미터)→`@RequestParam`, 서비스 `payoutTotal=0` 하드코딩 → settle_master.net_amount 실집계(`sumPayoutByMonth`)+`sumIncomeByMonth` 신규, `selectByMonthAndAgent`의 `#{agentId} IS NULL` Postgres typed-null 오류 → `<choose>`로 교체.
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# P8 — incar 갭 반영: 운영/정산 프로세스 7개 도메인 (🅱)
|
||||||
|
|
||||||
|
작성: PL (자율 모드), 2026-05-31. 사용자 지시 "다 적용". P7(🅰 수수료계산 7)에 이어 🅱 운영 7개로 incar 갭 14개 완성.
|
||||||
|
범위: 풀스택(DB→Core→API→Frontend→Infra). 패턴 100% 준수(template = 수금수수료 V41 / CollectionCommission*, P7과 동일).
|
||||||
|
Flyway: **V96이 최신 → V97부터.** 메뉴는 적절한 상위그룹 하위. 금액 NUMERIC(15,2), 율 NUMERIC(7,4), HALF_UP.
|
||||||
|
|
||||||
|
**확정 FK**: agent(agent_id), contract(contract_id), organization(org_id), insurance_company(company_id), grade(grade_id), settle_master.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 도메인별 설계
|
||||||
|
|
||||||
|
### D8. 단계마감 (STEP_CLOSE) — V97
|
||||||
|
월 정산을 단계별로 마감(수신→계산→검증→지급). 기존 PERIOD_CLOSE(단일 잠금) 보강.
|
||||||
|
|
||||||
|
**settlement_step_close**
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| step_close_id | BIGSERIAL PK | |
|
||||||
|
| settle_month | CHAR(6) NN | |
|
||||||
|
| step_code | VARCHAR(20) NN | RECEIVE/CALC/VERIFY/PAY |
|
||||||
|
| step_seq | INT NN | 단계 순서 1~4 |
|
||||||
|
| status | VARCHAR(20) NN DEFAULT 'OPEN' | OPEN/CLOSED |
|
||||||
|
| closed_at TIMESTAMP / closed_by BIGINT / created_at | | |
|
||||||
|
UNIQUE(settle_month, step_code); CHECK step_code IN(...); CHECK status IN('OPEN','CLOSED')
|
||||||
|
공식 없음(워크플로우). 액션: close(단계마감), reopen.
|
||||||
|
|
||||||
|
### D9. 본사정산 (HQ_SETTLE) — V98
|
||||||
|
GA 본사 자체의 수입/비용 정산 항목(설계사 지급과 별개의 GA 손익).
|
||||||
|
|
||||||
|
**hq_settle_item**
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| item_id | BIGSERIAL PK | |
|
||||||
|
| settle_month | CHAR(6) NN | |
|
||||||
|
| item_type | VARCHAR(20) NN | INCOME(수입)/EXPENSE(비용) |
|
||||||
|
| item_name | VARCHAR(100) NN | 항목명 |
|
||||||
|
| amount | NUMERIC(15,2) NN | 금액 |
|
||||||
|
| memo | VARCHAR(300) | |
|
||||||
|
| status | VARCHAR(20) NN DEFAULT 'REGISTERED' | REGISTERED/CONFIRMED |
|
||||||
|
| created_at/created_by/updated_at | | |
|
||||||
|
idx: (settle_month, item_type). CHECK item_type IN('INCOME','EXPENSE')
|
||||||
|
공식: 본사손익 = Σ(INCOME amount) − Σ(EXPENSE amount). 조회 집계 엔드포인트 제공.
|
||||||
|
|
||||||
|
### D10. 등급평가 (GRADE_EVAL) — V99
|
||||||
|
설계사 실적 점수로 등급 산정. rule(점수구간→등급) + 평가원장.
|
||||||
|
|
||||||
|
**grade_eval_rule**
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| rule_id BIGSERIAL PK | | |
|
||||||
|
| grade_id | BIGINT NN FK grade | 부여 등급 |
|
||||||
|
| min_score | NUMERIC(7,2) NN | 이 점수 이상이면 해당 등급 |
|
||||||
|
| effective_from DATE NN / effective_to DATE | | |
|
||||||
|
|
||||||
|
**agent_grade_evaluation**
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| eval_id BIGSERIAL PK | | |
|
||||||
|
| agent_id BIGINT NN FK agent | | |
|
||||||
|
| eval_period CHAR(6) NN | 평가기준월 | |
|
||||||
|
| performance_score NUMERIC(7,2) NN | 실적점수 | |
|
||||||
|
| prev_grade_id BIGINT FK grade | 기존 등급 | |
|
||||||
|
| evaluated_grade_id BIGINT FK grade | 산정 등급 | |
|
||||||
|
| status VARCHAR(20) NN DEFAULT 'EVALUATED' | EVALUATED/APPLIED | |
|
||||||
|
idx: (agent_id, eval_period). UNIQUE(agent_id, eval_period)
|
||||||
|
공식: evaluated_grade = score 이상인 최대 min_score 룰의 grade. Service가 grade_eval_rule 조회해 결정.
|
||||||
|
|
||||||
|
### D11. 공동계약 (JOINT_CONTRACT) — V100
|
||||||
|
한 계약을 여러 설계사가 공동 모집 → 지분율(share_rate)로 수수료 배분.
|
||||||
|
|
||||||
|
**joint_contract**
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| joint_id BIGSERIAL PK | | |
|
||||||
|
| contract_id BIGINT NN FK contract | | |
|
||||||
|
| agent_id BIGINT NN FK agent | | |
|
||||||
|
| role VARCHAR(10) NN | LEAD/SUB | |
|
||||||
|
| share_rate NUMERIC(7,4) NN | 지분율(합=1) | |
|
||||||
|
| created_at/created_by | | |
|
||||||
|
UNIQUE(contract_id, agent_id); CHECK role IN('LEAD','SUB'); CHECK share_rate>=0 AND share_rate<=1
|
||||||
|
공식: 설계사몫 = 계약수수료 × share_rate. (검증: 계약별 share_rate 합=1 — Service 가드)
|
||||||
|
|
||||||
|
### D12. 보증보험 (GUARANTEE_INS) — V101
|
||||||
|
설계사가 가입하는 신원보증보험 가입현황 추적(공식 없음, 관리).
|
||||||
|
|
||||||
|
**agent_guarantee_insurance**
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| guarantee_id BIGSERIAL PK | | |
|
||||||
|
| agent_id BIGINT NN FK agent | | |
|
||||||
|
| policy_no VARCHAR(50) NN | 증권번호 | |
|
||||||
|
| carrier_id BIGINT FK insurance_company(company_id) | 보증보험사 | |
|
||||||
|
| guarantee_amount NUMERIC(15,2) NN | 보증금액 | |
|
||||||
|
| start_date DATE NN / end_date DATE NN | 보증기간 | |
|
||||||
|
| status VARCHAR(20) NN DEFAULT 'ACTIVE' | ACTIVE/EXPIRED/CANCELLED | |
|
||||||
|
idx: (agent_id, status),(end_date)
|
||||||
|
|
||||||
|
### D13. 적립금 (RESERVE_FUND) — V102
|
||||||
|
환수 대비 수수료 일부 적립 + 잔액 관리.
|
||||||
|
|
||||||
|
**agent_reserve_fund**
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| reserve_id BIGSERIAL PK | | |
|
||||||
|
| agent_id BIGINT NN FK agent | | |
|
||||||
|
| settle_month CHAR(6) NN | | |
|
||||||
|
| reserve_in NUMERIC(15,2) NN DEFAULT 0 | 적립액 | |
|
||||||
|
| reserve_out NUMERIC(15,2) NN DEFAULT 0 | 인출/사용액 | |
|
||||||
|
| balance NUMERIC(15,2) NN | **= 전월잔액 + reserve_in − reserve_out** | |
|
||||||
|
| created_at | | |
|
||||||
|
UNIQUE(agent_id, settle_month). idx (agent_id, settle_month)
|
||||||
|
공식: balance = (직전월 balance) + reserve_in − reserve_out. Service가 직전월 조회해 누적.
|
||||||
|
|
||||||
|
### D14. 수수료 시뮬레이터 (COMM_SIMULATOR) — V103
|
||||||
|
보험료/요율/지급률 입력 → 수수료·세금·실지급·분급·환수예상 산출. 시나리오 저장.
|
||||||
|
|
||||||
|
**commission_simulation**
|
||||||
|
| 컬럼 | 타입 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| sim_id BIGSERIAL PK | | |
|
||||||
|
| sim_name VARCHAR(100) NN | 시나리오명 | |
|
||||||
|
| premium_amount NUMERIC(15,2) NN | 입력 보험료 | |
|
||||||
|
| company_rate NUMERIC(7,4) NN | 보험사요율 | |
|
||||||
|
| payout_rate NUMERIC(7,4) NN | 지급률 | |
|
||||||
|
| commission_year INT NN | 회차 | |
|
||||||
|
| company_payment NUMERIC(15,2) NN | = 보험료×요율 | |
|
||||||
|
| agent_commission NUMERIC(15,2) NN | = 보험사지급액×지급률 | |
|
||||||
|
| withholding_tax NUMERIC(15,2) NN | = 설계사수수료×3.3% | |
|
||||||
|
| net_amount NUMERIC(15,2) NN | = 수수료−원천세 | |
|
||||||
|
| expected_chargeback NUMERIC(15,2) | 예상 환수(13개월 가정 50% 등) | |
|
||||||
|
| created_at/created_by | | |
|
||||||
|
공식(DOMAIN_KNOWLEDGE 7장과 동일): company_payment=premium×company_rate, agent_commission=company_payment×payout_rate, withholding_tax=agent_commission×0.033, net=agent_commission−withholding_tax. Service의 simulate 액션이 입력만 받아 계산 후 저장/반환.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 메뉴 (V104)
|
||||||
|
| menu_code | menu_name | 상위그룹 | menu_path | component_path |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| STEP_CLOSE | 단계마감 | GRP_SETTLE | /settle/step-close | settle/StepClose |
|
||||||
|
| HQ_SETTLE | 본사정산 | GRP_SETTLE | /settle/hq-settle | settle/HqSettle |
|
||||||
|
| GRADE_EVAL | 등급평가 | GRP_ORG | /org/grade-eval | org/GradeEvaluation |
|
||||||
|
| JOINT_CONTRACT | 공동계약 | GRP_PRD | /contracts/joint | contracts/JointContract |
|
||||||
|
| GUARANTEE_INS | 보증보험 | GRP_ORG | /org/guarantee | org/GuaranteeInsurance |
|
||||||
|
| RESERVE_FUND | 적립금 | GRP_COMMISSION | /commission/reserve | commission/ReserveFund |
|
||||||
|
| COMM_SIMULATOR | 수수료 시뮬레이터 | GRP_COMMISSION | /commission/simulator | commission/CommissionSimulator |
|
||||||
|
|
||||||
|
권한 READ/CREATE/UPDATE/DELETE/EXPORT(STEP_CLOSE는 READ/UPDATE/APPROVE). 역할부여 V54/V95 패턴.
|
||||||
|
|
||||||
|
## 공통코드 (V105)
|
||||||
|
| group_code | 코드 |
|
||||||
|
|---|---|
|
||||||
|
| STEP_CODE | RECEIVE=수신마감/CALC=계산마감/VERIFY=검증마감/PAY=지급마감 |
|
||||||
|
| STEP_STATUS | OPEN=열림/CLOSED=마감 |
|
||||||
|
| HQ_ITEM_TYPE | INCOME=수입/EXPENSE=비용 |
|
||||||
|
| JOINT_ROLE | LEAD=주설계사/SUB=공동설계사 |
|
||||||
|
| GUARANTEE_STATUS | ACTIVE=유효/EXPIRED=만료/CANCELLED=해지 |
|
||||||
|
| GRADE_EVAL_STATUS | EVALUATED=평가완료/APPLIED=반영완료 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wave 실행 (P7과 동일 팀/패턴)
|
||||||
|
1 dba: V97~V105 · 2 core: VO/Mapper/Enum · 3 api: Service/Controller(공식·집계 내장) · 4 front: 7페이지+commission/settle/org api+App.tsx · 5 infra: build+smoke
|
||||||
|
**PL 검증**: GET뿐 아니라 액션 POST(close/reopen/simulate/recalc balance/등급산정)까지 실호출로 5xx 0 확인 — P7에서 GET-only smoke가 버그를 놓친 교훈 반영.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 완료 상태 (2026-05-31)
|
||||||
|
schema **v105** 적용(Flyway V97~V105 success), 전체 `./gradlew build` SUCCESSFUL, 프론트 빌드 OK, ga-api 8082 라이브. 8테이블·7메뉴 등록. GET 9 + 액션 POST 5(simulate/hq-settles/step-closes/close/reserve upsert) **전부 200, 5xx 0건**. 시뮬레이터 공식 실측: 보험료 1,000,000×0.6=600,000 → ×0.75=450,000 → 원천세 14,850 → net 435,150 → 예상환수 225,000 ✓. 테스트데이터 정리 완료. (P8은 P7과 달리 액션까지 PL이 직접 호출 검증 → 숨은 버그 0.)
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
# 신입사원 완전 기초 교육 — 보험과 수수료, 0부터 시작하기
|
||||||
|
|
||||||
|
> 대상: **보험을 한 번도 다뤄본 적 없는 신입사원/개발자.**
|
||||||
|
> 목표: 이 문서 하나만 끝까지 읽으면 "우리 회사가 무슨 일을 하고, 수수료가 어떻게 계산돼서 설계사 통장에 꽂히는지" 머릿속에 그림이 그려지게 하는 것.
|
||||||
|
> 읽는 법: 위에서 아래로 순서대로. 어려운 용어는 나올 때마다 바로 쉬운 말로 풀어 씁니다. 숫자 예시는 직접 손으로 따라 계산해 보세요.
|
||||||
|
> 더 깊은 코드/규정 레벨은 [`DOMAIN_KNOWLEDGE.md`](DOMAIN_KNOWLEDGE.md) 참고.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 목차
|
||||||
|
1. [보험이 뭔가요? (진짜 0단계)](#1-보험이-뭔가요-진짜-0단계)
|
||||||
|
2. [등장인물 4명 — 누가 누구에게 돈을 주나](#2-등장인물-4명--누가-누구에게-돈을-주나)
|
||||||
|
3. [보험 기초 용어 25개 — 이것만 알면 대화가 된다](#3-보험-기초-용어-25개--이것만-알면-대화가-된다)
|
||||||
|
4. [수수료가 뭐고 왜 종류가 많은가](#4-수수료가-뭐고-왜-종류가-많은가)
|
||||||
|
5. [수수료 종류 한눈에 (우리 시스템이 다루는 전부)](#5-수수료-종류-한눈에-우리-시스템이-다루는-전부)
|
||||||
|
6. [수수료 계산의 핵심 공식 — "세 번 곱하기"](#6-수수료-계산의-핵심-공식--세-번-곱하기)
|
||||||
|
7. [숫자로 끝까지 따라가기 (한 계약의 일생)](#7-숫자로-끝까지-따라가기-한-계약의-일생)
|
||||||
|
8. [그냥 곱하면 안 되는 이유 — 규제 3종](#8-그냥-곱하면-안-되는-이유--규제-3종)
|
||||||
|
9. [세금과 공제 — 통장에 적게 들어오는 이유](#9-세금과-공제--통장에-적게-들어오는-이유)
|
||||||
|
10. [한 달이 흘러가는 과정 — 월 정산 파이프라인](#10-한-달이-흘러가는-과정--월-정산-파이프라인)
|
||||||
|
11. [수입 vs 지급, 그리고 수지차 — 회사는 어디서 남기나](#11-수입-vs-지급-그리고-수지차--회사는-어디서-남기나)
|
||||||
|
12. [자주 나오는 질문 (FAQ)](#12-자주-나오는-질문-faq)
|
||||||
|
13. [용어 빠른 사전](#13-용어-빠른-사전)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 보험이 뭔가요? (진짜 0단계)
|
||||||
|
|
||||||
|
**보험 = 여럿이 조금씩 돈을 모아뒀다가, 사고를 당한 사람에게 몰아주는 약속.**
|
||||||
|
|
||||||
|
예를 들어 100명이 매달 1만 원씩 모읍니다(월 100만 원). 그 중 한 명이 큰 병에 걸리면, 모인 돈에서 큰 금액을 그 사람에게 줍니다. 나머지 99명은 "내가 안 걸려서 다행이다" 하고 넘어갑니다. 이 "돈 모으고 → 사고나면 주기"를 전문으로 운영하는 회사가 **보험사**입니다.
|
||||||
|
|
||||||
|
- 고객이 매달 내는 돈 = **보험료(premium)**
|
||||||
|
- 사고/사망/만기 때 받는 돈 = **보험금**
|
||||||
|
- 이 약속을 적은 계약서 한 장 = **보험계약** (증권 1장 = 계약 1건)
|
||||||
|
|
||||||
|
> 우리 회사는 보험사가 아닙니다. 우리는 **보험을 파는 일(영업)을 대행**하고, 그 대가로 받은 돈을 **영업한 사람들에게 정확히 나눠주는 회사**입니다. 그게 다음 장에서 나오는 GA입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 등장인물 4명 — 누가 누구에게 돈을 주나
|
||||||
|
|
||||||
|
| 누구 | 한 줄 설명 | 비유 |
|
||||||
|
|---|---|---|
|
||||||
|
| **고객** | 보험에 가입하고 보험료를 내는 사람 | 물건을 사는 손님 |
|
||||||
|
| **보험사 (Carrier)** | 보험 상품을 만들고, 팔아준 대가로 수수료를 주는 회사 (삼성생명, 메리츠 등) | 물건을 만든 제조사 |
|
||||||
|
| **GA (우리 회사)** | 여러 보험사 상품을 한데 모아 파는 대형 보험대리점 | 여러 브랜드를 파는 백화점 |
|
||||||
|
| **설계사 (Agent)** | 실제로 고객을 만나 계약을 따오는 사람 | 백화점 매장 직원 |
|
||||||
|
|
||||||
|
**돈의 흐름 (제일 중요한 그림):**
|
||||||
|
```
|
||||||
|
[고객] ──보험료──▶ [보험사] ──수수료──▶ [GA(우리)] ──정산──▶ [설계사]
|
||||||
|
└ 세금 떼고, 공제 빼고 통장으로
|
||||||
|
```
|
||||||
|
|
||||||
|
- 보험사는 직접 영업하는 대신, GA·설계사가 고객을 데려와 주면 그 대가로 **수수료**를 줍니다.
|
||||||
|
- GA는 보험사한테 받은 수수료(=**수입수수료**)에서, 영업한 설계사 몫(=**지급수수료**)을 떼어 줍니다.
|
||||||
|
- **우리 시스템이 하는 일 = 이 "설계사 몫을 정확히 계산해서 나눠주는 것(정산)".**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 보험 기초 용어 25개 — 이것만 알면 대화가 된다
|
||||||
|
|
||||||
|
| 용어 | 쉬운 설명 |
|
||||||
|
|---|---|
|
||||||
|
| 보험계약 | 고객이 "매달 낼 테니 사고 때 보장해줘"라고 맺은 약속. 증권 1장 = 1건. |
|
||||||
|
| 보험료(premium) | 고객이 내는 돈. **모든 수수료 계산의 출발점.** 월납/연납/일시납. |
|
||||||
|
| 보험금 | 사고·만기 때 고객이 받는 돈. (수수료와는 다른 개념!) |
|
||||||
|
| 계약자 / 피보험자 / 수익자 | 돈 내는 사람 / 보장 대상 / 보험금 받는 사람. (셋이 같을 수도 다를 수도) |
|
||||||
|
| 생명보험 | 사람의 사망·생존·연금 관련 보험. (종신, 연금보험 등) |
|
||||||
|
| 손해보험 | 자동차·화재·실손 등 재산·사고 관련 보험. |
|
||||||
|
| 회차 / 차년(commission year) | 계약이 몇 년째인지. **1차년(초년도)** 수수료가 가장 큼. |
|
||||||
|
| 납입 / 미납 / 실효 | 보험료를 냈다 / 안 냈다 / 안 내서 계약이 끊겼다. |
|
||||||
|
| 유지 / 해지 / 부활 | 계약 살아있음 / 고객이 끊음 / 끊긴 걸 되살림. |
|
||||||
|
| 유지율(persistency) | 가입한 계약이 13개월·25개월 뒤에도 살아있는 비율. **영업 건전성의 핵심 지표.** |
|
||||||
|
| 청약 / 인수(언더라이팅) | 고객이 가입 신청 → 보험사가 위험 심사 후 받아들임. |
|
||||||
|
| 위촉 / 해촉 | 설계사가 GA에 소속됨 / 소속이 끝남. |
|
||||||
|
| 승계 | 설계사가 해촉되면 그가 관리하던 계약을 후임에게 넘기는 것. |
|
||||||
|
| 모집 | 설계사가 새 계약을 따오는 행위. |
|
||||||
|
| 환산보험료 | 상품마다 조건이 달라 비교가 어렵기 때문에, 정해진 "환산율"을 곱해 **공통 기준으로 환산한 보험료**. 실적 비교·시상의 기준. |
|
||||||
|
| 인정실적 | 회사가 "이건 실적으로 인정한다"고 집계한 환산보험료 합. 등급·시상의 근거. |
|
||||||
|
| 등급 | 설계사 레벨. 등급이 높을수록 지급률(받는 비율)이 높음. |
|
||||||
|
| 오버라이드 | 부하 설계사 실적에 대해 상위 직책자가 받는 수당. |
|
||||||
|
| 시상 | 목표 달성/우수자에게 주는 상금 성격의 수당. |
|
||||||
|
| 시책(인센티브) | 한시적 캠페인 보너스. |
|
||||||
|
| 환수(chargeback) | 계약이 일찍 깨지면 이미 준 수수료를 일부 돌려받는 것. |
|
||||||
|
| 분급 | 큰 수수료를 한 번에 안 주고 여러 해에 나눠 주는 것. |
|
||||||
|
| 정산 | 매월 수수료를 계산·확정·지급하는 업무 전체. |
|
||||||
|
| 원장(ledger) | 계산의 최소 단위 기록 (계약 × 월 × 수수료종류 1줄). |
|
||||||
|
| 명세서 | 설계사에게 "이번 달 얼마 받는지" 보여주는 내역서. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 수수료가 뭐고 왜 종류가 많은가
|
||||||
|
|
||||||
|
**수수료 = 계약을 따오고(모집) 잘 유지시킨(유지) 대가로 보험사→GA→설계사로 흐르는 돈.**
|
||||||
|
|
||||||
|
왜 종류가 많을까요? 계약은 **태어나서(체결) → 유지되다가 → 갱신·부활도 하고 → 깨지기도(해지) 합니다.** 이 생애주기 단계마다, 또 영업을 도운 방식마다 다른 이름의 수수료가 붙기 때문입니다.
|
||||||
|
|
||||||
|
핵심 직관 하나만 기억하세요:
|
||||||
|
> **"초년도(1차년) 모집수수료가 제일 크고, 그 다음부터는 유지·관리 대가로 잘게 나뉜다."**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 수수료 종류 한눈에 (우리 시스템이 다루는 전부)
|
||||||
|
|
||||||
|
우리 시스템은 아래 수수료/수당을 모두 계산합니다. (★ = incar 벤치마킹으로 새로 추가한 것 — P7/P8)
|
||||||
|
|
||||||
|
### 5-1. 계약 생애주기 기반
|
||||||
|
| 종류 | 언제 | 누구에게 | 공식 한 줄 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 모집수수료 | 계약 체결(초년도) | 원 모집설계사 | 보험사지급액 × 지급률 |
|
||||||
|
| 유지수수료 | 유지되는 매월/매년 | 현재 관리설계사(승계 시 후임) | 보험사지급액 × 지급률 |
|
||||||
|
| 수금수수료 | 보험료가 실제 납입될 때 | 관리설계사 | 납입보험료 × 수금율 |
|
||||||
|
| 갱신수수료 | 갱신형 상품 갱신 시 | 관리설계사 | 갱신보험료 × 갱신율 |
|
||||||
|
| 부활수수료 | 실효 계약 부활 시 | 관리설계사 | 부활보험료 × 부활율 |
|
||||||
|
|
||||||
|
### 5-2. 성과·조직 기반
|
||||||
|
| 종류 | 의미 | 공식 한 줄 |
|
||||||
|
|---|---|---|
|
||||||
|
| 오버라이드 | 하위 실적에 대한 상위 직책 수당 | 하위실적 × 오버라이드율 (cap 제한) |
|
||||||
|
| 유지보수수당(지속수당) | 일정 유지율 달성 보너스 | 등급별 정액 또는 정률 |
|
||||||
|
| 시책(인센티브) | 캠페인/목표 달성 프로모션 | 캠페인 규칙대로 |
|
||||||
|
| ★ 시상(Award) | 신인도입/법인/유지율 등 시상 | 충족 시 실적×율 또는 정액 |
|
||||||
|
| ★ 지점장수당 | 지점장이 소속 조직 실적에 받는 수당 | min(조직실적 × 수당율, 한도) |
|
||||||
|
|
||||||
|
### 5-3. 소개·이관·지원 (★ 신규)
|
||||||
|
| 종류 | 의미 | 공식 한 줄 |
|
||||||
|
|---|---|---|
|
||||||
|
| ★ 소개수수료 | 계약을 소개해 준 설계사에게 | 기준금액 × 소개율 (또는 정액) |
|
||||||
|
| ★ 이관수수료 | 계약을 다른 설계사에게 이관할 때 | 기준금액 × 이관율 |
|
||||||
|
| ★ 정착지원금 | 신입/경력 설계사 정착 지원금(조기해촉 시 환수) | 등급별 정액 − 환수 |
|
||||||
|
| ★ 생보운영지원수수료 | 생보 운영지원 명목 수수료 | 실적 × 지급률 − 환수 |
|
||||||
|
|
||||||
|
### 5-4. 회사(GA) 레벨 (★ 신규)
|
||||||
|
| 종류 | 의미 | 공식 한 줄 |
|
||||||
|
|---|---|---|
|
||||||
|
| ★ 수입수수료 | 보험사가 GA에 준 돈(수입 측) | 보험료 × 보험사요율 |
|
||||||
|
| ★ 수지차 | GA가 남긴 마진 | 수입 합 − 지급 합 |
|
||||||
|
| ★ 인정실적 | 시상·등급의 기준이 되는 실적 | 환산보험료 × 인정율 |
|
||||||
|
| ★ 본사정산 | GA 본사 자체 수입/비용 정산 | Σ수입 − Σ비용 |
|
||||||
|
|
||||||
|
### 5-5. 보조 관리 (★ 신규, 공식보다 관리 중심)
|
||||||
|
| 항목 | 의미 |
|
||||||
|
|---|---|
|
||||||
|
| ★ 환산율(상품별) | 보험료를 공통 기준으로 환산하는 비율 |
|
||||||
|
| ★ 등급평가 | 실적 점수로 설계사 등급 산정 |
|
||||||
|
| ★ 공동계약 | 한 계약을 여러 설계사가 공동 모집, 지분율로 배분 |
|
||||||
|
| ★ 보증보험 | 설계사 신원보증보험 가입 관리 |
|
||||||
|
| ★ 적립금 | 환수 대비 수수료 일부 적립·잔액 관리 |
|
||||||
|
| ★ 단계마감 | 정산을 수신→계산→검증→지급 단계로 마감 |
|
||||||
|
| ★ 수수료 시뮬레이터 | 보험료/요율 입력 → 수수료·세금·실지급 미리 계산 |
|
||||||
|
|
||||||
|
> **모집 vs 유지의 귀속 차이(자주 묻는 함정):** 설계사가 해촉·이관되면 그 계약의 **유지수수료는 후임**에게 넘어가지만, **모집수수료는 원래 모집한 설계사**에게 그대로 남습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 수수료 계산의 핵심 공식 — "세 번 곱하기"
|
||||||
|
|
||||||
|
대부분의 수수료는 **곱하기 세 번**이면 끝납니다. 이것만 외우면 80%는 이해한 겁니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
① 보험사 지급액 = 보험료(premium) × 보험사요율(company_rate)
|
||||||
|
② 설계사 수수료 = 보험사 지급액 × 지급률(payout_rate)
|
||||||
|
③ 원천세 = 설계사 수수료 × 3.3%
|
||||||
|
──────────────────────────────────────────────
|
||||||
|
설계사 손에 = 설계사 수수료 − 원천세 (− 공제 + 부가세)
|
||||||
|
```
|
||||||
|
|
||||||
|
두 가지 율의 뜻:
|
||||||
|
- **보험사요율(company_rate)**: 보험사가 이 상품에 주기로 한 비율. **상품 × 회차(차년)**로 정해짐.
|
||||||
|
- **지급률(payout_rate)**: GA가 받은 돈 중 설계사에게 주는 비율. **설계사 등급 × 보험종류 × 회차**로 정해짐. → 같은 계약이라도 **등급 높은 설계사가 더 많이** 받음.
|
||||||
|
|
||||||
|
> 모집수수료와 유지수수료는 **공식이 똑같고**, 회차(commission_year)만 다릅니다. (모집 = 1차년, 유지 = 2차년 이후)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 숫자로 끝까지 따라가기 (한 계약의 일생)
|
||||||
|
|
||||||
|
연환산 보험료 **100만 원**짜리 계약 하나가 태어나서 깨질 때까지 따라가 봅시다.
|
||||||
|
|
||||||
|
### (A) 모집수수료 — 계약이 태어났다
|
||||||
|
| 항목 | 값 | 계산 |
|
||||||
|
|---|---|---|
|
||||||
|
| 보험료(연환산) | 1,000,000원 | (가정) |
|
||||||
|
| 보험사요율 | 60% | 상품·1차년 |
|
||||||
|
| **① 보험사 지급액** | **600,000원** | 1,000,000 × 60% |
|
||||||
|
| 지급률(이 설계사 등급) | 75% | 등급·1차년 |
|
||||||
|
| **② 설계사 수수료** | **450,000원** | 600,000 × 75% |
|
||||||
|
| **③ 원천세(3.3%)** | **14,850원** | 450,000 × 3.3% |
|
||||||
|
| **원장에 남는 실수령** | **435,150원** | 450,000 − 14,850 |
|
||||||
|
|
||||||
|
### (B) 분급 — 한 번에 안 주고 7년에 나눠 준다
|
||||||
|
큰 모집수수료(450,000원)는 규제상 7년에 걸쳐 비율대로 나눠 줍니다(35/25/15/10/7/5/3%).
|
||||||
|
|
||||||
|
| 차년 | 비율 | 지급액 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1차년 | 35% | 157,500원 |
|
||||||
|
| 2차년 | 25% | 112,500원 |
|
||||||
|
| 3차년 | 15% | 67,500원 |
|
||||||
|
| … | … | … |
|
||||||
|
| **합계** | **100%** | **450,000원** |
|
||||||
|
|
||||||
|
→ 계약 직후 통장에 들어오는 건 450,000원이 아니라 **1차년분 157,500원(에서 세금 차감)**.
|
||||||
|
|
||||||
|
### (C) 환수 — 고객이 13개월 만에 해지했다
|
||||||
|
유지 13~24개월 구간 환수율 **50%** 적용:
|
||||||
|
```
|
||||||
|
환수액 = 기지급 모집수수료 × 환수율 = 450,000 × 50% = 225,000원 ← 다음 정산에서 (−)로 차감
|
||||||
|
```
|
||||||
|
- 12개월 이내면 100%(전액 환수), 85개월 이상이면 0%(환수 없음).
|
||||||
|
|
||||||
|
### (D) 오버라이드 — 이 설계사의 팀장이 받는 수당
|
||||||
|
```
|
||||||
|
오버라이드 = 하위 실적 × 오버라이드율 = 2,000,000 × 4% = 80,000원 (cap 넘으면 cap까지만)
|
||||||
|
```
|
||||||
|
|
||||||
|
### (E) 수지차 — 회사는 이 계약으로 얼마 남겼나 (★신규)
|
||||||
|
```
|
||||||
|
수입수수료(보험사가 GA에 준 돈) = 1,000,000 × 60% = 600,000원
|
||||||
|
지급수수료(GA가 설계사에 준 돈) = 450,000원
|
||||||
|
수지차(GA 마진) = 600,000 − 450,000 = 150,000원
|
||||||
|
```
|
||||||
|
|
||||||
|
이 한 흐름이 머리에 들어왔다면, 나머지 수수료 종류는 "기준금액 × 율" 변형일 뿐입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 그냥 곱하면 안 되는 이유 — 규제 3종
|
||||||
|
|
||||||
|
수수료는 법으로 한도·방식이 정해져 있어, 단순 곱셈 결과를 그대로 줄 수 없습니다.
|
||||||
|
|
||||||
|
1. **1200% 룰** (보험업감독규정): 초년도 모집수수료 총액은 **연납보험료의 1200%**를 못 넘음. 시스템이 누적 지급액을 추적해 초과분을 잘라냄.
|
||||||
|
2. **분급**: 큰 수수료를 7년에 나눠 지급(위 7-B).
|
||||||
|
3. **차등 환수**: 조기 해지 시 유지기간이 짧을수록 많이 환수(위 7-C).
|
||||||
|
|
||||||
|
> 회사마다 세율·한도가 달라도 **코드는 안 고칩니다.** 전부 데이터(`system_config`, `regulatory_limit`, `installment_ratio`, `chargeback_grade`)로 빼놨기 때문에 값만 바꾸면 됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 세금과 공제 — 통장에 적게 들어오는 이유
|
||||||
|
|
||||||
|
설계사에게 주는 돈(gross)에서 **세금을 먼저 떼고(원천징수)**, **공제를 빼면** 실제 입금액(net)이 됩니다.
|
||||||
|
|
||||||
|
| 항목 | 율 | 설명 |
|
||||||
|
|---|---|---|
|
||||||
|
| 사업소득 원천징수 | 3.3% | 일반 설계사(사업소득자) |
|
||||||
|
| 기타소득 원천징수 | 8.8% | 기타소득 유형 |
|
||||||
|
| 지방소득세 | 소득세의 10% | 소득세에 부가 |
|
||||||
|
| 부가가치세 | 10% | 과세사업자가 세금계산서 발행 시 GA가 추가 지급(+) |
|
||||||
|
|
||||||
|
**공제(deduction)**: 미리 준 돈(가지급금) 회수, 대출 상환, **적립금** 등을 그 달 정산액에서 차감.
|
||||||
|
|
||||||
|
```
|
||||||
|
gross − 원천세(소득세+지방세) − 공제 (+부가세, 과세사업자) = net(실지급액) → 계좌이체(펌뱅킹)
|
||||||
|
```
|
||||||
|
|
||||||
|
**예시(과세사업자, 수수료 450,000원):** 소득세 14,850 + 지방세 1,485 빼고, 부가세 45,000 더하면 → **실지급 478,665원.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 한 달이 흘러가는 과정 — 월 정산 파이프라인
|
||||||
|
|
||||||
|
매월 배치가 아래 순서로 돈다고 생각하면 됩니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
보험사 데이터 수신 → 표준포맷 변환 → 대사(검증) →
|
||||||
|
모집수수료 계산 → 유지수수료 계산(승계 반영) → 환수·예외 →
|
||||||
|
오버라이드 계산 → 집계(설계사×월, 세금·공제 반영) →
|
||||||
|
회계 분개 → (분기/연말이면) 세무신고 자료
|
||||||
|
```
|
||||||
|
|
||||||
|
마감은 **단계마감(★신규)**으로 수신→계산→검증→지급 단계별로 잠급니다. 한 달을 닫으면(마감) 더는 못 고치고, 오류는 **정정 전표**로만 바로잡습니다(원본 보존). 설계사가 금액에 이의를 제기하면 **이의신청**으로 받습니다.
|
||||||
|
|
||||||
|
**상태 흐름:** `DRAFT(계산됨) → CONFIRMED(확정) → PAID(지급완료)`. 확정·지급된 건은 재정산해도 안 바뀝니다(보호).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 수입 vs 지급, 그리고 수지차 — 회사는 어디서 남기나 (★ 신규 핵심)
|
||||||
|
|
||||||
|
신입이 가장 헷갈리는 지점입니다. 두 방향을 구분하세요.
|
||||||
|
|
||||||
|
| 방향 | 누가 → 누구 | 시스템 용어 |
|
||||||
|
|---|---|---|
|
||||||
|
| **수입(income)** | 보험사 → GA | 수입수수료 |
|
||||||
|
| **지급(payout)** | GA → 설계사 | 모집/유지/… 수수료, 정산 |
|
||||||
|
|
||||||
|
```
|
||||||
|
수지차(마진) = Σ수입수수료 − Σ지급수수료
|
||||||
|
```
|
||||||
|
|
||||||
|
- 수지차가 (+)면 GA가 남긴 것, (−)면 그달 지급이 수입보다 많았던 것(분급·시상 등으로 일시적으로 발생 가능).
|
||||||
|
- 여기에 **본사정산**(GA 자체 수입/비용)까지 합치면 회사 전체 손익이 보입니다.
|
||||||
|
- 우리 시스템의 `수입수수료/수지차` 화면에서 **재계산 버튼**을 누르면 그달 수입 합과 지급 합(settle_master 기준)을 모아 수지차를 계산합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 자주 나오는 질문 (FAQ)
|
||||||
|
|
||||||
|
1. **"수수료를 왜 한 번에 안 주죠?"** → 1200% 룰·분급 규제 + 조기해지 환수 리스크 때문. 7년 분할이 표준.
|
||||||
|
2. **"설계사가 그만두면 그 계약 수수료는?"** → 유지수수료는 후임(승계)에게, 모집수수료는 원 설계사에게.
|
||||||
|
3. **"gross인데 통장엔 적게 들어와요."** → 원천세(3.3%) + 공제를 뺀 net이 실지급액. (과세사업자는 부가세가 더해짐)
|
||||||
|
4. **"같은 계약인데 설계사마다 수수료가 달라요."** → 지급률이 **설계사 등급**에 따라 다르기 때문.
|
||||||
|
5. **"수입수수료랑 지급수수료가 헷갈려요."** → 수입 = 보험사가 우리에게 준 돈, 지급 = 우리가 설계사에게 준 돈. 차이가 수지차(마진).
|
||||||
|
6. **"환산보험료는 왜 필요해요?"** → 상품마다 보험료 조건이 달라 그대로 비교하면 불공평. 환산율을 곱해 공통 기준으로 맞춘 게 환산보험료고, 그 합이 인정실적이 되어 시상·등급의 잣대가 됩니다.
|
||||||
|
7. **"표준 세율/한도가 우리 회사랑 달라요."** → 코드 수정 불필요. 데이터(`system_config` 등)만 변경.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 용어 빠른 사전
|
||||||
|
|
||||||
|
| 한글 | 영문 | 한 줄 |
|
||||||
|
|---|---|---|
|
||||||
|
| 보험료 | Premium | 고객이 내는 돈. 계산의 출발점 |
|
||||||
|
| 보험금 | Benefit | 사고·만기 때 고객이 받는 돈 |
|
||||||
|
| 회차/차년 | Commission year | 계약 몇 년째. 1차년이 가장 큼 |
|
||||||
|
| 유지율 | Persistency | 13·25개월 뒤 계약 생존율 |
|
||||||
|
| 위촉/해촉 | Appoint/Terminate | 설계사 소속 시작/종료 |
|
||||||
|
| 승계 | Succession | 해촉 시 후임 귀속 전환 |
|
||||||
|
| 보험사요율 | Company rate | 보험사가 GA에 주는 비율 |
|
||||||
|
| 지급률 | Payout rate | GA가 설계사에 주는 비율(등급별) |
|
||||||
|
| 모집수수료 | Recruit | 체결 초년도 수수료(제일 큼) |
|
||||||
|
| 유지수수료 | Maintain | 유지 기간 수수료 |
|
||||||
|
| 수금수수료 | Collection | 보험료 납입 시 |
|
||||||
|
| 갱신/부활수수료 | Renewal/Reinstatement | 갱신·부활 시 |
|
||||||
|
| 오버라이드 | Override | 상위 직책 수당 |
|
||||||
|
| 시상 | Award | 목표 달성 상금 |
|
||||||
|
| 소개/이관수수료 | Referral/Transfer | 소개·이관 대가 |
|
||||||
|
| 정착지원금 | Settling support | 신입 정착 지원금 |
|
||||||
|
| 수입수수료 | Income commission | 보험사가 GA에 준 돈 |
|
||||||
|
| 수지차 | Margin | 수입 − 지급 (GA 마진) |
|
||||||
|
| 환산보험료 | Converted premium | 공통 기준 환산 보험료 |
|
||||||
|
| 인정실적 | Recognized performance | 시상·등급 기준 실적 |
|
||||||
|
| 본사정산 | HQ settlement | GA 자체 수입/비용 정산 |
|
||||||
|
| 적립금 | Reserve fund | 환수 대비 적립·잔액 |
|
||||||
|
| 분급 | Installment | 수수료 7년 분할 |
|
||||||
|
| 차등환수 | Chargeback | 조기 해지 환수 |
|
||||||
|
| 원천징수 | Withholding | 세금 미리 떼기(3.3%) |
|
||||||
|
| 공제 | Deduction | 가지급금 등 차감 |
|
||||||
|
| 단계마감 | Step close | 단계별 정산 잠금 |
|
||||||
|
| 정산 | Settlement | 월별 계산·확정·지급 |
|
||||||
|
| 원장 | Ledger | 계산 최소 단위 기록 |
|
||||||
|
| 명세서 | Statement | 설계사 지급 내역서 |
|
||||||
|
| 펌뱅킹 | Firm banking | 대량 계좌이체 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*기준: 한국 보험업감독규정 표준 가정값. 시스템 schema (incar 갭 반영 P7+P8 14개 도메인 포함). 표준값은 데이터로 외부화되어 코드 변경 없이 조정 가능.*
|
||||||
|
*더 깊은 코드/배치/규정 레벨은 [`DOMAIN_KNOWLEDGE.md`](DOMAIN_KNOWLEDGE.md), [`DOMAIN_GAP_P7.md`](DOMAIN_GAP_P7.md), [`DOMAIN_GAP_P8.md`](DOMAIN_GAP_P8.md) 참고.*
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# 외부연동 활성화 가이드 (KFTC 펌뱅킹 / NHN Toast / FCM 푸시)
|
||||||
|
|
||||||
|
세 외부연동 어댑터는 **코드 구현 완료** 상태이며, 기본은 모두 **Mock** 으로 동작한다.
|
||||||
|
운영 활성화는 아래 환경변수(또는 application.yml)만 주입하면 된다. 키가 비어 있으면 어댑터는 **fail-fast**(IllegalStateException) 하여 잘못된 적용을 즉시 알린다.
|
||||||
|
|
||||||
|
> 공통 전환 스위치: `app.adapter.{bank|message|push}` = `mock`(기본) / `kftc` / `toast` / `fcm`
|
||||||
|
> 어댑터별 구현체는 `ga-external-adapter` 모듈, 모든 외부호출은 `external_call_log` 에 AOP 로 자동 감사된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. KFTC 오픈뱅킹 입금이체 (펌뱅킹)
|
||||||
|
|
||||||
|
- 구현체: `KftcBankTransferAdapter` (입금이체 `POST /v2.0/transfer/deposit/acnt_num` + 이체결과조회)
|
||||||
|
- 활성화: `ADAPTER_BANK=kftc`
|
||||||
|
|
||||||
|
| 환경변수 | 설명 | 예시 |
|
||||||
|
|---|---|---|
|
||||||
|
| `KFTC_BASE_URL` | API 베이스 (운영/테스트베드) | `https://openapi.openbanking.or.kr` |
|
||||||
|
| `KFTC_ACCESS_TOKEN` | 이용기관 2-legged Access Token (주기 재발급) | (발급 토큰) |
|
||||||
|
| `KFTC_CNTR_ACCOUNT_NUM` | 이용기관 약정 출금계좌 번호 | (약정계좌) |
|
||||||
|
| `KFTC_CNTR_ACCOUNT_TYPE` | 약정계좌 구분 | `N` |
|
||||||
|
| `KFTC_WD_PRINT` | 출금계좌 인자내역 | `수수료정산` |
|
||||||
|
| `KFTC_NAME_CHECK` | 수취인 실명확인 옵션 | `off` |
|
||||||
|
|
||||||
|
**사전 조건(비개발)**: 금융결제원 이용기관 계약, 약정 출금계좌 등록, Access Token 발급 체계.
|
||||||
|
**검증**: 테스트베드에서 출금 1건 → `withdraw_request.transfer_status` 전이 + `external_call_log` 확인.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. NHN Cloud(Toast) SMS / 카카오 알림톡
|
||||||
|
|
||||||
|
- 구현체: `ToastMessageAdapter` (SMS v3.0 + 알림톡 v2.3, `MessageChannel` 분기)
|
||||||
|
- 활성화: `ADAPTER_MESSAGE=toast`
|
||||||
|
|
||||||
|
| 환경변수 | 설명 |
|
||||||
|
|---|---|
|
||||||
|
| `TOAST_SMS_APP_KEY` / `TOAST_SMS_SECRET_KEY` | SMS 프로젝트 AppKey / Secret Key |
|
||||||
|
| `TOAST_SENDER_NO` | 사전 등록 발신번호 |
|
||||||
|
| `TOAST_ALIMTALK_APP_KEY` / `TOAST_ALIMTALK_SECRET_KEY` | 알림톡 AppKey / Secret Key |
|
||||||
|
| `TOAST_SENDER_KEY` | 카카오 비즈메시지 발신프로필 SenderKey |
|
||||||
|
|
||||||
|
**사전 조건(비개발)**: NHN Cloud 프로젝트 + SMS/알림톡 상품 활성화, 발신번호 등록, 알림톡 **템플릿 등록**(발송 시 `templateCode` 필수).
|
||||||
|
**검증**: 본인 번호로 SMS 1건 발송 → `header.isSuccessful=true` + `external_call_log` 확인.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. FCM 푸시 (모바일 PWA)
|
||||||
|
|
||||||
|
- 서버 구현체: `FcmPushAdapter` (HTTP v1 `POST /v1/projects/{projectId}/messages:send`)
|
||||||
|
- 활성화(서버): `ADAPTER_PUSH=fcm`
|
||||||
|
|
||||||
|
| 환경변수(서버) | 설명 |
|
||||||
|
|---|---|
|
||||||
|
| `FCM_PROJECT_ID` | Firebase 프로젝트 ID |
|
||||||
|
| `FCM_ACCESS_TOKEN` | 서비스계정 OAuth2 토큰(scope `firebase.messaging`, 주기 재발급) |
|
||||||
|
|
||||||
|
**PWA(클라이언트) 활성화** — `ga-mobile-pwa`:
|
||||||
|
1. Firebase 콘솔 → 웹 앱 등록 → 설정값을 `.env` 의 `VITE_FCM_*` 로 주입
|
||||||
|
(`VITE_FCM_API_KEY`, `VITE_FCM_PROJECT_ID`, `VITE_FCM_APP_ID`, `VITE_FCM_SENDER_ID`, `VITE_FCM_VAPID_KEY`)
|
||||||
|
2. `npm i firebase` 후 `src/push/initPush.ts` 의 TODO 블록(firebase/messaging `getToken`) 활성화
|
||||||
|
3. `public/firebase-messaging-sw.js` 서비스워커 배치
|
||||||
|
4. 로그인 시 `initPush()` 가 토큰 발급 → `POST /api/mobile/me/device-tokens` 자동 등록
|
||||||
|
|
||||||
|
**동작 흐름(구현 완료)**: 알림 발생(예: 출금 SETTLED) → `PushDispatchService` 가 `user_device_token` 의 활성 토큰 조회 → `PushAdapter.send`. 토큰 미등록/Mock 이면 no-op.
|
||||||
|
**검증**: PWA 로그인 → device-token 등록 확인(`user_device_token`) → 출금 SETTLED 트리거 → 푸시 수신 + `external_call_log(call_type=PUSH)` 확인.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 요약 — 남은 것은 "키 주입"뿐
|
||||||
|
| 연동 | 코드 | 활성화에 필요한 것(비개발) |
|
||||||
|
|---|---|---|
|
||||||
|
| KFTC 입금이체 | ✅ 완료 | 계약 + 약정계좌 + Access Token |
|
||||||
|
| Toast SMS/알림톡 | ✅ 완료 | AppKey/SecretKey + 발신번호 + 알림톡 템플릿 |
|
||||||
|
| FCM 푸시 | ✅ 완료(서버) / 스캐폴드(PWA) | Firebase 프로젝트 + `firebase` SDK + VAPID 키 |
|
||||||
@@ -10,4 +10,6 @@ jar.enabled = false
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation project(':ga-core')
|
implementation project(':ga-core')
|
||||||
|
implementation project(':ga-external-adapter')
|
||||||
|
testImplementation 'org.springframework.security:spring-security-test'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ package com.ga.api;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.cache.annotation.EnableCaching;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GA 수수료 정산 API 서버.
|
* GA 수수료 정산 API 서버.
|
||||||
* 컴포넌트 스캔: com.ga 패키지 (common/core/api 모두 포함)
|
* 컴포넌트 스캔: com.ga 패키지 (common/core/api 모두 포함)
|
||||||
*/
|
*/
|
||||||
@SpringBootApplication(scanBasePackages = {"com.ga"})
|
@SpringBootApplication(scanBasePackages = {"com.ga"})
|
||||||
|
@EnableCaching
|
||||||
|
@EnableScheduling
|
||||||
public class GaApiApplication {
|
public class GaApiApplication {
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(GaApiApplication.class, args);
|
SpringApplication.run(GaApiApplication.class, args);
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package com.ga.api.aop;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.ga.api.service.external.ExternalCallLogService;
|
||||||
|
import com.ga.common.adapter.bank.BankTransferRequest;
|
||||||
|
import com.ga.common.adapter.bank.BankTransferResponse;
|
||||||
|
import com.ga.common.adapter.message.MessageRequest;
|
||||||
|
import com.ga.common.adapter.message.MessageResponse;
|
||||||
|
import com.ga.common.util.SecurityUtil;
|
||||||
|
import com.ga.core.vo.external.ExternalCallLogVO;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.aspectj.lang.ProceedingJoinPoint;
|
||||||
|
import org.aspectj.lang.annotation.Around;
|
||||||
|
import org.aspectj.lang.annotation.Aspect;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 연동 어댑터 호출 감사 이력 자동 영속화 (V85 external_call_log).
|
||||||
|
*
|
||||||
|
* <p>대상: {@link com.ga.common.adapter.bank.BankTransferAdapter},
|
||||||
|
* {@link com.ga.common.adapter.message.MessageAdapter} 모든 메서드.
|
||||||
|
*
|
||||||
|
* <p>정책:
|
||||||
|
* <ul>
|
||||||
|
* <li>호출 전/후 (정상·예외) 모두 영속화. 예외는 그대로 propagate.
|
||||||
|
* <li>request/response 는 마스킹된 JSON 요약만 저장 (계좌·전화 뒤 4자리만).
|
||||||
|
* <li>영속화는 별도 트랜잭션({@link com.ga.api.service.external.ExternalCallLogService}#recordCall = REQUIRES_NEW).
|
||||||
|
* <li>영속화 자체가 실패해도 비즈니스 흐름을 깨지 않는다 (warn 로그만).
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Aspect
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ExternalCallLoggingAspect {
|
||||||
|
|
||||||
|
private final ExternalCallLogService logService;
|
||||||
|
private final ObjectMapper om;
|
||||||
|
|
||||||
|
@Around("execution(* com.ga.common.adapter.bank.BankTransferAdapter+.*(..))")
|
||||||
|
public Object aroundBank(ProceedingJoinPoint jp) throws Throwable {
|
||||||
|
return around(jp, "BANK");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Around("execution(* com.ga.common.adapter.message.MessageAdapter+.*(..))")
|
||||||
|
public Object aroundMessage(ProceedingJoinPoint jp) throws Throwable {
|
||||||
|
return around(jp, "MESSAGE");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Around("execution(* com.ga.common.adapter.push.PushAdapter+.*(..))")
|
||||||
|
public Object aroundPush(ProceedingJoinPoint jp) throws Throwable {
|
||||||
|
return around(jp, "PUSH");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object around(ProceedingJoinPoint jp, String callType) throws Throwable {
|
||||||
|
long start = System.currentTimeMillis();
|
||||||
|
ExternalCallLogVO vo = new ExternalCallLogVO();
|
||||||
|
vo.setCallType(callType);
|
||||||
|
vo.setAdapterClass(simpleName(jp.getTarget()));
|
||||||
|
vo.setMethodName(jp.getSignature().getName());
|
||||||
|
vo.setCalledAt(LocalDateTime.now());
|
||||||
|
try {
|
||||||
|
vo.setCreatedBy(SecurityUtil.getCurrentUserId());
|
||||||
|
} catch (Exception ignore) {
|
||||||
|
// 스케줄러/배치 컨텍스트에서는 인증 정보 없을 수 있음
|
||||||
|
}
|
||||||
|
applyRequest(vo, jp.getArgs());
|
||||||
|
|
||||||
|
try {
|
||||||
|
Object result = jp.proceed();
|
||||||
|
vo.setSuccess(true);
|
||||||
|
applyResponse(vo, result);
|
||||||
|
return result;
|
||||||
|
} catch (Throwable t) {
|
||||||
|
vo.setSuccess(false);
|
||||||
|
vo.setErrorClass(t.getClass().getName());
|
||||||
|
vo.setErrorMessage(truncate(t.getMessage(), 4000));
|
||||||
|
throw t;
|
||||||
|
} finally {
|
||||||
|
vo.setDurationMs((int) (System.currentTimeMillis() - start));
|
||||||
|
try {
|
||||||
|
logService.recordCall(vo);
|
||||||
|
} catch (Exception persistEx) {
|
||||||
|
log.warn("[external_call_log] persist failed: {}", persistEx.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyRequest(ExternalCallLogVO vo, Object[] args) {
|
||||||
|
if (args == null || args.length == 0) return;
|
||||||
|
Object a0 = args[0];
|
||||||
|
if (a0 instanceof BankTransferRequest btr) {
|
||||||
|
vo.setTargetRefType("WITHDRAW");
|
||||||
|
vo.setTargetRefId(btr.getWithdrawRequestId());
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("withdrawRequestId", btr.getWithdrawRequestId());
|
||||||
|
m.put("bankCode", btr.getBankCode());
|
||||||
|
m.put("accountNoMasked", maskAccount(btr.getAccountNo()));
|
||||||
|
m.put("holder", maskName(btr.getAccountHolderName()));
|
||||||
|
m.put("amount", btr.getAmount());
|
||||||
|
m.put("memo", btr.getMemo());
|
||||||
|
vo.setRequestSummary(toJson(m));
|
||||||
|
} else if (a0 instanceof MessageRequest mr) {
|
||||||
|
vo.setTargetRefType(mr.getRefType());
|
||||||
|
vo.setTargetRefId(mr.getRefId());
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("channel", mr.getChannel());
|
||||||
|
m.put("phoneMasked", maskPhone(mr.getPhoneNumber()));
|
||||||
|
m.put("title", mr.getTitle());
|
||||||
|
m.put("templateCode", mr.getTemplateCode());
|
||||||
|
m.put("refUserId", mr.getRefUserId());
|
||||||
|
m.put("contentPreview", truncate(mr.getContent(), 80));
|
||||||
|
vo.setRequestSummary(toJson(m));
|
||||||
|
} else if (a0 instanceof com.ga.common.adapter.push.PushRequest pr) {
|
||||||
|
vo.setTargetRefType(pr.getRefType());
|
||||||
|
vo.setTargetRefId(pr.getRefId());
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("tokenMasked", maskToken(pr.getDeviceToken()));
|
||||||
|
m.put("title", pr.getTitle());
|
||||||
|
m.put("refUserId", pr.getRefUserId());
|
||||||
|
m.put("bodyPreview", truncate(pr.getBody(), 80));
|
||||||
|
vo.setRequestSummary(toJson(m));
|
||||||
|
} else if (a0 instanceof String s) {
|
||||||
|
// BankTransferAdapter.queryStatus(txId)
|
||||||
|
vo.setRequestSummary("{\"arg\":\"" + safeJsonString(s) + "\"}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyResponse(ExternalCallLogVO vo, Object result) {
|
||||||
|
if (result instanceof BankTransferResponse btr) {
|
||||||
|
vo.setResultCode(btr.getResultCode());
|
||||||
|
vo.setResultMessage(truncate(btr.getResultMessage(), 1000));
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("txId", btr.getTxId());
|
||||||
|
m.put("status", btr.getStatus());
|
||||||
|
m.put("processedAt", btr.getProcessedAt());
|
||||||
|
vo.setResponseSummary(toJson(m));
|
||||||
|
} else if (result instanceof MessageResponse mr) {
|
||||||
|
vo.setResultCode(mr.getResultCode());
|
||||||
|
vo.setResultMessage(truncate(mr.getResultMessage(), 1000));
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("messageId", mr.getMessageId());
|
||||||
|
m.put("sent", mr.isSent());
|
||||||
|
m.put("sentAt", mr.getSentAt());
|
||||||
|
vo.setResponseSummary(toJson(m));
|
||||||
|
} else if (result instanceof com.ga.common.adapter.push.PushResponse pr) {
|
||||||
|
vo.setResultCode(pr.getResultCode());
|
||||||
|
vo.setResultMessage(truncate(pr.getResultMessage(), 1000));
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("messageId", pr.getMessageId());
|
||||||
|
m.put("sent", pr.isSent());
|
||||||
|
m.put("sentAt", pr.getSentAt());
|
||||||
|
vo.setResponseSummary(toJson(m));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String simpleName(Object o) {
|
||||||
|
return o == null ? null : o.getClass().getSimpleName();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String toJson(Object o) {
|
||||||
|
try {
|
||||||
|
return om.writeValueAsString(o);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String maskAccount(String accountNo) {
|
||||||
|
if (accountNo == null) return null;
|
||||||
|
String d = accountNo.replaceAll("[^0-9]", "");
|
||||||
|
if (d.length() <= 4) return "****";
|
||||||
|
return "****" + d.substring(d.length() - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String maskPhone(String phone) {
|
||||||
|
if (phone == null) return null;
|
||||||
|
String d = phone.replaceAll("[^0-9]", "");
|
||||||
|
if (d.length() <= 4) return "****";
|
||||||
|
return "***-****-" + d.substring(d.length() - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String maskName(String name) {
|
||||||
|
if (name == null || name.isEmpty()) return null;
|
||||||
|
return name.charAt(0) + "*".repeat(Math.max(0, name.length() - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String maskToken(String token) {
|
||||||
|
if (token == null) return null;
|
||||||
|
return token.length() <= 8 ? "****" : token.substring(0, 8) + "****";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String truncate(String s, int max) {
|
||||||
|
if (s == null) return null;
|
||||||
|
return s.length() <= max ? s : s.substring(0, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safeJsonString(String s) {
|
||||||
|
return s == null ? "" : s.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,6 @@ import jakarta.validation.Valid;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
@Tag(name = "인증")
|
@Tag(name = "인증")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/auth")
|
@RequestMapping("/api/auth")
|
||||||
@@ -24,8 +22,8 @@ public class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/refresh")
|
@PostMapping("/refresh")
|
||||||
public ApiResponse<LoginResp> refresh(@RequestBody Map<String, String> body) {
|
public ApiResponse<LoginResp> refresh(@Valid @RequestBody RefreshTokenReq req) {
|
||||||
return ApiResponse.ok(authService.refresh(body.get("refreshToken")));
|
return ApiResponse.ok(authService.refresh(req.getRefreshToken()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/me")
|
@GetMapping("/me")
|
||||||
@@ -34,8 +32,8 @@ public class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/password")
|
@PostMapping("/password")
|
||||||
public ApiResponse<Void> changePassword(@RequestBody Map<String, String> body) {
|
public ApiResponse<Void> changePassword(@Valid @RequestBody ChangePasswordReq req) {
|
||||||
authService.changePassword(body.get("oldPassword"), body.get("newPassword"));
|
authService.changePassword(req.getOldPassword(), req.getNewPassword());
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import com.ga.common.system.SystemLogMapper;
|
|||||||
import com.ga.common.util.SecurityUtil;
|
import com.ga.common.util.SecurityUtil;
|
||||||
import com.ga.core.mapper.user.UserMapper;
|
import com.ga.core.mapper.user.UserMapper;
|
||||||
import com.ga.core.vo.user.UserResp;
|
import com.ga.core.vo.user.UserResp;
|
||||||
|
import com.ga.core.vo.user.UserStatus;
|
||||||
import com.ga.core.vo.user.UserVO;
|
import com.ga.core.vo.user.UserVO;
|
||||||
import io.jsonwebtoken.Claims;
|
import io.jsonwebtoken.Claims;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -38,18 +39,18 @@ public class AuthService {
|
|||||||
saveLoginLog(null, req.getLoginId(), "FAIL", "USER_NOT_FOUND", ipAddress, userAgent);
|
saveLoginLog(null, req.getLoginId(), "FAIL", "USER_NOT_FOUND", ipAddress, userAgent);
|
||||||
throw new BizException(ErrorCode.LOGIN_FAIL);
|
throw new BizException(ErrorCode.LOGIN_FAIL);
|
||||||
}
|
}
|
||||||
if ("LOCKED".equals(user.getStatus())) {
|
if (UserStatus.LOCKED.name().equals(user.getStatus())) {
|
||||||
saveLoginLog(user.getUserId(), req.getLoginId(), "FAIL", "ACCOUNT_LOCKED", ipAddress, userAgent);
|
saveLoginLog(user.getUserId(), req.getLoginId(), "FAIL", "ACCOUNT_LOCKED", ipAddress, userAgent);
|
||||||
throw new BizException(ErrorCode.ACCOUNT_LOCKED);
|
throw new BizException(ErrorCode.ACCOUNT_LOCKED);
|
||||||
}
|
}
|
||||||
if ("RETIRED".equals(user.getStatus())) {
|
if (UserStatus.RETIRED.name().equals(user.getStatus())) {
|
||||||
throw new BizException(ErrorCode.ACCOUNT_RETIRED);
|
throw new BizException(ErrorCode.ACCOUNT_RETIRED);
|
||||||
}
|
}
|
||||||
if (!passwordEncoder.matches(req.getPassword(), user.getPassword())) {
|
if (!passwordEncoder.matches(req.getPassword(), user.getPassword())) {
|
||||||
userMapper.incrementFailCount(user.getUserId());
|
userMapper.incrementFailCount(user.getUserId());
|
||||||
int newFail = (user.getFailCount() == null ? 0 : user.getFailCount()) + 1;
|
int newFail = (user.getFailCount() == null ? 0 : user.getFailCount()) + 1;
|
||||||
if (newFail >= failLimit) {
|
if (newFail >= failLimit) {
|
||||||
userMapper.updateStatus(user.getUserId(), "LOCKED");
|
userMapper.updateStatus(user.getUserId(), UserStatus.LOCKED.name());
|
||||||
}
|
}
|
||||||
saveLoginLog(user.getUserId(), req.getLoginId(), "FAIL", "BAD_PASSWORD", ipAddress, userAgent);
|
saveLoginLog(user.getUserId(), req.getLoginId(), "FAIL", "BAD_PASSWORD", ipAddress, userAgent);
|
||||||
throw new BizException(ErrorCode.LOGIN_FAIL);
|
throw new BizException(ErrorCode.LOGIN_FAIL);
|
||||||
@@ -80,7 +81,7 @@ public class AuthService {
|
|||||||
}
|
}
|
||||||
Long userId = claims.get("uid", Long.class);
|
Long userId = claims.get("uid", Long.class);
|
||||||
UserVO user = userMapper.selectById(userId);
|
UserVO user = userMapper.selectById(userId);
|
||||||
if (user == null || !"ACTIVE".equals(user.getStatus())) {
|
if (user == null || !UserStatus.ACTIVE.name().equals(user.getStatus())) {
|
||||||
throw new BizException(ErrorCode.UNAUTHORIZED);
|
throw new BizException(ErrorCode.UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
List<String> roles = userMapper.selectRoleCodes(userId);
|
List<String> roles = userMapper.selectRoleCodes(userId);
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.ga.api.auth;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class ChangePasswordReq {
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
private String oldPassword;
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
private String newPassword;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.ga.api.auth;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class RefreshTokenReq {
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
private String refreshToken;
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.ga.api.controller.accounting;
|
||||||
|
|
||||||
|
import com.ga.api.service.accounting.AccountingCloseService;
|
||||||
|
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.accounting.AccountBalanceSnapshotVO;
|
||||||
|
import com.ga.core.vo.accounting.AccountingCloseResp;
|
||||||
|
import com.ga.core.vo.accounting.AccountingCloseSaveReq;
|
||||||
|
import com.ga.core.vo.accounting.AccountingCloseSearchParam;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
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/accounting-closes")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AccountingCloseController {
|
||||||
|
|
||||||
|
private final AccountingCloseService service;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "회계 결산 목록")
|
||||||
|
@RequirePermission(menu = "ACCOUNTING_CLOSE", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<AccountingCloseResp>> list(@ModelAttribute AccountingCloseSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.list(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@Operation(summary = "회계 결산 상세")
|
||||||
|
@RequirePermission(menu = "ACCOUNTING_CLOSE", perm = "READ")
|
||||||
|
public ApiResponse<AccountingCloseResp> detail(@PathVariable Long id) {
|
||||||
|
return ApiResponse.ok(service.detail(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/snapshots")
|
||||||
|
@Operation(summary = "계정과목별 잔액 스냅샷")
|
||||||
|
@RequirePermission(menu = "ACCOUNTING_CLOSE", perm = "READ")
|
||||||
|
public ApiResponse<List<AccountBalanceSnapshotVO>> snapshots(@PathVariable Long id) {
|
||||||
|
return ApiResponse.ok(service.snapshots(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/draft")
|
||||||
|
@Operation(summary = "결산 DRAFT 생성")
|
||||||
|
@RequirePermission(menu = "ACCOUNTING_CLOSE", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "ACCOUNTING_CLOSE", table = "accounting_close")
|
||||||
|
public ApiResponse<Long> createDraft(@Valid @RequestBody AccountingCloseSaveReq req) {
|
||||||
|
return ApiResponse.ok(service.createDraft(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/close")
|
||||||
|
@Operation(summary = "결산 마감 실행")
|
||||||
|
@RequirePermission(menu = "ACCOUNTING_CLOSE", perm = "EXECUTE")
|
||||||
|
@DataChangeLog(menu = "ACCOUNTING_CLOSE", table = "accounting_close")
|
||||||
|
public ApiResponse<AccountingCloseResp> close(@PathVariable Long id) {
|
||||||
|
return ApiResponse.ok(service.close(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/reopen")
|
||||||
|
@Operation(summary = "결산 재오픈")
|
||||||
|
@RequirePermission(menu = "ACCOUNTING_CLOSE", perm = "APPROVE")
|
||||||
|
@DataChangeLog(menu = "ACCOUNTING_CLOSE", table = "accounting_close")
|
||||||
|
public ApiResponse<Void> reopen(@PathVariable Long id, @RequestBody Map<String, String> body) {
|
||||||
|
service.reopen(id, body.get("reopenReason"));
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.ga.api.controller.accounting;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class CloseJournalReq {
|
||||||
|
@NotEmpty
|
||||||
|
private List<Long> entryIds;
|
||||||
|
@NotBlank
|
||||||
|
private String journalNo;
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
package com.ga.api.controller.accounting;
|
||||||
|
|
||||||
|
import com.ga.api.service.accounting.ContractAccountingService;
|
||||||
|
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.accounting.ContractAccountingEntryResp;
|
||||||
|
import com.ga.core.vo.accounting.ContractAccountingEntrySearchParam;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
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/accounting-entries")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ContractAccountingController {
|
||||||
|
|
||||||
|
private final ContractAccountingService service;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "분개 엔트리 목록 조회")
|
||||||
|
@RequirePermission(menu = "ACCOUNTING_JOURNAL", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<ContractAccountingEntryResp>> list(
|
||||||
|
@ModelAttribute ContractAccountingEntrySearchParam param) {
|
||||||
|
return ApiResponse.ok(service.list(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/generate")
|
||||||
|
@Operation(summary = "자동 분개 생성 (paymentIds 또는 settleMonth)")
|
||||||
|
@RequirePermission(menu = "ACCOUNTING_JOURNAL", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "ACCOUNTING_JOURNAL", table = "contract_accounting_entry")
|
||||||
|
public ApiResponse<Integer> generate(@RequestBody GenerateEntriesReq req) {
|
||||||
|
return ApiResponse.ok(service.generateFromPayments(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/close")
|
||||||
|
@Operation(summary = "분개 마감 처리")
|
||||||
|
@RequirePermission(menu = "ACCOUNTING_JOURNAL", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "ACCOUNTING_JOURNAL", table = "contract_accounting_entry")
|
||||||
|
public ApiResponse<Integer> close(@Valid @RequestBody CloseJournalReq req) {
|
||||||
|
return ApiResponse.ok(service.closeJournal(req));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.ga.api.controller.accounting;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class GenerateEntriesReq {
|
||||||
|
private List<Long> paymentIds;
|
||||||
|
private String settleMonth;
|
||||||
|
}
|
||||||
@@ -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,129 @@
|
|||||||
|
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/mine")
|
||||||
|
@RequirePermission(menu = "APPROVAL", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<ApprovalRequestResp>> myRequests(
|
||||||
|
@ModelAttribute ApprovalRequestSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.myRequests(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/requests/pending")
|
||||||
|
@RequirePermission(menu = "APPROVAL", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<ApprovalRequestResp>> pendingRequests(
|
||||||
|
@ModelAttribute ApprovalRequestSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.pendingRequests(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,24 @@
|
|||||||
|
package com.ga.api.controller.chargeback;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Positive;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class ChargebackCalculateReq {
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
private LocalDate contractDate;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
private LocalDate terminationDate;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Positive
|
||||||
|
private BigDecimal originalCommission;
|
||||||
|
|
||||||
|
private String productCategory;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package com.ga.api.controller.chargeback;
|
||||||
|
|
||||||
|
import com.ga.api.service.chargeback.ChargebackCalculator;
|
||||||
|
import com.ga.api.service.chargeback.ChargebackGradeService;
|
||||||
|
import com.ga.api.service.chargeback.ChargebackResult;
|
||||||
|
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.chargeback.ChargebackGradeResp;
|
||||||
|
import com.ga.core.vo.chargeback.ChargebackGradeSaveReq;
|
||||||
|
import com.ga.core.vo.chargeback.ChargebackGradeSearchParam;
|
||||||
|
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/chargeback-grades")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ChargebackGradeController {
|
||||||
|
|
||||||
|
private final ChargebackGradeService gradeService;
|
||||||
|
private final ChargebackCalculator calculator;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@RequirePermission(menu = "CHARGEBACK_GRADE", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<ChargebackGradeResp>> list(ChargebackGradeSearchParam param) {
|
||||||
|
return ApiResponse.ok(gradeService.list(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{gradeId}")
|
||||||
|
@RequirePermission(menu = "CHARGEBACK_GRADE", perm = "READ")
|
||||||
|
public ApiResponse<ChargebackGradeResp> detail(@PathVariable Long gradeId) {
|
||||||
|
return ApiResponse.ok(gradeService.detail(gradeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@RequirePermission(menu = "CHARGEBACK_GRADE", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "CHARGEBACK_GRADE", table = "chargeback_grade")
|
||||||
|
public ApiResponse<Void> create(@Valid @RequestBody ChargebackGradeSaveReq req) {
|
||||||
|
gradeService.create(req);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{gradeId}")
|
||||||
|
@RequirePermission(menu = "CHARGEBACK_GRADE", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "CHARGEBACK_GRADE", table = "chargeback_grade")
|
||||||
|
public ApiResponse<Void> update(@PathVariable Long gradeId,
|
||||||
|
@Valid @RequestBody ChargebackGradeSaveReq req) {
|
||||||
|
gradeService.update(gradeId, req);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{gradeId}/deactivate")
|
||||||
|
@RequirePermission(menu = "CHARGEBACK_GRADE", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "CHARGEBACK_GRADE", table = "chargeback_grade")
|
||||||
|
public ApiResponse<Void> deactivate(@PathVariable Long gradeId) {
|
||||||
|
gradeService.deactivate(gradeId);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/calculate")
|
||||||
|
@RequirePermission(menu = "CHARGEBACK_GRADE", perm = "READ")
|
||||||
|
public ApiResponse<ChargebackResult> calculate(@Valid @RequestBody ChargebackCalculateReq req) {
|
||||||
|
ChargebackResult result = calculator.calculate(
|
||||||
|
req.getContractDate(),
|
||||||
|
req.getTerminationDate(),
|
||||||
|
req.getOriginalCommission(),
|
||||||
|
req.getProductCategory()
|
||||||
|
);
|
||||||
|
return ApiResponse.ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.ga.api.controller.commission;
|
||||||
|
|
||||||
|
import com.ga.api.service.commission.AwardService;
|
||||||
|
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/awards")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AwardController {
|
||||||
|
|
||||||
|
private final AwardService service;
|
||||||
|
|
||||||
|
// ── Rule ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GetMapping("/rules")
|
||||||
|
@RequirePermission(menu = "AWARD", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<AwardRuleResp>> listRules(
|
||||||
|
@ModelAttribute AwardRuleSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.listRules(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/rules/{ruleId}")
|
||||||
|
@RequirePermission(menu = "AWARD", perm = "READ")
|
||||||
|
public ApiResponse<AwardRuleResp> detailRule(@PathVariable Long ruleId) {
|
||||||
|
return ApiResponse.ok(service.detailRule(ruleId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/rules")
|
||||||
|
@RequirePermission(menu = "AWARD", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "AWARD", table = "award_rule")
|
||||||
|
public ApiResponse<Long> createRule(@Valid @RequestBody AwardRuleSaveReq req) {
|
||||||
|
return ApiResponse.ok(service.createRule(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/rules/{ruleId}")
|
||||||
|
@RequirePermission(menu = "AWARD", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "AWARD", table = "award_rule")
|
||||||
|
public ApiResponse<Void> updateRule(@PathVariable Long ruleId,
|
||||||
|
@Valid @RequestBody AwardRuleSaveReq req) {
|
||||||
|
service.updateRule(ruleId, req);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/rules/{ruleId}")
|
||||||
|
@RequirePermission(menu = "AWARD", perm = "DELETE")
|
||||||
|
@DataChangeLog(menu = "AWARD", table = "award_rule")
|
||||||
|
public ApiResponse<Void> deleteRule(@PathVariable Long ruleId) {
|
||||||
|
service.deleteRule(ruleId);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ledger ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GetMapping("/ledgers")
|
||||||
|
@RequirePermission(menu = "AWARD", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<AwardLedgerResp>> listLedgers(
|
||||||
|
@ModelAttribute AwardLedgerSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.listLedgers(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/ledgers/{ledgerId}")
|
||||||
|
@RequirePermission(menu = "AWARD", perm = "READ")
|
||||||
|
public ApiResponse<AwardLedgerResp> detailLedger(@PathVariable Long ledgerId) {
|
||||||
|
return ApiResponse.ok(service.detailLedger(ledgerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/ledgers")
|
||||||
|
@RequirePermission(menu = "AWARD", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "AWARD", table = "award_ledger")
|
||||||
|
public ApiResponse<Long> createLedger(@Valid @RequestBody AwardLedgerSaveReq req) {
|
||||||
|
return ApiResponse.ok(service.createLedger(req));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.ga.api.controller.commission;
|
||||||
|
|
||||||
|
import com.ga.api.service.commission.BranchAllowanceService;
|
||||||
|
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/branch-allowances")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class BranchAllowanceController {
|
||||||
|
|
||||||
|
private final BranchAllowanceService service;
|
||||||
|
|
||||||
|
// ── Rule ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GetMapping("/rules")
|
||||||
|
@RequirePermission(menu = "BRANCH_MGR_ALLOWANCE", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<BranchMgrAllowanceRuleResp>> listRules(
|
||||||
|
@ModelAttribute BranchMgrAllowanceRuleSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.listRules(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/rules/{ruleId}")
|
||||||
|
@RequirePermission(menu = "BRANCH_MGR_ALLOWANCE", perm = "READ")
|
||||||
|
public ApiResponse<BranchMgrAllowanceRuleResp> detailRule(@PathVariable Long ruleId) {
|
||||||
|
return ApiResponse.ok(service.detailRule(ruleId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/rules")
|
||||||
|
@RequirePermission(menu = "BRANCH_MGR_ALLOWANCE", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "BRANCH_MGR_ALLOWANCE", table = "branch_mgr_allowance_rule")
|
||||||
|
public ApiResponse<Long> createRule(@Valid @RequestBody BranchMgrAllowanceRuleSaveReq req) {
|
||||||
|
return ApiResponse.ok(service.createRule(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/rules/{ruleId}")
|
||||||
|
@RequirePermission(menu = "BRANCH_MGR_ALLOWANCE", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "BRANCH_MGR_ALLOWANCE", table = "branch_mgr_allowance_rule")
|
||||||
|
public ApiResponse<Void> updateRule(@PathVariable Long ruleId,
|
||||||
|
@Valid @RequestBody BranchMgrAllowanceRuleSaveReq req) {
|
||||||
|
service.updateRule(ruleId, req);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/rules/{ruleId}")
|
||||||
|
@RequirePermission(menu = "BRANCH_MGR_ALLOWANCE", perm = "DELETE")
|
||||||
|
@DataChangeLog(menu = "BRANCH_MGR_ALLOWANCE", table = "branch_mgr_allowance_rule")
|
||||||
|
public ApiResponse<Void> deleteRule(@PathVariable Long ruleId) {
|
||||||
|
service.deleteRule(ruleId);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ledger ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GetMapping("/ledgers")
|
||||||
|
@RequirePermission(menu = "BRANCH_MGR_ALLOWANCE", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<BranchMgrAllowanceLedgerResp>> listLedgers(
|
||||||
|
@ModelAttribute BranchMgrAllowanceLedgerSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.listLedgers(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/ledgers/{ledgerId}")
|
||||||
|
@RequirePermission(menu = "BRANCH_MGR_ALLOWANCE", perm = "READ")
|
||||||
|
public ApiResponse<BranchMgrAllowanceLedgerResp> detailLedger(@PathVariable Long ledgerId) {
|
||||||
|
return ApiResponse.ok(service.detailLedger(ledgerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/ledgers")
|
||||||
|
@RequirePermission(menu = "BRANCH_MGR_ALLOWANCE", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "BRANCH_MGR_ALLOWANCE", table = "branch_mgr_allowance_ledger")
|
||||||
|
public ApiResponse<Long> createLedger(@Valid @RequestBody BranchMgrAllowanceLedgerSaveReq req) {
|
||||||
|
return ApiResponse.ok(service.createLedger(req));
|
||||||
|
}
|
||||||
|
}
|
||||||
+82
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
package com.ga.api.controller.commission;
|
||||||
|
|
||||||
|
import com.ga.api.service.commission.CommissionSimulationService;
|
||||||
|
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.CommissionSimulationResp;
|
||||||
|
import com.ga.core.vo.commission.CommissionSimulationSaveReq;
|
||||||
|
import com.ga.core.vo.commission.CommissionSimulationSearchParam;
|
||||||
|
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/commission-simulations")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CommissionSimulationController {
|
||||||
|
|
||||||
|
private final CommissionSimulationService service;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@RequirePermission(menu = "COMM_SIMULATOR", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<CommissionSimulationResp>> list(
|
||||||
|
@ModelAttribute CommissionSimulationSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.list(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{simId}")
|
||||||
|
@RequirePermission(menu = "COMM_SIMULATOR", perm = "READ")
|
||||||
|
public ApiResponse<CommissionSimulationResp> detail(@PathVariable Long simId) {
|
||||||
|
return ApiResponse.ok(service.detail(simId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/simulate")
|
||||||
|
@RequirePermission(menu = "COMM_SIMULATOR", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "COMM_SIMULATOR", table = "commission_simulation")
|
||||||
|
public ApiResponse<CommissionSimulationResp> simulate(@Valid @RequestBody CommissionSimulationSaveReq req) {
|
||||||
|
return ApiResponse.ok(service.simulate(req));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package com.ga.api.controller.commission;
|
||||||
|
|
||||||
|
import com.ga.api.service.commission.GradeEvaluationService;
|
||||||
|
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.AgentGradeEvaluationResp;
|
||||||
|
import com.ga.core.vo.commission.AgentGradeEvaluationSaveReq;
|
||||||
|
import com.ga.core.vo.commission.AgentGradeEvaluationSearchParam;
|
||||||
|
import com.ga.core.vo.commission.GradeEvalRuleResp;
|
||||||
|
import com.ga.core.vo.commission.GradeEvalRuleSaveReq;
|
||||||
|
import com.ga.core.vo.commission.GradeEvalRuleSearchParam;
|
||||||
|
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/grade-evaluations")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GradeEvaluationController {
|
||||||
|
|
||||||
|
private final GradeEvaluationService service;
|
||||||
|
|
||||||
|
// ── Rule ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GetMapping("/rules")
|
||||||
|
@RequirePermission(menu = "GRADE_EVAL", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<GradeEvalRuleResp>> listRules(@ModelAttribute GradeEvalRuleSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.listRules(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/rules/{ruleId}")
|
||||||
|
@RequirePermission(menu = "GRADE_EVAL", perm = "READ")
|
||||||
|
public ApiResponse<GradeEvalRuleResp> detailRule(@PathVariable Long ruleId) {
|
||||||
|
return ApiResponse.ok(service.detailRule(ruleId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/rules")
|
||||||
|
@RequirePermission(menu = "GRADE_EVAL", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "GRADE_EVAL", table = "grade_eval_rule")
|
||||||
|
public ApiResponse<Long> createRule(@Valid @RequestBody GradeEvalRuleSaveReq req) {
|
||||||
|
return ApiResponse.ok(service.createRule(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/rules/{ruleId}")
|
||||||
|
@RequirePermission(menu = "GRADE_EVAL", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "GRADE_EVAL", table = "grade_eval_rule")
|
||||||
|
public ApiResponse<Void> updateRule(@PathVariable Long ruleId,
|
||||||
|
@Valid @RequestBody GradeEvalRuleSaveReq req) {
|
||||||
|
service.updateRule(ruleId, req);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/rules/{ruleId}")
|
||||||
|
@RequirePermission(menu = "GRADE_EVAL", perm = "DELETE")
|
||||||
|
@DataChangeLog(menu = "GRADE_EVAL", table = "grade_eval_rule")
|
||||||
|
public ApiResponse<Void> deleteRule(@PathVariable Long ruleId) {
|
||||||
|
service.deleteRule(ruleId);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Evaluation ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@RequirePermission(menu = "GRADE_EVAL", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<AgentGradeEvaluationResp>> listEvals(
|
||||||
|
@ModelAttribute AgentGradeEvaluationSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.listEvals(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{evalId}")
|
||||||
|
@RequirePermission(menu = "GRADE_EVAL", perm = "READ")
|
||||||
|
public ApiResponse<AgentGradeEvaluationResp> detailEval(@PathVariable Long evalId) {
|
||||||
|
return ApiResponse.ok(service.detailEval(evalId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@RequirePermission(menu = "GRADE_EVAL", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "GRADE_EVAL", table = "agent_grade_evaluation")
|
||||||
|
public ApiResponse<Long> createEval(@Valid @RequestBody AgentGradeEvaluationSaveReq req) {
|
||||||
|
return ApiResponse.ok(service.createEval(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{evalId}/apply")
|
||||||
|
@RequirePermission(menu = "GRADE_EVAL", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "GRADE_EVAL", table = "agent_grade_evaluation")
|
||||||
|
public ApiResponse<Void> applyEval(@PathVariable Long evalId) {
|
||||||
|
service.applyEval(evalId);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{evalId}")
|
||||||
|
@RequirePermission(menu = "GRADE_EVAL", perm = "DELETE")
|
||||||
|
@DataChangeLog(menu = "GRADE_EVAL", table = "agent_grade_evaluation")
|
||||||
|
public ApiResponse<Void> deleteEval(@PathVariable Long evalId) {
|
||||||
|
service.deleteEval(evalId);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
package com.ga.api.controller.commission;
|
||||||
|
|
||||||
|
import com.ga.api.service.commission.GuaranteeInsuranceService;
|
||||||
|
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.AgentGuaranteeInsuranceResp;
|
||||||
|
import com.ga.core.vo.commission.AgentGuaranteeInsuranceSaveReq;
|
||||||
|
import com.ga.core.vo.commission.AgentGuaranteeInsuranceSearchParam;
|
||||||
|
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/guarantee-insurances")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class GuaranteeInsuranceController {
|
||||||
|
|
||||||
|
private final GuaranteeInsuranceService service;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@RequirePermission(menu = "GUARANTEE_INS", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<AgentGuaranteeInsuranceResp>> list(
|
||||||
|
@ModelAttribute AgentGuaranteeInsuranceSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.list(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{guaranteeId}")
|
||||||
|
@RequirePermission(menu = "GUARANTEE_INS", perm = "READ")
|
||||||
|
public ApiResponse<AgentGuaranteeInsuranceResp> detail(@PathVariable Long guaranteeId) {
|
||||||
|
return ApiResponse.ok(service.detail(guaranteeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@RequirePermission(menu = "GUARANTEE_INS", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "GUARANTEE_INS", table = "agent_guarantee_insurance")
|
||||||
|
public ApiResponse<Long> create(@Valid @RequestBody AgentGuaranteeInsuranceSaveReq req) {
|
||||||
|
return ApiResponse.ok(service.create(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{guaranteeId}")
|
||||||
|
@RequirePermission(menu = "GUARANTEE_INS", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "GUARANTEE_INS", table = "agent_guarantee_insurance")
|
||||||
|
public ApiResponse<Void> update(@PathVariable Long guaranteeId,
|
||||||
|
@Valid @RequestBody AgentGuaranteeInsuranceSaveReq req) {
|
||||||
|
service.update(guaranteeId, req);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{guaranteeId}")
|
||||||
|
@RequirePermission(menu = "GUARANTEE_INS", perm = "DELETE")
|
||||||
|
@DataChangeLog(menu = "GUARANTEE_INS", table = "agent_guarantee_insurance")
|
||||||
|
public ApiResponse<Void> delete(@PathVariable Long guaranteeId) {
|
||||||
|
service.delete(guaranteeId);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.ga.api.controller.commission;
|
||||||
|
|
||||||
|
import com.ga.api.service.commission.HqSettleService;
|
||||||
|
import com.ga.api.service.commission.HqSettleService.HqSummaryResp;
|
||||||
|
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.HqSettleItemResp;
|
||||||
|
import com.ga.core.vo.commission.HqSettleItemSaveReq;
|
||||||
|
import com.ga.core.vo.commission.HqSettleItemSearchParam;
|
||||||
|
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/hq-settles")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class HqSettleController {
|
||||||
|
|
||||||
|
private final HqSettleService service;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@RequirePermission(menu = "HQ_SETTLE", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<HqSettleItemResp>> list(@ModelAttribute HqSettleItemSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.list(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/summary")
|
||||||
|
@RequirePermission(menu = "HQ_SETTLE", perm = "READ")
|
||||||
|
public ApiResponse<HqSummaryResp> summary(@RequestParam String settleMonth) {
|
||||||
|
return ApiResponse.ok(service.summary(settleMonth));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{itemId}")
|
||||||
|
@RequirePermission(menu = "HQ_SETTLE", perm = "READ")
|
||||||
|
public ApiResponse<HqSettleItemResp> detail(@PathVariable Long itemId) {
|
||||||
|
return ApiResponse.ok(service.detail(itemId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@RequirePermission(menu = "HQ_SETTLE", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "HQ_SETTLE", table = "hq_settle_item")
|
||||||
|
public ApiResponse<Long> create(@Valid @RequestBody HqSettleItemSaveReq req) {
|
||||||
|
return ApiResponse.ok(service.create(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{itemId}")
|
||||||
|
@RequirePermission(menu = "HQ_SETTLE", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "HQ_SETTLE", table = "hq_settle_item")
|
||||||
|
public ApiResponse<Void> update(@PathVariable Long itemId,
|
||||||
|
@Valid @RequestBody HqSettleItemSaveReq req) {
|
||||||
|
service.update(itemId, req);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{itemId}")
|
||||||
|
@RequirePermission(menu = "HQ_SETTLE", perm = "DELETE")
|
||||||
|
@DataChangeLog(menu = "HQ_SETTLE", table = "hq_settle_item")
|
||||||
|
public ApiResponse<Void> delete(@PathVariable Long itemId) {
|
||||||
|
service.delete(itemId);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.ga.api.controller.commission;
|
||||||
|
|
||||||
|
import com.ga.api.service.commission.IncomeCommissionService;
|
||||||
|
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/income-commissions")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class IncomeCommissionController {
|
||||||
|
|
||||||
|
private final IncomeCommissionService service;
|
||||||
|
|
||||||
|
// ── Income Ledger ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GetMapping("/ledgers")
|
||||||
|
@RequirePermission(menu = "INCOME_COMM", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<IncomeCommissionLedgerResp>> listLedgers(
|
||||||
|
@ModelAttribute IncomeCommissionLedgerSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.listLedgers(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/ledgers/{incomeId}")
|
||||||
|
@RequirePermission(menu = "INCOME_COMM", perm = "READ")
|
||||||
|
public ApiResponse<IncomeCommissionLedgerResp> detailLedger(@PathVariable Long incomeId) {
|
||||||
|
return ApiResponse.ok(service.detailLedger(incomeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/ledgers")
|
||||||
|
@RequirePermission(menu = "INCOME_COMM", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "INCOME_COMM", table = "income_commission_ledger")
|
||||||
|
public ApiResponse<Long> createLedger(@Valid @RequestBody IncomeCommissionLedgerSaveReq req) {
|
||||||
|
return ApiResponse.ok(service.createLedger(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Margin Summary ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GetMapping("/margins")
|
||||||
|
@RequirePermission(menu = "INCOME_COMM", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<MarginSummaryResp>> listMargins(
|
||||||
|
@ModelAttribute MarginSummarySearchParam param) {
|
||||||
|
return ApiResponse.ok(service.listMargins(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/margins/{marginId}")
|
||||||
|
@RequirePermission(menu = "INCOME_COMM", perm = "READ")
|
||||||
|
public ApiResponse<MarginSummaryResp> detailMargin(@PathVariable Long marginId) {
|
||||||
|
return ApiResponse.ok(service.detailMargin(marginId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/margin/recalc")
|
||||||
|
@RequirePermission(menu = "INCOME_COMM", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "INCOME_COMM", table = "margin_summary")
|
||||||
|
public ApiResponse<Long> recalcMargin(@RequestParam String settleMonth,
|
||||||
|
@RequestParam(required = false) Long agentId) {
|
||||||
|
return ApiResponse.ok(service.recalcMargin(settleMonth, agentId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package com.ga.api.controller.commission;
|
||||||
|
|
||||||
|
import com.ga.api.service.commission.JointContractService;
|
||||||
|
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.JointContractResp;
|
||||||
|
import com.ga.core.vo.commission.JointContractSaveReq;
|
||||||
|
import com.ga.core.vo.commission.JointContractSearchParam;
|
||||||
|
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/joint-contracts")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class JointContractController {
|
||||||
|
|
||||||
|
private final JointContractService service;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@RequirePermission(menu = "JOINT_CONTRACT", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<JointContractResp>> list(@ModelAttribute JointContractSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.list(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{jointId}")
|
||||||
|
@RequirePermission(menu = "JOINT_CONTRACT", perm = "READ")
|
||||||
|
public ApiResponse<JointContractResp> detail(@PathVariable Long jointId) {
|
||||||
|
return ApiResponse.ok(service.detail(jointId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@RequirePermission(menu = "JOINT_CONTRACT", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "JOINT_CONTRACT", table = "joint_contract")
|
||||||
|
public ApiResponse<Long> create(@Valid @RequestBody JointContractSaveReq req) {
|
||||||
|
return ApiResponse.ok(service.create(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{jointId}")
|
||||||
|
@RequirePermission(menu = "JOINT_CONTRACT", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "JOINT_CONTRACT", table = "joint_contract")
|
||||||
|
public ApiResponse<Void> update(@PathVariable Long jointId,
|
||||||
|
@Valid @RequestBody JointContractSaveReq req) {
|
||||||
|
service.update(jointId, req);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{jointId}")
|
||||||
|
@RequirePermission(menu = "JOINT_CONTRACT", perm = "DELETE")
|
||||||
|
@DataChangeLog(menu = "JOINT_CONTRACT", table = "joint_contract")
|
||||||
|
public ApiResponse<Void> delete(@PathVariable Long jointId) {
|
||||||
|
service.delete(jointId);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.ga.api.controller.commission;
|
||||||
|
|
||||||
|
import com.ga.api.service.commission.LifeSupportService;
|
||||||
|
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/life-supports")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class LifeSupportController {
|
||||||
|
|
||||||
|
private final LifeSupportService service;
|
||||||
|
|
||||||
|
// ── Rule ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GetMapping("/rules")
|
||||||
|
@RequirePermission(menu = "LIFE_OP_SUPPORT", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<LifeSupportRuleResp>> listRules(
|
||||||
|
@ModelAttribute LifeSupportRuleSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.listRules(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/rules/{ruleId}")
|
||||||
|
@RequirePermission(menu = "LIFE_OP_SUPPORT", perm = "READ")
|
||||||
|
public ApiResponse<LifeSupportRuleResp> detailRule(@PathVariable Long ruleId) {
|
||||||
|
return ApiResponse.ok(service.detailRule(ruleId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/rules")
|
||||||
|
@RequirePermission(menu = "LIFE_OP_SUPPORT", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "LIFE_OP_SUPPORT", table = "life_support_rule")
|
||||||
|
public ApiResponse<Long> createRule(@Valid @RequestBody LifeSupportRuleSaveReq req) {
|
||||||
|
return ApiResponse.ok(service.createRule(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/rules/{ruleId}")
|
||||||
|
@RequirePermission(menu = "LIFE_OP_SUPPORT", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "LIFE_OP_SUPPORT", table = "life_support_rule")
|
||||||
|
public ApiResponse<Void> updateRule(@PathVariable Long ruleId,
|
||||||
|
@Valid @RequestBody LifeSupportRuleSaveReq req) {
|
||||||
|
service.updateRule(ruleId, req);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/rules/{ruleId}")
|
||||||
|
@RequirePermission(menu = "LIFE_OP_SUPPORT", perm = "DELETE")
|
||||||
|
@DataChangeLog(menu = "LIFE_OP_SUPPORT", table = "life_support_rule")
|
||||||
|
public ApiResponse<Void> deleteRule(@PathVariable Long ruleId) {
|
||||||
|
service.deleteRule(ruleId);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ledger ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GetMapping("/ledgers")
|
||||||
|
@RequirePermission(menu = "LIFE_OP_SUPPORT", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<LifeSupportLedgerResp>> listLedgers(
|
||||||
|
@ModelAttribute LifeSupportLedgerSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.listLedgers(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/ledgers/{ledgerId}")
|
||||||
|
@RequirePermission(menu = "LIFE_OP_SUPPORT", perm = "READ")
|
||||||
|
public ApiResponse<LifeSupportLedgerResp> detailLedger(@PathVariable Long ledgerId) {
|
||||||
|
return ApiResponse.ok(service.detailLedger(ledgerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/ledgers")
|
||||||
|
@RequirePermission(menu = "LIFE_OP_SUPPORT", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "LIFE_OP_SUPPORT", table = "life_support_ledger")
|
||||||
|
public ApiResponse<Long> createLedger(@Valid @RequestBody LifeSupportLedgerSaveReq req) {
|
||||||
|
return ApiResponse.ok(service.createLedger(req));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
package com.ga.api.controller.commission;
|
||||||
|
|
||||||
|
import com.ga.api.service.commission.RecognizedPerformanceService;
|
||||||
|
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/recognized-performances")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RecognizedPerformanceController {
|
||||||
|
|
||||||
|
private final RecognizedPerformanceService service;
|
||||||
|
|
||||||
|
// ── Product Conversion Rate (rule) ────────────────────────────────────────
|
||||||
|
|
||||||
|
@GetMapping("/rates")
|
||||||
|
@RequirePermission(menu = "RECOGNIZED_PERF", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<ProductConversionRateResp>> listRates(
|
||||||
|
@ModelAttribute ProductConversionRateSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.listRates(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/rates/{rateId}")
|
||||||
|
@RequirePermission(menu = "RECOGNIZED_PERF", perm = "READ")
|
||||||
|
public ApiResponse<ProductConversionRateResp> detailRate(@PathVariable Long rateId) {
|
||||||
|
return ApiResponse.ok(service.detailRate(rateId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/rates")
|
||||||
|
@RequirePermission(menu = "RECOGNIZED_PERF", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "RECOGNIZED_PERF", table = "product_conversion_rate")
|
||||||
|
public ApiResponse<Long> createRate(@Valid @RequestBody ProductConversionRateSaveReq req) {
|
||||||
|
return ApiResponse.ok(service.createRate(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/rates/{rateId}")
|
||||||
|
@RequirePermission(menu = "RECOGNIZED_PERF", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "RECOGNIZED_PERF", table = "product_conversion_rate")
|
||||||
|
public ApiResponse<Void> updateRate(@PathVariable Long rateId,
|
||||||
|
@Valid @RequestBody ProductConversionRateSaveReq req) {
|
||||||
|
service.updateRate(rateId, req);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/rates/{rateId}")
|
||||||
|
@RequirePermission(menu = "RECOGNIZED_PERF", perm = "DELETE")
|
||||||
|
@DataChangeLog(menu = "RECOGNIZED_PERF", table = "product_conversion_rate")
|
||||||
|
public ApiResponse<Void> deleteRate(@PathVariable Long rateId) {
|
||||||
|
service.deleteRate(rateId);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ledger ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GetMapping("/ledgers")
|
||||||
|
@RequirePermission(menu = "RECOGNIZED_PERF", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<RecognizedPerformanceLedgerResp>> listLedgers(
|
||||||
|
@ModelAttribute RecognizedPerformanceLedgerSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.listLedgers(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/ledgers/{ledgerId}")
|
||||||
|
@RequirePermission(menu = "RECOGNIZED_PERF", perm = "READ")
|
||||||
|
public ApiResponse<RecognizedPerformanceLedgerResp> detailLedger(@PathVariable Long ledgerId) {
|
||||||
|
return ApiResponse.ok(service.detailLedger(ledgerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/ledgers")
|
||||||
|
@RequirePermission(menu = "RECOGNIZED_PERF", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "RECOGNIZED_PERF", table = "recognized_performance_ledger")
|
||||||
|
public ApiResponse<Long> createLedger(@Valid @RequestBody RecognizedPerformanceLedgerSaveReq req) {
|
||||||
|
return ApiResponse.ok(service.createLedger(req));
|
||||||
|
}
|
||||||
|
}
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
package com.ga.api.controller.commission;
|
||||||
|
|
||||||
|
import com.ga.api.service.commission.ReferralCommissionService;
|
||||||
|
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/referral-commissions")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ReferralCommissionController {
|
||||||
|
|
||||||
|
private final ReferralCommissionService service;
|
||||||
|
|
||||||
|
// ── Rule ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GetMapping("/rules")
|
||||||
|
@RequirePermission(menu = "REFERRAL_COMM", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<ReferralCommissionRuleResp>> listRules(
|
||||||
|
@ModelAttribute ReferralCommissionRuleSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.listRules(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/rules/{ruleId}")
|
||||||
|
@RequirePermission(menu = "REFERRAL_COMM", perm = "READ")
|
||||||
|
public ApiResponse<ReferralCommissionRuleResp> detailRule(@PathVariable Long ruleId) {
|
||||||
|
return ApiResponse.ok(service.detailRule(ruleId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/rules")
|
||||||
|
@RequirePermission(menu = "REFERRAL_COMM", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "REFERRAL_COMM", table = "referral_commission_rule")
|
||||||
|
public ApiResponse<Long> createRule(@Valid @RequestBody ReferralCommissionRuleSaveReq req) {
|
||||||
|
return ApiResponse.ok(service.createRule(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/rules/{ruleId}")
|
||||||
|
@RequirePermission(menu = "REFERRAL_COMM", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "REFERRAL_COMM", table = "referral_commission_rule")
|
||||||
|
public ApiResponse<Void> updateRule(@PathVariable Long ruleId,
|
||||||
|
@Valid @RequestBody ReferralCommissionRuleSaveReq req) {
|
||||||
|
service.updateRule(ruleId, req);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/rules/{ruleId}")
|
||||||
|
@RequirePermission(menu = "REFERRAL_COMM", perm = "DELETE")
|
||||||
|
@DataChangeLog(menu = "REFERRAL_COMM", table = "referral_commission_rule")
|
||||||
|
public ApiResponse<Void> deleteRule(@PathVariable Long ruleId) {
|
||||||
|
service.deleteRule(ruleId);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ledger ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GetMapping("/ledgers")
|
||||||
|
@RequirePermission(menu = "REFERRAL_COMM", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<ReferralCommissionLedgerResp>> listLedgers(
|
||||||
|
@ModelAttribute ReferralCommissionLedgerSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.listLedgers(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/ledgers/{ledgerId}")
|
||||||
|
@RequirePermission(menu = "REFERRAL_COMM", perm = "READ")
|
||||||
|
public ApiResponse<ReferralCommissionLedgerResp> detailLedger(@PathVariable Long ledgerId) {
|
||||||
|
return ApiResponse.ok(service.detailLedger(ledgerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/ledgers")
|
||||||
|
@RequirePermission(menu = "REFERRAL_COMM", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "REFERRAL_COMM", table = "referral_commission_ledger")
|
||||||
|
public ApiResponse<Long> createLedger(@Valid @RequestBody ReferralCommissionLedgerSaveReq req) {
|
||||||
|
return ApiResponse.ok(service.createLedger(req));
|
||||||
|
}
|
||||||
|
}
|
||||||
+78
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+78
@@ -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,42 @@
|
|||||||
|
package com.ga.api.controller.commission;
|
||||||
|
|
||||||
|
import com.ga.api.service.commission.ReserveFundService;
|
||||||
|
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.AgentReserveFundResp;
|
||||||
|
import com.ga.core.vo.commission.AgentReserveFundSaveReq;
|
||||||
|
import com.ga.core.vo.commission.AgentReserveFundSearchParam;
|
||||||
|
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/reserve-funds")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ReserveFundController {
|
||||||
|
|
||||||
|
private final ReserveFundService service;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@RequirePermission(menu = "RESERVE_FUND", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<AgentReserveFundResp>> list(@ModelAttribute AgentReserveFundSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.list(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{reserveId}")
|
||||||
|
@RequirePermission(menu = "RESERVE_FUND", perm = "READ")
|
||||||
|
public ApiResponse<AgentReserveFundResp> detail(@PathVariable Long reserveId) {
|
||||||
|
return ApiResponse.ok(service.detail(reserveId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@RequirePermission(menu = "RESERVE_FUND", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "RESERVE_FUND", table = "agent_reserve_fund")
|
||||||
|
public ApiResponse<Long> create(@Valid @RequestBody AgentReserveFundSaveReq req) {
|
||||||
|
return ApiResponse.ok(service.create(req));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.ga.api.controller.commission;
|
||||||
|
|
||||||
|
import com.ga.api.service.commission.SettlingSupportService;
|
||||||
|
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/settling-supports")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SettlingSupportController {
|
||||||
|
|
||||||
|
private final SettlingSupportService service;
|
||||||
|
|
||||||
|
// ── Rule ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GetMapping("/rules")
|
||||||
|
@RequirePermission(menu = "SETTLING_SUPPORT", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<SettlingSupportRuleResp>> listRules(
|
||||||
|
@ModelAttribute SettlingSupportRuleSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.listRules(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/rules/{ruleId}")
|
||||||
|
@RequirePermission(menu = "SETTLING_SUPPORT", perm = "READ")
|
||||||
|
public ApiResponse<SettlingSupportRuleResp> detailRule(@PathVariable Long ruleId) {
|
||||||
|
return ApiResponse.ok(service.detailRule(ruleId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/rules")
|
||||||
|
@RequirePermission(menu = "SETTLING_SUPPORT", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "SETTLING_SUPPORT", table = "settling_support_rule")
|
||||||
|
public ApiResponse<Long> createRule(@Valid @RequestBody SettlingSupportRuleSaveReq req) {
|
||||||
|
return ApiResponse.ok(service.createRule(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/rules/{ruleId}")
|
||||||
|
@RequirePermission(menu = "SETTLING_SUPPORT", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "SETTLING_SUPPORT", table = "settling_support_rule")
|
||||||
|
public ApiResponse<Void> updateRule(@PathVariable Long ruleId,
|
||||||
|
@Valid @RequestBody SettlingSupportRuleSaveReq req) {
|
||||||
|
service.updateRule(ruleId, req);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/rules/{ruleId}")
|
||||||
|
@RequirePermission(menu = "SETTLING_SUPPORT", perm = "DELETE")
|
||||||
|
@DataChangeLog(menu = "SETTLING_SUPPORT", table = "settling_support_rule")
|
||||||
|
public ApiResponse<Void> deleteRule(@PathVariable Long ruleId) {
|
||||||
|
service.deleteRule(ruleId);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ledger ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GetMapping("/ledgers")
|
||||||
|
@RequirePermission(menu = "SETTLING_SUPPORT", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<SettlingSupportLedgerResp>> listLedgers(
|
||||||
|
@ModelAttribute SettlingSupportLedgerSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.listLedgers(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/ledgers/{ledgerId}")
|
||||||
|
@RequirePermission(menu = "SETTLING_SUPPORT", perm = "READ")
|
||||||
|
public ApiResponse<SettlingSupportLedgerResp> detailLedger(@PathVariable Long ledgerId) {
|
||||||
|
return ApiResponse.ok(service.detailLedger(ledgerId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/ledgers")
|
||||||
|
@RequirePermission(menu = "SETTLING_SUPPORT", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "SETTLING_SUPPORT", table = "settling_support_ledger")
|
||||||
|
public ApiResponse<Long> createLedger(@Valid @RequestBody SettlingSupportLedgerSaveReq req) {
|
||||||
|
return ApiResponse.ok(service.createLedger(req));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.ga.api.controller.commission;
|
||||||
|
|
||||||
|
import com.ga.api.service.commission.StepCloseService;
|
||||||
|
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.StepCloseResp;
|
||||||
|
import com.ga.core.vo.commission.StepCloseSaveReq;
|
||||||
|
import com.ga.core.vo.commission.StepCloseSearchParam;
|
||||||
|
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/step-closes")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class StepCloseController {
|
||||||
|
|
||||||
|
private final StepCloseService service;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@RequirePermission(menu = "STEP_CLOSE", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<StepCloseResp>> list(@ModelAttribute StepCloseSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.list(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/by-month")
|
||||||
|
@RequirePermission(menu = "STEP_CLOSE", perm = "READ")
|
||||||
|
public ApiResponse<List<StepCloseResp>> listByMonth(@RequestParam String settleMonth) {
|
||||||
|
return ApiResponse.ok(service.listByMonth(settleMonth));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{stepCloseId}")
|
||||||
|
@RequirePermission(menu = "STEP_CLOSE", perm = "READ")
|
||||||
|
public ApiResponse<StepCloseResp> detail(@PathVariable Long stepCloseId) {
|
||||||
|
return ApiResponse.ok(service.detail(stepCloseId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@RequirePermission(menu = "STEP_CLOSE", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "STEP_CLOSE", table = "settlement_step_close")
|
||||||
|
public ApiResponse<Long> create(@Valid @RequestBody StepCloseSaveReq req) {
|
||||||
|
return ApiResponse.ok(service.create(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{stepCloseId}/close")
|
||||||
|
@RequirePermission(menu = "STEP_CLOSE", perm = "APPROVE")
|
||||||
|
@DataChangeLog(menu = "STEP_CLOSE", table = "settlement_step_close")
|
||||||
|
public ApiResponse<Void> close(@PathVariable Long stepCloseId) {
|
||||||
|
service.close(stepCloseId);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{stepCloseId}/reopen")
|
||||||
|
@RequirePermission(menu = "STEP_CLOSE", perm = "APPROVE")
|
||||||
|
@DataChangeLog(menu = "STEP_CLOSE", table = "settlement_step_close")
|
||||||
|
public ApiResponse<Void> reopen(@PathVariable Long stepCloseId) {
|
||||||
|
service.reopen(stepCloseId);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.deduction;
|
||||||
|
|
||||||
|
import com.ga.api.service.deduction.DeductionService;
|
||||||
|
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.AgentDeductionResp;
|
||||||
|
import com.ga.core.vo.settle.AgentDeductionSaveReq;
|
||||||
|
import com.ga.core.vo.settle.AgentDeductionSearchParam;
|
||||||
|
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/deductions")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DeductionController {
|
||||||
|
|
||||||
|
private final DeductionService deductionService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@RequirePermission(menu = "DEDUCTION", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<AgentDeductionResp>> list(@ModelAttribute AgentDeductionSearchParam param) {
|
||||||
|
return ApiResponse.ok(deductionService.list(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{deductionId}")
|
||||||
|
@RequirePermission(menu = "DEDUCTION", perm = "READ")
|
||||||
|
public ApiResponse<AgentDeductionResp> detail(@PathVariable Long deductionId) {
|
||||||
|
return ApiResponse.ok(deductionService.detail(deductionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@RequirePermission(menu = "DEDUCTION", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "DEDUCTION", table = "agent_deduction")
|
||||||
|
public ApiResponse<Long> create(@Valid @RequestBody AgentDeductionSaveReq req) {
|
||||||
|
return ApiResponse.ok(deductionService.create(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{deductionId}")
|
||||||
|
@RequirePermission(menu = "DEDUCTION", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "DEDUCTION", table = "agent_deduction")
|
||||||
|
public ApiResponse<Void> update(@PathVariable Long deductionId,
|
||||||
|
@Valid @RequestBody AgentDeductionSaveReq req) {
|
||||||
|
deductionService.update(deductionId, req);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{deductionId}/cancel")
|
||||||
|
@RequirePermission(menu = "DEDUCTION", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "DEDUCTION", table = "agent_deduction")
|
||||||
|
public ApiResponse<Void> cancel(@PathVariable Long deductionId) {
|
||||||
|
deductionService.cancel(deductionId);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.ga.api.controller.dict;
|
||||||
|
|
||||||
|
import com.ga.api.service.dict.DataDictionaryService;
|
||||||
|
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.dict.DataDictionaryFullResp;
|
||||||
|
import com.ga.core.vo.dict.DataDictionaryResp;
|
||||||
|
import com.ga.core.vo.dict.DataDictionarySaveReq;
|
||||||
|
import com.ga.core.vo.dict.DataDictionarySearchParam;
|
||||||
|
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/data-dictionary")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DataDictionaryController {
|
||||||
|
|
||||||
|
private final DataDictionaryService dataDictionaryService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<DataDictionaryResp>> list(@ModelAttribute DataDictionarySearchParam param) {
|
||||||
|
return ApiResponse.ok(dataDictionaryService.list(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/tables")
|
||||||
|
@RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "READ")
|
||||||
|
public ApiResponse<List<String>> tables() {
|
||||||
|
return ApiResponse.ok(dataDictionaryService.tables());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/full")
|
||||||
|
@RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "READ")
|
||||||
|
public ApiResponse<List<DataDictionaryFullResp>> full(
|
||||||
|
@RequestParam(required = false) String tableName) {
|
||||||
|
return ApiResponse.ok(dataDictionaryService.full(tableName));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{dictId}")
|
||||||
|
@RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "READ")
|
||||||
|
public ApiResponse<DataDictionaryResp> detail(@PathVariable Long dictId) {
|
||||||
|
return ApiResponse.ok(dataDictionaryService.detail(dictId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "SYSTEM_DATA_DICT", table = "data_dictionary")
|
||||||
|
public ApiResponse<Void> create(@Valid @RequestBody DataDictionarySaveReq req) {
|
||||||
|
dataDictionaryService.create(req);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{dictId}")
|
||||||
|
@RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "SYSTEM_DATA_DICT", table = "data_dictionary")
|
||||||
|
public ApiResponse<Void> update(@PathVariable Long dictId,
|
||||||
|
@Valid @RequestBody DataDictionarySaveReq req) {
|
||||||
|
dataDictionaryService.update(dictId, req);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{dictId}")
|
||||||
|
@RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "DELETE")
|
||||||
|
@DataChangeLog(menu = "SYSTEM_DATA_DICT", table = "data_dictionary")
|
||||||
|
public ApiResponse<Void> delete(@PathVariable Long dictId) {
|
||||||
|
dataDictionaryService.delete(dictId);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.ga.api.controller.dict;
|
||||||
|
|
||||||
|
import com.ga.api.service.dict.DataDomainService;
|
||||||
|
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.dict.DataDomainOptionResp;
|
||||||
|
import com.ga.core.vo.dict.DataDomainResp;
|
||||||
|
import com.ga.core.vo.dict.DataDomainSaveReq;
|
||||||
|
import com.ga.core.vo.dict.DataDomainSearchParam;
|
||||||
|
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/data-domains")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DataDomainController {
|
||||||
|
|
||||||
|
private final DataDomainService dataDomainService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<DataDomainResp>> list(@ModelAttribute DataDomainSearchParam param) {
|
||||||
|
return ApiResponse.ok(dataDomainService.list(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/options")
|
||||||
|
@RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "READ")
|
||||||
|
public ApiResponse<List<DataDomainOptionResp>> options() {
|
||||||
|
return ApiResponse.ok(dataDomainService.options());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{domainCode}")
|
||||||
|
@RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "READ")
|
||||||
|
public ApiResponse<DataDomainResp> detail(@PathVariable String domainCode) {
|
||||||
|
return ApiResponse.ok(dataDomainService.detail(domainCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "SYSTEM_DATA_DOMAIN", table = "data_domain")
|
||||||
|
public ApiResponse<Void> create(@Valid @RequestBody DataDomainSaveReq req) {
|
||||||
|
dataDomainService.create(req);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{domainCode}")
|
||||||
|
@RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "SYSTEM_DATA_DOMAIN", table = "data_domain")
|
||||||
|
public ApiResponse<Void> update(@PathVariable String domainCode,
|
||||||
|
@Valid @RequestBody DataDomainSaveReq req) {
|
||||||
|
dataDomainService.update(domainCode, req);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{domainCode}")
|
||||||
|
@RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "DELETE")
|
||||||
|
@DataChangeLog(menu = "SYSTEM_DATA_DOMAIN", table = "data_domain")
|
||||||
|
public ApiResponse<Void> delete(@PathVariable String domainCode) {
|
||||||
|
dataDomainService.delete(domainCode);
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
package com.ga.api.controller.external;
|
||||||
|
|
||||||
|
import com.ga.api.service.external.ExternalCallLogService;
|
||||||
|
import com.ga.common.annotation.RequirePermission;
|
||||||
|
import com.ga.common.model.ApiResponse;
|
||||||
|
import com.ga.common.model.PageResponse;
|
||||||
|
import com.ga.core.vo.external.ExternalCallLogResp;
|
||||||
|
import com.ga.core.vo.external.ExternalCallLogSearchParam;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@Tag(name = "외부연동 호출이력")
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ExternalCallLogController {
|
||||||
|
|
||||||
|
private final ExternalCallLogService service;
|
||||||
|
|
||||||
|
@GetMapping("/api/external-call-logs")
|
||||||
|
@RequirePermission(menu = "SYSTEM_EXTERNAL_CALL", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<ExternalCallLogResp>> list(@ModelAttribute ExternalCallLogSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.list(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/external-call-logs/{logId}")
|
||||||
|
@RequirePermission(menu = "SYSTEM_EXTERNAL_CALL", perm = "READ")
|
||||||
|
public ApiResponse<ExternalCallLogResp> detail(@PathVariable Long logId) {
|
||||||
|
return ApiResponse.ok(service.detail(logId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.ga.api.controller.income;
|
||||||
|
|
||||||
|
import com.ga.api.service.income.AgentAnnualIncomeService;
|
||||||
|
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.income.AgentAnnualIncomeResp;
|
||||||
|
import com.ga.core.vo.income.AgentAnnualIncomeSearchParam;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@Tag(name = "연간 사업소득")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/agent-annual-incomes")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AgentAnnualIncomeController {
|
||||||
|
|
||||||
|
private final AgentAnnualIncomeService service;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "연간소득 목록 조회")
|
||||||
|
@RequirePermission(menu = "ANNUAL_INCOME", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<AgentAnnualIncomeResp>> list(
|
||||||
|
@ModelAttribute AgentAnnualIncomeSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.list(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{agentId}/{settleYear}")
|
||||||
|
@Operation(summary = "연간소득 상세 조회")
|
||||||
|
@RequirePermission(menu = "ANNUAL_INCOME", perm = "READ")
|
||||||
|
public ApiResponse<AgentAnnualIncomeResp> detail(
|
||||||
|
@PathVariable Long agentId,
|
||||||
|
@PathVariable int settleYear) {
|
||||||
|
return ApiResponse.ok(service.detail(agentId, settleYear));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/regenerate")
|
||||||
|
@Operation(summary = "연도 전체 재집계")
|
||||||
|
@RequirePermission(menu = "ANNUAL_INCOME", perm = "EXECUTE")
|
||||||
|
@DataChangeLog(menu = "ANNUAL_INCOME", table = "agent_annual_income")
|
||||||
|
public ApiResponse<Integer> regenerate(@RequestParam int year) {
|
||||||
|
return ApiResponse.ok(service.regenerate(year));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.ga.api.controller.income;
|
||||||
|
|
||||||
|
import com.ga.api.service.income.YearEndStatementService;
|
||||||
|
import com.ga.common.annotation.DataChangeLog;
|
||||||
|
import com.ga.common.annotation.RequirePermission;
|
||||||
|
import com.ga.common.excel.ExcelService;
|
||||||
|
import com.ga.common.model.ApiResponse;
|
||||||
|
import com.ga.common.model.PageResponse;
|
||||||
|
import com.ga.core.vo.income.YearEndStatementExcelVO;
|
||||||
|
import com.ga.core.vo.income.YearEndStatementResp;
|
||||||
|
import com.ga.core.vo.income.YearEndStatementSaveReq;
|
||||||
|
import com.ga.core.vo.income.YearEndStatementSearchParam;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Tag(name = "연말 지급명세서")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/year-end-statements")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class YearEndStatementController {
|
||||||
|
|
||||||
|
private final YearEndStatementService service;
|
||||||
|
private final ExcelService excelService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "연말 지급명세서 목록")
|
||||||
|
@RequirePermission(menu = "YEAR_END_STMT", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<YearEndStatementResp>> list(@ModelAttribute YearEndStatementSearchParam p) {
|
||||||
|
return ApiResponse.ok(service.list(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@Operation(summary = "연말 지급명세서 상세")
|
||||||
|
@RequirePermission(menu = "YEAR_END_STMT", perm = "READ")
|
||||||
|
public ApiResponse<YearEndStatementResp> detail(@PathVariable Long id) {
|
||||||
|
return ApiResponse.ok(service.detail(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/regenerate")
|
||||||
|
@Operation(summary = "연도별 재집계 (UPSERT)")
|
||||||
|
@RequirePermission(menu = "YEAR_END_STMT", perm = "EXECUTE")
|
||||||
|
@DataChangeLog(menu = "YEAR_END_STMT", table = "year_end_statement")
|
||||||
|
public ApiResponse<Integer> regenerate(@RequestBody YearEndStatementSaveReq req) {
|
||||||
|
return ApiResponse.ok(service.regenerate(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/generate")
|
||||||
|
@Operation(summary = "단건 파일 발급")
|
||||||
|
@RequirePermission(menu = "YEAR_END_STMT", perm = "EXECUTE")
|
||||||
|
@DataChangeLog(menu = "YEAR_END_STMT", table = "year_end_statement")
|
||||||
|
public ApiResponse<Void> generate(@PathVariable Long id, @RequestBody(required = false) Map<String, String> body) {
|
||||||
|
String format = body == null ? null : body.get("fileFormat");
|
||||||
|
service.generate(id, format);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/send")
|
||||||
|
@Operation(summary = "발송 처리")
|
||||||
|
@RequirePermission(menu = "YEAR_END_STMT", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "YEAR_END_STMT", table = "year_end_statement")
|
||||||
|
public ApiResponse<Void> send(@RequestBody YearEndStatementSaveReq req) {
|
||||||
|
service.send(req);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/export")
|
||||||
|
@Operation(summary = "엑셀 다운로드")
|
||||||
|
@RequirePermission(menu = "YEAR_END_STMT", perm = "EXPORT")
|
||||||
|
public void export(@ModelAttribute YearEndStatementSearchParam p, HttpServletResponse res) {
|
||||||
|
excelService.exportLargeExcel(
|
||||||
|
"year-end-statement.xlsx",
|
||||||
|
YearEndStatementExcelVO.class,
|
||||||
|
() -> service.exportCursor(p),
|
||||||
|
res);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.ga.api.controller.installment;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Positive;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class GeneratePlanReq {
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
private Long contractId;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Positive
|
||||||
|
private BigDecimal totalCommission;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
private LocalDate contractDate;
|
||||||
|
|
||||||
|
private String productCategory;
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.ga.api.controller.installment;
|
||||||
|
|
||||||
|
import com.ga.api.service.installment.InstallmentService;
|
||||||
|
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.installment.InstallmentPlanResp;
|
||||||
|
import com.ga.core.vo.installment.InstallmentPlanSearchParam;
|
||||||
|
import com.ga.core.vo.installment.InstallmentPlanVO;
|
||||||
|
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/installment-plans")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class InstallmentController {
|
||||||
|
|
||||||
|
private final InstallmentService installmentService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@RequirePermission(menu = "INSTALLMENT_PLAN", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<InstallmentPlanResp>> list(InstallmentPlanSearchParam param) {
|
||||||
|
return ApiResponse.ok(installmentService.list(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{planId}")
|
||||||
|
@RequirePermission(menu = "INSTALLMENT_PLAN", perm = "READ")
|
||||||
|
public ApiResponse<InstallmentPlanResp> detail(@PathVariable Long planId) {
|
||||||
|
return ApiResponse.ok(installmentService.detail(planId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/generate")
|
||||||
|
@RequirePermission(menu = "INSTALLMENT_PLAN", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "INSTALLMENT_PLAN", table = "installment_plan")
|
||||||
|
public ApiResponse<Void> generate(@Valid @RequestBody GeneratePlanReq req) {
|
||||||
|
installmentService.createPlan(
|
||||||
|
req.getContractId(),
|
||||||
|
req.getTotalCommission(),
|
||||||
|
req.getContractDate(),
|
||||||
|
req.getProductCategory()
|
||||||
|
);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{planId}/pay")
|
||||||
|
@RequirePermission(menu = "INSTALLMENT_PLAN", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "INSTALLMENT_PLAN", table = "installment_plan")
|
||||||
|
public ApiResponse<Void> pay(@PathVariable Long planId,
|
||||||
|
@Valid @RequestBody PayPlanReq req) {
|
||||||
|
installmentService.payPlan(planId, req.getPaymentId(), req.getPaidAmount());
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{planId}/defer")
|
||||||
|
@RequirePermission(menu = "INSTALLMENT_PLAN", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "INSTALLMENT_PLAN", table = "installment_plan")
|
||||||
|
public ApiResponse<Void> defer(@PathVariable Long planId) {
|
||||||
|
installmentService.deferPlan(planId);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{planId}/cancel")
|
||||||
|
@RequirePermission(menu = "INSTALLMENT_PLAN", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "INSTALLMENT_PLAN", table = "installment_plan")
|
||||||
|
public ApiResponse<Void> cancel(@PathVariable Long planId) {
|
||||||
|
installmentService.cancelPlan(planId);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/scheduled")
|
||||||
|
@RequirePermission(menu = "INSTALLMENT_PLAN", perm = "READ")
|
||||||
|
public ApiResponse<List<InstallmentPlanVO>> scheduled(@RequestParam String settleMonth) {
|
||||||
|
return ApiResponse.ok(installmentService.selectScheduledForMonth(settleMonth));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
package com.ga.api.controller.installment;
|
||||||
|
|
||||||
|
import com.ga.api.service.installment.InstallmentService;
|
||||||
|
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.installment.InstallmentRatioResp;
|
||||||
|
import com.ga.core.vo.installment.InstallmentRatioSaveReq;
|
||||||
|
import com.ga.core.vo.installment.InstallmentRatioSearchParam;
|
||||||
|
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/installment-ratios")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class InstallmentRatioController {
|
||||||
|
|
||||||
|
private final InstallmentService installmentService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@RequirePermission(menu = "INSTALLMENT_RATIO", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<InstallmentRatioResp>> list(InstallmentRatioSearchParam param) {
|
||||||
|
return ApiResponse.ok(installmentService.listRatios(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@RequirePermission(menu = "INSTALLMENT_RATIO", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "INSTALLMENT_RATIO", table = "installment_ratio")
|
||||||
|
public ApiResponse<Void> create(@Valid @RequestBody InstallmentRatioSaveReq req) {
|
||||||
|
installmentService.createRatio(req);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.ga.api.controller.installment;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Positive;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class PayPlanReq {
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
private Long paymentId;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Positive
|
||||||
|
private BigDecimal paidAmount;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.ga.api.controller.internal;
|
||||||
|
|
||||||
|
import com.ga.api.service.withdraw.WithdrawBankRetryService;
|
||||||
|
import com.ga.common.annotation.RequirePermission;
|
||||||
|
import com.ga.common.model.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 운영용 내부 트리거. 펌뱅킹 재시도를 수동으로 실행한다.
|
||||||
|
* cron 활성화 환경에서는 자동 실행되지만 운영 중 즉시 회수가 필요할 때 사용.
|
||||||
|
*/
|
||||||
|
@Tag(name = "내부 트리거")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/internal/bank-retry")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class BankRetryController {
|
||||||
|
|
||||||
|
private final WithdrawBankRetryService bankRetryService;
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@RequirePermission(menu = "WITHDRAW", perm = "EXECUTE")
|
||||||
|
public ApiResponse<Integer> retry() {
|
||||||
|
return ApiResponse.ok(bankRetryService.retryOnce());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.ga.api.controller.internal;
|
||||||
|
|
||||||
|
import com.ga.api.service.withdraw.WithdrawBankSettleService;
|
||||||
|
import com.ga.common.annotation.RequirePermission;
|
||||||
|
import com.ga.common.model.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 펌뱅킹 SETTLED 동기화 수동 트리거.
|
||||||
|
* cron 활성화 환경에서는 자동 실행되지만 운영 중 즉시 입금 확정 동기화가 필요할 때 사용.
|
||||||
|
*/
|
||||||
|
@Tag(name = "내부 트리거")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/internal/bank-settle")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class BankSettleController {
|
||||||
|
|
||||||
|
private final WithdrawBankSettleService bankSettleService;
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@RequirePermission(menu = "WITHDRAW", perm = "EXECUTE")
|
||||||
|
public ApiResponse<Integer> sync() {
|
||||||
|
return ApiResponse.ok(bankSettleService.syncOnce());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.ga.api.controller.kpi;
|
||||||
|
|
||||||
|
import com.ga.api.service.kpi.ForecastKpiMonthlyService;
|
||||||
|
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.kpi.ForecastKpiMonthlyResp;
|
||||||
|
import com.ga.core.vo.kpi.ForecastKpiMonthlySaveReq;
|
||||||
|
import com.ga.core.vo.kpi.ForecastKpiMonthlySearchParam;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@Tag(name = "차월 KPI 예측")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/forecast-kpi")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ForecastKpiMonthlyController {
|
||||||
|
|
||||||
|
private final ForecastKpiMonthlyService service;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "예측 목록")
|
||||||
|
@RequirePermission(menu = "FORECAST_KPI", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<ForecastKpiMonthlyResp>> list(@ModelAttribute ForecastKpiMonthlySearchParam p) {
|
||||||
|
return ApiResponse.ok(service.list(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/regenerate")
|
||||||
|
@Operation(summary = "차월 예측 재산출")
|
||||||
|
@RequirePermission(menu = "FORECAST_KPI", perm = "EXECUTE")
|
||||||
|
@DataChangeLog(menu = "FORECAST_KPI", table = "forecast_kpi_monthly")
|
||||||
|
public ApiResponse<Integer> regenerate(@Valid @RequestBody ForecastKpiMonthlySaveReq req) {
|
||||||
|
return ApiResponse.ok(service.regenerate(req));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,56 @@
|
|||||||
|
package com.ga.api.controller.kpi;
|
||||||
|
|
||||||
|
import com.ga.api.service.kpi.RetentionAnomalyAlertService;
|
||||||
|
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.kpi.RetentionAnomalyAlertResp;
|
||||||
|
import com.ga.core.vo.kpi.RetentionAnomalyAlertSaveReq;
|
||||||
|
import com.ga.core.vo.kpi.RetentionAnomalyAlertSearchParam;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Tag(name = "유지율 이상치")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/retention-anomalies")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RetentionAnomalyAlertController {
|
||||||
|
|
||||||
|
private final RetentionAnomalyAlertService service;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "이상치 목록")
|
||||||
|
@RequirePermission(menu = "RETENTION_ANOMALY", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<RetentionAnomalyAlertResp>> list(@ModelAttribute RetentionAnomalyAlertSearchParam p) {
|
||||||
|
return ApiResponse.ok(service.list(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
@Operation(summary = "이상치 단건 상세")
|
||||||
|
@RequirePermission(menu = "RETENTION_ANOMALY", perm = "READ")
|
||||||
|
public ApiResponse<RetentionAnomalyAlertResp> detail(@PathVariable Long id) {
|
||||||
|
return ApiResponse.ok(service.detail(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/detect")
|
||||||
|
@Operation(summary = "이상치 재탐지 (월 단위)")
|
||||||
|
@RequirePermission(menu = "RETENTION_ANOMALY", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "RETENTION_ANOMALY", table = "retention_anomaly_alert")
|
||||||
|
public ApiResponse<Integer> detect(@RequestBody Map<String, String> body) {
|
||||||
|
return ApiResponse.ok(service.detect(body.get("alertMonth")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/review")
|
||||||
|
@Operation(summary = "이상치 검토/완료 처리")
|
||||||
|
@RequirePermission(menu = "RETENTION_ANOMALY", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "RETENTION_ANOMALY", table = "retention_anomaly_alert")
|
||||||
|
public ApiResponse<Void> review(@RequestBody RetentionAnomalyAlertSaveReq req) {
|
||||||
|
service.review(req);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,6 @@ import com.ga.common.annotation.RequirePermission;
|
|||||||
import com.ga.common.excel.ExcelService;
|
import com.ga.common.excel.ExcelService;
|
||||||
import com.ga.common.model.ApiResponse;
|
import com.ga.common.model.ApiResponse;
|
||||||
import com.ga.common.model.PageResponse;
|
import com.ga.common.model.PageResponse;
|
||||||
import com.ga.core.mapper.ledger.MaintainLedgerMapper;
|
|
||||||
import com.ga.core.mapper.ledger.RecruitLedgerMapper;
|
|
||||||
import com.ga.core.vo.ledger.*;
|
import com.ga.core.vo.ledger.*;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
@@ -24,8 +22,6 @@ import java.util.Map;
|
|||||||
public class LedgerController {
|
public class LedgerController {
|
||||||
|
|
||||||
private final LedgerService service;
|
private final LedgerService service;
|
||||||
private final RecruitLedgerMapper recruitMapper;
|
|
||||||
private final MaintainLedgerMapper maintainMapper;
|
|
||||||
private final ExcelService excelService;
|
private final ExcelService excelService;
|
||||||
|
|
||||||
// ===== 모집 =====
|
// ===== 모집 =====
|
||||||
@@ -45,7 +41,7 @@ public class LedgerController {
|
|||||||
@RequirePermission(menu = "LEDGER_RECRUIT", perm = "EXPORT")
|
@RequirePermission(menu = "LEDGER_RECRUIT", perm = "EXPORT")
|
||||||
public void exportRecruit(@ModelAttribute LedgerSearchParam param, HttpServletResponse res) {
|
public void exportRecruit(@ModelAttribute LedgerSearchParam param, HttpServletResponse res) {
|
||||||
excelService.exportLargeExcel("모집수수료원장", LedgerExcelVO.class,
|
excelService.exportLargeExcel("모집수수료원장", LedgerExcelVO.class,
|
||||||
() -> recruitMapper.selectCursorForExcel(param), res);
|
() -> service.exportRecruitCursor(param), res);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 유지 =====
|
// ===== 유지 =====
|
||||||
@@ -65,7 +61,7 @@ public class LedgerController {
|
|||||||
@RequirePermission(menu = "LEDGER_MAINTAIN", perm = "EXPORT")
|
@RequirePermission(menu = "LEDGER_MAINTAIN", perm = "EXPORT")
|
||||||
public void exportMaintain(@ModelAttribute LedgerSearchParam param, HttpServletResponse res) {
|
public void exportMaintain(@ModelAttribute LedgerSearchParam param, HttpServletResponse res) {
|
||||||
excelService.exportLargeExcel("유지수수료원장", LedgerExcelVO.class,
|
excelService.exportLargeExcel("유지수수료원장", LedgerExcelVO.class,
|
||||||
() -> maintainMapper.selectCursorForExcel(param), res);
|
() -> service.exportMaintainCursor(param), res);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 예외 =====
|
// ===== 예외 =====
|
||||||
|
|||||||
@@ -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,136 @@
|
|||||||
|
package com.ga.api.controller.mobile;
|
||||||
|
|
||||||
|
import com.ga.api.service.mobile.MobileMeService;
|
||||||
|
import com.ga.api.service.mobile.MobileMeService.MeSummary;
|
||||||
|
import com.ga.api.service.mobile.MobileMeService.MobileWithdrawSaveReq;
|
||||||
|
import com.ga.api.service.mobile.MobileMeService.WithdrawableBalance;
|
||||||
|
import com.ga.common.model.ApiResponse;
|
||||||
|
import com.ga.common.model.PageResponse;
|
||||||
|
import com.ga.core.vo.notice.UserNotificationResp;
|
||||||
|
import com.ga.core.vo.notice.UserNotificationSearchParam;
|
||||||
|
import com.ga.core.vo.settle.CommissionStatementResp;
|
||||||
|
import com.ga.core.vo.settle.CommissionStatementSearchParam;
|
||||||
|
import com.ga.core.vo.settle.SettleMasterResp;
|
||||||
|
import com.ga.core.vo.withdraw.WithdrawRequestResp;
|
||||||
|
import com.ga.core.vo.withdraw.WithdrawRequestSearchParam;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모바일 PWA 전용 — 토큰의 사용자 본인 데이터만 반환.
|
||||||
|
*
|
||||||
|
* <p>모든 endpoint 는 인증만 통과하면 사용 가능 (별도 메뉴 권한 없음).
|
||||||
|
* agentId / userId 는 서버측에서 토큰 → user 조회로 강제 결정. 클라이언트 파라미터는 무시한다.
|
||||||
|
*/
|
||||||
|
@Tag(name = "모바일 셀프 조회")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/mobile/me")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MobileMeController {
|
||||||
|
|
||||||
|
private final MobileMeService service;
|
||||||
|
|
||||||
|
@GetMapping("/summary")
|
||||||
|
public ApiResponse<MeSummary> summary() {
|
||||||
|
return ApiResponse.ok(service.summary());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/statements")
|
||||||
|
public ApiResponse<PageResponse<CommissionStatementResp>> statements(
|
||||||
|
@ModelAttribute CommissionStatementSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.statements(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/statements/{statementId}")
|
||||||
|
public ApiResponse<CommissionStatementResp> statementDetail(@PathVariable Long statementId) {
|
||||||
|
return ApiResponse.ok(service.statementDetail(statementId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/statements/{statementId}/download")
|
||||||
|
public void downloadStatement(@PathVariable Long statementId, HttpServletResponse response) throws IOException {
|
||||||
|
byte[] content = service.downloadStatement(statementId);
|
||||||
|
String encoded = URLEncoder.encode("수수료명세서_" + statementId + ".xlsx", StandardCharsets.UTF_8)
|
||||||
|
.replace("+", "%20");
|
||||||
|
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||||
|
response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encoded);
|
||||||
|
response.setContentLength(content.length);
|
||||||
|
response.getOutputStream().write(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/settle-masters")
|
||||||
|
public ApiResponse<List<SettleMasterResp>> withdrawableSettleMasters() {
|
||||||
|
return ApiResponse.ok(service.withdrawableSettleMasters());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/settle-masters/{settleMasterId}/withdrawable-balance")
|
||||||
|
public ApiResponse<WithdrawableBalance> withdrawableBalance(@PathVariable Long settleMasterId) {
|
||||||
|
return ApiResponse.ok(service.withdrawableBalance(settleMasterId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/withdraw-requests")
|
||||||
|
public ApiResponse<PageResponse<WithdrawRequestResp>> withdrawRequests(
|
||||||
|
@ModelAttribute WithdrawRequestSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.withdrawRequests(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/withdraw-requests")
|
||||||
|
public ApiResponse<Long> createWithdrawRequest(@Valid @RequestBody MobileWithdrawSaveReq req) {
|
||||||
|
return ApiResponse.ok(service.createWithdrawRequest(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/withdraw-requests/{requestId}/cancel")
|
||||||
|
public ApiResponse<Void> cancelWithdrawRequest(@PathVariable Long requestId) {
|
||||||
|
service.cancelMyWithdrawRequest(requestId);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/profile/phone")
|
||||||
|
public ApiResponse<Void> updateMyPhone(@RequestBody PhoneReq req) {
|
||||||
|
service.updateMyPhone(req.getPhone());
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@lombok.Data
|
||||||
|
public static class PhoneReq {
|
||||||
|
private String phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/notifications")
|
||||||
|
public ApiResponse<PageResponse<UserNotificationResp>> notifications(
|
||||||
|
@ModelAttribute UserNotificationSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.notifications(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/notifications/{notificationId}/read")
|
||||||
|
public ApiResponse<Void> markRead(@PathVariable Long notificationId) {
|
||||||
|
service.markNotificationRead(notificationId);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/notifications/read-all")
|
||||||
|
public ApiResponse<Integer> markAllRead() {
|
||||||
|
return ApiResponse.ok(service.markAllNotificationsRead());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 푸시용 디바이스 토큰 등록. body: {token, platform?} */
|
||||||
|
@PostMapping("/device-tokens")
|
||||||
|
public ApiResponse<Void> registerDeviceToken(@RequestBody java.util.Map<String, String> body) {
|
||||||
|
service.registerDeviceToken(body.get("token"), body.get("platform"));
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 디바이스 토큰 해제(로그아웃 등). body: {token} */
|
||||||
|
@PostMapping("/device-tokens/unregister")
|
||||||
|
public ApiResponse<Void> unregisterDeviceToken(@RequestBody java.util.Map<String, String> body) {
|
||||||
|
service.unregisterDeviceToken(body.get("token"));
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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(required = false) Long userId) {
|
||||||
|
Long target = userId != null ? userId : SecurityUtil.getCurrentUserId();
|
||||||
|
return ApiResponse.ok(service.unreadCount(target));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/user-notifications/{notificationId}/read")
|
||||||
|
@RequirePermission(menu = "NOTICE", perm = "READ")
|
||||||
|
public ApiResponse<Void> markRead(@PathVariable Long notificationId,
|
||||||
|
@RequestParam(required = false) Long userId) {
|
||||||
|
Long target = userId != null ? userId : SecurityUtil.getCurrentUserId();
|
||||||
|
service.markRead(notificationId, target);
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import com.ga.common.annotation.RequirePermission;
|
|||||||
import com.ga.common.excel.ExcelService;
|
import com.ga.common.excel.ExcelService;
|
||||||
import com.ga.common.model.ApiResponse;
|
import com.ga.common.model.ApiResponse;
|
||||||
import com.ga.common.model.PageResponse;
|
import com.ga.common.model.PageResponse;
|
||||||
import com.ga.core.mapper.org.AgentMapper;
|
|
||||||
import com.ga.core.vo.org.*;
|
import com.ga.core.vo.org.*;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
@@ -24,7 +23,6 @@ import java.util.Map;
|
|||||||
public class AgentController {
|
public class AgentController {
|
||||||
|
|
||||||
private final AgentService agentService;
|
private final AgentService agentService;
|
||||||
private final AgentMapper agentMapper;
|
|
||||||
private final ExcelService excelService;
|
private final ExcelService excelService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -64,7 +62,7 @@ public class AgentController {
|
|||||||
|
|
||||||
@GetMapping("/{agentId}/history")
|
@GetMapping("/{agentId}/history")
|
||||||
@RequirePermission(menu = "ORG_AGENT", perm = "READ")
|
@RequirePermission(menu = "ORG_AGENT", perm = "READ")
|
||||||
public ApiResponse<List<AgentOrgHistoryVO>> history(@PathVariable Long agentId) {
|
public ApiResponse<List<AgentOrgHistoryResp>> history(@PathVariable Long agentId) {
|
||||||
return ApiResponse.ok(agentService.history(agentId));
|
return ApiResponse.ok(agentService.history(agentId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +70,6 @@ public class AgentController {
|
|||||||
@RequirePermission(menu = "ORG_AGENT", perm = "EXPORT")
|
@RequirePermission(menu = "ORG_AGENT", perm = "EXPORT")
|
||||||
public void export(@ModelAttribute AgentSearchParam param, HttpServletResponse response) {
|
public void export(@ModelAttribute AgentSearchParam param, HttpServletResponse response) {
|
||||||
excelService.exportLargeExcel("설계사목록", AgentExcelVO.class,
|
excelService.exportLargeExcel("설계사목록", AgentExcelVO.class,
|
||||||
() -> agentMapper.selectCursorForExcel(param), response);
|
() -> agentService.exportCursor(param), response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import com.ga.common.annotation.DataChangeLog;
|
|||||||
import com.ga.common.annotation.RequirePermission;
|
import com.ga.common.annotation.RequirePermission;
|
||||||
import com.ga.common.model.ApiResponse;
|
import com.ga.common.model.ApiResponse;
|
||||||
import com.ga.core.vo.org.OrganizationResp;
|
import com.ga.core.vo.org.OrganizationResp;
|
||||||
|
import com.ga.core.vo.org.OrganizationSaveReq;
|
||||||
import com.ga.core.vo.org.OrganizationVO;
|
import com.ga.core.vo.org.OrganizationVO;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
@@ -43,15 +45,15 @@ public class OrganizationController {
|
|||||||
@PostMapping
|
@PostMapping
|
||||||
@RequirePermission(menu = "ORG_TREE", perm = "CREATE")
|
@RequirePermission(menu = "ORG_TREE", perm = "CREATE")
|
||||||
@DataChangeLog(menu = "ORG_TREE", table = "organization")
|
@DataChangeLog(menu = "ORG_TREE", table = "organization")
|
||||||
public ApiResponse<Long> create(@RequestBody OrganizationVO vo) {
|
public ApiResponse<Long> create(@Valid @RequestBody OrganizationSaveReq req) {
|
||||||
return ApiResponse.ok(service.create(vo));
|
return ApiResponse.ok(service.create(req));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{orgId}")
|
@PutMapping("/{orgId}")
|
||||||
@RequirePermission(menu = "ORG_TREE", perm = "UPDATE")
|
@RequirePermission(menu = "ORG_TREE", perm = "UPDATE")
|
||||||
@DataChangeLog(menu = "ORG_TREE", table = "organization")
|
@DataChangeLog(menu = "ORG_TREE", table = "organization")
|
||||||
public ApiResponse<Void> update(@PathVariable Long orgId, @RequestBody OrganizationVO vo) {
|
public ApiResponse<Void> update(@PathVariable Long orgId, @Valid @RequestBody OrganizationSaveReq req) {
|
||||||
service.update(orgId, vo);
|
service.update(orgId, req);
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package com.ga.api.controller.product;
|
package com.ga.api.controller.product;
|
||||||
|
|
||||||
|
import com.ga.api.service.product.InsuranceCompanyService;
|
||||||
import com.ga.common.annotation.DataChangeLog;
|
import com.ga.common.annotation.DataChangeLog;
|
||||||
import com.ga.common.annotation.RequirePermission;
|
import com.ga.common.annotation.RequirePermission;
|
||||||
import com.ga.common.model.ApiResponse;
|
import com.ga.common.model.ApiResponse;
|
||||||
import com.ga.core.mapper.product.InsuranceCompanyMapper;
|
|
||||||
import com.ga.core.vo.product.InsuranceCompanyVO;
|
import com.ga.core.vo.product.InsuranceCompanyVO;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -17,34 +17,32 @@ import java.util.List;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class CompanyController {
|
public class CompanyController {
|
||||||
|
|
||||||
private final InsuranceCompanyMapper mapper;
|
private final InsuranceCompanyService companyService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@RequirePermission(menu = "COMPANY", perm = "READ")
|
@RequirePermission(menu = "COMPANY", perm = "READ")
|
||||||
public ApiResponse<List<InsuranceCompanyVO>> list(@RequestParam(required = false, defaultValue = "false") boolean activeOnly) {
|
public ApiResponse<List<InsuranceCompanyVO>> list(@RequestParam(required = false, defaultValue = "false") boolean activeOnly) {
|
||||||
return ApiResponse.ok(activeOnly ? mapper.selectActive() : mapper.selectAll());
|
return ApiResponse.ok(companyService.list(activeOnly));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{companyId}")
|
@GetMapping("/{companyId}")
|
||||||
@RequirePermission(menu = "COMPANY", perm = "READ")
|
@RequirePermission(menu = "COMPANY", perm = "READ")
|
||||||
public ApiResponse<InsuranceCompanyVO> detail(@PathVariable Long companyId) {
|
public ApiResponse<InsuranceCompanyVO> detail(@PathVariable Long companyId) {
|
||||||
return ApiResponse.ok(mapper.selectById(companyId));
|
return ApiResponse.ok(companyService.detail(companyId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@RequirePermission(menu = "COMPANY", perm = "CREATE")
|
@RequirePermission(menu = "COMPANY", perm = "CREATE")
|
||||||
@DataChangeLog(menu = "COMPANY", table = "insurance_company")
|
@DataChangeLog(menu = "COMPANY", table = "insurance_company")
|
||||||
public ApiResponse<Long> create(@RequestBody InsuranceCompanyVO vo) {
|
public ApiResponse<Long> create(@RequestBody InsuranceCompanyVO vo) {
|
||||||
mapper.insert(vo);
|
return ApiResponse.ok(companyService.create(vo));
|
||||||
return ApiResponse.ok(vo.getCompanyId());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{companyId}")
|
@PutMapping("/{companyId}")
|
||||||
@RequirePermission(menu = "COMPANY", perm = "UPDATE")
|
@RequirePermission(menu = "COMPANY", perm = "UPDATE")
|
||||||
@DataChangeLog(menu = "COMPANY", table = "insurance_company")
|
@DataChangeLog(menu = "COMPANY", table = "insurance_company")
|
||||||
public ApiResponse<Void> update(@PathVariable Long companyId, @RequestBody InsuranceCompanyVO vo) {
|
public ApiResponse<Void> update(@PathVariable Long companyId, @RequestBody InsuranceCompanyVO vo) {
|
||||||
vo.setCompanyId(companyId);
|
companyService.update(companyId, vo);
|
||||||
mapper.update(vo);
|
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,68 +1,61 @@
|
|||||||
package com.ga.api.controller.product;
|
package com.ga.api.controller.product;
|
||||||
|
|
||||||
|
import com.ga.api.service.product.ProductService;
|
||||||
import com.ga.common.annotation.DataChangeLog;
|
import com.ga.common.annotation.DataChangeLog;
|
||||||
import com.ga.common.annotation.RequirePermission;
|
import com.ga.common.annotation.RequirePermission;
|
||||||
import com.ga.common.exception.BizException;
|
|
||||||
import com.ga.common.exception.ErrorCode;
|
|
||||||
import com.ga.common.model.ApiResponse;
|
import com.ga.common.model.ApiResponse;
|
||||||
import com.ga.core.mapper.product.ProductMapper;
|
import com.ga.common.model.PageResponse;
|
||||||
import com.ga.core.vo.product.ProductResp;
|
import com.ga.core.vo.product.ProductResp;
|
||||||
import com.ga.core.vo.product.ProductVO;
|
import com.ga.core.vo.product.ProductVO;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Tag(name = "상품 관리")
|
@Tag(name = "상품 관리")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/products")
|
@RequestMapping("/api/products")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ProductController {
|
public class ProductController {
|
||||||
|
|
||||||
private final ProductMapper mapper;
|
private final ProductService productService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@RequirePermission(menu = "PRODUCT", perm = "READ")
|
@RequirePermission(menu = "PRODUCT", perm = "READ")
|
||||||
public ApiResponse<List<ProductResp>> list(@RequestParam(required = false) Long companyId,
|
public ApiResponse<PageResponse<ProductResp>> list(@RequestParam(defaultValue = "1") int pageNum,
|
||||||
|
@RequestParam(defaultValue = "10") int pageSize,
|
||||||
|
@RequestParam(required = false) Long companyId,
|
||||||
@RequestParam(required = false) String insuranceType,
|
@RequestParam(required = false) String insuranceType,
|
||||||
@RequestParam(required = false) String keyword,
|
@RequestParam(required = false) String keyword,
|
||||||
@RequestParam(required = false) String isActive) {
|
@RequestParam(required = false) String isActive) {
|
||||||
return ApiResponse.ok(mapper.selectList(companyId, insuranceType, keyword, isActive));
|
return ApiResponse.ok(productService.list(pageNum, pageSize, companyId, insuranceType, keyword, isActive));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{productId}")
|
@GetMapping("/{productId}")
|
||||||
@RequirePermission(menu = "PRODUCT", perm = "READ")
|
@RequirePermission(menu = "PRODUCT", perm = "READ")
|
||||||
public ApiResponse<ProductResp> detail(@PathVariable Long productId) {
|
public ApiResponse<ProductResp> detail(@PathVariable Long productId) {
|
||||||
ProductResp r = mapper.selectDetailById(productId);
|
return ApiResponse.ok(productService.detail(productId));
|
||||||
if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
return ApiResponse.ok(r);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@RequirePermission(menu = "PRODUCT", perm = "CREATE")
|
@RequirePermission(menu = "PRODUCT", perm = "CREATE")
|
||||||
@DataChangeLog(menu = "PRODUCT", table = "product")
|
@DataChangeLog(menu = "PRODUCT", table = "product")
|
||||||
public ApiResponse<Long> create(@RequestBody ProductVO vo) {
|
public ApiResponse<Long> create(@RequestBody ProductVO vo) {
|
||||||
if (mapper.existsByCode(vo.getCompanyId(), vo.getProductCode()) > 0) {
|
return ApiResponse.ok(productService.create(vo));
|
||||||
throw new BizException(ErrorCode.DUPLICATE_DATA);
|
|
||||||
}
|
|
||||||
mapper.insert(vo);
|
|
||||||
return ApiResponse.ok(vo.getProductId());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{productId}")
|
@PutMapping("/{productId}")
|
||||||
@RequirePermission(menu = "PRODUCT", perm = "UPDATE")
|
@RequirePermission(menu = "PRODUCT", perm = "UPDATE")
|
||||||
@DataChangeLog(menu = "PRODUCT", table = "product")
|
@DataChangeLog(menu = "PRODUCT", table = "product")
|
||||||
public ApiResponse<Void> update(@PathVariable Long productId, @RequestBody ProductVO vo) {
|
public ApiResponse<Void> update(@PathVariable Long productId, @RequestBody ProductVO vo) {
|
||||||
vo.setProductId(productId);
|
productService.update(productId, vo);
|
||||||
mapper.update(vo);
|
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{productId}")
|
@DeleteMapping("/{productId}")
|
||||||
@RequirePermission(menu = "PRODUCT", perm = "DELETE")
|
@RequirePermission(menu = "PRODUCT", perm = "DELETE")
|
||||||
|
@DataChangeLog(menu = "PRODUCT", table = "product")
|
||||||
public ApiResponse<Void> delete(@PathVariable Long productId) {
|
public ApiResponse<Void> delete(@PathVariable Long productId) {
|
||||||
mapper.deleteById(productId);
|
productService.delete(productId);
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
package com.ga.api.controller.receive;
|
package com.ga.api.controller.receive;
|
||||||
|
|
||||||
|
import com.ga.api.service.receive.ReceiveService;
|
||||||
|
import com.ga.common.annotation.DataChangeLog;
|
||||||
import com.ga.common.annotation.RequirePermission;
|
import com.ga.common.annotation.RequirePermission;
|
||||||
import com.ga.common.model.ApiResponse;
|
import com.ga.common.model.ApiResponse;
|
||||||
import com.ga.core.mapper.receive.ReceiveMapper;
|
|
||||||
import com.ga.core.vo.receive.*;
|
import com.ga.core.vo.receive.*;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
@Tag(name = "데이터 수신")
|
@Tag(name = "데이터 수신")
|
||||||
@RestController
|
@RestController
|
||||||
@@ -17,21 +18,20 @@ import java.util.Map;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ReceiveController {
|
public class ReceiveController {
|
||||||
|
|
||||||
private final ReceiveMapper mapper;
|
private final ReceiveService receiveService;
|
||||||
|
|
||||||
// 보험사 프로파일
|
// 보험사 프로파일
|
||||||
@GetMapping("/profiles")
|
@GetMapping("/profiles")
|
||||||
@RequirePermission(menu = "RECEIVE_MAPPING", perm = "READ")
|
@RequirePermission(menu = "RECEIVE_MAPPING", perm = "READ")
|
||||||
public ApiResponse<List<CompanyProfileVO>> profiles() {
|
public ApiResponse<List<CompanyProfileVO>> profiles() {
|
||||||
return ApiResponse.ok(mapper.selectAllProfiles());
|
return ApiResponse.ok(receiveService.listProfiles());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/profiles/{companyCode}")
|
@PutMapping("/profiles/{companyCode}")
|
||||||
@RequirePermission(menu = "RECEIVE_MAPPING", perm = "UPDATE")
|
@RequirePermission(menu = "RECEIVE_MAPPING", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "RECEIVE_MAPPING", table = "company_profile")
|
||||||
public ApiResponse<Void> updateProfile(@PathVariable String companyCode, @RequestBody CompanyProfileVO vo) {
|
public ApiResponse<Void> updateProfile(@PathVariable String companyCode, @RequestBody CompanyProfileVO vo) {
|
||||||
vo.setCompanyCode(companyCode);
|
receiveService.saveProfile(companyCode, vo);
|
||||||
if (mapper.selectProfile(companyCode) == null) mapper.insertProfile(vo);
|
|
||||||
else mapper.updateProfile(vo);
|
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,29 +40,29 @@ public class ReceiveController {
|
|||||||
@RequirePermission(menu = "RECEIVE_MAPPING", perm = "READ")
|
@RequirePermission(menu = "RECEIVE_MAPPING", perm = "READ")
|
||||||
public ApiResponse<List<CompanyFieldMappingVO>> fields(@PathVariable String companyCode,
|
public ApiResponse<List<CompanyFieldMappingVO>> fields(@PathVariable String companyCode,
|
||||||
@RequestParam(required = false) String targetTable) {
|
@RequestParam(required = false) String targetTable) {
|
||||||
return ApiResponse.ok(mapper.selectFieldMappings(companyCode, targetTable));
|
return ApiResponse.ok(receiveService.listFields(companyCode, targetTable));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/mapping/{companyCode}/fields")
|
@PostMapping("/mapping/{companyCode}/fields")
|
||||||
@RequirePermission(menu = "RECEIVE_MAPPING", perm = "CREATE")
|
@RequirePermission(menu = "RECEIVE_MAPPING", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "RECEIVE_MAPPING", table = "company_field_mapping")
|
||||||
public ApiResponse<Long> createField(@PathVariable String companyCode, @RequestBody CompanyFieldMappingVO vo) {
|
public ApiResponse<Long> createField(@PathVariable String companyCode, @RequestBody CompanyFieldMappingVO vo) {
|
||||||
vo.setCompanyCode(companyCode);
|
return ApiResponse.ok(receiveService.createField(companyCode, vo));
|
||||||
mapper.insertFieldMapping(vo);
|
|
||||||
return ApiResponse.ok(vo.getMappingId());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/mapping/fields/{mappingId}")
|
@PutMapping("/mapping/fields/{mappingId}")
|
||||||
@RequirePermission(menu = "RECEIVE_MAPPING", perm = "UPDATE")
|
@RequirePermission(menu = "RECEIVE_MAPPING", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "RECEIVE_MAPPING", table = "company_field_mapping")
|
||||||
public ApiResponse<Void> updateField(@PathVariable Long mappingId, @RequestBody CompanyFieldMappingVO vo) {
|
public ApiResponse<Void> updateField(@PathVariable Long mappingId, @RequestBody CompanyFieldMappingVO vo) {
|
||||||
vo.setMappingId(mappingId);
|
receiveService.updateField(mappingId, vo);
|
||||||
mapper.updateFieldMapping(vo);
|
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/mapping/fields/{mappingId}")
|
@DeleteMapping("/mapping/fields/{mappingId}")
|
||||||
@RequirePermission(menu = "RECEIVE_MAPPING", perm = "DELETE")
|
@RequirePermission(menu = "RECEIVE_MAPPING", perm = "DELETE")
|
||||||
|
@DataChangeLog(menu = "RECEIVE_MAPPING", table = "company_field_mapping")
|
||||||
public ApiResponse<Void> deleteField(@PathVariable Long mappingId) {
|
public ApiResponse<Void> deleteField(@PathVariable Long mappingId) {
|
||||||
mapper.deleteFieldMapping(mappingId);
|
receiveService.deleteField(mappingId);
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,15 +71,14 @@ public class ReceiveController {
|
|||||||
@RequirePermission(menu = "RECEIVE_MAPPING", perm = "READ")
|
@RequirePermission(menu = "RECEIVE_MAPPING", perm = "READ")
|
||||||
public ApiResponse<List<CodeMappingVO>> codes(@PathVariable String companyCode,
|
public ApiResponse<List<CodeMappingVO>> codes(@PathVariable String companyCode,
|
||||||
@RequestParam(required = false) String codeType) {
|
@RequestParam(required = false) String codeType) {
|
||||||
return ApiResponse.ok(mapper.selectCodeMappings(companyCode, codeType));
|
return ApiResponse.ok(receiveService.listCodes(companyCode, codeType));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/mapping/{companyCode}/codes")
|
@PostMapping("/mapping/{companyCode}/codes")
|
||||||
@RequirePermission(menu = "RECEIVE_MAPPING", perm = "CREATE")
|
@RequirePermission(menu = "RECEIVE_MAPPING", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "RECEIVE_MAPPING", table = "code_mapping")
|
||||||
public ApiResponse<Long> createCode(@PathVariable String companyCode, @RequestBody CodeMappingVO vo) {
|
public ApiResponse<Long> createCode(@PathVariable String companyCode, @RequestBody CodeMappingVO vo) {
|
||||||
vo.setCompanyCode(companyCode);
|
return ApiResponse.ok(receiveService.createCode(companyCode, vo));
|
||||||
mapper.insertCodeMapping(vo);
|
|
||||||
return ApiResponse.ok(vo.getCodeId());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 원본 데이터 + 에러
|
// 원본 데이터 + 에러
|
||||||
@@ -88,23 +87,21 @@ public class ReceiveController {
|
|||||||
public ApiResponse<List<RawCommissionDataVO>> rawList(@RequestParam(required = false) String companyCode,
|
public ApiResponse<List<RawCommissionDataVO>> rawList(@RequestParam(required = false) String companyCode,
|
||||||
@RequestParam(required = false) String settleMonth,
|
@RequestParam(required = false) String settleMonth,
|
||||||
@RequestParam(required = false) String parseStatus) {
|
@RequestParam(required = false) String parseStatus) {
|
||||||
return ApiResponse.ok(mapper.selectRawList(companyCode, settleMonth, parseStatus));
|
return ApiResponse.ok(receiveService.listRaw(companyCode, settleMonth, parseStatus));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/errors")
|
@GetMapping("/errors")
|
||||||
@RequirePermission(menu = "RECEIVE_DATA", perm = "READ")
|
@RequirePermission(menu = "RECEIVE_DATA", perm = "READ")
|
||||||
public ApiResponse<List<ParseErrorLogVO>> errors(@RequestParam(required = false) String companyCode,
|
public ApiResponse<List<ParseErrorLogVO>> errors(@RequestParam(required = false) String companyCode,
|
||||||
@RequestParam(required = false) String resolveStatus) {
|
@RequestParam(required = false) String resolveStatus) {
|
||||||
return ApiResponse.ok(mapper.selectErrors(companyCode, resolveStatus));
|
return ApiResponse.ok(receiveService.listErrors(companyCode, resolveStatus));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/errors/{errorId}/resolve")
|
@PutMapping("/errors/{errorId}/resolve")
|
||||||
@RequirePermission(menu = "RECEIVE_DATA", perm = "UPDATE")
|
@RequirePermission(menu = "RECEIVE_DATA", perm = "UPDATE")
|
||||||
public ApiResponse<Void> resolveError(@PathVariable Long errorId, @RequestBody Map<String, String> body) {
|
@DataChangeLog(menu = "RECEIVE_DATA", table = "parse_error_log")
|
||||||
mapper.resolveError(errorId,
|
public ApiResponse<Void> resolveError(@PathVariable Long errorId, @Valid @RequestBody ResolveErrorReq req) {
|
||||||
body.getOrDefault("status", "RESOLVED"),
|
receiveService.resolveError(errorId, req.getStatus(), req.getNote());
|
||||||
body.get("note"),
|
|
||||||
com.ga.common.util.SecurityUtil.getCurrentUserId());
|
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.ga.api.controller.receive;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class ResolveErrorReq {
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
private String note;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.ga.api.controller.regulatory;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Positive;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class ContractLimitCheckReq {
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
private Long contractId;
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
@Positive
|
||||||
|
private BigDecimal additionalAmount;
|
||||||
|
|
||||||
|
/** 기준일 (null이면 오늘) */
|
||||||
|
private LocalDate baseDate;
|
||||||
|
|
||||||
|
/** TOTAL_RATIO | FIRST_YEAR_RATIO */
|
||||||
|
@NotNull
|
||||||
|
private String limitType;
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package com.ga.api.controller.regulatory;
|
||||||
|
|
||||||
|
import com.ga.api.service.regulatory.RegulatoryLimitChecker;
|
||||||
|
import com.ga.api.service.regulatory.RegulatoryLimitService;
|
||||||
|
import com.ga.api.service.regulatory.ViolationResult;
|
||||||
|
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.regulatory.RegulatoryLimitResp;
|
||||||
|
import com.ga.core.vo.regulatory.RegulatoryLimitSaveReq;
|
||||||
|
import com.ga.core.vo.regulatory.RegulatoryLimitSearchParam;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
@Tag(name = "규제 한도 관리")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/regulatory-limits")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RegulatoryLimitController {
|
||||||
|
|
||||||
|
private final RegulatoryLimitService limitService;
|
||||||
|
private final RegulatoryLimitChecker limitChecker;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@RequirePermission(menu = "REGULATORY_LIMIT", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<RegulatoryLimitResp>> list(RegulatoryLimitSearchParam param) {
|
||||||
|
return ApiResponse.ok(limitService.list(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{limitId}")
|
||||||
|
@RequirePermission(menu = "REGULATORY_LIMIT", perm = "READ")
|
||||||
|
public ApiResponse<RegulatoryLimitResp> detail(@PathVariable Long limitId) {
|
||||||
|
return ApiResponse.ok(limitService.detail(limitId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@RequirePermission(menu = "REGULATORY_LIMIT", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "REGULATORY_LIMIT", table = "regulatory_limit")
|
||||||
|
public ApiResponse<Void> create(@Valid @RequestBody RegulatoryLimitSaveReq req) {
|
||||||
|
limitService.create(req);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{limitId}")
|
||||||
|
@RequirePermission(menu = "REGULATORY_LIMIT", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "REGULATORY_LIMIT", table = "regulatory_limit")
|
||||||
|
public ApiResponse<Void> update(@PathVariable Long limitId,
|
||||||
|
@Valid @RequestBody RegulatoryLimitSaveReq req) {
|
||||||
|
limitService.update(limitId, req);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{limitId}/deactivate")
|
||||||
|
@RequirePermission(menu = "REGULATORY_LIMIT", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "REGULATORY_LIMIT", table = "regulatory_limit")
|
||||||
|
public ApiResponse<Void> deactivate(@PathVariable Long limitId) {
|
||||||
|
limitService.deactivate(limitId);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{limitId}/activate")
|
||||||
|
@RequirePermission(menu = "REGULATORY_LIMIT", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "REGULATORY_LIMIT", table = "regulatory_limit")
|
||||||
|
public ApiResponse<Void> activate(@PathVariable Long limitId) {
|
||||||
|
limitService.activate(limitId);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/check-contract")
|
||||||
|
@RequirePermission(menu = "REGULATORY_LIMIT", perm = "READ")
|
||||||
|
public ApiResponse<ViolationResult> checkContract(@Valid @RequestBody ContractLimitCheckReq req) {
|
||||||
|
LocalDate baseDate = req.getBaseDate() != null ? req.getBaseDate() : LocalDate.now();
|
||||||
|
ViolationResult result;
|
||||||
|
if ("FIRST_YEAR_RATIO".equals(req.getLimitType())) {
|
||||||
|
result = limitChecker.checkFirstYear(req.getContractId(), req.getAdditionalAmount(), baseDate);
|
||||||
|
} else {
|
||||||
|
result = limitChecker.checkContractTotal(req.getContractId(), req.getAdditionalAmount(), baseDate);
|
||||||
|
}
|
||||||
|
return ApiResponse.ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.ga.api.controller.report;
|
||||||
|
|
||||||
|
import com.ga.api.service.report.ReportService;
|
||||||
|
import com.ga.common.annotation.RequirePermission;
|
||||||
|
import com.ga.common.excel.ExcelService;
|
||||||
|
import com.ga.common.model.ApiResponse;
|
||||||
|
import com.ga.core.vo.report.ChargebackRiskRow;
|
||||||
|
import com.ga.core.vo.report.OrgReportRow;
|
||||||
|
import com.ga.core.vo.report.PerfReportRow;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리포트 — 실적 / 조직별 / 환수위험. 읽기전용 집계 (신규 테이블 없음).
|
||||||
|
* 각 화면의 조회 + 엑셀 export 엔드포인트 제공.
|
||||||
|
*/
|
||||||
|
@Tag(name = "리포트")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/report")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ReportController {
|
||||||
|
|
||||||
|
private final ReportService service;
|
||||||
|
private final ExcelService excelService;
|
||||||
|
|
||||||
|
private String defaultYear(String year) {
|
||||||
|
return (year != null && !year.isBlank()) ? year : String.valueOf(LocalDate.now().getYear());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String defaultMonth(String settleMonth) {
|
||||||
|
return (settleMonth != null && !settleMonth.isBlank())
|
||||||
|
? settleMonth : LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMM"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 실적 리포트 ──
|
||||||
|
@GetMapping("/performance")
|
||||||
|
@RequirePermission(menu = "REPORT_PERFORMANCE", perm = "READ")
|
||||||
|
public ApiResponse<List<PerfReportRow>> performance(@RequestParam(required = false) String year) {
|
||||||
|
return ApiResponse.ok(service.performance(defaultYear(year)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/performance/export")
|
||||||
|
@RequirePermission(menu = "REPORT_PERFORMANCE", perm = "READ")
|
||||||
|
public void performanceExport(@RequestParam(required = false) String year, HttpServletResponse response) {
|
||||||
|
String y = defaultYear(year);
|
||||||
|
excelService.exportLargeExcel("실적리포트", PerfReportRow.class,
|
||||||
|
() -> service.performanceCursor(y), response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 조직별 리포트 ──
|
||||||
|
@GetMapping("/org")
|
||||||
|
@RequirePermission(menu = "REPORT_ORG", perm = "READ")
|
||||||
|
public ApiResponse<List<OrgReportRow>> org(@RequestParam(required = false) String settleMonth) {
|
||||||
|
return ApiResponse.ok(service.org(defaultMonth(settleMonth)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/org/export")
|
||||||
|
@RequirePermission(menu = "REPORT_ORG", perm = "READ")
|
||||||
|
public void orgExport(@RequestParam(required = false) String settleMonth, HttpServletResponse response) {
|
||||||
|
String m = defaultMonth(settleMonth);
|
||||||
|
excelService.exportLargeExcel("조직별리포트", OrgReportRow.class,
|
||||||
|
() -> service.orgCursor(m), response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 환수위험 리포트 ──
|
||||||
|
@GetMapping("/chargeback-risk")
|
||||||
|
@RequirePermission(menu = "REPORT_CHARGEBACK", perm = "READ")
|
||||||
|
public ApiResponse<List<ChargebackRiskRow>> chargebackRisk() {
|
||||||
|
return ApiResponse.ok(service.chargebackRisk());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/chargeback-risk/export")
|
||||||
|
@RequirePermission(menu = "REPORT_CHARGEBACK", perm = "READ")
|
||||||
|
public void chargebackRiskExport(HttpServletResponse response) {
|
||||||
|
excelService.exportLargeExcel("환수위험리포트", ChargebackRiskRow.class,
|
||||||
|
() -> service.chargebackRiskCursor(), response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
package com.ga.api.controller.rule;
|
package com.ga.api.controller.rule;
|
||||||
|
|
||||||
|
import com.ga.api.service.rule.RuleService;
|
||||||
import com.ga.common.annotation.DataChangeLog;
|
import com.ga.common.annotation.DataChangeLog;
|
||||||
import com.ga.common.annotation.RequirePermission;
|
import com.ga.common.annotation.RequirePermission;
|
||||||
import com.ga.common.model.ApiResponse;
|
import com.ga.common.model.ApiResponse;
|
||||||
import com.ga.core.mapper.rule.RuleMapper;
|
|
||||||
import com.ga.core.vo.rule.*;
|
import com.ga.core.vo.rule.*;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -17,37 +17,36 @@ import java.util.List;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class RuleController {
|
public class RuleController {
|
||||||
|
|
||||||
private final RuleMapper mapper;
|
private final RuleService ruleService;
|
||||||
|
|
||||||
// ========== commission_rate ==========
|
// ========== commission_rate ==========
|
||||||
@GetMapping("/commission-rates")
|
@GetMapping("/commission-rates")
|
||||||
@RequirePermission(menu = "RULE_COMMISSION", perm = "READ")
|
@RequirePermission(menu = "RULE_COMMISSION", perm = "READ")
|
||||||
public ApiResponse<List<CommissionRateVO>> listCommissionRates(@RequestParam(required = false) Long productId,
|
public ApiResponse<List<CommissionRateVO>> listCommissionRates(@RequestParam(required = false) Long productId,
|
||||||
@RequestParam(required = false) Integer year) {
|
@RequestParam(required = false) Integer year) {
|
||||||
return ApiResponse.ok(mapper.selectCommissionRates(productId, year));
|
return ApiResponse.ok(ruleService.listCommissionRates(productId, year));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/commission-rates")
|
@PostMapping("/commission-rates")
|
||||||
@RequirePermission(menu = "RULE_COMMISSION", perm = "CREATE")
|
@RequirePermission(menu = "RULE_COMMISSION", perm = "CREATE")
|
||||||
@DataChangeLog(menu = "RULE_COMMISSION", table = "commission_rate")
|
@DataChangeLog(menu = "RULE_COMMISSION", table = "commission_rate")
|
||||||
public ApiResponse<Long> createCommissionRate(@RequestBody CommissionRateVO vo) {
|
public ApiResponse<Long> createCommissionRate(@RequestBody CommissionRateVO vo) {
|
||||||
mapper.insertCommissionRate(vo);
|
return ApiResponse.ok(ruleService.createCommissionRate(vo));
|
||||||
return ApiResponse.ok(vo.getRateId());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/commission-rates/{rateId}")
|
@PutMapping("/commission-rates/{rateId}")
|
||||||
@RequirePermission(menu = "RULE_COMMISSION", perm = "UPDATE")
|
@RequirePermission(menu = "RULE_COMMISSION", perm = "UPDATE")
|
||||||
@DataChangeLog(menu = "RULE_COMMISSION", table = "commission_rate")
|
@DataChangeLog(menu = "RULE_COMMISSION", table = "commission_rate")
|
||||||
public ApiResponse<Void> updateCommissionRate(@PathVariable Long rateId, @RequestBody CommissionRateVO vo) {
|
public ApiResponse<Void> updateCommissionRate(@PathVariable Long rateId, @RequestBody CommissionRateVO vo) {
|
||||||
vo.setRateId(rateId);
|
ruleService.updateCommissionRate(rateId, vo);
|
||||||
mapper.updateCommissionRate(vo);
|
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/commission-rates/{rateId}")
|
@DeleteMapping("/commission-rates/{rateId}")
|
||||||
@RequirePermission(menu = "RULE_COMMISSION", perm = "DELETE")
|
@RequirePermission(menu = "RULE_COMMISSION", perm = "DELETE")
|
||||||
|
@DataChangeLog(menu = "RULE_COMMISSION", table = "commission_rate")
|
||||||
public ApiResponse<Void> deleteCommissionRate(@PathVariable Long rateId) {
|
public ApiResponse<Void> deleteCommissionRate(@PathVariable Long rateId) {
|
||||||
mapper.deleteCommissionRate(rateId);
|
ruleService.deleteCommissionRate(rateId);
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,30 +55,29 @@ public class RuleController {
|
|||||||
@RequirePermission(menu = "RULE_PAYOUT", perm = "READ")
|
@RequirePermission(menu = "RULE_PAYOUT", perm = "READ")
|
||||||
public ApiResponse<List<PayoutRuleVO>> listPayoutRules(@RequestParam(required = false) Long gradeId,
|
public ApiResponse<List<PayoutRuleVO>> listPayoutRules(@RequestParam(required = false) Long gradeId,
|
||||||
@RequestParam(required = false) String insuranceType) {
|
@RequestParam(required = false) String insuranceType) {
|
||||||
return ApiResponse.ok(mapper.selectPayoutRules(gradeId, insuranceType));
|
return ApiResponse.ok(ruleService.listPayoutRules(gradeId, insuranceType));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/payout")
|
@PostMapping("/payout")
|
||||||
@RequirePermission(menu = "RULE_PAYOUT", perm = "CREATE")
|
@RequirePermission(menu = "RULE_PAYOUT", perm = "CREATE")
|
||||||
@DataChangeLog(menu = "RULE_PAYOUT", table = "payout_rule")
|
@DataChangeLog(menu = "RULE_PAYOUT", table = "payout_rule")
|
||||||
public ApiResponse<Long> createPayoutRule(@RequestBody PayoutRuleVO vo) {
|
public ApiResponse<Long> createPayoutRule(@RequestBody PayoutRuleVO vo) {
|
||||||
mapper.insertPayoutRule(vo);
|
return ApiResponse.ok(ruleService.createPayoutRule(vo));
|
||||||
return ApiResponse.ok(vo.getRuleId());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/payout/{ruleId}")
|
@PutMapping("/payout/{ruleId}")
|
||||||
@RequirePermission(menu = "RULE_PAYOUT", perm = "UPDATE")
|
@RequirePermission(menu = "RULE_PAYOUT", perm = "UPDATE")
|
||||||
@DataChangeLog(menu = "RULE_PAYOUT", table = "payout_rule")
|
@DataChangeLog(menu = "RULE_PAYOUT", table = "payout_rule")
|
||||||
public ApiResponse<Void> updatePayoutRule(@PathVariable Long ruleId, @RequestBody PayoutRuleVO vo) {
|
public ApiResponse<Void> updatePayoutRule(@PathVariable Long ruleId, @RequestBody PayoutRuleVO vo) {
|
||||||
vo.setRuleId(ruleId);
|
ruleService.updatePayoutRule(ruleId, vo);
|
||||||
mapper.updatePayoutRule(vo);
|
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/payout/{ruleId}")
|
@DeleteMapping("/payout/{ruleId}")
|
||||||
@RequirePermission(menu = "RULE_PAYOUT", perm = "DELETE")
|
@RequirePermission(menu = "RULE_PAYOUT", perm = "DELETE")
|
||||||
|
@DataChangeLog(menu = "RULE_PAYOUT", table = "payout_rule")
|
||||||
public ApiResponse<Void> deletePayoutRule(@PathVariable Long ruleId) {
|
public ApiResponse<Void> deletePayoutRule(@PathVariable Long ruleId) {
|
||||||
mapper.deletePayoutRule(ruleId);
|
ruleService.deletePayoutRule(ruleId);
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,28 +85,29 @@ public class RuleController {
|
|||||||
@GetMapping("/override")
|
@GetMapping("/override")
|
||||||
@RequirePermission(menu = "RULE_OVERRIDE", perm = "READ")
|
@RequirePermission(menu = "RULE_OVERRIDE", perm = "READ")
|
||||||
public ApiResponse<List<OverrideRuleVO>> listOverrideRules() {
|
public ApiResponse<List<OverrideRuleVO>> listOverrideRules() {
|
||||||
return ApiResponse.ok(mapper.selectOverrideRules());
|
return ApiResponse.ok(ruleService.listOverrideRules());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/override")
|
@PostMapping("/override")
|
||||||
@RequirePermission(menu = "RULE_OVERRIDE", perm = "CREATE")
|
@RequirePermission(menu = "RULE_OVERRIDE", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "RULE_OVERRIDE", table = "override_rule")
|
||||||
public ApiResponse<Long> createOverrideRule(@RequestBody OverrideRuleVO vo) {
|
public ApiResponse<Long> createOverrideRule(@RequestBody OverrideRuleVO vo) {
|
||||||
mapper.insertOverrideRule(vo);
|
return ApiResponse.ok(ruleService.createOverrideRule(vo));
|
||||||
return ApiResponse.ok(vo.getOverrideId());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/override/{overrideId}")
|
@PutMapping("/override/{overrideId}")
|
||||||
@RequirePermission(menu = "RULE_OVERRIDE", perm = "UPDATE")
|
@RequirePermission(menu = "RULE_OVERRIDE", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "RULE_OVERRIDE", table = "override_rule")
|
||||||
public ApiResponse<Void> updateOverrideRule(@PathVariable Long overrideId, @RequestBody OverrideRuleVO vo) {
|
public ApiResponse<Void> updateOverrideRule(@PathVariable Long overrideId, @RequestBody OverrideRuleVO vo) {
|
||||||
vo.setOverrideId(overrideId);
|
ruleService.updateOverrideRule(overrideId, vo);
|
||||||
mapper.updateOverrideRule(vo);
|
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/override/{overrideId}")
|
@DeleteMapping("/override/{overrideId}")
|
||||||
@RequirePermission(menu = "RULE_OVERRIDE", perm = "DELETE")
|
@RequirePermission(menu = "RULE_OVERRIDE", perm = "DELETE")
|
||||||
|
@DataChangeLog(menu = "RULE_OVERRIDE", table = "override_rule")
|
||||||
public ApiResponse<Void> deleteOverrideRule(@PathVariable Long overrideId) {
|
public ApiResponse<Void> deleteOverrideRule(@PathVariable Long overrideId) {
|
||||||
mapper.deleteOverrideRule(overrideId);
|
ruleService.deleteOverrideRule(overrideId);
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,28 +115,29 @@ public class RuleController {
|
|||||||
@GetMapping("/chargeback")
|
@GetMapping("/chargeback")
|
||||||
@RequirePermission(menu = "RULE_CHARGEBACK", perm = "READ")
|
@RequirePermission(menu = "RULE_CHARGEBACK", perm = "READ")
|
||||||
public ApiResponse<List<ChargebackRuleVO>> listChargebackRules(@RequestParam(required = false) String companyCode) {
|
public ApiResponse<List<ChargebackRuleVO>> listChargebackRules(@RequestParam(required = false) String companyCode) {
|
||||||
return ApiResponse.ok(mapper.selectChargebackRules(companyCode));
|
return ApiResponse.ok(ruleService.listChargebackRules(companyCode));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/chargeback")
|
@PostMapping("/chargeback")
|
||||||
@RequirePermission(menu = "RULE_CHARGEBACK", perm = "CREATE")
|
@RequirePermission(menu = "RULE_CHARGEBACK", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "RULE_CHARGEBACK", table = "chargeback_rule")
|
||||||
public ApiResponse<Long> createChargebackRule(@RequestBody ChargebackRuleVO vo) {
|
public ApiResponse<Long> createChargebackRule(@RequestBody ChargebackRuleVO vo) {
|
||||||
mapper.insertChargebackRule(vo);
|
return ApiResponse.ok(ruleService.createChargebackRule(vo));
|
||||||
return ApiResponse.ok(vo.getCbRuleId());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/chargeback/{cbRuleId}")
|
@PutMapping("/chargeback/{cbRuleId}")
|
||||||
@RequirePermission(menu = "RULE_CHARGEBACK", perm = "UPDATE")
|
@RequirePermission(menu = "RULE_CHARGEBACK", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "RULE_CHARGEBACK", table = "chargeback_rule")
|
||||||
public ApiResponse<Void> updateChargebackRule(@PathVariable Long cbRuleId, @RequestBody ChargebackRuleVO vo) {
|
public ApiResponse<Void> updateChargebackRule(@PathVariable Long cbRuleId, @RequestBody ChargebackRuleVO vo) {
|
||||||
vo.setCbRuleId(cbRuleId);
|
ruleService.updateChargebackRule(cbRuleId, vo);
|
||||||
mapper.updateChargebackRule(vo);
|
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/chargeback/{cbRuleId}")
|
@DeleteMapping("/chargeback/{cbRuleId}")
|
||||||
@RequirePermission(menu = "RULE_CHARGEBACK", perm = "DELETE")
|
@RequirePermission(menu = "RULE_CHARGEBACK", perm = "DELETE")
|
||||||
|
@DataChangeLog(menu = "RULE_CHARGEBACK", table = "chargeback_rule")
|
||||||
public ApiResponse<Void> deleteChargebackRule(@PathVariable Long cbRuleId) {
|
public ApiResponse<Void> deleteChargebackRule(@PathVariable Long cbRuleId) {
|
||||||
mapper.deleteChargebackRule(cbRuleId);
|
ruleService.deleteChargebackRule(cbRuleId);
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,22 +145,23 @@ public class RuleController {
|
|||||||
@GetMapping("/exception-codes")
|
@GetMapping("/exception-codes")
|
||||||
@RequirePermission(menu = "RULE_EXCEPTION", perm = "READ")
|
@RequirePermission(menu = "RULE_EXCEPTION", perm = "READ")
|
||||||
public ApiResponse<List<ExceptionTypeCodeVO>> listExceptionCodes() {
|
public ApiResponse<List<ExceptionTypeCodeVO>> listExceptionCodes() {
|
||||||
return ApiResponse.ok(mapper.selectExceptionCodes());
|
return ApiResponse.ok(ruleService.listExceptionCodes());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/exception-codes")
|
@PostMapping("/exception-codes")
|
||||||
@RequirePermission(menu = "RULE_EXCEPTION", perm = "CREATE")
|
@RequirePermission(menu = "RULE_EXCEPTION", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "RULE_EXCEPTION", table = "exception_type_code")
|
||||||
public ApiResponse<Void> createExceptionCode(@RequestBody ExceptionTypeCodeVO vo) {
|
public ApiResponse<Void> createExceptionCode(@RequestBody ExceptionTypeCodeVO vo) {
|
||||||
mapper.insertExceptionCode(vo);
|
ruleService.createExceptionCode(vo);
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/exception-codes/{exceptionCode}")
|
@PutMapping("/exception-codes/{exceptionCode}")
|
||||||
@RequirePermission(menu = "RULE_EXCEPTION", perm = "UPDATE")
|
@RequirePermission(menu = "RULE_EXCEPTION", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "RULE_EXCEPTION", table = "exception_type_code")
|
||||||
public ApiResponse<Void> updateExceptionCode(@PathVariable String exceptionCode,
|
public ApiResponse<Void> updateExceptionCode(@PathVariable String exceptionCode,
|
||||||
@RequestBody ExceptionTypeCodeVO vo) {
|
@RequestBody ExceptionTypeCodeVO vo) {
|
||||||
vo.setExceptionCode(exceptionCode);
|
ruleService.updateExceptionCode(exceptionCode, vo);
|
||||||
mapper.updateExceptionCode(vo);
|
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+50
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user