Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e2e5c4e61d |
@@ -1,87 +0,0 @@
|
|||||||
---
|
|
||||||
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 안에 비즈니스 로직
|
|
||||||
- 수동 권한 체크
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
---
|
|
||||||
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에게 대량 처리 쿼리 검토 요청
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
---
|
|
||||||
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 사용
|
|
||||||
- 다른 모듈 디렉토리 수정
|
|
||||||
- 추상화 과잉(불필요한 인터페이스, 제네릭 남용)
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
---
|
|
||||||
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의 쿼리 리뷰 피드백 수용
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
---
|
|
||||||
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 코드 직접 수정 (피드백만)
|
|
||||||
- 인덱스 남발 (꼭 필요한 것만)
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
---
|
|
||||||
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 남발
|
|
||||||
- 페이지 컴포넌트 안에 비즈니스 로직 덩어리
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
---
|
|
||||||
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)
|
|
||||||
|
|
||||||
## 금지 사항
|
|
||||||
- 다른 모듈의 비즈니스 코드 수정
|
|
||||||
- 검증 단계 스킵 후 "완료" 보고
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
|
||||||
"env": {
|
|
||||||
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
|
|
||||||
},
|
|
||||||
"teammateMode": "auto"
|
|
||||||
}
|
|
||||||
+2
-9
@@ -36,12 +36,5 @@ dist/
|
|||||||
out/
|
out/
|
||||||
target/
|
target/
|
||||||
|
|
||||||
# Claude Code: 팀 공용 파일만 커밋, 개인 설정/로컬 데이터는 제외
|
# Claude Code 개인 설정 (Agent Teams 등)
|
||||||
.claude/*
|
.claude/
|
||||||
!.claude/settings.json
|
|
||||||
!.claude/agents/
|
|
||||||
.claude/settings.local.json
|
|
||||||
|
|
||||||
# vite/tsc 빌드 산출물
|
|
||||||
ga-frontend/tsconfig.tsbuildinfo
|
|
||||||
ga-frontend/dist/
|
|
||||||
|
|||||||
-148
@@ -1,148 +0,0 @@
|
|||||||
# Handoff Note — 2026-05-12
|
|
||||||
|
|
||||||
다음 세션에서 이어가기 위한 핸드오프 문서. 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. 잔여 작업
|
|
||||||
|
|
||||||
### 3-1. P1 정산 통합 (보류 권장)
|
|
||||||
- ga-batch의 8 Step 정산 잡에 TaxCalculator/DeductionService/RegulatoryLimitChecker/InstallmentService/ChargebackCalculator 통합
|
|
||||||
- batch-engineer 작업
|
|
||||||
- 운영 비즈니스 룰 의존이 커 **인프라만 완성하고 통합은 사용자 결정 후** 진행 권장
|
|
||||||
- 보류 가능 — 현 시점에 인프라/CRUD/API는 모두 동작
|
|
||||||
|
|
||||||
### 3-2. 도메인 P2 — 8개 항목 (Wave 방식)
|
|
||||||
| # | 도메인 | 마이그레이션 | 핵심 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| P2-1 | 마감 (Period Close) | V29 | period_close 테이블 + 마감 후 수정 차단 AOP |
|
|
||||||
| P2-2 | 정정 (Correction) | V30 | settle_correction + 원본 참조 + 정정 사유 |
|
|
||||||
| P2-3 | 이의 신청 (Dispute) | V31 | commission_dispute + OPEN/IN_REVIEW/APPROVED/REJECTED |
|
|
||||||
| P2-4 | 보류 사유 (Hold Reasons) | V32 | settle_master.hold_reason_code + HOLD_REASON 코드그룹 |
|
|
||||||
| P2-5 | 시책·인센티브 | V33 | incentive_program + incentive_payment |
|
|
||||||
| P2-6 | 해촉 정산 (Termination) | V34 | termination_settlement (agent_org_history 연계) |
|
|
||||||
| P2-7 | 세금계산서 (Tax Invoice) | V35 | tax_invoice 발행 이력 (SUPPLY/PURCHASE) |
|
|
||||||
| P2-8 | 외부 회신 | V36 | carrier_reconciliation_out (보험사 회신 인터페이스) |
|
|
||||||
|
|
||||||
**Wave 디스패치 계획**:
|
|
||||||
- Wave 1 (dba): V29~V36 8개 마이그레이션 한 번에
|
|
||||||
- Wave 2 (core): 8개 도메인 VO/Mapper 한 번에
|
|
||||||
- Wave 3 (api): 8개 도메인 Service/Controller 한 번에
|
|
||||||
- 디스패치 24 → 3으로 축소
|
|
||||||
|
|
||||||
### 3-3. 도메인 P3 — KPI 대시보드
|
|
||||||
| 작업 | 담당 |
|
|
||||||
|---|---|
|
|
||||||
| V37 Materialized View 4종 (월별/조직별/유지율/누계) | dba-architect |
|
|
||||||
| KPI VO/Mapper | core-architect |
|
|
||||||
| KpiService + KpiController | api-developer |
|
|
||||||
| KPI 대시보드 화면 (AG Grid 합계행, 차트) | frontend-developer |
|
|
||||||
|
|
||||||
### 3-4. 마무리 통합
|
|
||||||
| 작업 | 담당 |
|
|
||||||
|---|---|
|
|
||||||
| ga-batch 8 Step에 도메인 로직 통합 (TaxCalculator/Deduction/Regulatory/Installment/Chargeback) | batch-engineer |
|
|
||||||
| 통합 테스트 | infra-reviewer |
|
|
||||||
| README/DEVELOPER_GUIDE 업데이트 | infra-reviewer |
|
|
||||||
|
|
||||||
## 4. 재개 절차 (다음 세션)
|
|
||||||
|
|
||||||
1. **Claude Code 시작 시 cwd 확인**: `C:\Users\kyu\Desktop\ga_pro` (현재 세션과 동일)
|
|
||||||
2. **Agent Teams 활성화 확인**: `.claude/settings.json` 이미 커밋되어 있음 (`418ff7c`)
|
|
||||||
3. **팀 재생성**: 새 세션은 팀이 없음. 다음 명령 흐름:
|
|
||||||
- PL 역할로 `TeamCreate(team_name="ga-pro-team", agent_type="pl", description="...")` 호출
|
|
||||||
- 7명 팀원 spawn (common/core/api/batch/frontend/dba/infra) — 첫 세션과 동일
|
|
||||||
4. **HANDOFF.md 읽기**: 본 문서 + 최근 git log로 컨텍스트 확보
|
|
||||||
5. **권장 시작 지점**: P2 Wave 1 디스패치 (dba-architect에게 V29~V36 8개 마이그레이션)
|
|
||||||
6. **사용자 결정 필요 항목**:
|
|
||||||
- P1 정산 통합 진행 여부 (Wave 작업 전 또는 P3 후)
|
|
||||||
- P2-1 마감 도메인의 마감 후 수정 차단 AOP 구현 방식 (전역 AOP vs Service-level 가드)
|
|
||||||
|
|
||||||
## 5. 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)로 통과 안 되는 게 알려진 상태
|
|
||||||
|
|
||||||
## 6. 표준 가정 (도메인 작업 진행 시 채택한 값)
|
|
||||||
|
|
||||||
도메인 룰은 한국 보험업감독규정 표준 가정을 외부화 가능한 구조로 등록. 실제 회사 정책과 다르면 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% | 가정값 |
|
|
||||||
|
|
||||||
가정값과 실제가 다르면 운영 데이터 수정으로 해결 가능. 코드 변경 불필요.
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
# 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` 사용 시 모든 팀원 동일 권한
|
|
||||||
@@ -1,313 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
@@ -1,503 +0,0 @@
|
|||||||
# 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 은 모든 테스트가 사용해야 함.
|
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
# 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 트랜잭션으로 통합 테스트. (현재 미구현, 향후 작업.)
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
# 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` 으로 멀티스레드화
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
# 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
# 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`)
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,625 +0,0 @@
|
|||||||
# 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 공통 컴포넌트
|
|
||||||
@@ -10,5 +10,4 @@ jar.enabled = false
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation project(':ga-core')
|
implementation project(':ga-core')
|
||||||
testImplementation 'org.springframework.security:spring-security-test'
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ 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")
|
||||||
@@ -22,8 +24,8 @@ public class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/refresh")
|
@PostMapping("/refresh")
|
||||||
public ApiResponse<LoginResp> refresh(@Valid @RequestBody RefreshTokenReq req) {
|
public ApiResponse<LoginResp> refresh(@RequestBody Map<String, String> body) {
|
||||||
return ApiResponse.ok(authService.refresh(req.getRefreshToken()));
|
return ApiResponse.ok(authService.refresh(body.get("refreshToken")));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/me")
|
@GetMapping("/me")
|
||||||
@@ -32,8 +34,8 @@ public class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/password")
|
@PostMapping("/password")
|
||||||
public ApiResponse<Void> changePassword(@Valid @RequestBody ChangePasswordReq req) {
|
public ApiResponse<Void> changePassword(@RequestBody Map<String, String> body) {
|
||||||
authService.changePassword(req.getOldPassword(), req.getNewPassword());
|
authService.changePassword(body.get("oldPassword"), body.get("newPassword"));
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ 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;
|
||||||
@@ -39,18 +38,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 (UserStatus.LOCKED.name().equals(user.getStatus())) {
|
if ("LOCKED".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 (UserStatus.RETIRED.name().equals(user.getStatus())) {
|
if ("RETIRED".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(), UserStatus.LOCKED.name());
|
userMapper.updateStatus(user.getUserId(), "LOCKED");
|
||||||
}
|
}
|
||||||
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);
|
||||||
@@ -81,7 +80,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 || !UserStatus.ACTIVE.name().equals(user.getStatus())) {
|
if (user == null || !"ACTIVE".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);
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
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", perm = "READ")
|
|
||||||
public ApiResponse<PageResponse<InstallmentPlanResp>> list(InstallmentPlanSearchParam param) {
|
|
||||||
return ApiResponse.ok(installmentService.list(param));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/{planId}")
|
|
||||||
@RequirePermission(menu = "INSTALLMENT", perm = "READ")
|
|
||||||
public ApiResponse<InstallmentPlanResp> detail(@PathVariable Long planId) {
|
|
||||||
return ApiResponse.ok(installmentService.detail(planId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping("/generate")
|
|
||||||
@RequirePermission(menu = "INSTALLMENT", perm = "CREATE")
|
|
||||||
@DataChangeLog(menu = "INSTALLMENT", 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", perm = "UPDATE")
|
|
||||||
@DataChangeLog(menu = "INSTALLMENT", 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", perm = "UPDATE")
|
|
||||||
@DataChangeLog(menu = "INSTALLMENT", table = "installment_plan")
|
|
||||||
public ApiResponse<Void> defer(@PathVariable Long planId) {
|
|
||||||
installmentService.deferPlan(planId);
|
|
||||||
return ApiResponse.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
@PutMapping("/{planId}/cancel")
|
|
||||||
@RequirePermission(menu = "INSTALLMENT", perm = "UPDATE")
|
|
||||||
@DataChangeLog(menu = "INSTALLMENT", table = "installment_plan")
|
|
||||||
public ApiResponse<Void> cancel(@PathVariable Long planId) {
|
|
||||||
installmentService.cancelPlan(planId);
|
|
||||||
return ApiResponse.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/scheduled")
|
|
||||||
@RequirePermission(menu = "INSTALLMENT", perm = "READ")
|
|
||||||
public ApiResponse<List<InstallmentPlanVO>> scheduled(@RequestParam String settleMonth) {
|
|
||||||
return ApiResponse.ok(installmentService.selectScheduledForMonth(settleMonth));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
-37
@@ -1,37 +0,0 @@
|
|||||||
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", perm = "READ")
|
|
||||||
public ApiResponse<PageResponse<InstallmentRatioResp>> list(InstallmentRatioSearchParam param) {
|
|
||||||
return ApiResponse.ok(installmentService.listRatios(param));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping
|
|
||||||
@RequirePermission(menu = "INSTALLMENT", perm = "CREATE")
|
|
||||||
@DataChangeLog(menu = "INSTALLMENT", table = "installment_ratio")
|
|
||||||
public ApiResponse<Void> create(@Valid @RequestBody InstallmentRatioSaveReq req) {
|
|
||||||
installmentService.createRatio(req);
|
|
||||||
return ApiResponse.ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,8 @@ 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;
|
||||||
@@ -22,6 +24,8 @@ 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;
|
||||||
|
|
||||||
// ===== 모집 =====
|
// ===== 모집 =====
|
||||||
@@ -41,7 +45,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,
|
||||||
() -> service.exportRecruitCursor(param), res);
|
() -> recruitMapper.selectCursorForExcel(param), res);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 유지 =====
|
// ===== 유지 =====
|
||||||
@@ -61,7 +65,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,
|
||||||
() -> service.exportMaintainCursor(param), res);
|
() -> maintainMapper.selectCursorForExcel(param), res);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 예외 =====
|
// ===== 예외 =====
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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;
|
||||||
@@ -23,6 +24,7 @@ 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
|
||||||
@@ -62,7 +64,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<AgentOrgHistoryResp>> history(@PathVariable Long agentId) {
|
public ApiResponse<List<AgentOrgHistoryVO>> history(@PathVariable Long agentId) {
|
||||||
return ApiResponse.ok(agentService.history(agentId));
|
return ApiResponse.ok(agentService.history(agentId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +72,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,
|
||||||
() -> agentService.exportCursor(param), response);
|
() -> agentMapper.selectCursorForExcel(param), response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ 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.*;
|
||||||
@@ -45,15 +43,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(@Valid @RequestBody OrganizationSaveReq req) {
|
public ApiResponse<Long> create(@RequestBody OrganizationVO vo) {
|
||||||
return ApiResponse.ok(service.create(req));
|
return ApiResponse.ok(service.create(vo));
|
||||||
}
|
}
|
||||||
|
|
||||||
@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, @Valid @RequestBody OrganizationSaveReq req) {
|
public ApiResponse<Void> update(@PathVariable Long orgId, @RequestBody OrganizationVO vo) {
|
||||||
service.update(orgId, req);
|
service.update(orgId, vo);
|
||||||
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,32 +17,34 @@ import java.util.List;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class CompanyController {
|
public class CompanyController {
|
||||||
|
|
||||||
private final InsuranceCompanyService companyService;
|
private final InsuranceCompanyMapper mapper;
|
||||||
|
|
||||||
@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(companyService.list(activeOnly));
|
return ApiResponse.ok(activeOnly ? mapper.selectActive() : mapper.selectAll());
|
||||||
}
|
}
|
||||||
|
|
||||||
@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(companyService.detail(companyId));
|
return ApiResponse.ok(mapper.selectById(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) {
|
||||||
return ApiResponse.ok(companyService.create(vo));
|
mapper.insert(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) {
|
||||||
companyService.update(companyId, vo);
|
vo.setCompanyId(companyId);
|
||||||
|
mapper.update(vo);
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
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.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;
|
||||||
@@ -18,7 +20,7 @@ import java.util.List;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ProductController {
|
public class ProductController {
|
||||||
|
|
||||||
private final ProductService productService;
|
private final ProductMapper mapper;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@RequirePermission(menu = "PRODUCT", perm = "READ")
|
@RequirePermission(menu = "PRODUCT", perm = "READ")
|
||||||
@@ -26,35 +28,41 @@ public class ProductController {
|
|||||||
@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(productService.list(companyId, insuranceType, keyword, isActive));
|
return ApiResponse.ok(mapper.selectList(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) {
|
||||||
return ApiResponse.ok(productService.detail(productId));
|
ProductResp r = mapper.selectDetailById(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) {
|
||||||
return ApiResponse.ok(productService.create(vo));
|
if (mapper.existsByCode(vo.getCompanyId(), vo.getProductCode()) > 0) {
|
||||||
|
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) {
|
||||||
productService.update(productId, vo);
|
vo.setProductId(productId);
|
||||||
|
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) {
|
||||||
productService.delete(productId);
|
mapper.deleteById(productId);
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
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
|
||||||
@@ -18,20 +17,21 @@ import java.util.List;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class ReceiveController {
|
public class ReceiveController {
|
||||||
|
|
||||||
private final ReceiveService receiveService;
|
private final ReceiveMapper mapper;
|
||||||
|
|
||||||
// 보험사 프로파일
|
// 보험사 프로파일
|
||||||
@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(receiveService.listProfiles());
|
return ApiResponse.ok(mapper.selectAllProfiles());
|
||||||
}
|
}
|
||||||
|
|
||||||
@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) {
|
||||||
receiveService.saveProfile(companyCode, vo);
|
vo.setCompanyCode(companyCode);
|
||||||
|
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(receiveService.listFields(companyCode, targetTable));
|
return ApiResponse.ok(mapper.selectFieldMappings(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) {
|
||||||
return ApiResponse.ok(receiveService.createField(companyCode, vo));
|
vo.setCompanyCode(companyCode);
|
||||||
|
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) {
|
||||||
receiveService.updateField(mappingId, vo);
|
vo.setMappingId(mappingId);
|
||||||
|
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) {
|
||||||
receiveService.deleteField(mappingId);
|
mapper.deleteFieldMapping(mappingId);
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,14 +71,15 @@ 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(receiveService.listCodes(companyCode, codeType));
|
return ApiResponse.ok(mapper.selectCodeMappings(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) {
|
||||||
return ApiResponse.ok(receiveService.createCode(companyCode, vo));
|
vo.setCompanyCode(companyCode);
|
||||||
|
mapper.insertCodeMapping(vo);
|
||||||
|
return ApiResponse.ok(vo.getCodeId());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 원본 데이터 + 에러
|
// 원본 데이터 + 에러
|
||||||
@@ -87,21 +88,23 @@ 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(receiveService.listRaw(companyCode, settleMonth, parseStatus));
|
return ApiResponse.ok(mapper.selectRawList(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(receiveService.listErrors(companyCode, resolveStatus));
|
return ApiResponse.ok(mapper.selectErrors(companyCode, resolveStatus));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/errors/{errorId}/resolve")
|
@PutMapping("/errors/{errorId}/resolve")
|
||||||
@RequirePermission(menu = "RECEIVE_DATA", perm = "UPDATE")
|
@RequirePermission(menu = "RECEIVE_DATA", perm = "UPDATE")
|
||||||
@DataChangeLog(menu = "RECEIVE_DATA", table = "parse_error_log")
|
public ApiResponse<Void> resolveError(@PathVariable Long errorId, @RequestBody Map<String, String> body) {
|
||||||
public ApiResponse<Void> resolveError(@PathVariable Long errorId, @Valid @RequestBody ResolveErrorReq req) {
|
mapper.resolveError(errorId,
|
||||||
receiveService.resolveError(errorId, req.getStatus(), req.getNote());
|
body.getOrDefault("status", "RESOLVED"),
|
||||||
|
body.get("note"),
|
||||||
|
com.ga.common.util.SecurityUtil.getCurrentUserId());
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
@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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,36 +17,37 @@ import java.util.List;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class RuleController {
|
public class RuleController {
|
||||||
|
|
||||||
private final RuleService ruleService;
|
private final RuleMapper mapper;
|
||||||
|
|
||||||
// ========== 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(ruleService.listCommissionRates(productId, year));
|
return ApiResponse.ok(mapper.selectCommissionRates(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) {
|
||||||
return ApiResponse.ok(ruleService.createCommissionRate(vo));
|
mapper.insertCommissionRate(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) {
|
||||||
ruleService.updateCommissionRate(rateId, vo);
|
vo.setRateId(rateId);
|
||||||
|
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) {
|
||||||
ruleService.deleteCommissionRate(rateId);
|
mapper.deleteCommissionRate(rateId);
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,29 +56,30 @@ 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(ruleService.listPayoutRules(gradeId, insuranceType));
|
return ApiResponse.ok(mapper.selectPayoutRules(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) {
|
||||||
return ApiResponse.ok(ruleService.createPayoutRule(vo));
|
mapper.insertPayoutRule(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) {
|
||||||
ruleService.updatePayoutRule(ruleId, vo);
|
vo.setRuleId(ruleId);
|
||||||
|
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) {
|
||||||
ruleService.deletePayoutRule(ruleId);
|
mapper.deletePayoutRule(ruleId);
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,29 +87,28 @@ 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(ruleService.listOverrideRules());
|
return ApiResponse.ok(mapper.selectOverrideRules());
|
||||||
}
|
}
|
||||||
|
|
||||||
@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) {
|
||||||
return ApiResponse.ok(ruleService.createOverrideRule(vo));
|
mapper.insertOverrideRule(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) {
|
||||||
ruleService.updateOverrideRule(overrideId, vo);
|
vo.setOverrideId(overrideId);
|
||||||
|
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) {
|
||||||
ruleService.deleteOverrideRule(overrideId);
|
mapper.deleteOverrideRule(overrideId);
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,29 +116,28 @@ 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(ruleService.listChargebackRules(companyCode));
|
return ApiResponse.ok(mapper.selectChargebackRules(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) {
|
||||||
return ApiResponse.ok(ruleService.createChargebackRule(vo));
|
mapper.insertChargebackRule(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) {
|
||||||
ruleService.updateChargebackRule(cbRuleId, vo);
|
vo.setCbRuleId(cbRuleId);
|
||||||
|
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) {
|
||||||
ruleService.deleteChargebackRule(cbRuleId);
|
mapper.deleteChargebackRule(cbRuleId);
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,23 +145,22 @@ 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(ruleService.listExceptionCodes());
|
return ApiResponse.ok(mapper.selectExceptionCodes());
|
||||||
}
|
}
|
||||||
|
|
||||||
@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) {
|
||||||
ruleService.createExceptionCode(vo);
|
mapper.insertExceptionCode(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) {
|
||||||
ruleService.updateExceptionCode(exceptionCode, vo);
|
vo.setExceptionCode(exceptionCode);
|
||||||
|
mapper.updateExceptionCode(vo);
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,91 +1,38 @@
|
|||||||
package com.ga.api.controller.settle;
|
package com.ga.api.controller.settle;
|
||||||
|
|
||||||
import com.ga.api.service.settle.PaymentService;
|
|
||||||
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.common.model.PageResponse;
|
import com.ga.common.model.PageResponse;
|
||||||
|
import com.ga.core.mapper.settle.PaymentMapper;
|
||||||
import com.ga.core.vo.settle.PaymentResp;
|
import com.ga.core.vo.settle.PaymentResp;
|
||||||
import com.ga.core.vo.settle.SettleSearchParam;
|
import com.ga.core.vo.settle.SettleSearchParam;
|
||||||
import com.ga.core.vo.settle.TransferFileResp;
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import jakarta.validation.constraints.Pattern;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.validation.annotation.Validated;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Tag(name = "지급 관리")
|
@Tag(name = "지급 관리")
|
||||||
@Validated
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/payments")
|
@RequestMapping("/api/payments")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class PaymentController {
|
public class PaymentController {
|
||||||
|
|
||||||
private final PaymentService paymentService;
|
private final PaymentMapper mapper;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@RequirePermission(menu = "PAYMENT", perm = "READ")
|
@RequirePermission(menu = "PAYMENT", perm = "READ")
|
||||||
public ApiResponse<PageResponse<PaymentResp>> list(@ModelAttribute SettleSearchParam param) {
|
public ApiResponse<PageResponse<PaymentResp>> list(@ModelAttribute SettleSearchParam param) {
|
||||||
return ApiResponse.ok(paymentService.list(param));
|
param.startPage();
|
||||||
|
return ApiResponse.ok(PageResponse.of(mapper.selectList(param)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{paymentId}/status")
|
@PutMapping("/{paymentId}/status")
|
||||||
@RequirePermission(menu = "PAYMENT", perm = "UPDATE")
|
@RequirePermission(menu = "PAYMENT", perm = "UPDATE")
|
||||||
@DataChangeLog(menu = "PAYMENT", table = "payment")
|
@DataChangeLog(menu = "PAYMENT", table = "payment")
|
||||||
public ApiResponse<Void> updateStatus(@PathVariable Long paymentId, @Valid @RequestBody PaymentStatusUpdateReq req) {
|
public ApiResponse<Void> updateStatus(@PathVariable Long paymentId, @RequestBody Map<String, String> body) {
|
||||||
paymentService.updateStatus(paymentId, req.getStatus(), req.getFailReason());
|
mapper.updateStatus(paymentId, body.get("status"), body.get("failReason"));
|
||||||
return ApiResponse.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Operation(summary = "이체파일 생성", description = "정산월+은행코드 기준 PENDING 지급건을 이체파일로 생성하고 SENT 상태로 갱신")
|
|
||||||
@PostMapping("/transfer-file")
|
|
||||||
@RequirePermission(menu = "PAYMENT", perm = "EXPORT")
|
|
||||||
@DataChangeLog(menu = "PAYMENT", table = "payment")
|
|
||||||
public ApiResponse<TransferFileResp> generateTransferFile(
|
|
||||||
@Pattern(regexp = "\\d{6}", message = "settleMonth는 YYYYMM 형식이어야 합니다") @RequestParam String settleMonth,
|
|
||||||
@RequestParam String bankCode) {
|
|
||||||
return ApiResponse.ok(paymentService.generateTransferFile(settleMonth, bankCode));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Operation(summary = "세금 단건 재계산")
|
|
||||||
@PostMapping("/{paymentId}/calculate-tax")
|
|
||||||
@RequirePermission(menu = "PAYMENT", perm = "UPDATE")
|
|
||||||
@DataChangeLog(menu = "PAYMENT", table = "payment")
|
|
||||||
public ApiResponse<Void> calculateTax(@PathVariable Long paymentId) {
|
|
||||||
paymentService.calculateTaxes(paymentId);
|
|
||||||
return ApiResponse.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Operation(summary = "세금 월별 일괄 재계산")
|
|
||||||
@PostMapping("/calculate-taxes")
|
|
||||||
@RequirePermission(menu = "PAYMENT", perm = "UPDATE")
|
|
||||||
@DataChangeLog(menu = "PAYMENT", table = "payment")
|
|
||||||
public ApiResponse<Void> calculateTaxesForMonth(
|
|
||||||
@Pattern(regexp = "\\d{6}", message = "settleMonth는 YYYYMM 형식이어야 합니다") @RequestParam String settleMonth) {
|
|
||||||
paymentService.calculateTaxesForMonth(settleMonth);
|
|
||||||
return ApiResponse.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Operation(summary = "차감 단건 적용")
|
|
||||||
@PostMapping("/{paymentId}/apply-deductions")
|
|
||||||
@RequirePermission(menu = "PAYMENT", perm = "UPDATE")
|
|
||||||
@DataChangeLog(menu = "PAYMENT", table = "payment")
|
|
||||||
public ApiResponse<Void> applyDeductions(
|
|
||||||
@PathVariable Long paymentId,
|
|
||||||
@Pattern(regexp = "\\d{6}", message = "settleMonth는 YYYYMM 형식이어야 합니다") @RequestParam String settleMonth) {
|
|
||||||
paymentService.applyDeductions(paymentId, settleMonth);
|
|
||||||
return ApiResponse.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Operation(summary = "차감 월별 일괄 적용")
|
|
||||||
@PostMapping("/apply-deductions")
|
|
||||||
@RequirePermission(menu = "PAYMENT", perm = "UPDATE")
|
|
||||||
@DataChangeLog(menu = "PAYMENT", table = "payment")
|
|
||||||
public ApiResponse<Void> applyDeductionsForMonth(
|
|
||||||
@Pattern(regexp = "\\d{6}", message = "settleMonth는 YYYYMM 형식이어야 합니다") @RequestParam String settleMonth) {
|
|
||||||
paymentService.applyDeductionsForMonth(settleMonth);
|
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
package com.ga.api.controller.settle;
|
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotBlank;
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.Setter;
|
|
||||||
|
|
||||||
@Getter
|
|
||||||
@Setter
|
|
||||||
public class PaymentStatusUpdateReq {
|
|
||||||
|
|
||||||
@NotBlank
|
|
||||||
private String status;
|
|
||||||
|
|
||||||
private String failReason;
|
|
||||||
}
|
|
||||||
-85
@@ -1,85 +0,0 @@
|
|||||||
package com.ga.api.controller.statement;
|
|
||||||
|
|
||||||
import com.ga.api.service.statement.CommissionStatementService;
|
|
||||||
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.CommissionStatementResp;
|
|
||||||
import com.ga.core.vo.settle.CommissionStatementSaveReq;
|
|
||||||
import com.ga.core.vo.settle.CommissionStatementSearchParam;
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
|
||||||
import jakarta.validation.Valid;
|
|
||||||
import jakarta.validation.constraints.Pattern;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.validation.annotation.Validated;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.URLEncoder;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
|
|
||||||
@Tag(name = "수수료 명세서")
|
|
||||||
@Validated
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/statements")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class CommissionStatementController {
|
|
||||||
|
|
||||||
private final CommissionStatementService statementService;
|
|
||||||
|
|
||||||
@GetMapping
|
|
||||||
@RequirePermission(menu = "STATEMENT", perm = "READ")
|
|
||||||
public ApiResponse<PageResponse<CommissionStatementResp>> list(@ModelAttribute CommissionStatementSearchParam param) {
|
|
||||||
return ApiResponse.ok(statementService.list(param));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/{statementId}")
|
|
||||||
@RequirePermission(menu = "STATEMENT", perm = "READ")
|
|
||||||
public ApiResponse<CommissionStatementResp> detail(@PathVariable Long statementId) {
|
|
||||||
return ApiResponse.ok(statementService.detail(statementId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Operation(summary = "명세서 단건 발급")
|
|
||||||
@PostMapping("/generate")
|
|
||||||
@RequirePermission(menu = "STATEMENT", perm = "CREATE")
|
|
||||||
@DataChangeLog(menu = "STATEMENT", table = "commission_statement")
|
|
||||||
public ApiResponse<Long> generate(@Valid @RequestBody CommissionStatementSaveReq req) {
|
|
||||||
return ApiResponse.ok(statementService.generate(req));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Operation(summary = "명세서 월별 일괄 발급")
|
|
||||||
@PostMapping("/generate-for-month")
|
|
||||||
@RequirePermission(menu = "STATEMENT", perm = "CREATE")
|
|
||||||
@DataChangeLog(menu = "STATEMENT", table = "commission_statement")
|
|
||||||
public ApiResponse<Void> generateForMonth(
|
|
||||||
@Pattern(regexp = "\\d{6}", message = "settleMonth는 YYYYMM 형식이어야 합니다") @RequestParam String settleMonth,
|
|
||||||
@RequestParam String statementType) {
|
|
||||||
statementService.generateForMonth(settleMonth, statementType);
|
|
||||||
return ApiResponse.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Operation(summary = "명세서 다운로드")
|
|
||||||
@GetMapping("/{statementId}/download")
|
|
||||||
@RequirePermission(menu = "STATEMENT", perm = "READ")
|
|
||||||
public void download(@PathVariable Long statementId, HttpServletResponse response) throws IOException {
|
|
||||||
byte[] content = statementService.download(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);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Operation(summary = "명세서 취소")
|
|
||||||
@PutMapping("/{statementId}/cancel")
|
|
||||||
@RequirePermission(menu = "STATEMENT", perm = "UPDATE")
|
|
||||||
@DataChangeLog(menu = "STATEMENT", table = "commission_statement")
|
|
||||||
public ApiResponse<Void> cancel(@PathVariable Long statementId) {
|
|
||||||
statementService.cancel(statementId);
|
|
||||||
return ApiResponse.ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
package com.ga.api.controller.system;
|
package com.ga.api.controller.system;
|
||||||
|
|
||||||
import com.ga.api.service.system.RoleService;
|
|
||||||
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.user.RoleMapper;
|
||||||
import com.ga.core.vo.user.RoleVO;
|
import com.ga.core.vo.user.RoleVO;
|
||||||
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.transaction.annotation.Transactional;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -18,55 +19,63 @@ import java.util.Map;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class RoleController {
|
public class RoleController {
|
||||||
|
|
||||||
private final RoleService roleService;
|
private final RoleMapper mapper;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@RequirePermission(menu = "SYSTEM_ROLE", perm = "READ")
|
@RequirePermission(menu = "SYSTEM_ROLE", perm = "READ")
|
||||||
public ApiResponse<List<RoleVO>> list() {
|
public ApiResponse<List<RoleVO>> list() {
|
||||||
return ApiResponse.ok(roleService.list());
|
return ApiResponse.ok(mapper.selectAll());
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{roleId}")
|
@GetMapping("/{roleId}")
|
||||||
@RequirePermission(menu = "SYSTEM_ROLE", perm = "READ")
|
@RequirePermission(menu = "SYSTEM_ROLE", perm = "READ")
|
||||||
public ApiResponse<RoleVO> detail(@PathVariable Long roleId) {
|
public ApiResponse<RoleVO> detail(@PathVariable Long roleId) {
|
||||||
return ApiResponse.ok(roleService.detail(roleId));
|
return ApiResponse.ok(mapper.selectById(roleId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{roleId}/permissions")
|
@GetMapping("/{roleId}/permissions")
|
||||||
@RequirePermission(menu = "SYSTEM_ROLE", perm = "READ")
|
@RequirePermission(menu = "SYSTEM_ROLE", perm = "READ")
|
||||||
public ApiResponse<List<Map<String, Object>>> permissions(@PathVariable Long roleId) {
|
public ApiResponse<List<Map<String, Object>>> permissions(@PathVariable Long roleId) {
|
||||||
return ApiResponse.ok(roleService.permissions(roleId));
|
return ApiResponse.ok(mapper.selectRolePermissions(roleId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@RequirePermission(menu = "SYSTEM_ROLE", perm = "CREATE")
|
@RequirePermission(menu = "SYSTEM_ROLE", perm = "CREATE")
|
||||||
@DataChangeLog(menu = "SYSTEM_ROLE", table = "role")
|
@DataChangeLog(menu = "SYSTEM_ROLE", table = "role")
|
||||||
public ApiResponse<Long> create(@RequestBody RoleVO vo) {
|
public ApiResponse<Long> create(@RequestBody RoleVO vo) {
|
||||||
return ApiResponse.ok(roleService.create(vo));
|
mapper.insert(vo);
|
||||||
|
return ApiResponse.ok(vo.getRoleId());
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{roleId}")
|
@PutMapping("/{roleId}")
|
||||||
@RequirePermission(menu = "SYSTEM_ROLE", perm = "UPDATE")
|
@RequirePermission(menu = "SYSTEM_ROLE", perm = "UPDATE")
|
||||||
@DataChangeLog(menu = "SYSTEM_ROLE", table = "role")
|
@DataChangeLog(menu = "SYSTEM_ROLE", table = "role")
|
||||||
public ApiResponse<Void> update(@PathVariable Long roleId, @RequestBody RoleVO vo) {
|
public ApiResponse<Void> update(@PathVariable Long roleId, @RequestBody RoleVO vo) {
|
||||||
roleService.update(roleId, vo);
|
vo.setRoleId(roleId);
|
||||||
|
mapper.update(vo);
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{roleId}")
|
@DeleteMapping("/{roleId}")
|
||||||
@RequirePermission(menu = "SYSTEM_ROLE", perm = "DELETE")
|
@RequirePermission(menu = "SYSTEM_ROLE", perm = "DELETE")
|
||||||
@DataChangeLog(menu = "SYSTEM_ROLE", table = "role")
|
|
||||||
public ApiResponse<Void> delete(@PathVariable Long roleId) {
|
public ApiResponse<Void> delete(@PathVariable Long roleId) {
|
||||||
roleService.delete(roleId);
|
mapper.deleteById(roleId);
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 권한 매트릭스 일괄 저장 */
|
||||||
@PutMapping("/{roleId}/permissions")
|
@PutMapping("/{roleId}/permissions")
|
||||||
@RequirePermission(menu = "SYSTEM_ROLE", perm = "UPDATE")
|
@RequirePermission(menu = "SYSTEM_ROLE", perm = "UPDATE")
|
||||||
@DataChangeLog(menu = "SYSTEM_ROLE", table = "role_permission")
|
@Transactional
|
||||||
public ApiResponse<Void> savePermissions(@PathVariable Long roleId,
|
public ApiResponse<Void> savePermissions(@PathVariable Long roleId,
|
||||||
@RequestBody List<Map<String, Object>> permissions) {
|
@RequestBody List<Map<String, Object>> permissions) {
|
||||||
roleService.savePermissions(roleId, permissions);
|
mapper.deleteRolePermissions(roleId);
|
||||||
|
for (Map<String, Object> p : permissions) {
|
||||||
|
Long menuId = ((Number) p.get("menuId")).longValue();
|
||||||
|
String permCode = (String) p.get("permCode");
|
||||||
|
String isGranted = (String) p.getOrDefault("isGranted", "Y");
|
||||||
|
mapper.insertRolePermission(roleId, menuId, permCode, isGranted);
|
||||||
|
}
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
package com.ga.api.controller.upload;
|
|
||||||
|
|
||||||
import com.ga.api.service.upload.TargetFieldMetaService;
|
|
||||||
import com.ga.api.service.upload.TargetFieldMetaService.TargetFieldMeta;
|
|
||||||
import com.ga.api.service.upload.UploadResultResp;
|
|
||||||
import com.ga.api.service.upload.UploadService;
|
|
||||||
import com.ga.api.service.upload.UploadService.ExcelHeaderResp;
|
|
||||||
import com.ga.common.annotation.RequirePermission;
|
|
||||||
import com.ga.common.model.ApiResponse;
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
|
||||||
import jakarta.validation.constraints.Max;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.validation.annotation.Validated;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
@Tag(name = "엑셀 업로드")
|
|
||||||
@Validated
|
|
||||||
@RestController
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class UploadController {
|
|
||||||
|
|
||||||
private final UploadService uploadService;
|
|
||||||
private final TargetFieldMetaService metaService;
|
|
||||||
|
|
||||||
@Operation(summary = "엑셀 업로드 처리",
|
|
||||||
description = "templateCode 에 해당하는 템플릿으로 파일을 파싱 · 변환 · 배치 INSERT 한다.")
|
|
||||||
@PostMapping("/api/upload/{templateCode}")
|
|
||||||
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "CREATE")
|
|
||||||
public ApiResponse<UploadResultResp> upload(@PathVariable String templateCode,
|
|
||||||
@RequestParam("file") MultipartFile file) {
|
|
||||||
UploadResultResp result = uploadService.processUpload(templateCode, file);
|
|
||||||
return ApiResponse.ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Operation(summary = "업로드 미리보기 (상위 5행)",
|
|
||||||
description = "DB 에 저장하지 않고 변환 결과만 반환한다.")
|
|
||||||
@PostMapping("/api/upload-templates/{templateCode}/preview")
|
|
||||||
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ")
|
|
||||||
public ApiResponse<List<Map<String, Object>>> preview(
|
|
||||||
@PathVariable String templateCode,
|
|
||||||
@RequestParam("file") MultipartFile file,
|
|
||||||
@Max(100) @RequestParam(value = "limit", defaultValue = "5") int limit) {
|
|
||||||
List<Map<String, Object>> rows = uploadService.preview(templateCode, file, limit);
|
|
||||||
return ApiResponse.ok(rows);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Operation(summary = "샘플 엑셀 헤더 추출",
|
|
||||||
description = "템플릿 등록 전에 샘플 파일 첫 행 헤더 + 미리보기 행을 추출한다. " +
|
|
||||||
"프론트는 이 결과로 sourceColumn(A,B,C..) + sourceHeader 를 자동 채운다.")
|
|
||||||
@PostMapping("/api/upload-templates/excel-headers")
|
|
||||||
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ")
|
|
||||||
public ApiResponse<ExcelHeaderResp> extractHeaders(
|
|
||||||
@RequestParam("file") MultipartFile file,
|
|
||||||
@RequestParam(value = "headerRow", defaultValue = "1") int headerRow,
|
|
||||||
@RequestParam(value = "sheetIndex", defaultValue = "0") int sheetIndex,
|
|
||||||
@RequestParam(value = "previewRows", defaultValue = "5") int previewRows) {
|
|
||||||
return ApiResponse.ok(uploadService.extractHeaders(file, headerRow, sheetIndex, previewRows));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Operation(summary = "target_table 별 사용 가능한 컬럼 메타",
|
|
||||||
description = "프론트의 target_field Select 옵션 + 타입 자동 채우기에 사용한다.")
|
|
||||||
@GetMapping("/api/upload-templates/target-fields/{targetTable}")
|
|
||||||
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ")
|
|
||||||
public ApiResponse<List<TargetFieldMeta>> targetFields(@PathVariable String targetTable) {
|
|
||||||
return ApiResponse.ok(metaService.getFields(targetTable));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Operation(summary = "사용 가능한 target_table 목록")
|
|
||||||
@GetMapping("/api/upload-templates/target-tables")
|
|
||||||
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ")
|
|
||||||
public ApiResponse<List<String>> targetTables() {
|
|
||||||
return ApiResponse.ok(metaService.getAvailableTables());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
package com.ga.api.controller.upload;
|
|
||||||
|
|
||||||
import com.ga.api.service.upload.UploadTemplateService;
|
|
||||||
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.upload.*;
|
|
||||||
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;
|
|
||||||
|
|
||||||
@Tag(name = "업로드 템플릿 관리")
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/upload-templates")
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class UploadTemplateController {
|
|
||||||
|
|
||||||
private final UploadTemplateService templateService;
|
|
||||||
|
|
||||||
@Operation(summary = "템플릿 목록 조회")
|
|
||||||
@GetMapping
|
|
||||||
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ")
|
|
||||||
public ApiResponse<PageResponse<UploadTemplateResp>> list(@ModelAttribute UploadTemplateSearchParam param) {
|
|
||||||
return ApiResponse.ok(templateService.list(param));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Operation(summary = "템플릿 상세 조회 (컬럼 포함)")
|
|
||||||
@GetMapping("/{templateId}")
|
|
||||||
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ")
|
|
||||||
public ApiResponse<UploadTemplateResp> detail(@PathVariable Long templateId) {
|
|
||||||
return ApiResponse.ok(templateService.detail(templateId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Operation(summary = "템플릿 등록")
|
|
||||||
@PostMapping
|
|
||||||
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "CREATE")
|
|
||||||
@DataChangeLog(menu = "SYSTEM_UPLOAD_TEMPLATE", table = "upload_template")
|
|
||||||
public ApiResponse<Long> create(@Valid @RequestBody UploadTemplateSaveReq req) {
|
|
||||||
return ApiResponse.ok(templateService.create(req));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Operation(summary = "템플릿 수정")
|
|
||||||
@PutMapping("/{templateId}")
|
|
||||||
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "UPDATE")
|
|
||||||
@DataChangeLog(menu = "SYSTEM_UPLOAD_TEMPLATE", table = "upload_template")
|
|
||||||
public ApiResponse<Void> update(@PathVariable Long templateId,
|
|
||||||
@Valid @RequestBody UploadTemplateSaveReq req) {
|
|
||||||
templateService.update(templateId, req);
|
|
||||||
return ApiResponse.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Operation(summary = "템플릿 삭제")
|
|
||||||
@DeleteMapping("/{templateId}")
|
|
||||||
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "DELETE")
|
|
||||||
@DataChangeLog(menu = "SYSTEM_UPLOAD_TEMPLATE", table = "upload_template")
|
|
||||||
public ApiResponse<Void> delete(@PathVariable Long templateId) {
|
|
||||||
templateService.delete(templateId);
|
|
||||||
return ApiResponse.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Operation(summary = "템플릿 컬럼 목록 조회")
|
|
||||||
@GetMapping("/{templateId}/columns")
|
|
||||||
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ")
|
|
||||||
public ApiResponse<List<UploadTemplateColumnVO>> columns(@PathVariable Long templateId) {
|
|
||||||
return ApiResponse.ok(templateService.columns(templateId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Operation(summary = "템플릿 컬럼 전체 갱신 (기존 삭제 후 재등록)")
|
|
||||||
@PutMapping("/{templateId}/columns")
|
|
||||||
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "UPDATE")
|
|
||||||
@DataChangeLog(menu = "SYSTEM_UPLOAD_TEMPLATE", table = "upload_template_column")
|
|
||||||
public ApiResponse<Void> updateColumns(@PathVariable Long templateId,
|
|
||||||
@RequestBody List<UploadTemplateColumnVO> columns) {
|
|
||||||
templateService.updateColumns(templateId, columns);
|
|
||||||
return ApiResponse.ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Operation(summary = "템플릿 업로드 이력 조회")
|
|
||||||
@GetMapping("/{templateId}/history")
|
|
||||||
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ")
|
|
||||||
public ApiResponse<PageResponse<UploadHistoryResp>> history(@PathVariable Long templateId,
|
|
||||||
@ModelAttribute UploadTemplateSearchParam param) {
|
|
||||||
return ApiResponse.ok(templateService.history(templateId, param));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
package com.ga.api.service.chargeback;
|
|
||||||
|
|
||||||
import com.ga.core.mapper.chargeback.ChargebackGradeMapper;
|
|
||||||
import com.ga.core.vo.chargeback.ChargebackGradeVO;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.math.RoundingMode;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.time.temporal.ChronoUnit;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class ChargebackCalculator {
|
|
||||||
|
|
||||||
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
|
||||||
|
|
||||||
private final ChargebackGradeMapper gradeMapper;
|
|
||||||
|
|
||||||
public ChargebackResult calculate(LocalDate contractDate, LocalDate terminationDate,
|
|
||||||
BigDecimal originalCommission, String productCategory) {
|
|
||||||
int monthsElapsed = (int) ChronoUnit.MONTHS.between(contractDate, terminationDate);
|
|
||||||
String today = LocalDate.now().format(DATE_FMT);
|
|
||||||
|
|
||||||
ChargebackGradeVO grade = gradeMapper.selectByMonths(productCategory, monthsElapsed, today);
|
|
||||||
|
|
||||||
if (grade == null) {
|
|
||||||
return new ChargebackResult(BigDecimal.ZERO, monthsElapsed, BigDecimal.ZERO,
|
|
||||||
"적용 환수 구간 없음");
|
|
||||||
}
|
|
||||||
|
|
||||||
BigDecimal chargebackAmount = originalCommission
|
|
||||||
.multiply(grade.getChargebackPercent())
|
|
||||||
.divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP);
|
|
||||||
|
|
||||||
return new ChargebackResult(
|
|
||||||
chargebackAmount,
|
|
||||||
monthsElapsed,
|
|
||||||
grade.getChargebackPercent(),
|
|
||||||
String.format("경과월 %d개월 → 환수율 %s%%", monthsElapsed, grade.getChargebackPercent())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
package com.ga.api.service.chargeback;
|
|
||||||
|
|
||||||
import com.ga.common.exception.BizException;
|
|
||||||
import com.ga.common.exception.ErrorCode;
|
|
||||||
import com.ga.common.model.PageResponse;
|
|
||||||
import com.ga.core.mapper.chargeback.ChargebackGradeMapper;
|
|
||||||
import com.ga.core.vo.chargeback.ChargebackGradeResp;
|
|
||||||
import com.ga.core.vo.chargeback.ChargebackGradeSaveReq;
|
|
||||||
import com.ga.core.vo.chargeback.ChargebackGradeSearchParam;
|
|
||||||
import com.ga.core.vo.chargeback.ChargebackGradeVO;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class ChargebackGradeService {
|
|
||||||
|
|
||||||
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
|
||||||
|
|
||||||
private final ChargebackGradeMapper gradeMapper;
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public PageResponse<ChargebackGradeResp> list(ChargebackGradeSearchParam param) {
|
|
||||||
param.startPage();
|
|
||||||
return PageResponse.of(gradeMapper.selectList(param));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public ChargebackGradeResp detail(Long gradeId) {
|
|
||||||
ChargebackGradeResp resp = gradeMapper.selectDetailById(gradeId);
|
|
||||||
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
return resp;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void create(ChargebackGradeSaveReq req) {
|
|
||||||
String today = LocalDate.now().format(DATE_FMT);
|
|
||||||
ChargebackGradeVO existing = gradeMapper.selectByMonths(
|
|
||||||
req.getProductCategory(), req.getMonthsFrom(), today);
|
|
||||||
if (existing != null) {
|
|
||||||
throw new BizException(ErrorCode.DUPLICATE_DATA);
|
|
||||||
}
|
|
||||||
gradeMapper.insert(toVO(req));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void update(Long gradeId, ChargebackGradeSaveReq req) {
|
|
||||||
ChargebackGradeVO existing = gradeMapper.selectById(gradeId);
|
|
||||||
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
ChargebackGradeVO vo = toVO(req);
|
|
||||||
vo.setGradeId(gradeId);
|
|
||||||
vo.setIsActive(existing.getIsActive());
|
|
||||||
gradeMapper.update(vo);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void deactivate(Long gradeId) {
|
|
||||||
ChargebackGradeVO existing = gradeMapper.selectById(gradeId);
|
|
||||||
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
if (Boolean.FALSE.equals(existing.getIsActive())) {
|
|
||||||
throw new BizException(ErrorCode.INVALID_PARAMETER);
|
|
||||||
}
|
|
||||||
gradeMapper.updateStatus(gradeId, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public ChargebackGradeVO selectByMonths(String productCategory, Integer monthsElapsed, String baseDate) {
|
|
||||||
return gradeMapper.selectByMonths(productCategory, monthsElapsed, baseDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
private ChargebackGradeVO toVO(ChargebackGradeSaveReq req) {
|
|
||||||
ChargebackGradeVO vo = new ChargebackGradeVO();
|
|
||||||
vo.setProductCategory(req.getProductCategory());
|
|
||||||
vo.setMonthsFrom(req.getMonthsFrom());
|
|
||||||
vo.setMonthsTo(req.getMonthsTo());
|
|
||||||
vo.setChargebackPercent(req.getChargebackPercent());
|
|
||||||
vo.setEffectiveFrom(req.getEffectiveFrom());
|
|
||||||
vo.setEffectiveTo(req.getEffectiveTo());
|
|
||||||
vo.setIsActive(true);
|
|
||||||
return vo;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package com.ga.api.service.chargeback;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
|
|
||||||
public record ChargebackResult(
|
|
||||||
BigDecimal chargebackAmount,
|
|
||||||
int monthsElapsed,
|
|
||||||
BigDecimal appliedPercent,
|
|
||||||
String reason
|
|
||||||
) {}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
package com.ga.api.service.deduction;
|
|
||||||
|
|
||||||
import com.ga.common.exception.BizException;
|
|
||||||
import com.ga.common.exception.ErrorCode;
|
|
||||||
import com.ga.common.model.PageResponse;
|
|
||||||
import com.ga.core.mapper.settle.AgentDeductionMapper;
|
|
||||||
import com.ga.core.vo.settle.AgentDeductionResp;
|
|
||||||
import com.ga.core.vo.settle.AgentDeductionSaveReq;
|
|
||||||
import com.ga.core.vo.settle.AgentDeductionSearchParam;
|
|
||||||
import com.ga.core.vo.settle.AgentDeductionVO;
|
|
||||||
import com.ga.core.vo.settle.DeductionStatus;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class DeductionService {
|
|
||||||
|
|
||||||
private final AgentDeductionMapper mapper;
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public PageResponse<AgentDeductionResp> list(AgentDeductionSearchParam param) {
|
|
||||||
param.startPage();
|
|
||||||
return PageResponse.of(mapper.selectList(param));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public AgentDeductionResp detail(Long deductionId) {
|
|
||||||
AgentDeductionResp resp = mapper.selectDetailById(deductionId);
|
|
||||||
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
return resp;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public Long create(AgentDeductionSaveReq req) {
|
|
||||||
if (mapper.countByAgentMonthType(req.getAgentId(), req.getSettleMonth(), req.getDeductionType()) > 0) {
|
|
||||||
throw new BizException(ErrorCode.DUPLICATE_DATA,
|
|
||||||
"동일 설계사/정산월/차감유형 조합이 이미 존재합니다");
|
|
||||||
}
|
|
||||||
AgentDeductionVO vo = new AgentDeductionVO();
|
|
||||||
vo.setAgentId(req.getAgentId());
|
|
||||||
vo.setSettleMonth(req.getSettleMonth());
|
|
||||||
vo.setDeductionType(req.getDeductionType());
|
|
||||||
vo.setAmount(req.getAmount());
|
|
||||||
vo.setDescription(req.getDescription());
|
|
||||||
vo.setStatus(DeductionStatus.PENDING.name());
|
|
||||||
mapper.insert(vo);
|
|
||||||
return vo.getDeductionId();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void update(Long deductionId, AgentDeductionSaveReq req) {
|
|
||||||
AgentDeductionVO vo = mapper.selectById(deductionId);
|
|
||||||
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
if (!DeductionStatus.PENDING.name().equals(vo.getStatus())) {
|
|
||||||
throw new BizException(ErrorCode.INVALID_PARAMETER, "PENDING 상태만 수정 가능합니다");
|
|
||||||
}
|
|
||||||
vo.setAmount(req.getAmount());
|
|
||||||
vo.setDescription(req.getDescription());
|
|
||||||
mapper.update(vo);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void cancel(Long deductionId) {
|
|
||||||
AgentDeductionVO vo = mapper.selectById(deductionId);
|
|
||||||
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
if (DeductionStatus.APPLIED.name().equals(vo.getStatus())) {
|
|
||||||
throw new BizException(ErrorCode.INVALID_PARAMETER, "이미 적용된 차감은 취소할 수 없습니다");
|
|
||||||
}
|
|
||||||
mapper.updateStatus(deductionId, DeductionStatus.CANCELLED.name());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public BigDecimal sumPendingByAgent(Long agentId, String settleMonth) {
|
|
||||||
BigDecimal sum = mapper.sumPendingByAgent(agentId, settleMonth);
|
|
||||||
return sum != null ? sum : BigDecimal.ZERO;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<AgentDeductionVO> selectPendingByMonth(String settleMonth) {
|
|
||||||
return mapper.selectPendingByMonth(settleMonth);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<AgentDeductionVO> selectPendingByAgent(Long agentId, String settleMonth) {
|
|
||||||
return mapper.selectPendingByAgent(agentId, settleMonth);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void markAsApplied(List<Long> deductionIds) {
|
|
||||||
if (deductionIds.isEmpty()) return;
|
|
||||||
mapper.updateStatusBatch(deductionIds, DeductionStatus.APPLIED.name());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
package com.ga.api.service.dict;
|
|
||||||
|
|
||||||
import com.ga.common.exception.BizException;
|
|
||||||
import com.ga.common.exception.ErrorCode;
|
|
||||||
import com.ga.common.model.PageResponse;
|
|
||||||
import com.ga.core.mapper.dict.DataDictionaryMapper;
|
|
||||||
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 com.ga.core.vo.dict.DataDictionaryVO;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class DataDictionaryService {
|
|
||||||
|
|
||||||
private final DataDictionaryMapper mapper;
|
|
||||||
|
|
||||||
public PageResponse<DataDictionaryResp> list(DataDictionarySearchParam param) {
|
|
||||||
param.startPage();
|
|
||||||
return PageResponse.of(mapper.selectList(param));
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<String> tables() {
|
|
||||||
return mapper.selectTables();
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<DataDictionaryFullResp> full(String tableName) {
|
|
||||||
return mapper.selectFromView(tableName);
|
|
||||||
}
|
|
||||||
|
|
||||||
public DataDictionaryResp detail(Long dictId) {
|
|
||||||
DataDictionaryResp resp = mapper.selectById(dictId);
|
|
||||||
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
return resp;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void create(DataDictionarySaveReq req) {
|
|
||||||
if (req.getDomainCode() != null && !req.getDomainCode().isBlank()) {
|
|
||||||
if (mapper.countDomainByCode(req.getDomainCode()) == 0) {
|
|
||||||
throw new BizException(ErrorCode.NOT_FOUND, "존재하지 않는 도메인 코드입니다: " + req.getDomainCode());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mapper.insert(req.toVO());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void update(Long dictId, DataDictionarySaveReq req) {
|
|
||||||
DataDictionaryResp existing = mapper.selectById(dictId);
|
|
||||||
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
if (req.getDomainCode() != null && !req.getDomainCode().isBlank()) {
|
|
||||||
if (mapper.countDomainByCode(req.getDomainCode()) == 0) {
|
|
||||||
throw new BizException(ErrorCode.NOT_FOUND, "존재하지 않는 도메인 코드입니다: " + req.getDomainCode());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DataDictionaryVO vo = req.toVO();
|
|
||||||
vo.setDictId(dictId);
|
|
||||||
mapper.update(vo);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void delete(Long dictId) {
|
|
||||||
if (mapper.delete(dictId) == 0) {
|
|
||||||
throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
package com.ga.api.service.dict;
|
|
||||||
|
|
||||||
import com.ga.common.exception.BizException;
|
|
||||||
import com.ga.common.exception.ErrorCode;
|
|
||||||
import com.ga.common.model.PageResponse;
|
|
||||||
import com.ga.core.mapper.dict.DataDomainMapper;
|
|
||||||
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 com.ga.core.vo.dict.DataDomainVO;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class DataDomainService {
|
|
||||||
|
|
||||||
private final DataDomainMapper mapper;
|
|
||||||
|
|
||||||
public PageResponse<DataDomainResp> list(DataDomainSearchParam param) {
|
|
||||||
param.startPage();
|
|
||||||
return PageResponse.of(mapper.selectList(param));
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<DataDomainOptionResp> options() {
|
|
||||||
return mapper.selectAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
public DataDomainResp detail(String domainCode) {
|
|
||||||
DataDomainResp resp = mapper.selectByCode(domainCode);
|
|
||||||
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
return resp;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void create(DataDomainSaveReq req) {
|
|
||||||
if (mapper.selectByCode(req.getDomainCode()) != null) {
|
|
||||||
throw new BizException(ErrorCode.DUPLICATE_DATA, "이미 존재하는 도메인 코드입니다");
|
|
||||||
}
|
|
||||||
mapper.insert(req.toVO());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void update(String domainCode, DataDomainSaveReq req) {
|
|
||||||
if (mapper.selectByCode(domainCode) == null) {
|
|
||||||
throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
}
|
|
||||||
DataDomainVO vo = req.toVO();
|
|
||||||
vo.setDomainCode(domainCode);
|
|
||||||
mapper.update(vo);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void delete(String domainCode) {
|
|
||||||
int usageCount = mapper.countUsageByCode(domainCode);
|
|
||||||
if (usageCount > 0) {
|
|
||||||
throw new BizException(ErrorCode.DEPENDENT_DATA,
|
|
||||||
"이 도메인을 사용하는 컬럼이 " + usageCount + "개 있습니다");
|
|
||||||
}
|
|
||||||
if (mapper.delete(domainCode) == 0) {
|
|
||||||
throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
package com.ga.api.service.installment;
|
|
||||||
|
|
||||||
import com.ga.core.mapper.installment.InstallmentRatioMapper;
|
|
||||||
import com.ga.core.vo.installment.InstallmentPlanVO;
|
|
||||||
import com.ga.core.vo.installment.InstallmentRatioVO;
|
|
||||||
import com.ga.core.vo.installment.InstallmentStatus;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.math.RoundingMode;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class InstallmentPlanGenerator {
|
|
||||||
|
|
||||||
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
|
||||||
private static final DateTimeFormatter MONTH_FMT = DateTimeFormatter.ofPattern("yyyyMM");
|
|
||||||
|
|
||||||
private final InstallmentRatioMapper ratioMapper;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 계약 체결일 기준 1~7차년 분급 계획 생성.
|
|
||||||
* ratioPercent 합이 100%가 안 되는 경우 각 연차별 비율만큼 지급.
|
|
||||||
*/
|
|
||||||
public List<InstallmentPlanVO> generate(Long contractId, BigDecimal totalCommission,
|
|
||||||
LocalDate contractDate, String productCategory) {
|
|
||||||
String baseDate = contractDate.format(DATE_FMT);
|
|
||||||
List<InstallmentRatioVO> ratios = ratioMapper.selectAllActiveRatios(productCategory, baseDate);
|
|
||||||
|
|
||||||
List<InstallmentPlanVO> plans = new ArrayList<>();
|
|
||||||
for (InstallmentRatioVO ratio : ratios) {
|
|
||||||
InstallmentPlanVO plan = new InstallmentPlanVO();
|
|
||||||
plan.setContractId(contractId);
|
|
||||||
plan.setPlanYear(ratio.getPlanYear());
|
|
||||||
plan.setPlanMonth(1); // 단순화: 연 1회 지급 (plan_month=1)
|
|
||||||
plan.setSettleMonth(contractDate.plusYears(ratio.getPlanYear()).format(MONTH_FMT));
|
|
||||||
plan.setExpectedAmount(
|
|
||||||
totalCommission.multiply(ratio.getRatioPercent())
|
|
||||||
.divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP)
|
|
||||||
);
|
|
||||||
plan.setStatus(InstallmentStatus.SCHEDULED.code());
|
|
||||||
plans.add(plan);
|
|
||||||
}
|
|
||||||
return plans;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
package com.ga.api.service.installment;
|
|
||||||
|
|
||||||
import com.ga.common.exception.BizException;
|
|
||||||
import com.ga.common.exception.ErrorCode;
|
|
||||||
import com.ga.common.model.PageResponse;
|
|
||||||
import com.ga.core.mapper.installment.InstallmentPlanMapper;
|
|
||||||
import com.ga.core.mapper.installment.InstallmentRatioMapper;
|
|
||||||
import com.ga.core.vo.installment.InstallmentPlanResp;
|
|
||||||
import com.ga.core.vo.installment.InstallmentPlanSearchParam;
|
|
||||||
import com.ga.core.vo.installment.InstallmentPlanVO;
|
|
||||||
import com.ga.core.vo.installment.InstallmentRatioResp;
|
|
||||||
import com.ga.core.vo.installment.InstallmentRatioSaveReq;
|
|
||||||
import com.ga.core.vo.installment.InstallmentRatioSearchParam;
|
|
||||||
import com.ga.core.vo.installment.InstallmentRatioVO;
|
|
||||||
import com.ga.core.vo.installment.InstallmentStatus;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class InstallmentService {
|
|
||||||
|
|
||||||
private final InstallmentPlanMapper planMapper;
|
|
||||||
private final InstallmentRatioMapper ratioMapper;
|
|
||||||
private final InstallmentPlanGenerator generator;
|
|
||||||
|
|
||||||
// ---- Plan 조회 ----
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public PageResponse<InstallmentPlanResp> list(InstallmentPlanSearchParam param) {
|
|
||||||
param.startPage();
|
|
||||||
return PageResponse.of(planMapper.selectList(param));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public InstallmentPlanResp detail(Long planId) {
|
|
||||||
InstallmentPlanResp resp = planMapper.selectDetailById(planId);
|
|
||||||
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
return resp;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<InstallmentPlanVO> selectScheduledForMonth(String settleMonth) {
|
|
||||||
return planMapper.selectScheduledByMonth(settleMonth);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Ratio 조회 ----
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public PageResponse<InstallmentRatioResp> listRatios(InstallmentRatioSearchParam param) {
|
|
||||||
param.startPage();
|
|
||||||
return PageResponse.of(ratioMapper.selectList(param));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Plan 생성 ----
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void createPlan(Long contractId, BigDecimal totalCommission,
|
|
||||||
LocalDate contractDate, String productCategory) {
|
|
||||||
List<InstallmentPlanVO> plans = generator.generate(contractId, totalCommission, contractDate, productCategory);
|
|
||||||
if (!plans.isEmpty()) {
|
|
||||||
planMapper.insertBatch(plans);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Plan 상태 전이 ----
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void payPlan(Long planId, Long paymentId, BigDecimal paidAmount) {
|
|
||||||
InstallmentPlanVO plan = requirePlan(planId);
|
|
||||||
if (!InstallmentStatus.SCHEDULED.code().equals(plan.getStatus())
|
|
||||||
&& !InstallmentStatus.DEFERRED.code().equals(plan.getStatus())) {
|
|
||||||
throw new BizException(ErrorCode.INVALID_PARAMETER, "지급 처리 가능한 상태가 아닙니다");
|
|
||||||
}
|
|
||||||
plan.setStatus(InstallmentStatus.PAID.code());
|
|
||||||
plan.setPaidAmount(paidAmount);
|
|
||||||
plan.setPaidAt(LocalDateTime.now());
|
|
||||||
plan.setPaymentId(paymentId);
|
|
||||||
planMapper.update(plan);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void deferPlan(Long planId) {
|
|
||||||
InstallmentPlanVO plan = requirePlan(planId);
|
|
||||||
if (!InstallmentStatus.SCHEDULED.code().equals(plan.getStatus())) {
|
|
||||||
throw new BizException(ErrorCode.INVALID_PARAMETER, "SCHEDULED 상태만 유예 가능합니다");
|
|
||||||
}
|
|
||||||
planMapper.updateStatus(planId, InstallmentStatus.DEFERRED.code());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void cancelPlan(Long planId) {
|
|
||||||
InstallmentPlanVO plan = requirePlan(planId);
|
|
||||||
if (InstallmentStatus.PAID.code().equals(plan.getStatus())) {
|
|
||||||
throw new BizException(ErrorCode.INVALID_PARAMETER, "지급 완료된 분급은 취소할 수 없습니다");
|
|
||||||
}
|
|
||||||
planMapper.updateStatus(planId, InstallmentStatus.CANCELLED.code());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void payScheduledForMonth(String settleMonth) {
|
|
||||||
List<InstallmentPlanVO> scheduled = planMapper.selectScheduledByMonth(settleMonth);
|
|
||||||
for (InstallmentPlanVO plan : scheduled) {
|
|
||||||
plan.setStatus(InstallmentStatus.PAID.code());
|
|
||||||
plan.setPaidAmount(plan.getExpectedAmount());
|
|
||||||
plan.setPaidAt(LocalDateTime.now());
|
|
||||||
planMapper.update(plan);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Ratio 생성 ----
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void createRatio(InstallmentRatioSaveReq req) {
|
|
||||||
InstallmentRatioVO vo = new InstallmentRatioVO();
|
|
||||||
vo.setProductCategory(req.getProductCategory());
|
|
||||||
vo.setPlanYear(req.getPlanYear());
|
|
||||||
vo.setRatioPercent(req.getRatioPercent());
|
|
||||||
vo.setEffectiveFrom(req.getEffectiveFrom());
|
|
||||||
vo.setEffectiveTo(req.getEffectiveTo());
|
|
||||||
vo.setIsActive(true);
|
|
||||||
ratioMapper.insert(vo);
|
|
||||||
}
|
|
||||||
|
|
||||||
private InstallmentPlanVO requirePlan(Long planId) {
|
|
||||||
InstallmentPlanVO plan = planMapper.selectById(planId);
|
|
||||||
if (plan == null) throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
return plan;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,11 +7,7 @@ import com.ga.common.util.SecurityUtil;
|
|||||||
import com.ga.core.mapper.ledger.ExceptionLedgerMapper;
|
import com.ga.core.mapper.ledger.ExceptionLedgerMapper;
|
||||||
import com.ga.core.mapper.ledger.MaintainLedgerMapper;
|
import com.ga.core.mapper.ledger.MaintainLedgerMapper;
|
||||||
import com.ga.core.mapper.ledger.RecruitLedgerMapper;
|
import com.ga.core.mapper.ledger.RecruitLedgerMapper;
|
||||||
import com.ga.core.mapstruct.ExceptionLedgerMapStruct;
|
|
||||||
import com.ga.core.vo.ledger.*;
|
import com.ga.core.vo.ledger.*;
|
||||||
import com.ga.core.vo.ledger.ApproveStatus;
|
|
||||||
import com.ga.core.vo.ledger.ExceptionLedgerStatus;
|
|
||||||
import org.apache.ibatis.cursor.Cursor;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -23,41 +19,34 @@ public class LedgerService {
|
|||||||
private final RecruitLedgerMapper recruitMapper;
|
private final RecruitLedgerMapper recruitMapper;
|
||||||
private final MaintainLedgerMapper maintainMapper;
|
private final MaintainLedgerMapper maintainMapper;
|
||||||
private final ExceptionLedgerMapper exceptionMapper;
|
private final ExceptionLedgerMapper exceptionMapper;
|
||||||
private final ExceptionLedgerMapStruct exceptionLedgerMapStruct;
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public PageResponse<LedgerResp> listRecruit(LedgerSearchParam param) {
|
public PageResponse<LedgerResp> listRecruit(LedgerSearchParam param) {
|
||||||
param.startPage();
|
param.startPage();
|
||||||
return PageResponse.of(recruitMapper.selectList(param));
|
return PageResponse.of(recruitMapper.selectList(param));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public PageResponse<LedgerResp> listMaintain(LedgerSearchParam param) {
|
public PageResponse<LedgerResp> listMaintain(LedgerSearchParam param) {
|
||||||
param.startPage();
|
param.startPage();
|
||||||
return PageResponse.of(maintainMapper.selectList(param));
|
return PageResponse.of(maintainMapper.selectList(param));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public PageResponse<ExceptionLedgerResp> listException(LedgerSearchParam param) {
|
public PageResponse<ExceptionLedgerResp> listException(LedgerSearchParam param) {
|
||||||
param.startPage();
|
param.startPage();
|
||||||
return PageResponse.of(exceptionMapper.selectList(param));
|
return PageResponse.of(exceptionMapper.selectList(param));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public LedgerResp detailRecruit(Long ledgerId) {
|
public LedgerResp detailRecruit(Long ledgerId) {
|
||||||
LedgerResp r = recruitMapper.selectDetailById(ledgerId);
|
LedgerResp r = recruitMapper.selectDetailById(ledgerId);
|
||||||
if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
|
if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public LedgerResp detailMaintain(Long ledgerId) {
|
public LedgerResp detailMaintain(Long ledgerId) {
|
||||||
LedgerResp r = maintainMapper.selectDetailById(ledgerId);
|
LedgerResp r = maintainMapper.selectDetailById(ledgerId);
|
||||||
if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
|
if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public ExceptionLedgerResp detailException(Long ledgerId) {
|
public ExceptionLedgerResp detailException(Long ledgerId) {
|
||||||
ExceptionLedgerResp r = exceptionMapper.selectDetailById(ledgerId);
|
ExceptionLedgerResp r = exceptionMapper.selectDetailById(ledgerId);
|
||||||
if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
|
if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
|
||||||
@@ -66,25 +55,27 @@ public class LedgerService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Long createException(ExceptionLedgerSaveReq req) {
|
public Long createException(ExceptionLedgerSaveReq req) {
|
||||||
ExceptionLedgerVO vo = exceptionLedgerMapStruct.toVO(req);
|
ExceptionLedgerVO vo = new ExceptionLedgerVO();
|
||||||
|
vo.setAgentId(req.getAgentId());
|
||||||
|
vo.setExceptionCode(req.getExceptionCode());
|
||||||
|
vo.setSettleMonth(req.getSettleMonth());
|
||||||
|
vo.setAmount(req.getAmount());
|
||||||
|
vo.setPolicyNo(req.getPolicyNo());
|
||||||
|
vo.setCompanyCode(req.getCompanyCode());
|
||||||
|
vo.setInsuranceType(req.getInsuranceType());
|
||||||
|
vo.setDescription(req.getDescription());
|
||||||
|
vo.setRecurringYn(req.getRecurringYn());
|
||||||
|
vo.setRecurringMonths(req.getRecurringMonths());
|
||||||
vo.setRecurringLeft(req.getRecurringMonths());
|
vo.setRecurringLeft(req.getRecurringMonths());
|
||||||
vo.setApproveStatus(ApproveStatus.PENDING.name());
|
vo.setApproveStatus("PENDING");
|
||||||
vo.setStatus(ExceptionLedgerStatus.NEW.name());
|
vo.setStatus("NEW");
|
||||||
exceptionMapper.insert(vo);
|
exceptionMapper.insert(vo);
|
||||||
return vo.getLedgerId();
|
return vo.getLedgerId();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Cursor<LedgerExcelVO> exportRecruitCursor(LedgerSearchParam param) {
|
|
||||||
return recruitMapper.selectCursorForExcel(param);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Cursor<LedgerExcelVO> exportMaintainCursor(LedgerSearchParam param) {
|
|
||||||
return maintainMapper.selectCursorForExcel(param);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void approveException(Long ledgerId, String status) {
|
public void approveException(Long ledgerId, String status) {
|
||||||
if (!ApproveStatus.APPROVED.name().equals(status) && !ApproveStatus.REJECTED.name().equals(status)) {
|
if (!"APPROVED".equals(status) && !"REJECTED".equals(status)) {
|
||||||
throw new BizException(ErrorCode.INVALID_PARAMETER, "status는 APPROVED/REJECTED만 가능합니다");
|
throw new BizException(ErrorCode.INVALID_PARAMETER, "status는 APPROVED/REJECTED만 가능합니다");
|
||||||
}
|
}
|
||||||
Long approverId = SecurityUtil.getCurrentUserId();
|
Long approverId = SecurityUtil.getCurrentUserId();
|
||||||
|
|||||||
@@ -6,16 +6,10 @@ import com.ga.common.exception.ErrorCode;
|
|||||||
import com.ga.common.model.PageResponse;
|
import com.ga.common.model.PageResponse;
|
||||||
import com.ga.core.mapper.org.AgentMapper;
|
import com.ga.core.mapper.org.AgentMapper;
|
||||||
import com.ga.core.vo.org.*;
|
import com.ga.core.vo.org.*;
|
||||||
import com.ga.core.vo.org.AgentStatus;
|
|
||||||
import com.ga.core.vo.org.TaxType;
|
|
||||||
import com.ga.core.vo.org.VatType;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.apache.ibatis.cursor.Cursor;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AgentService {
|
public class AgentService {
|
||||||
@@ -23,13 +17,11 @@ public class AgentService {
|
|||||||
private final AgentMapper mapper;
|
private final AgentMapper mapper;
|
||||||
private final CommonCodeService codeService;
|
private final CommonCodeService codeService;
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public PageResponse<AgentResp> list(AgentSearchParam param) {
|
public PageResponse<AgentResp> list(AgentSearchParam param) {
|
||||||
param.startPage();
|
param.startPage();
|
||||||
return PageResponse.of(mapper.selectList(param));
|
return PageResponse.of(mapper.selectList(param));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public AgentResp detail(Long agentId) {
|
public AgentResp detail(Long agentId) {
|
||||||
AgentResp resp = mapper.selectDetailById(agentId);
|
AgentResp resp = mapper.selectDetailById(agentId);
|
||||||
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
|
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
|
||||||
@@ -42,7 +34,7 @@ public class AgentService {
|
|||||||
throw new BizException(ErrorCode.DUPLICATE_DATA, "이미 등록된 사번입니다");
|
throw new BizException(ErrorCode.DUPLICATE_DATA, "이미 등록된 사번입니다");
|
||||||
}
|
}
|
||||||
AgentVO vo = toVO(req, null);
|
AgentVO vo = toVO(req, null);
|
||||||
vo.setStatus(AgentStatus.ACTIVE.name());
|
vo.setStatus("ACTIVE");
|
||||||
mapper.insert(vo);
|
mapper.insert(vo);
|
||||||
// 입사 이력 기록
|
// 입사 이력 기록
|
||||||
AgentOrgHistoryVO h = new AgentOrgHistoryVO();
|
AgentOrgHistoryVO h = new AgentOrgHistoryVO();
|
||||||
@@ -84,13 +76,8 @@ public class AgentService {
|
|||||||
mapper.updateStatus(agentId, status);
|
mapper.updateStatus(agentId, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
public java.util.List<AgentOrgHistoryVO> history(Long agentId) {
|
||||||
public java.util.List<AgentOrgHistoryResp> history(Long agentId) {
|
return mapper.selectHistory(agentId);
|
||||||
return mapper.selectHistoryResp(agentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Cursor<AgentExcelVO> exportCursor(AgentSearchParam param) {
|
|
||||||
return mapper.selectCursorForExcel(param);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private AgentVO toVO(AgentSaveReq req, Long agentId) {
|
private AgentVO toVO(AgentSaveReq req, Long agentId) {
|
||||||
@@ -106,17 +93,6 @@ public class AgentService {
|
|||||||
vo.setBankCode(req.getBankCode());
|
vo.setBankCode(req.getBankCode());
|
||||||
vo.setAccountNo(req.getAccountNo());
|
vo.setAccountNo(req.getAccountNo());
|
||||||
vo.setJoinDate(req.getJoinDate());
|
vo.setJoinDate(req.getJoinDate());
|
||||||
|
|
||||||
String taxType = req.getTaxType() != null ? req.getTaxType() : TaxType.BUSINESS_INCOME.name();
|
|
||||||
String vatType = req.getVatType() != null ? req.getVatType() : VatType.NONE.name();
|
|
||||||
vo.setTaxType(taxType);
|
|
||||||
vo.setVatType(vatType);
|
|
||||||
vo.setBusinessNo(req.getBusinessNo());
|
|
||||||
|
|
||||||
if (TaxType.BUSINESS_INCOME.name().equals(taxType)
|
|
||||||
&& (req.getBusinessNo() == null || req.getBusinessNo().isBlank())) {
|
|
||||||
log.warn("agentId={} taxType=BUSINESS_INCOME이지만 businessNo가 비어있습니다", agentId);
|
|
||||||
}
|
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import com.ga.common.exception.BizException;
|
|||||||
import com.ga.common.exception.ErrorCode;
|
import com.ga.common.exception.ErrorCode;
|
||||||
import com.ga.core.mapper.org.OrganizationMapper;
|
import com.ga.core.mapper.org.OrganizationMapper;
|
||||||
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 lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -18,18 +17,15 @@ public class OrganizationService {
|
|||||||
|
|
||||||
private final OrganizationMapper mapper;
|
private final OrganizationMapper mapper;
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<OrganizationResp> getTree() {
|
public List<OrganizationResp> getTree() {
|
||||||
List<OrganizationResp> flat = mapper.selectAllForTree();
|
List<OrganizationResp> flat = mapper.selectAllForTree();
|
||||||
return buildTree(flat);
|
return buildTree(flat);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<OrganizationResp> list(String keyword, String orgType, String isActive) {
|
public List<OrganizationResp> list(String keyword, String orgType, String isActive) {
|
||||||
return mapper.selectList(keyword, orgType, isActive);
|
return mapper.selectList(keyword, orgType, isActive);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public OrganizationVO get(Long orgId) {
|
public OrganizationVO get(Long orgId) {
|
||||||
OrganizationVO vo = mapper.selectById(orgId);
|
OrganizationVO vo = mapper.selectById(orgId);
|
||||||
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
|
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
|
||||||
@@ -37,31 +33,16 @@ public class OrganizationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Long create(OrganizationSaveReq req) {
|
public Long create(OrganizationVO vo) {
|
||||||
OrganizationVO vo = toVO(req, null);
|
|
||||||
mapper.insert(vo);
|
mapper.insert(vo);
|
||||||
return vo.getOrgId();
|
return vo.getOrgId();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void update(Long orgId, OrganizationSaveReq req) {
|
public void update(Long orgId, OrganizationVO vo) {
|
||||||
get(orgId);
|
get(orgId);
|
||||||
OrganizationVO vo = toVO(req, orgId);
|
|
||||||
mapper.update(vo);
|
|
||||||
}
|
|
||||||
|
|
||||||
private OrganizationVO toVO(OrganizationSaveReq req, Long orgId) {
|
|
||||||
OrganizationVO vo = new OrganizationVO();
|
|
||||||
vo.setOrgId(orgId);
|
vo.setOrgId(orgId);
|
||||||
vo.setOrgName(req.getOrgName());
|
mapper.update(vo);
|
||||||
vo.setParentOrgId(req.getParentOrgId());
|
|
||||||
vo.setOrgType(req.getOrgType());
|
|
||||||
vo.setOrgLevel(req.getOrgLevel());
|
|
||||||
vo.setRegion(req.getRegion());
|
|
||||||
vo.setEffectiveFrom(req.getEffectiveFrom());
|
|
||||||
vo.setEffectiveTo(req.getEffectiveTo());
|
|
||||||
vo.setIsActive(req.getIsActive());
|
|
||||||
return vo;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ import com.ga.common.exception.BizException;
|
|||||||
import com.ga.common.exception.ErrorCode;
|
import com.ga.common.exception.ErrorCode;
|
||||||
import com.ga.common.model.PageResponse;
|
import com.ga.common.model.PageResponse;
|
||||||
import com.ga.core.mapper.product.ContractMapper;
|
import com.ga.core.mapper.product.ContractMapper;
|
||||||
import com.ga.core.mapstruct.ContractMapStruct;
|
|
||||||
import com.ga.core.vo.product.ContractResp;
|
import com.ga.core.vo.product.ContractResp;
|
||||||
import com.ga.core.vo.product.ContractSaveReq;
|
import com.ga.core.vo.product.ContractSaveReq;
|
||||||
import com.ga.core.vo.product.ContractSearchParam;
|
import com.ga.core.vo.product.ContractSearchParam;
|
||||||
import com.ga.core.vo.product.ContractStatus;
|
|
||||||
import com.ga.core.vo.product.ContractVO;
|
import com.ga.core.vo.product.ContractVO;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -19,15 +17,12 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
public class ContractService {
|
public class ContractService {
|
||||||
|
|
||||||
private final ContractMapper mapper;
|
private final ContractMapper mapper;
|
||||||
private final ContractMapStruct contractMapStruct;
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public PageResponse<ContractResp> list(ContractSearchParam param) {
|
public PageResponse<ContractResp> list(ContractSearchParam param) {
|
||||||
param.startPage();
|
param.startPage();
|
||||||
return PageResponse.of(mapper.selectList(param));
|
return PageResponse.of(mapper.selectList(param));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public ContractResp detail(Long contractId) {
|
public ContractResp detail(Long contractId) {
|
||||||
ContractResp resp = mapper.selectDetailById(contractId);
|
ContractResp resp = mapper.selectDetailById(contractId);
|
||||||
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
|
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
|
||||||
@@ -39,8 +34,19 @@ public class ContractService {
|
|||||||
if (mapper.existsByPolicyNo(req.getPolicyNo()) > 0) {
|
if (mapper.existsByPolicyNo(req.getPolicyNo()) > 0) {
|
||||||
throw new BizException(ErrorCode.DUPLICATE_DATA, "이미 등록된 증권번호입니다");
|
throw new BizException(ErrorCode.DUPLICATE_DATA, "이미 등록된 증권번호입니다");
|
||||||
}
|
}
|
||||||
ContractVO vo = contractMapStruct.toVO(req);
|
ContractVO vo = new ContractVO();
|
||||||
vo.setStatus(ContractStatus.ACTIVE.name());
|
vo.setAgentId(req.getAgentId());
|
||||||
|
vo.setProductId(req.getProductId());
|
||||||
|
vo.setPolicyNo(req.getPolicyNo());
|
||||||
|
vo.setContractorName(req.getContractorName());
|
||||||
|
vo.setInsuredName(req.getInsuredName());
|
||||||
|
vo.setPremium(req.getPremium());
|
||||||
|
vo.setPayCycle(req.getPayCycle());
|
||||||
|
vo.setPayPeriod(req.getPayPeriod());
|
||||||
|
vo.setCoveragePeriod(req.getCoveragePeriod());
|
||||||
|
vo.setStatus("ACTIVE");
|
||||||
|
vo.setContractDate(req.getContractDate());
|
||||||
|
vo.setEffectiveDate(req.getEffectiveDate());
|
||||||
mapper.insert(vo);
|
mapper.insert(vo);
|
||||||
return vo.getContractId();
|
return vo.getContractId();
|
||||||
}
|
}
|
||||||
@@ -49,7 +55,15 @@ public class ContractService {
|
|||||||
public void update(Long contractId, ContractSaveReq req) {
|
public void update(Long contractId, ContractSaveReq req) {
|
||||||
ContractVO vo = mapper.selectById(contractId);
|
ContractVO vo = mapper.selectById(contractId);
|
||||||
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
|
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
|
||||||
contractMapStruct.updateVO(req, vo);
|
vo.setAgentId(req.getAgentId());
|
||||||
|
vo.setProductId(req.getProductId());
|
||||||
|
vo.setContractorName(req.getContractorName());
|
||||||
|
vo.setInsuredName(req.getInsuredName());
|
||||||
|
vo.setPremium(req.getPremium());
|
||||||
|
vo.setPayCycle(req.getPayCycle());
|
||||||
|
vo.setPayPeriod(req.getPayPeriod());
|
||||||
|
vo.setCoveragePeriod(req.getCoveragePeriod());
|
||||||
|
vo.setEffectiveDate(req.getEffectiveDate());
|
||||||
mapper.update(vo);
|
mapper.update(vo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
package com.ga.api.service.product;
|
|
||||||
|
|
||||||
import com.ga.common.exception.BizException;
|
|
||||||
import com.ga.common.exception.ErrorCode;
|
|
||||||
import com.ga.core.mapper.product.InsuranceCompanyMapper;
|
|
||||||
import com.ga.core.vo.product.InsuranceCompanyVO;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class InsuranceCompanyService {
|
|
||||||
|
|
||||||
private final InsuranceCompanyMapper mapper;
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<InsuranceCompanyVO> list(boolean activeOnly) {
|
|
||||||
return activeOnly ? mapper.selectActive() : mapper.selectAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public InsuranceCompanyVO detail(Long companyId) {
|
|
||||||
InsuranceCompanyVO vo = mapper.selectById(companyId);
|
|
||||||
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
return vo;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public Long create(InsuranceCompanyVO vo) {
|
|
||||||
mapper.insert(vo);
|
|
||||||
return vo.getCompanyId();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void update(Long companyId, InsuranceCompanyVO vo) {
|
|
||||||
if (mapper.selectById(companyId) == null) throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
vo.setCompanyId(companyId);
|
|
||||||
mapper.update(vo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package com.ga.api.service.product;
|
|
||||||
|
|
||||||
import com.ga.common.exception.BizException;
|
|
||||||
import com.ga.common.exception.ErrorCode;
|
|
||||||
import com.ga.core.mapper.product.ProductMapper;
|
|
||||||
import com.ga.core.vo.product.ProductResp;
|
|
||||||
import com.ga.core.vo.product.ProductVO;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class ProductService {
|
|
||||||
|
|
||||||
private final ProductMapper mapper;
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<ProductResp> list(Long companyId, String insuranceType, String keyword, String isActive) {
|
|
||||||
return mapper.selectList(companyId, insuranceType, keyword, isActive);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public ProductResp detail(Long productId) {
|
|
||||||
ProductResp r = mapper.selectDetailById(productId);
|
|
||||||
if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public Long create(ProductVO vo) {
|
|
||||||
if (mapper.existsByCode(vo.getCompanyId(), vo.getProductCode()) > 0) {
|
|
||||||
throw new BizException(ErrorCode.DUPLICATE_DATA);
|
|
||||||
}
|
|
||||||
mapper.insert(vo);
|
|
||||||
return vo.getProductId();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void update(Long productId, ProductVO vo) {
|
|
||||||
if (mapper.selectById(productId) == null) throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
vo.setProductId(productId);
|
|
||||||
mapper.update(vo);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void delete(Long productId) {
|
|
||||||
if (mapper.selectById(productId) == null) throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
mapper.deleteById(productId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
package com.ga.api.service.receive;
|
|
||||||
|
|
||||||
import com.ga.common.util.SecurityUtil;
|
|
||||||
import com.ga.core.mapper.receive.ReceiveMapper;
|
|
||||||
import com.ga.core.vo.receive.*;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class ReceiveService {
|
|
||||||
|
|
||||||
private final ReceiveMapper mapper;
|
|
||||||
|
|
||||||
// === company_profile ===
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<CompanyProfileVO> listProfiles() {
|
|
||||||
return mapper.selectAllProfiles();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void saveProfile(String companyCode, CompanyProfileVO vo) {
|
|
||||||
vo.setCompanyCode(companyCode);
|
|
||||||
if (mapper.selectProfile(companyCode) == null) mapper.insertProfile(vo);
|
|
||||||
else mapper.updateProfile(vo);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === field_mapping ===
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<CompanyFieldMappingVO> listFields(String companyCode, String targetTable) {
|
|
||||||
return mapper.selectFieldMappings(companyCode, targetTable);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public Long createField(String companyCode, CompanyFieldMappingVO vo) {
|
|
||||||
vo.setCompanyCode(companyCode);
|
|
||||||
mapper.insertFieldMapping(vo);
|
|
||||||
return vo.getMappingId();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void updateField(Long mappingId, CompanyFieldMappingVO vo) {
|
|
||||||
vo.setMappingId(mappingId);
|
|
||||||
mapper.updateFieldMapping(vo);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void deleteField(Long mappingId) {
|
|
||||||
mapper.deleteFieldMapping(mappingId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === code_mapping ===
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<CodeMappingVO> listCodes(String companyCode, String codeType) {
|
|
||||||
return mapper.selectCodeMappings(companyCode, codeType);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public Long createCode(String companyCode, CodeMappingVO vo) {
|
|
||||||
vo.setCompanyCode(companyCode);
|
|
||||||
mapper.insertCodeMapping(vo);
|
|
||||||
return vo.getCodeId();
|
|
||||||
}
|
|
||||||
|
|
||||||
// === raw + errors ===
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<RawCommissionDataVO> listRaw(String companyCode, String settleMonth, String parseStatus) {
|
|
||||||
return mapper.selectRawList(companyCode, settleMonth, parseStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<ParseErrorLogVO> listErrors(String companyCode, String resolveStatus) {
|
|
||||||
return mapper.selectErrors(companyCode, resolveStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void resolveError(Long errorId, String status, String note) {
|
|
||||||
mapper.resolveError(errorId, status, note, SecurityUtil.getCurrentUserId());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
package com.ga.api.service.regulatory;
|
|
||||||
|
|
||||||
import com.ga.core.mapper.ledger.MaintainLedgerMapper;
|
|
||||||
import com.ga.core.mapper.ledger.RecruitLedgerMapper;
|
|
||||||
import com.ga.core.mapper.regulatory.RegulatoryLimitMapper;
|
|
||||||
import com.ga.core.vo.regulatory.RegulatoryLimitVO;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class RegulatoryLimitChecker {
|
|
||||||
|
|
||||||
private static final String TOTAL_RATIO = "TOTAL_RATIO";
|
|
||||||
private static final String FIRST_YEAR_RATIO = "FIRST_YEAR_RATIO";
|
|
||||||
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
|
||||||
|
|
||||||
private final RegulatoryLimitMapper limitMapper;
|
|
||||||
private final RecruitLedgerMapper recruitLedgerMapper;
|
|
||||||
private final MaintainLedgerMapper maintainLedgerMapper;
|
|
||||||
|
|
||||||
/** 누적 수수료율 한도 체크 (1200%). 신계약 + 유지 원장 합산. */
|
|
||||||
public ViolationResult checkContractTotal(Long contractId, BigDecimal additionalAmount, LocalDate baseDate) {
|
|
||||||
String baseDateStr = baseDate.format(DATE_FMT);
|
|
||||||
RegulatoryLimitVO limit = limitMapper.selectActiveByType(TOTAL_RATIO, null, baseDateStr);
|
|
||||||
if (limit == null) {
|
|
||||||
return ViolationResult.ok(additionalAmount, null, TOTAL_RATIO);
|
|
||||||
}
|
|
||||||
|
|
||||||
BigDecimal currentTotal = sumPaidByContract(contractId);
|
|
||||||
BigDecimal projected = currentTotal.add(additionalAmount);
|
|
||||||
|
|
||||||
if (projected.compareTo(limit.getLimitValue()) > 0) {
|
|
||||||
return ViolationResult.violated(
|
|
||||||
currentTotal,
|
|
||||||
limit.getLimitValue(),
|
|
||||||
TOTAL_RATIO,
|
|
||||||
String.format("누적 수수료 한도 초과: 현재 %s, 추가 %s, 한도 %s %s",
|
|
||||||
currentTotal, additionalAmount, limit.getLimitValue(), limit.getUnit())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return ViolationResult.ok(currentTotal, limit.getLimitValue(), TOTAL_RATIO);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 1차년 수수료율 한도 체크. 신계약 원장 1차년도만 (유지는 1차년 개념 없음). */
|
|
||||||
public ViolationResult checkFirstYear(Long contractId, BigDecimal additionalAmount, LocalDate baseDate) {
|
|
||||||
String baseDateStr = baseDate.format(DATE_FMT);
|
|
||||||
RegulatoryLimitVO limit = limitMapper.selectActiveByType(FIRST_YEAR_RATIO, null, baseDateStr);
|
|
||||||
if (limit == null) {
|
|
||||||
return ViolationResult.ok(additionalAmount, null, FIRST_YEAR_RATIO);
|
|
||||||
}
|
|
||||||
|
|
||||||
BigDecimal currentTotal = sumFirstYearPaidByContract(contractId);
|
|
||||||
BigDecimal projected = currentTotal.add(additionalAmount);
|
|
||||||
|
|
||||||
if (projected.compareTo(limit.getLimitValue()) > 0) {
|
|
||||||
return ViolationResult.violated(
|
|
||||||
currentTotal,
|
|
||||||
limit.getLimitValue(),
|
|
||||||
FIRST_YEAR_RATIO,
|
|
||||||
String.format("1차년 수수료 한도 초과: 현재 %s, 추가 %s, 한도 %s %s",
|
|
||||||
currentTotal, additionalAmount, limit.getLimitValue(), limit.getUnit())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return ViolationResult.ok(currentTotal, limit.getLimitValue(), FIRST_YEAR_RATIO);
|
|
||||||
}
|
|
||||||
|
|
||||||
private BigDecimal sumPaidByContract(Long contractId) {
|
|
||||||
BigDecimal recruit = recruitLedgerMapper.sumByContract(contractId);
|
|
||||||
BigDecimal maintain = maintainLedgerMapper.sumByContract(contractId);
|
|
||||||
return nullSafe(recruit).add(nullSafe(maintain));
|
|
||||||
}
|
|
||||||
|
|
||||||
private BigDecimal sumFirstYearPaidByContract(Long contractId) {
|
|
||||||
return nullSafe(recruitLedgerMapper.sumFirstYearByContract(contractId));
|
|
||||||
}
|
|
||||||
|
|
||||||
private BigDecimal nullSafe(BigDecimal value) {
|
|
||||||
return value != null ? value : BigDecimal.ZERO;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
package com.ga.api.service.regulatory;
|
|
||||||
|
|
||||||
import com.ga.common.exception.BizException;
|
|
||||||
import com.ga.common.exception.ErrorCode;
|
|
||||||
import com.ga.common.model.PageResponse;
|
|
||||||
import com.ga.core.mapper.regulatory.RegulatoryLimitMapper;
|
|
||||||
import com.ga.core.vo.regulatory.RegulatoryLimitResp;
|
|
||||||
import com.ga.core.vo.regulatory.RegulatoryLimitSaveReq;
|
|
||||||
import com.ga.core.vo.regulatory.RegulatoryLimitSearchParam;
|
|
||||||
import com.ga.core.vo.regulatory.RegulatoryLimitVO;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class RegulatoryLimitService {
|
|
||||||
|
|
||||||
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
|
||||||
|
|
||||||
private final RegulatoryLimitMapper limitMapper;
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public PageResponse<RegulatoryLimitResp> list(RegulatoryLimitSearchParam param) {
|
|
||||||
param.startPage();
|
|
||||||
return PageResponse.of(limitMapper.selectList(param));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public RegulatoryLimitResp detail(Long limitId) {
|
|
||||||
RegulatoryLimitResp resp = limitMapper.selectDetailById(limitId);
|
|
||||||
if (resp == null) {
|
|
||||||
throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
}
|
|
||||||
return resp;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void create(RegulatoryLimitSaveReq req) {
|
|
||||||
// 동일 limitType + productCategory + 기간 중복 체크
|
|
||||||
String today = LocalDate.now().format(DATE_FMT);
|
|
||||||
RegulatoryLimitVO existing = limitMapper.selectActiveByType(
|
|
||||||
req.getLimitType(), req.getProductCategory(), today);
|
|
||||||
if (existing != null) {
|
|
||||||
throw new BizException(ErrorCode.DUPLICATE_DATA);
|
|
||||||
}
|
|
||||||
|
|
||||||
RegulatoryLimitVO vo = toVO(req);
|
|
||||||
vo.setIsActive(true);
|
|
||||||
limitMapper.insert(vo);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void update(Long limitId, RegulatoryLimitSaveReq req) {
|
|
||||||
RegulatoryLimitVO existing = limitMapper.selectById(limitId);
|
|
||||||
if (existing == null) {
|
|
||||||
throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
RegulatoryLimitVO vo = toVO(req);
|
|
||||||
vo.setLimitId(limitId);
|
|
||||||
vo.setIsActive(existing.getIsActive());
|
|
||||||
limitMapper.update(vo);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void deactivate(Long limitId) {
|
|
||||||
RegulatoryLimitVO existing = limitMapper.selectById(limitId);
|
|
||||||
if (existing == null) {
|
|
||||||
throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
}
|
|
||||||
if (Boolean.FALSE.equals(existing.getIsActive())) {
|
|
||||||
throw new BizException(ErrorCode.INVALID_PARAMETER);
|
|
||||||
}
|
|
||||||
limitMapper.updateStatus(limitId, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public RegulatoryLimitVO selectActiveByType(String limitType, String productCategory, String baseDate) {
|
|
||||||
return limitMapper.selectActiveByType(limitType, productCategory, baseDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<RegulatoryLimitVO> selectAllActive(String baseDate) {
|
|
||||||
return limitMapper.selectAllActive(baseDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
private RegulatoryLimitVO toVO(RegulatoryLimitSaveReq req) {
|
|
||||||
RegulatoryLimitVO vo = new RegulatoryLimitVO();
|
|
||||||
vo.setLimitType(req.getLimitType());
|
|
||||||
vo.setProductCategory(req.getProductCategory());
|
|
||||||
vo.setLimitValue(req.getLimitValue());
|
|
||||||
vo.setUnit(req.getUnit());
|
|
||||||
vo.setEffectiveFrom(req.getEffectiveFrom());
|
|
||||||
vo.setEffectiveTo(req.getEffectiveTo());
|
|
||||||
vo.setRegulationRef(req.getRegulationRef());
|
|
||||||
vo.setDescription(req.getDescription());
|
|
||||||
return vo;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package com.ga.api.service.regulatory;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
|
|
||||||
public record ViolationResult(
|
|
||||||
boolean violated,
|
|
||||||
BigDecimal currentTotal,
|
|
||||||
BigDecimal limitAmount,
|
|
||||||
String limitType,
|
|
||||||
String message
|
|
||||||
) {
|
|
||||||
public static ViolationResult ok(BigDecimal currentTotal, BigDecimal limitAmount, String limitType) {
|
|
||||||
return new ViolationResult(false, currentTotal, limitAmount, limitType, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static ViolationResult violated(BigDecimal currentTotal, BigDecimal limitAmount, String limitType, String message) {
|
|
||||||
return new ViolationResult(true, currentTotal, limitAmount, limitType, message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
package com.ga.api.service.rule;
|
|
||||||
|
|
||||||
import com.ga.common.exception.BizException;
|
|
||||||
import com.ga.common.exception.ErrorCode;
|
|
||||||
import com.ga.core.mapper.rule.RuleMapper;
|
|
||||||
import com.ga.core.vo.rule.*;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class RuleService {
|
|
||||||
|
|
||||||
private final RuleMapper mapper;
|
|
||||||
|
|
||||||
// === commission_rate ===
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<CommissionRateVO> listCommissionRates(Long productId, Integer year) {
|
|
||||||
return mapper.selectCommissionRates(productId, year);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public Long createCommissionRate(CommissionRateVO vo) {
|
|
||||||
mapper.insertCommissionRate(vo);
|
|
||||||
return vo.getRateId();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void updateCommissionRate(Long rateId, CommissionRateVO vo) {
|
|
||||||
vo.setRateId(rateId);
|
|
||||||
mapper.updateCommissionRate(vo);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void deleteCommissionRate(Long rateId) {
|
|
||||||
mapper.deleteCommissionRate(rateId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === payout_rule ===
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<PayoutRuleVO> listPayoutRules(Long gradeId, String insuranceType) {
|
|
||||||
return mapper.selectPayoutRules(gradeId, insuranceType);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public Long createPayoutRule(PayoutRuleVO vo) {
|
|
||||||
mapper.insertPayoutRule(vo);
|
|
||||||
return vo.getRuleId();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void updatePayoutRule(Long ruleId, PayoutRuleVO vo) {
|
|
||||||
vo.setRuleId(ruleId);
|
|
||||||
mapper.updatePayoutRule(vo);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void deletePayoutRule(Long ruleId) {
|
|
||||||
mapper.deletePayoutRule(ruleId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === override_rule ===
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<OverrideRuleVO> listOverrideRules() {
|
|
||||||
return mapper.selectOverrideRules();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public Long createOverrideRule(OverrideRuleVO vo) {
|
|
||||||
mapper.insertOverrideRule(vo);
|
|
||||||
return vo.getOverrideId();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void updateOverrideRule(Long overrideId, OverrideRuleVO vo) {
|
|
||||||
vo.setOverrideId(overrideId);
|
|
||||||
mapper.updateOverrideRule(vo);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void deleteOverrideRule(Long overrideId) {
|
|
||||||
mapper.deleteOverrideRule(overrideId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === chargeback_rule ===
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<ChargebackRuleVO> listChargebackRules(String companyCode) {
|
|
||||||
return mapper.selectChargebackRules(companyCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public Long createChargebackRule(ChargebackRuleVO vo) {
|
|
||||||
mapper.insertChargebackRule(vo);
|
|
||||||
return vo.getCbRuleId();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void updateChargebackRule(Long cbRuleId, ChargebackRuleVO vo) {
|
|
||||||
vo.setCbRuleId(cbRuleId);
|
|
||||||
mapper.updateChargebackRule(vo);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void deleteChargebackRule(Long cbRuleId) {
|
|
||||||
mapper.deleteChargebackRule(cbRuleId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// === exception_type_code ===
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<ExceptionTypeCodeVO> listExceptionCodes() {
|
|
||||||
return mapper.selectExceptionCodes();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void createExceptionCode(ExceptionTypeCodeVO vo) {
|
|
||||||
if (mapper.selectExceptionCode(vo.getExceptionCode()) != null) {
|
|
||||||
throw new BizException(ErrorCode.DUPLICATE_DATA);
|
|
||||||
}
|
|
||||||
mapper.insertExceptionCode(vo);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void updateExceptionCode(String exceptionCode, ExceptionTypeCodeVO vo) {
|
|
||||||
vo.setExceptionCode(exceptionCode);
|
|
||||||
mapper.updateExceptionCode(vo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
package com.ga.api.service.settle;
|
|
||||||
|
|
||||||
import com.ga.api.service.deduction.DeductionService;
|
|
||||||
import com.ga.api.service.tax.TaxBreakdown;
|
|
||||||
import com.ga.api.service.tax.TaxCalculator;
|
|
||||||
import com.ga.api.service.transfer.TransferFileService;
|
|
||||||
import com.ga.common.exception.BizException;
|
|
||||||
import com.ga.common.exception.ErrorCode;
|
|
||||||
import com.ga.common.model.PageResponse;
|
|
||||||
import com.ga.core.mapper.org.AgentMapper;
|
|
||||||
import com.ga.core.mapper.settle.PaymentDeductionDetailMapper;
|
|
||||||
import com.ga.core.mapper.settle.PaymentMapper;
|
|
||||||
import com.ga.core.vo.org.AgentVO;
|
|
||||||
import com.ga.core.vo.org.TaxType;
|
|
||||||
import com.ga.core.vo.org.VatType;
|
|
||||||
import com.ga.core.vo.settle.AgentDeductionVO;
|
|
||||||
import com.ga.core.vo.settle.PaymentDeductionDetailVO;
|
|
||||||
import com.ga.core.vo.settle.PaymentResp;
|
|
||||||
import com.ga.core.vo.settle.PaymentVO;
|
|
||||||
import com.ga.core.vo.settle.SettleSearchParam;
|
|
||||||
import com.ga.core.vo.settle.TransferFileResp;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class PaymentService {
|
|
||||||
|
|
||||||
private final PaymentMapper mapper;
|
|
||||||
private final AgentMapper agentMapper;
|
|
||||||
private final PaymentDeductionDetailMapper deductionDetailMapper;
|
|
||||||
private final TransferFileService transferFileService;
|
|
||||||
private final TaxCalculator taxCalculator;
|
|
||||||
private final DeductionService deductionService;
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public PageResponse<PaymentResp> list(SettleSearchParam param) {
|
|
||||||
param.startPage();
|
|
||||||
return PageResponse.of(mapper.selectList(param));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void updateStatus(Long paymentId, String status, String failReason) {
|
|
||||||
mapper.updateStatus(paymentId, status, failReason);
|
|
||||||
}
|
|
||||||
|
|
||||||
public TransferFileResp generateTransferFile(String settleMonth, String bankCode) {
|
|
||||||
return transferFileService.generate(settleMonth, bankCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void calculateTaxes(Long paymentId) {
|
|
||||||
PaymentVO payment = mapper.selectById(paymentId);
|
|
||||||
if (payment == null) throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
applyTaxes(payment);
|
|
||||||
mapper.updateTaxes(payment.getPaymentId(),
|
|
||||||
payment.getIncomeTaxAmount(), payment.getLocalTaxAmount(),
|
|
||||||
payment.getVatAmount(), payment.getNetAmount());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void calculateTaxesForMonth(String settleMonth) {
|
|
||||||
List<PaymentVO> payments = mapper.selectBySettleMonth(settleMonth);
|
|
||||||
for (PaymentVO payment : payments) {
|
|
||||||
applyTaxes(payment);
|
|
||||||
}
|
|
||||||
if (!payments.isEmpty()) {
|
|
||||||
mapper.updateTaxesBatch(payments);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void applyDeductions(Long paymentId, String settleMonth) {
|
|
||||||
PaymentVO payment = mapper.selectById(paymentId);
|
|
||||||
if (payment == null) throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
|
|
||||||
List<AgentDeductionVO> pending = deductionService.selectPendingByAgent(payment.getAgentId(), settleMonth);
|
|
||||||
if (pending.isEmpty()) return;
|
|
||||||
|
|
||||||
// 명세 기록
|
|
||||||
List<PaymentDeductionDetailVO> details = pending.stream().map(d -> {
|
|
||||||
PaymentDeductionDetailVO detail = new PaymentDeductionDetailVO();
|
|
||||||
detail.setPaymentId(paymentId);
|
|
||||||
detail.setDeductionType(d.getDeductionType());
|
|
||||||
detail.setAmount(d.getAmount());
|
|
||||||
detail.setDescription(d.getDescription());
|
|
||||||
detail.setAppliedAt(LocalDateTime.now());
|
|
||||||
return detail;
|
|
||||||
}).collect(Collectors.toList());
|
|
||||||
deductionDetailMapper.insertBatch(details);
|
|
||||||
|
|
||||||
// 상태 전이
|
|
||||||
List<Long> ids = pending.stream().map(AgentDeductionVO::getDeductionId).collect(Collectors.toList());
|
|
||||||
deductionService.markAsApplied(ids);
|
|
||||||
|
|
||||||
// deduction_amount + net_amount UPDATE
|
|
||||||
// net_amount = amount − incomeTax − localTax + vat − deductionAmount
|
|
||||||
BigDecimal deductionTotal = pending.stream()
|
|
||||||
.map(AgentDeductionVO::getAmount)
|
|
||||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
||||||
BigDecimal currentNet = payment.getNetAmount() != null ? payment.getNetAmount() : payment.getPayAmount();
|
|
||||||
BigDecimal newNet = currentNet.subtract(deductionTotal);
|
|
||||||
mapper.updateDeduction(paymentId, deductionTotal, newNet);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void applyDeductionsForMonth(String settleMonth) {
|
|
||||||
List<PaymentVO> payments = mapper.selectBySettleMonth(settleMonth);
|
|
||||||
for (PaymentVO payment : payments) {
|
|
||||||
applyDeductions(payment.getPaymentId(), settleMonth);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void applyTaxes(PaymentVO payment) {
|
|
||||||
AgentVO agent = agentMapper.selectById(payment.getAgentId());
|
|
||||||
TaxType taxType = parseTaxType(agent);
|
|
||||||
VatType vatType = parseVatType(agent);
|
|
||||||
|
|
||||||
if (taxType == TaxType.BUSINESS_INCOME && (agent == null || agent.getBusinessNo() == null || agent.getBusinessNo().isBlank())) {
|
|
||||||
log.warn("agentId={} taxType=BUSINESS_INCOME이지만 businessNo가 비어있습니다", payment.getAgentId());
|
|
||||||
}
|
|
||||||
|
|
||||||
TaxBreakdown breakdown = taxCalculator.calculate(payment.getPayAmount(), taxType, vatType);
|
|
||||||
payment.setIncomeTaxAmount(breakdown.incomeTax());
|
|
||||||
payment.setLocalTaxAmount(breakdown.localTax());
|
|
||||||
payment.setVatAmount(breakdown.vat());
|
|
||||||
payment.setNetAmount(breakdown.netAmount());
|
|
||||||
}
|
|
||||||
|
|
||||||
private TaxType parseTaxType(AgentVO agent) {
|
|
||||||
if (agent != null && agent.getTaxType() != null) {
|
|
||||||
try { return TaxType.valueOf(agent.getTaxType()); } catch (IllegalArgumentException ignored) {}
|
|
||||||
}
|
|
||||||
return TaxType.BUSINESS_INCOME;
|
|
||||||
}
|
|
||||||
|
|
||||||
private VatType parseVatType(AgentVO agent) {
|
|
||||||
if (agent != null && agent.getVatType() != null) {
|
|
||||||
try { return VatType.valueOf(agent.getVatType()); } catch (IllegalArgumentException ignored) {}
|
|
||||||
}
|
|
||||||
return VatType.NONE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ import com.ga.core.mapper.settle.SettleMasterMapper;
|
|||||||
import com.ga.core.vo.settle.SettleMasterResp;
|
import com.ga.core.vo.settle.SettleMasterResp;
|
||||||
import com.ga.core.vo.settle.SettleMasterVO;
|
import com.ga.core.vo.settle.SettleMasterVO;
|
||||||
import com.ga.core.vo.settle.SettleSearchParam;
|
import com.ga.core.vo.settle.SettleSearchParam;
|
||||||
import com.ga.core.vo.settle.SettleStatus;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -22,25 +21,21 @@ public class SettleService {
|
|||||||
|
|
||||||
private final SettleMasterMapper masterMapper;
|
private final SettleMasterMapper masterMapper;
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public PageResponse<SettleMasterResp> list(SettleSearchParam param) {
|
public PageResponse<SettleMasterResp> list(SettleSearchParam param) {
|
||||||
param.startPage();
|
param.startPage();
|
||||||
return PageResponse.of(masterMapper.selectList(param));
|
return PageResponse.of(masterMapper.selectList(param));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public SettleMasterResp detail(Long agentId, String settleMonth) {
|
public SettleMasterResp detail(Long agentId, String settleMonth) {
|
||||||
SettleMasterResp r = masterMapper.selectDetail(agentId, settleMonth);
|
SettleMasterResp r = masterMapper.selectDetail(agentId, settleMonth);
|
||||||
if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
|
if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public Map<String, Object> summary(String settleMonth) {
|
public Map<String, Object> summary(String settleMonth) {
|
||||||
return masterMapper.selectMonthSummary(settleMonth);
|
return masterMapper.selectMonthSummary(settleMonth);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<Map<String, Object>> orgSummary(String settleMonth) {
|
public List<Map<String, Object>> orgSummary(String settleMonth) {
|
||||||
return masterMapper.selectOrgSummary(settleMonth);
|
return masterMapper.selectOrgSummary(settleMonth);
|
||||||
}
|
}
|
||||||
@@ -49,17 +44,17 @@ public class SettleService {
|
|||||||
public void confirm(Long settleId) {
|
public void confirm(Long settleId) {
|
||||||
SettleMasterVO vo = masterMapper.selectById(settleId);
|
SettleMasterVO vo = masterMapper.selectById(settleId);
|
||||||
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
|
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
|
||||||
if (SettleStatus.CONFIRMED.name().equals(vo.getStatus()) || SettleStatus.PAID.name().equals(vo.getStatus())) {
|
if ("CONFIRMED".equals(vo.getStatus()) || "PAID".equals(vo.getStatus())) {
|
||||||
throw new BizException(ErrorCode.ALREADY_CONFIRMED);
|
throw new BizException(ErrorCode.ALREADY_CONFIRMED);
|
||||||
}
|
}
|
||||||
masterMapper.updateStatus(settleId, SettleStatus.CONFIRMED.name(), SecurityUtil.getCurrentUserId());
|
masterMapper.updateStatus(settleId, "CONFIRMED", SecurityUtil.getCurrentUserId());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void hold(Long settleId, String reason) {
|
public void hold(Long settleId, String reason) {
|
||||||
SettleMasterVO vo = masterMapper.selectById(settleId);
|
SettleMasterVO vo = masterMapper.selectById(settleId);
|
||||||
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
|
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
|
||||||
if (SettleStatus.PAID.name().equals(vo.getStatus())) {
|
if ("PAID".equals(vo.getStatus())) {
|
||||||
throw new BizException(ErrorCode.NOT_PERMITTED, "지급완료된 정산은 보류할 수 없습니다");
|
throw new BizException(ErrorCode.NOT_PERMITTED, "지급완료된 정산은 보류할 수 없습니다");
|
||||||
}
|
}
|
||||||
masterMapper.updateHold(settleId, reason);
|
masterMapper.updateHold(settleId, reason);
|
||||||
@@ -69,9 +64,9 @@ public class SettleService {
|
|||||||
public void release(Long settleId) {
|
public void release(Long settleId) {
|
||||||
SettleMasterVO vo = masterMapper.selectById(settleId);
|
SettleMasterVO vo = masterMapper.selectById(settleId);
|
||||||
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
|
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
|
||||||
if (!SettleStatus.HOLD.name().equals(vo.getStatus())) {
|
if (!"HOLD".equals(vo.getStatus())) {
|
||||||
throw new BizException(ErrorCode.INVALID_STATUS, "보류 상태가 아닙니다");
|
throw new BizException(ErrorCode.INVALID_STATUS, "보류 상태가 아닙니다");
|
||||||
}
|
}
|
||||||
masterMapper.updateStatus(settleId, SettleStatus.CALCULATED.name(), SecurityUtil.getCurrentUserId());
|
masterMapper.updateStatus(settleId, "CALCULATED", SecurityUtil.getCurrentUserId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
package com.ga.api.service.statement;
|
|
||||||
|
|
||||||
import com.ga.common.exception.BizException;
|
|
||||||
import com.ga.common.exception.ErrorCode;
|
|
||||||
import com.ga.common.file.FileService;
|
|
||||||
import com.ga.common.model.PageResponse;
|
|
||||||
import com.ga.common.util.SecurityUtil;
|
|
||||||
import com.ga.core.mapper.org.AgentMapper;
|
|
||||||
import com.ga.core.mapper.settle.CommissionStatementMapper;
|
|
||||||
import com.ga.core.mapper.settle.PaymentMapper;
|
|
||||||
import com.ga.core.vo.org.AgentResp;
|
|
||||||
import com.ga.core.vo.settle.CommissionStatementResp;
|
|
||||||
import com.ga.core.vo.settle.CommissionStatementSaveReq;
|
|
||||||
import com.ga.core.vo.settle.CommissionStatementSearchParam;
|
|
||||||
import com.ga.core.vo.settle.CommissionStatementVO;
|
|
||||||
import com.ga.core.vo.settle.PaymentVO;
|
|
||||||
import com.ga.core.vo.settle.StatementStatus;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class CommissionStatementService {
|
|
||||||
|
|
||||||
private final CommissionStatementMapper statementMapper;
|
|
||||||
private final AgentMapper agentMapper;
|
|
||||||
private final PaymentMapper paymentMapper;
|
|
||||||
private final StatementAggregator aggregator;
|
|
||||||
private final ExcelStatementGenerator excelGenerator;
|
|
||||||
private final FileService fileService;
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public PageResponse<CommissionStatementResp> list(CommissionStatementSearchParam param) {
|
|
||||||
param.startPage();
|
|
||||||
return PageResponse.of(statementMapper.selectList(param));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public CommissionStatementResp detail(Long statementId) {
|
|
||||||
CommissionStatementResp resp = statementMapper.selectDetailById(statementId);
|
|
||||||
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
return resp;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public Long generate(CommissionStatementSaveReq req) {
|
|
||||||
if ("PDF".equalsIgnoreCase(req.getFileFormat())) {
|
|
||||||
throw new BizException(ErrorCode.INVALID_PARAMETER, "PDF 형식은 아직 지원되지 않습니다");
|
|
||||||
}
|
|
||||||
|
|
||||||
AgentResp agent = agentMapper.selectDetailById(req.getAgentId());
|
|
||||||
if (agent == null) throw new BizException(ErrorCode.NOT_FOUND, "설계사를 찾을 수 없습니다");
|
|
||||||
|
|
||||||
// 1. 집계
|
|
||||||
StatementSummary summary = aggregator.aggregate(req.getAgentId(), req.getSettleMonth());
|
|
||||||
|
|
||||||
// 2. 엑셀 생성
|
|
||||||
List<PaymentVO> payments = paymentMapper.selectByAgentAndMonth(req.getAgentId(), req.getSettleMonth());
|
|
||||||
byte[] excelBytes = excelGenerator.generate(agent, summary, payments);
|
|
||||||
|
|
||||||
// 3. 파일 저장
|
|
||||||
String fileName = "statement_" + req.getAgentId() + "_" + req.getSettleMonth() + ".xlsx";
|
|
||||||
Long fileId = fileService.saveBytes(excelBytes, fileName, "COMMISSION_STATEMENT",
|
|
||||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
|
||||||
|
|
||||||
// 4. INSERT 또는 UPDATE (재발급)
|
|
||||||
CommissionStatementVO existing = statementMapper.selectByAgentMonth(
|
|
||||||
req.getAgentId(), req.getSettleMonth(), req.getStatementType());
|
|
||||||
|
|
||||||
Long issuedBy = SecurityUtil.getCurrentUserId();
|
|
||||||
LocalDateTime now = LocalDateTime.now();
|
|
||||||
|
|
||||||
if (existing == null) {
|
|
||||||
CommissionStatementVO vo = new CommissionStatementVO();
|
|
||||||
vo.setAgentId(req.getAgentId());
|
|
||||||
vo.setSettleMonth(req.getSettleMonth());
|
|
||||||
vo.setStatementType(req.getStatementType());
|
|
||||||
vo.setGrossAmount(summary.grossAmount());
|
|
||||||
vo.setIncomeTaxAmount(summary.incomeTaxAmount());
|
|
||||||
vo.setLocalTaxAmount(summary.localTaxAmount());
|
|
||||||
vo.setVatAmount(summary.vatAmount());
|
|
||||||
vo.setDeductionAmount(summary.deductionAmount());
|
|
||||||
vo.setNetAmount(summary.netAmount());
|
|
||||||
vo.setFilePath(String.valueOf(fileId));
|
|
||||||
vo.setFileFormat("EXCEL");
|
|
||||||
vo.setIssueStatus(StatementStatus.GENERATED.name());
|
|
||||||
vo.setIssuedAt(now);
|
|
||||||
vo.setIssuedBy(issuedBy != null ? issuedBy.toString() : null);
|
|
||||||
statementMapper.insert(vo);
|
|
||||||
return vo.getStatementId();
|
|
||||||
} else {
|
|
||||||
existing.setGrossAmount(summary.grossAmount());
|
|
||||||
existing.setIncomeTaxAmount(summary.incomeTaxAmount());
|
|
||||||
existing.setLocalTaxAmount(summary.localTaxAmount());
|
|
||||||
existing.setVatAmount(summary.vatAmount());
|
|
||||||
existing.setDeductionAmount(summary.deductionAmount());
|
|
||||||
existing.setNetAmount(summary.netAmount());
|
|
||||||
existing.setFilePath(String.valueOf(fileId));
|
|
||||||
existing.setFileFormat("EXCEL");
|
|
||||||
existing.setIssueStatus(StatementStatus.GENERATED.name());
|
|
||||||
existing.setIssuedAt(now);
|
|
||||||
existing.setIssuedBy(issuedBy != null ? issuedBy.toString() : null);
|
|
||||||
statementMapper.update(existing);
|
|
||||||
return existing.getStatementId();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void generateForMonth(String settleMonth, String statementType) {
|
|
||||||
List<PaymentVO> allPayments = paymentMapper.selectBySettleMonth(settleMonth);
|
|
||||||
List<Long> agentIds = allPayments.stream()
|
|
||||||
.map(PaymentVO::getAgentId)
|
|
||||||
.distinct()
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
|
|
||||||
for (Long agentId : agentIds) {
|
|
||||||
CommissionStatementSaveReq req = new CommissionStatementSaveReq();
|
|
||||||
req.setAgentId(agentId);
|
|
||||||
req.setSettleMonth(settleMonth);
|
|
||||||
req.setStatementType(statementType);
|
|
||||||
req.setFileFormat("EXCEL");
|
|
||||||
generate(req);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public byte[] download(Long statementId) {
|
|
||||||
CommissionStatementVO vo = statementMapper.selectById(statementId);
|
|
||||||
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
|
|
||||||
Long fileId = Long.parseLong(vo.getFilePath());
|
|
||||||
com.ga.common.file.FileVO fileVO = fileService.download(fileId);
|
|
||||||
|
|
||||||
// DOWNLOADED 상태 전이
|
|
||||||
statementMapper.updateStatus(statementId, StatementStatus.DOWNLOADED.name());
|
|
||||||
|
|
||||||
try {
|
|
||||||
return java.nio.file.Files.readAllBytes(java.nio.file.Paths.get(fileVO.getFilePath()));
|
|
||||||
} catch (java.io.IOException e) {
|
|
||||||
throw new BizException(ErrorCode.FILE_NOT_FOUND, "파일을 읽을 수 없습니다");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void cancel(Long statementId) {
|
|
||||||
CommissionStatementVO vo = statementMapper.selectById(statementId);
|
|
||||||
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
statementMapper.updateStatus(statementId, StatementStatus.CANCELLED.name());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
package com.ga.api.service.statement;
|
|
||||||
|
|
||||||
import com.ga.common.exception.BizException;
|
|
||||||
import com.ga.common.exception.ErrorCode;
|
|
||||||
import com.ga.core.vo.org.AgentResp;
|
|
||||||
import com.ga.core.vo.settle.PaymentVO;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.apache.poi.ss.usermodel.*;
|
|
||||||
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.text.DecimalFormat;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
@Component
|
|
||||||
public class ExcelStatementGenerator {
|
|
||||||
|
|
||||||
private static final DecimalFormat AMOUNT_FMT = new DecimalFormat("#,##0");
|
|
||||||
|
|
||||||
public byte[] generate(AgentResp agent, StatementSummary summary, List<PaymentVO> payments) {
|
|
||||||
try (SXSSFWorkbook wb = new SXSSFWorkbook(100)) {
|
|
||||||
wb.setCompressTempFiles(true);
|
|
||||||
Sheet sheet = wb.createSheet("수수료명세서");
|
|
||||||
|
|
||||||
int rowNum = 0;
|
|
||||||
|
|
||||||
// 상단 헤더 정보
|
|
||||||
rowNum = writeHeaderSection(sheet, wb, agent, summary, rowNum);
|
|
||||||
|
|
||||||
// 공백 행
|
|
||||||
sheet.createRow(rowNum++);
|
|
||||||
|
|
||||||
// 지급 테이블 헤더
|
|
||||||
Row tableHeader = sheet.createRow(rowNum++);
|
|
||||||
CellStyle boldStyle = boldStyle(wb);
|
|
||||||
String[] cols = {"지급ID", "지급금액", "원천세", "지방소득세", "부가세", "공제금액", "실수령액"};
|
|
||||||
for (int i = 0; i < cols.length; i++) {
|
|
||||||
Cell c = tableHeader.createCell(i);
|
|
||||||
c.setCellValue(cols[i]);
|
|
||||||
c.setCellStyle(boldStyle);
|
|
||||||
sheet.setColumnWidth(i, 18 * 256);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 지급 데이터 행
|
|
||||||
for (PaymentVO p : payments) {
|
|
||||||
Row row = sheet.createRow(rowNum++);
|
|
||||||
row.createCell(0).setCellValue(p.getPaymentId() != null ? p.getPaymentId() : 0L);
|
|
||||||
row.createCell(1).setCellValue(fmt(p.getPayAmount()));
|
|
||||||
row.createCell(2).setCellValue(fmt(p.getIncomeTaxAmount()));
|
|
||||||
row.createCell(3).setCellValue(fmt(p.getLocalTaxAmount()));
|
|
||||||
row.createCell(4).setCellValue(fmt(p.getVatAmount()));
|
|
||||||
row.createCell(5).setCellValue(fmt(p.getDeductionAmount()));
|
|
||||||
row.createCell(6).setCellValue(fmt(p.getNetAmount()));
|
|
||||||
}
|
|
||||||
|
|
||||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
|
||||||
wb.write(out);
|
|
||||||
wb.dispose();
|
|
||||||
return out.toByteArray();
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("ExcelStatementGenerator error", e);
|
|
||||||
throw new BizException(ErrorCode.INTERNAL_ERROR, "명세서 엑셀 생성 실패");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private int writeHeaderSection(Sheet sheet, SXSSFWorkbook wb, AgentResp agent,
|
|
||||||
StatementSummary summary, int rowNum) {
|
|
||||||
CellStyle bold = boldStyle(wb);
|
|
||||||
writeKv(sheet, rowNum++, bold, "설계사명", agent.getAgentName());
|
|
||||||
writeKv(sheet, rowNum++, bold, "사업자번호", agent.getBusinessNo());
|
|
||||||
writeKv(sheet, rowNum++, bold, "소득세유형", agent.getTaxType());
|
|
||||||
writeKv(sheet, rowNum++, bold, "부가세유형", agent.getVatType());
|
|
||||||
sheet.createRow(rowNum++);
|
|
||||||
writeKv(sheet, rowNum++, bold, "총 지급액", fmt(summary.grossAmount()));
|
|
||||||
writeKv(sheet, rowNum++, bold, "원천세", fmt(summary.incomeTaxAmount()));
|
|
||||||
writeKv(sheet, rowNum++, bold, "지방소득세", fmt(summary.localTaxAmount()));
|
|
||||||
writeKv(sheet, rowNum++, bold, "부가세", fmt(summary.vatAmount()));
|
|
||||||
writeKv(sheet, rowNum++, bold, "공제금액", fmt(summary.deductionAmount()));
|
|
||||||
writeKv(sheet, rowNum++, bold, "실수령액", fmt(summary.netAmount()));
|
|
||||||
return rowNum;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void writeKv(Sheet sheet, int rowNum, CellStyle keyStyle, String key, String value) {
|
|
||||||
Row row = sheet.createRow(rowNum);
|
|
||||||
Cell k = row.createCell(0);
|
|
||||||
k.setCellValue(key);
|
|
||||||
k.setCellStyle(keyStyle);
|
|
||||||
row.createCell(1).setCellValue(value != null ? value : "");
|
|
||||||
}
|
|
||||||
|
|
||||||
private CellStyle boldStyle(SXSSFWorkbook wb) {
|
|
||||||
CellStyle style = wb.createCellStyle();
|
|
||||||
Font font = wb.createFont();
|
|
||||||
font.setBold(true);
|
|
||||||
style.setFont(font);
|
|
||||||
return style;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String fmt(BigDecimal val) {
|
|
||||||
if (val == null) return "0";
|
|
||||||
return AMOUNT_FMT.format(val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package com.ga.api.service.statement;
|
|
||||||
|
|
||||||
import com.ga.core.mapper.settle.PaymentMapper;
|
|
||||||
import com.ga.core.vo.settle.PaymentVO;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class StatementAggregator {
|
|
||||||
|
|
||||||
private final PaymentMapper paymentMapper;
|
|
||||||
|
|
||||||
public StatementSummary aggregate(Long agentId, String settleMonth) {
|
|
||||||
List<PaymentVO> payments = paymentMapper.selectByAgentAndMonth(agentId, settleMonth);
|
|
||||||
|
|
||||||
BigDecimal grossAmount = sum(payments, PaymentVO::getPayAmount);
|
|
||||||
BigDecimal incomeTax = sum(payments, PaymentVO::getIncomeTaxAmount);
|
|
||||||
BigDecimal localTax = sum(payments, PaymentVO::getLocalTaxAmount);
|
|
||||||
BigDecimal vat = sum(payments, PaymentVO::getVatAmount);
|
|
||||||
BigDecimal deduction = sum(payments, PaymentVO::getDeductionAmount);
|
|
||||||
BigDecimal netAmount = sum(payments, PaymentVO::getNetAmount);
|
|
||||||
|
|
||||||
return new StatementSummary(grossAmount, incomeTax, localTax, vat, deduction, netAmount);
|
|
||||||
}
|
|
||||||
|
|
||||||
private BigDecimal sum(List<PaymentVO> payments, java.util.function.Function<PaymentVO, BigDecimal> getter) {
|
|
||||||
return payments.stream()
|
|
||||||
.map(p -> getter.apply(p) != null ? getter.apply(p) : BigDecimal.ZERO)
|
|
||||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package com.ga.api.service.statement;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
|
|
||||||
public record StatementSummary(
|
|
||||||
BigDecimal grossAmount,
|
|
||||||
BigDecimal incomeTaxAmount,
|
|
||||||
BigDecimal localTaxAmount,
|
|
||||||
BigDecimal vatAmount,
|
|
||||||
BigDecimal deductionAmount,
|
|
||||||
BigDecimal netAmount
|
|
||||||
) {}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package com.ga.api.service.system;
|
|
||||||
|
|
||||||
import com.ga.common.exception.BizException;
|
|
||||||
import com.ga.common.exception.ErrorCode;
|
|
||||||
import com.ga.core.mapper.user.RoleMapper;
|
|
||||||
import com.ga.core.vo.user.RoleVO;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class RoleService {
|
|
||||||
|
|
||||||
private final RoleMapper mapper;
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<RoleVO> list() {
|
|
||||||
return mapper.selectAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public RoleVO detail(Long roleId) {
|
|
||||||
RoleVO vo = mapper.selectById(roleId);
|
|
||||||
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
return vo;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<Map<String, Object>> permissions(Long roleId) {
|
|
||||||
return mapper.selectRolePermissions(roleId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public Long create(RoleVO vo) {
|
|
||||||
mapper.insert(vo);
|
|
||||||
return vo.getRoleId();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void update(Long roleId, RoleVO vo) {
|
|
||||||
if (mapper.selectById(roleId) == null) throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
vo.setRoleId(roleId);
|
|
||||||
mapper.update(vo);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void delete(Long roleId) {
|
|
||||||
if (mapper.selectById(roleId) == null) throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
mapper.deleteById(roleId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void savePermissions(Long roleId, List<Map<String, Object>> permissions) {
|
|
||||||
mapper.deleteRolePermissions(roleId);
|
|
||||||
for (Map<String, Object> p : permissions) {
|
|
||||||
Long menuId = ((Number) p.get("menuId")).longValue();
|
|
||||||
String permCode = (String) p.get("permCode");
|
|
||||||
String isGranted = (String) p.getOrDefault("isGranted", "Y");
|
|
||||||
mapper.insertRolePermission(roleId, menuId, permCode, isGranted);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,11 +5,9 @@ import com.ga.common.exception.ErrorCode;
|
|||||||
import com.ga.common.model.PageResponse;
|
import com.ga.common.model.PageResponse;
|
||||||
import com.ga.common.system.SystemConfigService;
|
import com.ga.common.system.SystemConfigService;
|
||||||
import com.ga.core.mapper.user.UserMapper;
|
import com.ga.core.mapper.user.UserMapper;
|
||||||
import com.ga.core.mapstruct.UserMapStruct;
|
|
||||||
import com.ga.core.vo.user.UserResp;
|
import com.ga.core.vo.user.UserResp;
|
||||||
import com.ga.core.vo.user.UserSaveReq;
|
import com.ga.core.vo.user.UserSaveReq;
|
||||||
import com.ga.core.vo.user.UserSearchParam;
|
import com.ga.core.vo.user.UserSearchParam;
|
||||||
import com.ga.core.vo.user.UserStatus;
|
|
||||||
import com.ga.core.vo.user.UserVO;
|
import com.ga.core.vo.user.UserVO;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
@@ -25,15 +23,12 @@ public class UserService {
|
|||||||
private final UserMapper mapper;
|
private final UserMapper mapper;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final SystemConfigService configService;
|
private final SystemConfigService configService;
|
||||||
private final UserMapStruct userMapStruct;
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public PageResponse<UserResp> list(UserSearchParam param) {
|
public PageResponse<UserResp> list(UserSearchParam param) {
|
||||||
param.startPage();
|
param.startPage();
|
||||||
return PageResponse.of(mapper.selectList(param));
|
return PageResponse.of(mapper.selectList(param));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public UserResp detail(Long userId) {
|
public UserResp detail(Long userId) {
|
||||||
UserResp r = mapper.selectDetailById(userId);
|
UserResp r = mapper.selectDetailById(userId);
|
||||||
if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
|
if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
|
||||||
@@ -50,9 +45,14 @@ public class UserService {
|
|||||||
if (initialPwd == null || initialPwd.isBlank()) {
|
if (initialPwd == null || initialPwd.isBlank()) {
|
||||||
initialPwd = "init1234!"; // 임시 — 로그인 후 변경 강제
|
initialPwd = "init1234!"; // 임시 — 로그인 후 변경 강제
|
||||||
}
|
}
|
||||||
UserVO vo = userMapStruct.toVO(req);
|
UserVO vo = new UserVO();
|
||||||
|
vo.setLoginId(req.getLoginId());
|
||||||
vo.setPassword(passwordEncoder.encode(initialPwd));
|
vo.setPassword(passwordEncoder.encode(initialPwd));
|
||||||
vo.setStatus(req.getStatus() != null ? req.getStatus() : UserStatus.ACTIVE.name());
|
vo.setUserName(req.getUserName());
|
||||||
|
vo.setEmail(req.getEmail());
|
||||||
|
vo.setPhone(req.getPhone());
|
||||||
|
vo.setAgentId(req.getAgentId());
|
||||||
|
vo.setStatus(req.getStatus() != null ? req.getStatus() : "ACTIVE");
|
||||||
mapper.insert(vo);
|
mapper.insert(vo);
|
||||||
|
|
||||||
if (req.getRoleIds() != null) {
|
if (req.getRoleIds() != null) {
|
||||||
@@ -63,10 +63,15 @@ public class UserService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void update(Long userId, UserSaveReq req) {
|
public void update(Long userId, UserSaveReq req) {
|
||||||
UserVO vo = mapper.selectById(userId);
|
UserVO old = mapper.selectById(userId);
|
||||||
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
|
if (old == null) throw new BizException(ErrorCode.NOT_FOUND);
|
||||||
userMapStruct.updateVO(req, vo);
|
UserVO vo = new UserVO();
|
||||||
vo.setUserId(userId);
|
vo.setUserId(userId);
|
||||||
|
vo.setUserName(req.getUserName());
|
||||||
|
vo.setEmail(req.getEmail());
|
||||||
|
vo.setPhone(req.getPhone());
|
||||||
|
vo.setAgentId(req.getAgentId());
|
||||||
|
vo.setStatus(req.getStatus());
|
||||||
mapper.update(vo);
|
mapper.update(vo);
|
||||||
|
|
||||||
if (req.getRoleIds() != null) {
|
if (req.getRoleIds() != null) {
|
||||||
@@ -86,10 +91,9 @@ public class UserService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void unlock(Long userId) {
|
public void unlock(Long userId) {
|
||||||
mapper.resetFailCount(userId);
|
mapper.resetFailCount(userId);
|
||||||
mapper.updateStatus(userId, UserStatus.ACTIVE.name());
|
mapper.updateStatus(userId, "ACTIVE");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<String> roleCodes(Long userId) {
|
public List<String> roleCodes(Long userId) {
|
||||||
return mapper.selectRoleCodes(userId);
|
return mapper.selectRoleCodes(userId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
package com.ga.api.service.tax;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
|
|
||||||
public record TaxBreakdown(
|
|
||||||
BigDecimal incomeTax,
|
|
||||||
BigDecimal localTax,
|
|
||||||
BigDecimal vat,
|
|
||||||
BigDecimal netAmount
|
|
||||||
) {}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package com.ga.api.service.tax;
|
|
||||||
|
|
||||||
import com.ga.common.system.SystemConfigService;
|
|
||||||
import com.ga.core.vo.org.TaxType;
|
|
||||||
import com.ga.core.vo.org.VatType;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.math.RoundingMode;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class TaxCalculator {
|
|
||||||
|
|
||||||
private final SystemConfigService configService;
|
|
||||||
|
|
||||||
public TaxBreakdown calculate(BigDecimal amount, TaxType taxType, VatType vatType) {
|
|
||||||
BigDecimal incomeRate = resolveIncomeRate(taxType);
|
|
||||||
BigDecimal localRate = configService.getDecimal("TAX_RATE_LOCAL_INCOME", new BigDecimal("10.0"));
|
|
||||||
BigDecimal vatRate = configService.getDecimal("TAX_RATE_VAT", new BigDecimal("10.0"));
|
|
||||||
|
|
||||||
BigDecimal incomeTax = amount.multiply(incomeRate)
|
|
||||||
.divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP);
|
|
||||||
BigDecimal localTax = incomeTax.multiply(localRate)
|
|
||||||
.divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP);
|
|
||||||
BigDecimal vat = VatType.TAXABLE.equals(vatType)
|
|
||||||
? amount.multiply(vatRate).divide(BigDecimal.valueOf(100), 0, RoundingMode.HALF_UP)
|
|
||||||
: BigDecimal.ZERO;
|
|
||||||
BigDecimal netAmount = amount.subtract(incomeTax).subtract(localTax).add(vat);
|
|
||||||
|
|
||||||
return new TaxBreakdown(incomeTax, localTax, vat, netAmount);
|
|
||||||
}
|
|
||||||
|
|
||||||
private BigDecimal resolveIncomeRate(TaxType taxType) {
|
|
||||||
if (TaxType.OTHER_INCOME.equals(taxType)) {
|
|
||||||
return configService.getDecimal("TAX_RATE_OTHER_INCOME", new BigDecimal("8.8"));
|
|
||||||
}
|
|
||||||
return configService.getDecimal("TAX_RATE_BUSINESS_INCOME", new BigDecimal("3.3"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
package com.ga.api.service.transfer;
|
|
||||||
|
|
||||||
import com.ga.api.transfer.BankTransferFactory;
|
|
||||||
import com.ga.api.transfer.BankTransferFileGenerator;
|
|
||||||
import com.ga.common.exception.BizException;
|
|
||||||
import com.ga.common.exception.ErrorCode;
|
|
||||||
import com.ga.common.file.FileService;
|
|
||||||
import com.ga.common.util.EncryptUtil;
|
|
||||||
import com.ga.core.mapper.settle.PaymentMapper;
|
|
||||||
import com.ga.core.vo.settle.PaymentVO;
|
|
||||||
import com.ga.core.vo.settle.TransferFileResp;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class TransferFileService {
|
|
||||||
|
|
||||||
private final PaymentMapper paymentMapper;
|
|
||||||
private final BankTransferFactory factory;
|
|
||||||
private final FileService fileService;
|
|
||||||
private final EncryptUtil encryptUtil;
|
|
||||||
|
|
||||||
public TransferFileResp generate(String settleMonth, String bankCode) {
|
|
||||||
// 1) PENDING payment 조회 — readOnly 트랜잭션 (accountNo는 EncryptTypeHandler로 자동복호화됨)
|
|
||||||
List<PaymentVO> payments = paymentMapper.selectPendingByMonthAndBank(settleMonth, bankCode);
|
|
||||||
|
|
||||||
if (payments.isEmpty()) {
|
|
||||||
throw new BizException(ErrorCode.NOT_FOUND, "이체 대상 지급 건이 없습니다 (settleMonth=" + settleMonth + ", bankCode=" + bankCode + ")");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) 파일 byte 생성 + 저장 — 트랜잭션 밖 (IO 작업이므로 커넥션 점유 불필요)
|
|
||||||
BankTransferFileGenerator generator = factory.get(bankCode);
|
|
||||||
byte[] fileContent = generator.generate(payments, settleMonth);
|
|
||||||
String fileName = "transfer_" + settleMonth + "_" + bankCode + generator.getFileExtension();
|
|
||||||
Long fileId = fileService.saveBytes(fileContent, fileName, "TRANSFER_FILE", "application/octet-stream");
|
|
||||||
|
|
||||||
// 3) payment 상태 갱신 — 파일 저장 성공 후 별도 트랜잭션 (파일만 잔존하는 역방향 불일치 방지)
|
|
||||||
List<Long> paymentIds = payments.stream()
|
|
||||||
.map(PaymentVO::getPaymentId)
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
markPaymentsSent(paymentIds, fileId);
|
|
||||||
|
|
||||||
BigDecimal totalAmount = payments.stream()
|
|
||||||
.map(p -> p.getPayAmount() != null ? p.getPayAmount() : BigDecimal.ZERO)
|
|
||||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
||||||
|
|
||||||
return new TransferFileResp(fileId, fileName, payments.size(), totalAmount);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void markPaymentsSent(List<Long> paymentIds, Long fileId) {
|
|
||||||
paymentMapper.updateTransferFile(paymentIds, fileId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package com.ga.api.service.upload;
|
|
||||||
|
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
|
||||||
import org.apache.ibatis.annotations.Param;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 업로드 템플릿에 따른 동적 INSERT 매퍼.
|
|
||||||
*
|
|
||||||
* targetTable 은 반드시 UploadService 의 whitelist 검증 후 전달해야 한다.
|
|
||||||
* (SQL Injection 방지)
|
|
||||||
*/
|
|
||||||
@Mapper
|
|
||||||
public interface DynamicInsertMapper {
|
|
||||||
|
|
||||||
int insertRow(@Param("targetTable") String targetTable,
|
|
||||||
@Param("row") Map<String, Object> row);
|
|
||||||
|
|
||||||
int insertBatch(@Param("targetTable") String targetTable,
|
|
||||||
@Param("rows") List<Map<String, Object>> rows);
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
package com.ga.api.service.upload;
|
|
||||||
|
|
||||||
import com.ga.common.exception.BizException;
|
|
||||||
import com.ga.common.exception.ErrorCode;
|
|
||||||
import com.ga.core.vo.upload.UploadTemplateColumnVO;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* LOOKUP 변환 규칙 최적화 캐시.
|
|
||||||
*
|
|
||||||
* preload()가 새 Map 인스턴스를 반환한다 — 싱글톤 상태를 보유하지 않으므로 thread-safe.
|
|
||||||
* 호출자(UploadService)가 반환된 Map을 보유하고 TransformEngine에 전달한다.
|
|
||||||
*
|
|
||||||
* 캐시 구조: Map< "table:sourceField:targetField" → Map<sourceValue, targetValue> >
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Component
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class LookupCache {
|
|
||||||
|
|
||||||
private final LookupMapper lookupMapper;
|
|
||||||
|
|
||||||
/** 허용 테이블 whitelist (SQL Injection 방지) */
|
|
||||||
private static final Set<String> ALLOWED_TABLES = Set.of(
|
|
||||||
"recruit_ledger", "maintain_ledger", "contract", "agent",
|
|
||||||
"commission_rate", "payout_rule", "chargeback_rule",
|
|
||||||
"exception_ledger", "payment",
|
|
||||||
"common_code", "organization", "grade", "insurance_company", "product"
|
|
||||||
);
|
|
||||||
|
|
||||||
/** 컬럼 이름 허용 패턴 (영문/숫자/밑줄만) */
|
|
||||||
private static final Pattern FIELD_PATTERN =
|
|
||||||
Pattern.compile("^[A-Za-z][A-Za-z0-9_]{0,63}$");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* LOOKUP 컬럼 목록에서 필요한 참조 데이터를 한 번에 로드하여 새 Map으로 반환.
|
|
||||||
* 반환된 Map은 요청 범위에서만 사용되므로 동기화 불필요.
|
|
||||||
*/
|
|
||||||
public Map<String, Map<String, Object>> preload(List<UploadTemplateColumnVO> columns) {
|
|
||||||
Map<String, Map<String, Object>> cache = new HashMap<>();
|
|
||||||
|
|
||||||
columns.stream()
|
|
||||||
.filter(c -> "LOOKUP".equalsIgnoreCase(c.getTransformRule()))
|
|
||||||
.forEach(c -> {
|
|
||||||
String key = buildKey(c.getLookupTable(), c.getLookupSourceField(), c.getLookupTargetField());
|
|
||||||
if (!cache.containsKey(key)) {
|
|
||||||
validateTableAndFields(c.getLookupTable(), c.getLookupSourceField(), c.getLookupTargetField());
|
|
||||||
|
|
||||||
List<Map<String, Object>> rows = lookupMapper.selectAll(
|
|
||||||
c.getLookupTable(), c.getLookupSourceField(), c.getLookupTargetField());
|
|
||||||
|
|
||||||
Map<String, Object> map = new HashMap<>(rows.size() * 2);
|
|
||||||
for (Map<String, Object> row : rows) {
|
|
||||||
Object src = row.get("source_value");
|
|
||||||
Object tgt = row.get("target_value");
|
|
||||||
if (src != null) {
|
|
||||||
map.put(src.toString(), tgt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cache.put(key, map);
|
|
||||||
log.debug("LookupCache preloaded: {} → {} entries", key, map.size());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return cache;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* O(1) 조회. cache는 preload()의 반환값이어야 한다.
|
|
||||||
*/
|
|
||||||
public Object lookup(Map<String, Map<String, Object>> cache,
|
|
||||||
String table, String sourceField, String value, String targetField) {
|
|
||||||
if (value == null) return null;
|
|
||||||
String key = buildKey(table, sourceField, targetField);
|
|
||||||
Map<String, Object> map = cache.get(key);
|
|
||||||
if (map == null) return null;
|
|
||||||
return map.get(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String buildKey(String table, String sourceField, String targetField) {
|
|
||||||
return table + ":" + sourceField + ":" + targetField;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void validateTableAndFields(String table, String sourceField, String targetField) {
|
|
||||||
if (table == null || !ALLOWED_TABLES.contains(table)) {
|
|
||||||
throw new BizException(ErrorCode.INVALID_PARAMETER,
|
|
||||||
"LOOKUP 허용되지 않는 테이블: " + table);
|
|
||||||
}
|
|
||||||
if (sourceField == null || !FIELD_PATTERN.matcher(sourceField).matches()) {
|
|
||||||
throw new BizException(ErrorCode.INVALID_PARAMETER,
|
|
||||||
"LOOKUP 허용되지 않는 sourceField: " + sourceField);
|
|
||||||
}
|
|
||||||
if (targetField == null || !FIELD_PATTERN.matcher(targetField).matches()) {
|
|
||||||
throw new BizException(ErrorCode.INVALID_PARAMETER,
|
|
||||||
"LOOKUP 허용되지 않는 targetField: " + targetField);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package com.ga.api.service.upload;
|
|
||||||
|
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
|
||||||
import org.apache.ibatis.annotations.Param;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* LOOKUP 변환 규칙에 사용하는 동적 SELECT 매퍼.
|
|
||||||
*
|
|
||||||
* table/field 는 반드시 LookupCache 의 whitelist 검증 후 전달해야 한다.
|
|
||||||
* (${} 로 동적 테이블/컬럼 이름 처리 — SQL Injection 방지 필수)
|
|
||||||
*/
|
|
||||||
@Mapper
|
|
||||||
public interface LookupMapper {
|
|
||||||
|
|
||||||
List<Map<String, Object>> selectAll(@Param("table") String table,
|
|
||||||
@Param("sourceField") String sourceField,
|
|
||||||
@Param("targetField") String targetField);
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
package com.ga.api.service.upload;
|
|
||||||
|
|
||||||
import com.ga.common.exception.BizException;
|
|
||||||
import com.ga.common.exception.ErrorCode;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 업로드 템플릿의 target_table 별로 사용 가능한 컬럼(target_field) 메타데이터.
|
|
||||||
* 화이트리스트 역할 + 프론트 Select 옵션 제공.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
public class TargetFieldMetaService {
|
|
||||||
|
|
||||||
public record TargetFieldMeta(
|
|
||||||
String field, // DB 컬럼명 (e.g. policy_no)
|
|
||||||
String label, // 한글 표시명
|
|
||||||
String type, // STRING / NUMBER / DECIMAL / DATE / INTEGER
|
|
||||||
boolean required) {}
|
|
||||||
|
|
||||||
private static final Map<String, List<TargetFieldMeta>> META = Map.of(
|
|
||||||
"recruit_ledger", List.of(
|
|
||||||
new TargetFieldMeta("policy_no", "증권번호", "STRING", true),
|
|
||||||
new TargetFieldMeta("agent_id", "설계사ID", "INTEGER", false),
|
|
||||||
new TargetFieldMeta("contract_id", "계약ID", "INTEGER", false),
|
|
||||||
new TargetFieldMeta("company_code", "보험사코드", "STRING", false),
|
|
||||||
new TargetFieldMeta("product_code", "상품코드", "STRING", false),
|
|
||||||
new TargetFieldMeta("insurance_type", "보험종류", "STRING", false),
|
|
||||||
new TargetFieldMeta("commission_year", "회차", "INTEGER", false),
|
|
||||||
new TargetFieldMeta("premium", "보험료", "DECIMAL", true),
|
|
||||||
new TargetFieldMeta("pay_method", "납입주기", "STRING", false),
|
|
||||||
new TargetFieldMeta("company_rate", "회사율(%)", "DECIMAL", false),
|
|
||||||
new TargetFieldMeta("company_amount", "회사수수료", "DECIMAL", true),
|
|
||||||
new TargetFieldMeta("payout_rate", "지급율(%)", "DECIMAL", false),
|
|
||||||
new TargetFieldMeta("agent_amount", "설계사수수료", "DECIMAL", false),
|
|
||||||
new TargetFieldMeta("settle_month", "정산월", "STRING", true),
|
|
||||||
new TargetFieldMeta("status", "상태", "STRING", false)
|
|
||||||
),
|
|
||||||
"maintain_ledger", List.of(
|
|
||||||
new TargetFieldMeta("policy_no", "증권번호", "STRING", true),
|
|
||||||
new TargetFieldMeta("agent_id", "설계사ID", "INTEGER", false),
|
|
||||||
new TargetFieldMeta("contract_id", "계약ID", "INTEGER", false),
|
|
||||||
new TargetFieldMeta("company_code", "보험사코드", "STRING", false),
|
|
||||||
new TargetFieldMeta("product_code", "상품코드", "STRING", false),
|
|
||||||
new TargetFieldMeta("insurance_type", "보험종류", "STRING", false),
|
|
||||||
new TargetFieldMeta("commission_year", "회차", "INTEGER", true),
|
|
||||||
new TargetFieldMeta("premium", "보험료", "DECIMAL", true),
|
|
||||||
new TargetFieldMeta("company_amount", "회사수수료", "DECIMAL", true),
|
|
||||||
new TargetFieldMeta("agent_amount", "설계사수수료", "DECIMAL", false),
|
|
||||||
new TargetFieldMeta("settle_month", "정산월", "STRING", true)
|
|
||||||
),
|
|
||||||
"contract", List.of(
|
|
||||||
new TargetFieldMeta("policy_no", "증권번호", "STRING", true),
|
|
||||||
new TargetFieldMeta("agent_id", "설계사ID", "INTEGER", true),
|
|
||||||
new TargetFieldMeta("product_id", "상품ID", "INTEGER", true),
|
|
||||||
new TargetFieldMeta("contractor_name", "계약자", "STRING", false),
|
|
||||||
new TargetFieldMeta("insured_name", "피보험자", "STRING", false),
|
|
||||||
new TargetFieldMeta("premium", "보험료", "DECIMAL", false),
|
|
||||||
new TargetFieldMeta("pay_cycle", "납입주기", "STRING", false),
|
|
||||||
new TargetFieldMeta("contract_date", "계약일", "DATE", true),
|
|
||||||
new TargetFieldMeta("effective_date", "효력발생일", "DATE", false),
|
|
||||||
new TargetFieldMeta("status", "상태", "STRING", false)
|
|
||||||
),
|
|
||||||
"agent", List.of(
|
|
||||||
new TargetFieldMeta("agent_name", "설계사명", "STRING", true),
|
|
||||||
new TargetFieldMeta("license_no", "사번", "STRING", false),
|
|
||||||
new TargetFieldMeta("org_id", "조직ID", "INTEGER", true),
|
|
||||||
new TargetFieldMeta("grade_id", "직급ID", "INTEGER", true),
|
|
||||||
new TargetFieldMeta("phone", "전화번호", "STRING", false),
|
|
||||||
new TargetFieldMeta("email", "이메일", "STRING", false),
|
|
||||||
new TargetFieldMeta("bank_code", "은행코드", "STRING", false),
|
|
||||||
new TargetFieldMeta("account_no", "계좌번호", "STRING", false),
|
|
||||||
new TargetFieldMeta("status", "상태", "STRING", false),
|
|
||||||
new TargetFieldMeta("join_date", "입사일", "DATE", false)
|
|
||||||
),
|
|
||||||
"commission_rate", List.of(
|
|
||||||
new TargetFieldMeta("product_id", "상품ID", "INTEGER", true),
|
|
||||||
new TargetFieldMeta("commission_year", "회차", "INTEGER", true),
|
|
||||||
new TargetFieldMeta("rate_pct", "수수료율(%)", "DECIMAL", true),
|
|
||||||
new TargetFieldMeta("pay_method_cond", "납입조건", "STRING", false),
|
|
||||||
new TargetFieldMeta("min_premium", "최소보험료", "DECIMAL", false),
|
|
||||||
new TargetFieldMeta("effective_from", "적용시작", "DATE", true),
|
|
||||||
new TargetFieldMeta("effective_to", "적용종료", "DATE", false)
|
|
||||||
),
|
|
||||||
"payout_rule", List.of(
|
|
||||||
new TargetFieldMeta("grade_id", "직급ID", "INTEGER", true),
|
|
||||||
new TargetFieldMeta("insurance_type", "보험종류", "STRING", true),
|
|
||||||
new TargetFieldMeta("commission_year", "회차", "INTEGER", true),
|
|
||||||
new TargetFieldMeta("payout_pct", "지급율(%)", "DECIMAL", true),
|
|
||||||
new TargetFieldMeta("pay_method_cond", "납입조건", "STRING", false),
|
|
||||||
new TargetFieldMeta("performance_grade","실적등급", "STRING", false),
|
|
||||||
new TargetFieldMeta("effective_from", "적용시작", "DATE", true)
|
|
||||||
),
|
|
||||||
"chargeback_rule", List.of(
|
|
||||||
new TargetFieldMeta("company_code", "보험사코드", "STRING", true),
|
|
||||||
new TargetFieldMeta("insurance_type", "보험종류", "STRING", true),
|
|
||||||
new TargetFieldMeta("lapse_month_from","실효시작월", "INTEGER", true),
|
|
||||||
new TargetFieldMeta("lapse_month_to", "실효종료월", "INTEGER", true),
|
|
||||||
new TargetFieldMeta("cb_rate", "환수율(%)", "DECIMAL", true),
|
|
||||||
new TargetFieldMeta("include_override","오버라이드포함", "STRING", false)
|
|
||||||
),
|
|
||||||
"exception_ledger", List.of(
|
|
||||||
new TargetFieldMeta("agent_id", "설계사ID", "INTEGER", true),
|
|
||||||
new TargetFieldMeta("exception_code", "예외코드", "STRING", true),
|
|
||||||
new TargetFieldMeta("amount", "금액", "DECIMAL", true),
|
|
||||||
new TargetFieldMeta("settle_month", "정산월", "STRING", true),
|
|
||||||
new TargetFieldMeta("policy_no", "증권번호", "STRING", false),
|
|
||||||
new TargetFieldMeta("description", "내용", "STRING", false)
|
|
||||||
),
|
|
||||||
"payment", List.of(
|
|
||||||
new TargetFieldMeta("settle_id", "정산ID", "INTEGER", true),
|
|
||||||
new TargetFieldMeta("agent_id", "설계사ID", "INTEGER", true),
|
|
||||||
new TargetFieldMeta("pay_month", "지급월", "STRING", true),
|
|
||||||
new TargetFieldMeta("net_amount", "지급액", "DECIMAL", true),
|
|
||||||
new TargetFieldMeta("bank_code", "은행코드", "STRING", false),
|
|
||||||
new TargetFieldMeta("account_no", "계좌번호", "STRING", false)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
public List<TargetFieldMeta> getFields(String targetTable) {
|
|
||||||
if (targetTable == null) {
|
|
||||||
throw new BizException(ErrorCode.INVALID_PARAMETER, "target_table 누락");
|
|
||||||
}
|
|
||||||
List<TargetFieldMeta> fields = META.get(targetTable);
|
|
||||||
if (fields == null) {
|
|
||||||
throw new BizException(ErrorCode.INVALID_PARAMETER,
|
|
||||||
"허용되지 않은 target_table: " + targetTable + " (가능: " + META.keySet() + ")");
|
|
||||||
}
|
|
||||||
return fields;
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<String> getAvailableTables() {
|
|
||||||
return List.copyOf(META.keySet());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
package com.ga.api.service.upload;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.ga.common.exception.BizException;
|
|
||||||
import com.ga.common.exception.ErrorCode;
|
|
||||||
import com.ga.core.vo.upload.UploadTemplateColumnVO;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.apache.poi.ss.usermodel.Cell;
|
|
||||||
import org.apache.poi.ss.usermodel.DateUtil;
|
|
||||||
import org.apache.poi.ss.usermodel.Row;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.format.DateTimeFormatter;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 12종 변환 규칙 실행기.
|
|
||||||
*
|
|
||||||
* 지원 규칙: TRIM / UPPER / LOWER / DATE / DIVIDE / MULTIPLY /
|
|
||||||
* LOOKUP / CODE_MAP / FIXED / REPLACE / SUBSTRING / CONCAT / DEFAULT
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Component
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class TransformEngine {
|
|
||||||
|
|
||||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
|
||||||
|
|
||||||
private final LookupCache lookupCache;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 엑셀 Row 를 템플릿 컬럼 정의에 따라 변환하여 Map 으로 반환.
|
|
||||||
*
|
|
||||||
* @param row xlsx-streamer Row
|
|
||||||
* @param columns 템플릿 컬럼 정의
|
|
||||||
* @param lookupData preload()가 반환한 요청 범위 LOOKUP 캐시
|
|
||||||
* @return { targetField → 변환된 값 }
|
|
||||||
*/
|
|
||||||
public Map<String, Object> transform(Row row, List<UploadTemplateColumnVO> columns,
|
|
||||||
Map<String, Map<String, Object>> lookupData) {
|
|
||||||
Map<String, Object> result = new LinkedHashMap<>();
|
|
||||||
|
|
||||||
for (UploadTemplateColumnVO col : columns) {
|
|
||||||
try {
|
|
||||||
Object rawValue = extractRaw(row, col);
|
|
||||||
Object transformed = applyTransform(rawValue, col, row, columns, lookupData);
|
|
||||||
result.put(col.getTargetField(), transformed);
|
|
||||||
} catch (BizException e) {
|
|
||||||
throw e;
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new BizException(ErrorCode.EXCEL_PARSE_FAIL,
|
|
||||||
"컬럼[" + col.getTargetField() + "] 변환 오류: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 단일 컬럼에 변환 규칙을 적용.
|
|
||||||
*/
|
|
||||||
public Object applyTransform(Object value, UploadTemplateColumnVO col,
|
|
||||||
Row row, List<UploadTemplateColumnVO> allColumns,
|
|
||||||
Map<String, Map<String, Object>> lookupData) {
|
|
||||||
String rule = col.getTransformRule();
|
|
||||||
|
|
||||||
// FIXED 는 원본 값 무시
|
|
||||||
if ("FIXED".equalsIgnoreCase(rule)) {
|
|
||||||
return col.getFixedValue();
|
|
||||||
}
|
|
||||||
|
|
||||||
// DEFAULT: 값 없을 때만 적용
|
|
||||||
if ("DEFAULT".equalsIgnoreCase(rule)) {
|
|
||||||
return (value == null || value.toString().isBlank()) ? col.getDefaultValue() : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rule == null || rule.isBlank()) {
|
|
||||||
return applyTargetTypeCast(value, col);
|
|
||||||
}
|
|
||||||
|
|
||||||
return switch (rule.toUpperCase()) {
|
|
||||||
case "TRIM" -> value == null ? null : value.toString().trim();
|
|
||||||
case "UPPER" -> value == null ? null : value.toString().trim().toUpperCase();
|
|
||||||
case "LOWER" -> value == null ? null : value.toString().trim().toLowerCase();
|
|
||||||
case "DATE" -> applyDate(value, col);
|
|
||||||
case "DIVIDE" -> applyDivide(value, col);
|
|
||||||
case "MULTIPLY" -> applyMultiply(value, col);
|
|
||||||
case "LOOKUP" -> applyLookup(value, col, lookupData);
|
|
||||||
case "CODE_MAP" -> applyCodeMap(value, col, lookupData);
|
|
||||||
case "REPLACE" -> applyReplace(value, col);
|
|
||||||
case "SUBSTRING" -> applySubstring(value, col);
|
|
||||||
case "CONCAT" -> applyConcat(row, col);
|
|
||||||
default -> applyTargetTypeCast(value, col);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================
|
|
||||||
// Raw 값 추출
|
|
||||||
// =========================================================
|
|
||||||
|
|
||||||
private Object extractRaw(Row row, UploadTemplateColumnVO col) {
|
|
||||||
if ("FIXED".equalsIgnoreCase(col.getSourceType())) {
|
|
||||||
return col.getFixedValue();
|
|
||||||
}
|
|
||||||
if ("EXPRESSION".equalsIgnoreCase(col.getSourceType())) {
|
|
||||||
return col.getFixedValue();
|
|
||||||
}
|
|
||||||
|
|
||||||
// COLUMN: sourceColumn (0-based index)
|
|
||||||
Integer srcCol = col.getSourceColumn();
|
|
||||||
if (srcCol == null) return null;
|
|
||||||
|
|
||||||
Cell cell = row.getCell(srcCol);
|
|
||||||
if (cell == null) return null;
|
|
||||||
|
|
||||||
return readCell(cell);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Object readCell(Cell cell) {
|
|
||||||
return switch (cell.getCellType()) {
|
|
||||||
case STRING -> {
|
|
||||||
String v = cell.getStringCellValue();
|
|
||||||
yield (v == null || v.isBlank()) ? null : v;
|
|
||||||
}
|
|
||||||
case NUMERIC -> {
|
|
||||||
if (DateUtil.isCellDateFormatted(cell)) {
|
|
||||||
yield cell.getDateCellValue().toInstant()
|
|
||||||
.atZone(java.time.ZoneId.systemDefault()).toLocalDate();
|
|
||||||
}
|
|
||||||
double d = cell.getNumericCellValue();
|
|
||||||
yield (d == Math.floor(d) && !Double.isInfinite(d))
|
|
||||||
? String.valueOf((long) d)
|
|
||||||
: String.valueOf(d);
|
|
||||||
}
|
|
||||||
case BOOLEAN -> String.valueOf(cell.getBooleanCellValue());
|
|
||||||
case FORMULA -> {
|
|
||||||
try { yield cell.getStringCellValue(); }
|
|
||||||
catch (Exception e) { yield String.valueOf(cell.getNumericCellValue()); }
|
|
||||||
}
|
|
||||||
default -> null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// =========================================================
|
|
||||||
// 개별 변환 규칙
|
|
||||||
// =========================================================
|
|
||||||
|
|
||||||
private Object applyDate(Object value, UploadTemplateColumnVO col) {
|
|
||||||
if (value == null) return null;
|
|
||||||
if (value instanceof LocalDate) return value.toString();
|
|
||||||
String s = value.toString().trim();
|
|
||||||
if (s.isBlank()) return null;
|
|
||||||
String fmt = col.getDateFormat() != null ? col.getDateFormat() : "yyyyMMdd";
|
|
||||||
try {
|
|
||||||
LocalDate d = LocalDate.parse(s, DateTimeFormatter.ofPattern(fmt));
|
|
||||||
return d.toString();
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new BizException(ErrorCode.EXCEL_PARSE_FAIL,
|
|
||||||
"날짜 파싱 실패 [" + s + "] format=" + fmt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Object applyDivide(Object value, UploadTemplateColumnVO col) {
|
|
||||||
if (value == null) return null;
|
|
||||||
Integer divisor = col.getNumberDivide();
|
|
||||||
if (divisor == null || divisor == 0) return value;
|
|
||||||
BigDecimal bd = toBigDecimal(value);
|
|
||||||
return bd.divide(BigDecimal.valueOf(divisor), 10, java.math.RoundingMode.HALF_UP)
|
|
||||||
.stripTrailingZeros();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Object applyMultiply(Object value, UploadTemplateColumnVO col) {
|
|
||||||
if (value == null) return null;
|
|
||||||
Integer multiplier = col.getNumberMultiply();
|
|
||||||
if (multiplier == null) return value;
|
|
||||||
BigDecimal bd = toBigDecimal(value);
|
|
||||||
return bd.multiply(BigDecimal.valueOf(multiplier)).stripTrailingZeros();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Object applyLookup(Object value, UploadTemplateColumnVO col,
|
|
||||||
Map<String, Map<String, Object>> lookupData) {
|
|
||||||
if (value == null) return null;
|
|
||||||
Object result = lookupCache.lookup(
|
|
||||||
lookupData,
|
|
||||||
col.getLookupTable(),
|
|
||||||
col.getLookupSourceField(),
|
|
||||||
value.toString(),
|
|
||||||
col.getLookupTargetField());
|
|
||||||
if (result == null && col.getDefaultValue() != null) {
|
|
||||||
return col.getDefaultValue();
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Object applyCodeMap(Object value, UploadTemplateColumnVO col,
|
|
||||||
Map<String, Map<String, Object>> lookupData) {
|
|
||||||
if (value == null) return null;
|
|
||||||
Object mapped = lookupCache.lookup(
|
|
||||||
lookupData, "common_code", "code", value.toString(), "code_name");
|
|
||||||
return mapped != null ? mapped : (col.getDefaultValue() != null ? col.getDefaultValue() : value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Object applyReplace(Object value, UploadTemplateColumnVO col) {
|
|
||||||
if (value == null) return null;
|
|
||||||
String s = value.toString();
|
|
||||||
String param = col.getTransformParam();
|
|
||||||
if (param == null || param.isBlank()) return s;
|
|
||||||
try {
|
|
||||||
JsonNode node = MAPPER.readTree(param);
|
|
||||||
String find = node.path("find").asText("");
|
|
||||||
String replace = node.path("replace").asText("");
|
|
||||||
return s.replace(find, replace);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("REPLACE param 파싱 실패: {}", param);
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Object applySubstring(Object value, UploadTemplateColumnVO col) {
|
|
||||||
if (value == null) return null;
|
|
||||||
String s = value.toString();
|
|
||||||
String param = col.getTransformParam();
|
|
||||||
if (param == null || param.isBlank()) return s;
|
|
||||||
try {
|
|
||||||
JsonNode node = MAPPER.readTree(param);
|
|
||||||
int start = node.path("start").asInt(0);
|
|
||||||
int end = node.path("end").asInt(s.length());
|
|
||||||
end = Math.min(end, s.length());
|
|
||||||
start = Math.max(0, Math.min(start, end));
|
|
||||||
return s.substring(start, end);
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("SUBSTRING param 파싱 실패: {}", param);
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Object applyConcat(Row row, UploadTemplateColumnVO col) {
|
|
||||||
String param = col.getTransformParam();
|
|
||||||
if (param == null || param.isBlank()) return null;
|
|
||||||
try {
|
|
||||||
JsonNode node = MAPPER.readTree(param);
|
|
||||||
JsonNode colsNode = node.path("columns");
|
|
||||||
String separator = node.path("separator").asText("");
|
|
||||||
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
for (JsonNode c : colsNode) {
|
|
||||||
int colIdx = c.asInt(-1);
|
|
||||||
if (colIdx < 0) continue;
|
|
||||||
Cell cell = row.getCell(colIdx);
|
|
||||||
if (cell != null) {
|
|
||||||
Object v = readCell(cell);
|
|
||||||
if (v != null) {
|
|
||||||
if (sb.length() > 0) sb.append(separator);
|
|
||||||
sb.append(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return sb.toString();
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("CONCAT param 파싱 실패: {}", param);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Object applyTargetTypeCast(Object value, UploadTemplateColumnVO col) {
|
|
||||||
if (value == null) return null;
|
|
||||||
String targetType = col.getTargetType();
|
|
||||||
if (targetType == null) return value;
|
|
||||||
String s = value.toString().trim();
|
|
||||||
return switch (targetType.toUpperCase()) {
|
|
||||||
case "NUMBER", "INTEGER" -> {
|
|
||||||
try { yield Long.parseLong(s.replace(",", "")); }
|
|
||||||
catch (NumberFormatException e) { yield s; }
|
|
||||||
}
|
|
||||||
case "DECIMAL" -> {
|
|
||||||
try { yield new BigDecimal(s.replace(",", "")); }
|
|
||||||
catch (NumberFormatException e) { yield s; }
|
|
||||||
}
|
|
||||||
case "DATE" -> applyDate(value, col);
|
|
||||||
default -> s;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private BigDecimal toBigDecimal(Object value) {
|
|
||||||
if (value instanceof BigDecimal bd) return bd;
|
|
||||||
try {
|
|
||||||
return new BigDecimal(value.toString().replace(",", "").trim());
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
throw new BizException(ErrorCode.EXCEL_PARSE_FAIL,
|
|
||||||
"숫자 변환 실패: " + value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package com.ga.api.service.upload;
|
|
||||||
|
|
||||||
import com.ga.common.excel.ExcelErrorRow;
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 업로드 처리 결과 응답.
|
|
||||||
*/
|
|
||||||
@Data
|
|
||||||
@Builder
|
|
||||||
public class UploadResultResp {
|
|
||||||
|
|
||||||
private Long historyId;
|
|
||||||
private int totalCount;
|
|
||||||
private int successCount;
|
|
||||||
private int errorCount;
|
|
||||||
private int skipCount;
|
|
||||||
private Long errorFileId;
|
|
||||||
private int durationSec;
|
|
||||||
|
|
||||||
/** 에러 행 목록 (최대 maxErrorCount 까지) */
|
|
||||||
private List<UploadErrorRow> errors;
|
|
||||||
|
|
||||||
@Data
|
|
||||||
@Builder
|
|
||||||
public static class UploadErrorRow {
|
|
||||||
private int rowNumber;
|
|
||||||
private String fieldName;
|
|
||||||
private String value;
|
|
||||||
private String errorMessage;
|
|
||||||
|
|
||||||
public static UploadErrorRow from(ExcelErrorRow e) {
|
|
||||||
return UploadErrorRow.builder()
|
|
||||||
.rowNumber(e.getRowNumber())
|
|
||||||
.fieldName(e.getFieldName())
|
|
||||||
.value(e.getValue())
|
|
||||||
.errorMessage(e.getErrorMessage())
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,457 +0,0 @@
|
|||||||
package com.ga.api.service.upload;
|
|
||||||
|
|
||||||
import com.ga.common.excel.ExcelErrorRow;
|
|
||||||
import com.ga.common.exception.BizException;
|
|
||||||
import com.ga.common.exception.ErrorCode;
|
|
||||||
import com.ga.common.file.FileService;
|
|
||||||
import com.ga.common.util.SecurityUtil;
|
|
||||||
import com.ga.core.mapper.upload.UploadColumnMapper;
|
|
||||||
import com.ga.core.mapper.upload.UploadTemplateMapper;
|
|
||||||
import com.ga.core.vo.upload.UploadHistoryVO;
|
|
||||||
import com.ga.core.vo.upload.UploadTemplateColumnVO;
|
|
||||||
import com.ga.core.vo.upload.UploadTemplateVO;
|
|
||||||
import com.monitorjbl.xlsx.StreamingReader;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.apache.poi.ss.usermodel.CellType;
|
|
||||||
import org.apache.poi.ss.usermodel.Row;
|
|
||||||
import org.apache.poi.ss.usermodel.Sheet;
|
|
||||||
import org.apache.poi.ss.usermodel.Workbook;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
|
||||||
import org.springframework.lang.NonNull;
|
|
||||||
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 엑셀 업로드 템플릿 처리 핵심 서비스.
|
|
||||||
*
|
|
||||||
* processUpload: SAX 파싱 + 변환 + 검증 + 배치 INSERT → upload_history 저장
|
|
||||||
* preview: 첫 N행 변환 결과만 반환 (DB INSERT 없음)
|
|
||||||
*
|
|
||||||
* 트랜잭션 경계: 파일 파싱은 트랜잭션 밖. history 저장/갱신과 배치 INSERT는
|
|
||||||
* UploadTxHelper를 통해 REQUIRES_NEW 독립 트랜잭션으로 처리하여
|
|
||||||
* 롱 트랜잭션으로 인한 커넥션 풀 고갈을 방지한다.
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class UploadService {
|
|
||||||
|
|
||||||
private final UploadTemplateMapper templateMapper;
|
|
||||||
private final UploadColumnMapper columnMapper;
|
|
||||||
private final TransformEngine transformEngine;
|
|
||||||
private final LookupCache lookupCache;
|
|
||||||
private final FileService fileService;
|
|
||||||
private final UploadTxHelper txHelper;
|
|
||||||
|
|
||||||
/** target_table whitelist — SQL Injection 방지 */
|
|
||||||
private static final Set<String> ALLOWED_TARGET_TABLES = Set.of(
|
|
||||||
"recruit_ledger", "maintain_ledger", "contract", "agent",
|
|
||||||
"commission_rate", "payout_rule", "chargeback_rule",
|
|
||||||
"exception_ledger", "payment"
|
|
||||||
);
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 메인: 업로드 처리
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
public UploadResultResp processUpload(String templateCode, MultipartFile file) {
|
|
||||||
long startMs = System.currentTimeMillis();
|
|
||||||
|
|
||||||
// 1. 템플릿 + 컬럼 로드
|
|
||||||
UploadTemplateVO template = loadTemplate(templateCode);
|
|
||||||
List<UploadTemplateColumnVO> columns = columnMapper.selectByTemplateId(template.getTemplateId());
|
|
||||||
if (columns.isEmpty()) {
|
|
||||||
throw new BizException(ErrorCode.INVALID_PARAMETER, "컬럼 정의가 없는 템플릿입니다: " + templateCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
validateTargetTable(template.getTargetTable());
|
|
||||||
|
|
||||||
// 2. LOOKUP 캐시 사전 로드 (요청 범위 Map — thread-safe)
|
|
||||||
Map<String, Map<String, Object>> lookupData = lookupCache.preload(columns);
|
|
||||||
|
|
||||||
// 3. history 레코드 생성 (PROCESSING 상태) — 독립 트랜잭션
|
|
||||||
UploadHistoryVO history = initHistory(template, file);
|
|
||||||
txHelper.saveHistory(history);
|
|
||||||
|
|
||||||
List<ExcelErrorRow> errors = new ArrayList<>();
|
|
||||||
int successCount = 0;
|
|
||||||
int skipCount = 0;
|
|
||||||
int totalCount = 0;
|
|
||||||
int maxErrors = template.getMaxErrorCount() != null ? template.getMaxErrorCount() : 100;
|
|
||||||
int batchSize = template.getBatchSize() != null ? template.getBatchSize() : 1000;
|
|
||||||
int dataStartRow = template.getDataStartRow() != null ? template.getDataStartRow() : 2;
|
|
||||||
int sheetIndex = template.getSheetIndex() != null ? template.getSheetIndex() : 0;
|
|
||||||
|
|
||||||
List<Map<String, Object>> batch = new ArrayList<>(batchSize);
|
|
||||||
|
|
||||||
// 4. 파일 파싱 — 트랜잭션 밖에서 실행
|
|
||||||
try (InputStream is = file.getInputStream();
|
|
||||||
Workbook wb = StreamingReader.builder()
|
|
||||||
.rowCacheSize(100)
|
|
||||||
.bufferSize(4096)
|
|
||||||
.open(is)) {
|
|
||||||
|
|
||||||
Sheet sheet = wb.getSheetAt(sheetIndex);
|
|
||||||
int rowNum = 0;
|
|
||||||
|
|
||||||
for (Row row : sheet) {
|
|
||||||
rowNum++;
|
|
||||||
if (rowNum < dataStartRow) continue;
|
|
||||||
|
|
||||||
if ("Y".equalsIgnoreCase(template.getSkipEmptyRow()) && isEmptyRow(row)) {
|
|
||||||
skipCount++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
totalCount++;
|
|
||||||
|
|
||||||
if (errors.size() >= maxErrors) {
|
|
||||||
skipCount++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
Map<String, Object> mapped = transformEngine.transform(row, columns, lookupData);
|
|
||||||
validateRow(mapped, columns, rowNum, errors);
|
|
||||||
|
|
||||||
final int currentRow = rowNum;
|
|
||||||
boolean rowHasError = errors.stream().anyMatch(e -> e.getRowNumber() == currentRow);
|
|
||||||
if (!rowHasError) {
|
|
||||||
batch.add(mapped);
|
|
||||||
if (batch.size() >= batchSize) {
|
|
||||||
// 청크 단위 독립 트랜잭션으로 커밋
|
|
||||||
successCount += txHelper.insertBatch(template.getTargetTable(), new ArrayList<>(batch));
|
|
||||||
batch.clear();
|
|
||||||
log.debug("Batch flush: {} rows processed so far", totalCount);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (BizException e) {
|
|
||||||
errors.add(new ExcelErrorRow(rowNum, null, null, e.getDisplayMessage()));
|
|
||||||
} catch (Exception e) {
|
|
||||||
errors.add(new ExcelErrorRow(rowNum, null, null, e.getMessage()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!batch.isEmpty()) {
|
|
||||||
successCount += txHelper.insertBatch(template.getTargetTable(), new ArrayList<>(batch));
|
|
||||||
batch.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (BizException e) {
|
|
||||||
txHelper.updateHistory(history.getHistoryId(), "FAIL", e.getDisplayMessage(),
|
|
||||||
0, 0, 0, 0, null, 0);
|
|
||||||
throw e;
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Upload processing error: templateCode={}", templateCode, e);
|
|
||||||
txHelper.updateHistory(history.getHistoryId(), "FAIL", e.getMessage(),
|
|
||||||
0, 0, 0, 0, null, 0);
|
|
||||||
throw new BizException(ErrorCode.EXCEL_PARSE_FAIL, e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
int durationSec = (int) ((System.currentTimeMillis() - startMs) / 1000);
|
|
||||||
int errorCount = errors.size();
|
|
||||||
String status = errorCount == 0 ? "SUCCESS" : (successCount > 0 ? "PARTIAL" : "FAIL");
|
|
||||||
|
|
||||||
Long errorFileId = null;
|
|
||||||
if (!errors.isEmpty()) {
|
|
||||||
errorFileId = createErrorFile(errors, file.getOriginalFilename());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. history 완료 갱신 — 독립 트랜잭션
|
|
||||||
txHelper.updateHistory(history.getHistoryId(), status, null,
|
|
||||||
successCount, errorCount, skipCount, totalCount, errorFileId, durationSec);
|
|
||||||
|
|
||||||
return UploadResultResp.builder()
|
|
||||||
.historyId(history.getHistoryId())
|
|
||||||
.totalCount(totalCount)
|
|
||||||
.successCount(successCount)
|
|
||||||
.errorCount(errorCount)
|
|
||||||
.skipCount(skipCount)
|
|
||||||
.errorFileId(errorFileId)
|
|
||||||
.durationSec(durationSec)
|
|
||||||
.errors(errors.stream().map(UploadResultResp.UploadErrorRow::from).toList())
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 미리보기: DB INSERT 없이 변환 결과만 반환
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
public List<Map<String, Object>> preview(String templateCode, MultipartFile file, int limit) {
|
|
||||||
UploadTemplateVO template = loadTemplate(templateCode);
|
|
||||||
List<UploadTemplateColumnVO> columns = columnMapper.selectByTemplateId(template.getTemplateId());
|
|
||||||
|
|
||||||
Map<String, Map<String, Object>> lookupData = lookupCache.preload(columns);
|
|
||||||
|
|
||||||
List<Map<String, Object>> result = new ArrayList<>();
|
|
||||||
int dataStartRow = template.getDataStartRow() != null ? template.getDataStartRow() : 2;
|
|
||||||
int sheetIndex = template.getSheetIndex() != null ? template.getSheetIndex() : 0;
|
|
||||||
|
|
||||||
try (InputStream is = file.getInputStream();
|
|
||||||
Workbook wb = StreamingReader.builder()
|
|
||||||
.rowCacheSize(100)
|
|
||||||
.bufferSize(4096)
|
|
||||||
.open(is)) {
|
|
||||||
|
|
||||||
Sheet sheet = wb.getSheetAt(sheetIndex);
|
|
||||||
int rowNum = 0;
|
|
||||||
|
|
||||||
for (Row row : sheet) {
|
|
||||||
rowNum++;
|
|
||||||
if (rowNum < dataStartRow) continue;
|
|
||||||
if ("Y".equalsIgnoreCase(template.getSkipEmptyRow()) && isEmptyRow(row)) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
result.add(transformEngine.transform(row, columns, lookupData));
|
|
||||||
} catch (Exception e) {
|
|
||||||
result.add(Map.of("_error", "행 " + rowNum + ": " + e.getMessage()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.size() >= limit) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("Preview error: templateCode={}", templateCode, e);
|
|
||||||
throw new BizException(ErrorCode.EXCEL_PARSE_FAIL, e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 샘플 엑셀에서 헤더(첫 행) + 미리보기 행 추출. 템플릿 등록 전에 사용.
|
|
||||||
* @param file 엑셀 파일
|
|
||||||
* @param headerRow 헤더가 있는 행 번호 (1-base, 보통 1)
|
|
||||||
* @param sheetIndex 시트 인덱스 (0-base)
|
|
||||||
* @param previewRows 헤더 다음 N행을 함께 반환
|
|
||||||
*/
|
|
||||||
public ExcelHeaderResp extractHeaders(MultipartFile file, int headerRow, int sheetIndex, int previewRows) {
|
|
||||||
List<String> headers = new ArrayList<>();
|
|
||||||
List<List<String>> samples = new ArrayList<>();
|
|
||||||
try (InputStream is = file.getInputStream();
|
|
||||||
Workbook wb = StreamingReader.builder()
|
|
||||||
.rowCacheSize(50).bufferSize(4096).open(is)) {
|
|
||||||
|
|
||||||
Sheet sheet = wb.getSheetAt(sheetIndex);
|
|
||||||
int rowNum = 0;
|
|
||||||
for (Row row : sheet) {
|
|
||||||
rowNum++;
|
|
||||||
if (rowNum == headerRow) {
|
|
||||||
short last = row.getLastCellNum();
|
|
||||||
for (int i = 0; i < last; i++) {
|
|
||||||
var cell = row.getCell(i);
|
|
||||||
headers.add(cell != null ? safeStringValue(cell) : "");
|
|
||||||
}
|
|
||||||
} else if (rowNum > headerRow && samples.size() < previewRows) {
|
|
||||||
List<String> rowVals = new ArrayList<>();
|
|
||||||
for (int i = 0; i < headers.size(); i++) {
|
|
||||||
var cell = row.getCell(i);
|
|
||||||
rowVals.add(cell != null ? safeStringValue(cell) : "");
|
|
||||||
}
|
|
||||||
samples.add(rowVals);
|
|
||||||
}
|
|
||||||
if (rowNum > headerRow + previewRows) break;
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.error("extractHeaders error", e);
|
|
||||||
throw new BizException(ErrorCode.EXCEL_PARSE_FAIL, e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 엑셀 컬럼 라벨 (A, B, C, ... AA, AB ...)
|
|
||||||
List<String> columnLabels = new ArrayList<>();
|
|
||||||
for (int i = 0; i < headers.size(); i++) columnLabels.add(toColumnLabel(i));
|
|
||||||
|
|
||||||
return new ExcelHeaderResp(columnLabels, headers, samples);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String safeStringValue(org.apache.poi.ss.usermodel.Cell cell) {
|
|
||||||
try {
|
|
||||||
CellType type = cell.getCellType();
|
|
||||||
return switch (type) {
|
|
||||||
case NUMERIC -> cell.getNumericCellValue() == Math.floor(cell.getNumericCellValue())
|
|
||||||
? String.valueOf((long) cell.getNumericCellValue())
|
|
||||||
: String.valueOf(cell.getNumericCellValue());
|
|
||||||
case BOOLEAN -> String.valueOf(cell.getBooleanCellValue());
|
|
||||||
case FORMULA -> cell.getCellFormula();
|
|
||||||
case BLANK -> "";
|
|
||||||
default -> cell.getStringCellValue();
|
|
||||||
};
|
|
||||||
} catch (Exception e) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 0 → A, 25 → Z, 26 → AA */
|
|
||||||
private String toColumnLabel(int index) {
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
int n = index;
|
|
||||||
while (n >= 0) {
|
|
||||||
sb.insert(0, (char) ('A' + (n % 26)));
|
|
||||||
n = n / 26 - 1;
|
|
||||||
}
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
public record ExcelHeaderResp(
|
|
||||||
List<String> columns, // ["A","B","C",...]
|
|
||||||
List<String> headers, // ["증권번호","설계사명",...]
|
|
||||||
List<List<String>> samples // 첫 N행
|
|
||||||
) {}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// 내부 헬퍼
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
private UploadTemplateVO loadTemplate(String templateCode) {
|
|
||||||
UploadTemplateVO template = templateMapper.selectByCode(templateCode);
|
|
||||||
if (template == null) {
|
|
||||||
throw new BizException(ErrorCode.NOT_FOUND, "업로드 템플릿을 찾을 수 없습니다: " + templateCode);
|
|
||||||
}
|
|
||||||
if (!"Y".equalsIgnoreCase(template.getIsActive())) {
|
|
||||||
throw new BizException(ErrorCode.INVALID_STATUS, "비활성 템플릿입니다: " + templateCode);
|
|
||||||
}
|
|
||||||
return template;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void validateTargetTable(String targetTable) {
|
|
||||||
if (targetTable == null || !ALLOWED_TARGET_TABLES.contains(targetTable)) {
|
|
||||||
throw new BizException(ErrorCode.INVALID_PARAMETER,
|
|
||||||
"허용되지 않는 대상 테이블: " + targetTable);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private UploadHistoryVO initHistory(UploadTemplateVO template, MultipartFile file) {
|
|
||||||
UploadHistoryVO h = new UploadHistoryVO();
|
|
||||||
h.setTemplateId(template.getTemplateId());
|
|
||||||
h.setTemplateCode(template.getTemplateCode());
|
|
||||||
h.setFileName(file.getOriginalFilename());
|
|
||||||
h.setFileSize(file.getSize());
|
|
||||||
h.setStatus("PROCESSING");
|
|
||||||
h.setStartedAt(LocalDateTime.now());
|
|
||||||
h.setCreatedBy(SecurityUtil.getCurrentUserId());
|
|
||||||
return h;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void validateRow(Map<String, Object> row, List<UploadTemplateColumnVO> columns,
|
|
||||||
int rowNum, List<ExcelErrorRow> errors) {
|
|
||||||
for (UploadTemplateColumnVO col : columns) {
|
|
||||||
Object value = row.get(col.getTargetField());
|
|
||||||
String strVal = value == null ? null : value.toString();
|
|
||||||
|
|
||||||
// 필수값 검증
|
|
||||||
if ("Y".equalsIgnoreCase(col.getIsRequired()) && (strVal == null || strVal.isBlank())) {
|
|
||||||
String msg = col.getValidationMessage() != null
|
|
||||||
? col.getValidationMessage()
|
|
||||||
: "[" + col.getTargetField() + "] 은(는) 필수입니다";
|
|
||||||
errors.add(new ExcelErrorRow(rowNum, col.getTargetField(), strVal, msg));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 정규식 검증
|
|
||||||
if (strVal != null && col.getValidationRegex() != null && !col.getValidationRegex().isBlank()) {
|
|
||||||
if (!Pattern.matches(col.getValidationRegex(), strVal)) {
|
|
||||||
String msg = col.getValidationMessage() != null
|
|
||||||
? col.getValidationMessage()
|
|
||||||
: "[" + col.getTargetField() + "] 형식이 올바르지 않습니다: " + strVal;
|
|
||||||
errors.add(new ExcelErrorRow(rowNum, col.getTargetField(), strVal, msg));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isEmptyRow(Row row) {
|
|
||||||
if (row == null) return true;
|
|
||||||
for (org.apache.poi.ss.usermodel.Cell cell : row) {
|
|
||||||
if (cell != null && cell.getCellType() != CellType.BLANK) {
|
|
||||||
String v = "";
|
|
||||||
try { v = cell.getStringCellValue(); } catch (Exception ignored) {}
|
|
||||||
if (!v.isBlank()) return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 에러 행 목록을 엑셀로 만들어 file_storage 에 적재하고 fileId 반환.
|
|
||||||
* 실패 시 업로드를 중단하지 않고 null 반환.
|
|
||||||
*/
|
|
||||||
private Long createErrorFile(List<ExcelErrorRow> errors, String originalFilename) {
|
|
||||||
try {
|
|
||||||
byte[] bytes = buildErrorExcelBytes(errors);
|
|
||||||
String name = "error_" + (originalFilename != null ? originalFilename : "upload.xlsx");
|
|
||||||
BytesMultipartFile mockFile = new BytesMultipartFile(name, bytes);
|
|
||||||
com.ga.common.file.FileVO saved = fileService.upload(mockFile, "UPLOAD_ERROR");
|
|
||||||
return saved.getFileId();
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("에러 파일 생성 실패 (업로드는 계속 진행)", e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private byte[] buildErrorExcelBytes(List<ExcelErrorRow> errors) throws Exception {
|
|
||||||
try (org.apache.poi.xssf.streaming.SXSSFWorkbook wb =
|
|
||||||
new org.apache.poi.xssf.streaming.SXSSFWorkbook(100)) {
|
|
||||||
|
|
||||||
org.apache.poi.ss.usermodel.Sheet sheet = wb.createSheet("errors");
|
|
||||||
|
|
||||||
org.apache.poi.ss.usermodel.Row header = sheet.createRow(0);
|
|
||||||
header.createCell(0).setCellValue("행번호");
|
|
||||||
header.createCell(1).setCellValue("필드");
|
|
||||||
header.createCell(2).setCellValue("값");
|
|
||||||
header.createCell(3).setCellValue("오류내용");
|
|
||||||
|
|
||||||
int rowIdx = 1;
|
|
||||||
for (ExcelErrorRow e : errors) {
|
|
||||||
org.apache.poi.ss.usermodel.Row row = sheet.createRow(rowIdx++);
|
|
||||||
row.createCell(0).setCellValue(e.getRowNumber());
|
|
||||||
row.createCell(1).setCellValue(e.getFieldName() != null ? e.getFieldName() : "");
|
|
||||||
row.createCell(2).setCellValue(e.getValue() != null ? e.getValue() : "");
|
|
||||||
row.createCell(3).setCellValue(e.getErrorMessage() != null ? e.getErrorMessage() : "");
|
|
||||||
}
|
|
||||||
|
|
||||||
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
|
|
||||||
wb.write(baos);
|
|
||||||
wb.dispose();
|
|
||||||
return baos.toByteArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
|
||||||
// byte[] → MultipartFile 래퍼 (MockMultipartFile 의존성 제거)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
private static class BytesMultipartFile implements MultipartFile {
|
|
||||||
private final String filename;
|
|
||||||
private final byte[] content;
|
|
||||||
|
|
||||||
BytesMultipartFile(String filename, byte[] content) {
|
|
||||||
this.filename = filename;
|
|
||||||
this.content = content;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override @NonNull public String getName() { return "file"; }
|
|
||||||
@Override public String getOriginalFilename() { return filename; }
|
|
||||||
@Override public String getContentType() {
|
|
||||||
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
|
||||||
}
|
|
||||||
@Override public boolean isEmpty() { return content == null || content.length == 0; }
|
|
||||||
@Override public long getSize() { return content == null ? 0 : content.length; }
|
|
||||||
@Override @NonNull public byte[] getBytes() { return content == null ? new byte[0] : content; }
|
|
||||||
@Override @NonNull public java.io.InputStream getInputStream() {
|
|
||||||
return new java.io.ByteArrayInputStream(content == null ? new byte[0] : content);
|
|
||||||
}
|
|
||||||
@Override public void transferTo(@NonNull java.io.File dest) throws java.io.IOException {
|
|
||||||
try (java.io.FileOutputStream fos = new java.io.FileOutputStream(dest)) {
|
|
||||||
fos.write(content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
package com.ga.api.service.upload;
|
|
||||||
|
|
||||||
import com.ga.common.exception.BizException;
|
|
||||||
import com.ga.common.exception.ErrorCode;
|
|
||||||
import com.ga.common.model.PageResponse;
|
|
||||||
import com.ga.common.util.SecurityUtil;
|
|
||||||
import com.ga.core.mapper.upload.UploadColumnMapper;
|
|
||||||
import com.ga.core.mapper.upload.UploadHistoryMapper;
|
|
||||||
import com.ga.core.mapper.upload.UploadTemplateMapper;
|
|
||||||
import com.ga.core.vo.upload.*;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class UploadTemplateService {
|
|
||||||
|
|
||||||
private final UploadTemplateMapper templateMapper;
|
|
||||||
private final UploadColumnMapper columnMapper;
|
|
||||||
private final UploadHistoryMapper historyMapper;
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public PageResponse<UploadTemplateResp> list(UploadTemplateSearchParam param) {
|
|
||||||
param.startPage();
|
|
||||||
return PageResponse.of(templateMapper.selectList(param));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public UploadTemplateResp detail(Long templateId) {
|
|
||||||
UploadTemplateResp resp = templateMapper.selectById(templateId);
|
|
||||||
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
resp.setColumns(columnMapper.selectByTemplateId(templateId));
|
|
||||||
return resp;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public Long create(UploadTemplateSaveReq req) {
|
|
||||||
if (templateMapper.existsByCode(req.getTemplateCode()) > 0) {
|
|
||||||
throw new BizException(ErrorCode.DUPLICATE_DATA, "이미 존재하는 템플릿 코드입니다: " + req.getTemplateCode());
|
|
||||||
}
|
|
||||||
UploadTemplateVO vo = req.toVO();
|
|
||||||
vo.setCreatedBy(SecurityUtil.getCurrentUserId());
|
|
||||||
templateMapper.insert(vo);
|
|
||||||
if (req.getColumns() != null && !req.getColumns().isEmpty()) {
|
|
||||||
columnMapper.insertBatch(vo.getTemplateId(), req.getColumns());
|
|
||||||
}
|
|
||||||
return vo.getTemplateId();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void update(Long templateId, UploadTemplateSaveReq req) {
|
|
||||||
if (templateMapper.selectById(templateId) == null) {
|
|
||||||
throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
}
|
|
||||||
UploadTemplateVO vo = req.toVO();
|
|
||||||
vo.setTemplateId(templateId);
|
|
||||||
vo.setUpdatedBy(SecurityUtil.getCurrentUserId());
|
|
||||||
templateMapper.update(vo);
|
|
||||||
if (req.getColumns() != null) {
|
|
||||||
columnMapper.deleteByTemplateId(templateId);
|
|
||||||
if (!req.getColumns().isEmpty()) {
|
|
||||||
columnMapper.insertBatch(templateId, req.getColumns());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void delete(Long templateId) {
|
|
||||||
if (templateMapper.selectById(templateId) == null) {
|
|
||||||
throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
}
|
|
||||||
columnMapper.deleteByTemplateId(templateId);
|
|
||||||
templateMapper.delete(templateId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public List<UploadTemplateColumnVO> columns(Long templateId) {
|
|
||||||
return columnMapper.selectByTemplateId(templateId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional
|
|
||||||
public void updateColumns(Long templateId, List<UploadTemplateColumnVO> columns) {
|
|
||||||
if (templateMapper.selectById(templateId) == null) {
|
|
||||||
throw new BizException(ErrorCode.NOT_FOUND);
|
|
||||||
}
|
|
||||||
columnMapper.deleteByTemplateId(templateId);
|
|
||||||
if (columns != null && !columns.isEmpty()) {
|
|
||||||
columnMapper.insertBatch(templateId, columns);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
|
||||||
public PageResponse<UploadHistoryResp> history(Long templateId, UploadTemplateSearchParam param) {
|
|
||||||
param.startPage();
|
|
||||||
return PageResponse.of(historyMapper.selectByTemplate(templateId, param));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package com.ga.api.service.upload;
|
|
||||||
|
|
||||||
import com.ga.core.mapper.upload.UploadHistoryMapper;
|
|
||||||
import com.ga.core.vo.upload.UploadHistoryVO;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
import org.springframework.transaction.annotation.Propagation;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UploadService의 트랜잭션 경계를 분리한 헬퍼.
|
|
||||||
*
|
|
||||||
* UploadService 내에서 self-invocation으로는 프록시가 적용되지 않으므로
|
|
||||||
* 별도 Bean으로 분리하여 REQUIRES_NEW 트랜잭션을 보장한다.
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Component
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class UploadTxHelper {
|
|
||||||
|
|
||||||
private final UploadHistoryMapper historyMapper;
|
|
||||||
private final DynamicInsertMapper dynamicInsertMapper;
|
|
||||||
|
|
||||||
/** history INSERT를 독립 트랜잭션으로 커밋 — 본 작업 실패 시에도 기록이 남는다. */
|
|
||||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
|
||||||
public void saveHistory(UploadHistoryVO history) {
|
|
||||||
historyMapper.insert(history);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 배치 INSERT를 독립 트랜잭션으로 커밋 — 청크 단위 커밋으로 커넥션 점유 시간 단축. */
|
|
||||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
|
||||||
public int insertBatch(String targetTable, List<Map<String, Object>> batch) {
|
|
||||||
dynamicInsertMapper.insertBatch(targetTable, batch);
|
|
||||||
return batch.size();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** history 상태 갱신을 독립 트랜잭션으로 커밋. */
|
|
||||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
|
||||||
public void updateHistory(Long historyId, String status, String errorMessage,
|
|
||||||
int successCount, int errorCount, int skipCount,
|
|
||||||
int totalCount, Long errorFileId, int durationSec) {
|
|
||||||
historyMapper.updateStatus(historyId, status, errorMessage,
|
|
||||||
successCount, errorCount, skipCount, totalCount, errorFileId, durationSec);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package com.ga.api.transfer;
|
|
||||||
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.function.Function;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 은행코드 → BankTransferFileGenerator 조회 팩토리.
|
|
||||||
* 등록되지 않은 은행코드는 GENERIC_CSV 기본 생성기로 폴백.
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
public class BankTransferFactory {
|
|
||||||
|
|
||||||
private final Map<String, BankTransferFileGenerator> generators;
|
|
||||||
|
|
||||||
public BankTransferFactory(List<BankTransferFileGenerator> list) {
|
|
||||||
this.generators = list.stream()
|
|
||||||
.collect(Collectors.toMap(BankTransferFileGenerator::getBankCode, Function.identity()));
|
|
||||||
}
|
|
||||||
|
|
||||||
public BankTransferFileGenerator get(String bankCode) {
|
|
||||||
return generators.getOrDefault(bankCode, generators.get("GENERIC_CSV"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package com.ga.api.transfer;
|
|
||||||
|
|
||||||
import com.ga.core.vo.settle.PaymentVO;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 은행별 이체파일 생성 인터페이스.
|
|
||||||
* 새 은행 포맷 추가 시 이 인터페이스 구현체만 추가하면 됨.
|
|
||||||
*/
|
|
||||||
public interface BankTransferFileGenerator {
|
|
||||||
|
|
||||||
/** 은행 코드 ("088", "020" 등) 또는 기본 "GENERIC_CSV" */
|
|
||||||
String getBankCode();
|
|
||||||
|
|
||||||
/** 파일 확장자 ".csv", ".dat" 등 */
|
|
||||||
String getFileExtension();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이체파일 바이트 생성.
|
|
||||||
* accountNo 는 PaymentVO 에서 이미 복호화된 상태로 전달됨.
|
|
||||||
*/
|
|
||||||
byte[] generate(List<PaymentVO> payments, String settleMonth);
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
package com.ga.api.transfer;
|
|
||||||
|
|
||||||
import com.ga.core.vo.settle.PaymentVO;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 기본 CSV 이체파일 생성기. 은행별 전용 포맷이 없을 때 사용.
|
|
||||||
* UTF-8 BOM + 헤더 + 데이터 행.
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
public class GenericCsvTransferGenerator implements BankTransferFileGenerator {
|
|
||||||
|
|
||||||
private static final byte[] UTF8_BOM = new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF};
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String getBankCode() {
|
|
||||||
return "GENERIC_CSV";
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String getFileExtension() {
|
|
||||||
return ".csv";
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public byte[] generate(List<PaymentVO> payments, String settleMonth) {
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
sb.append("정산월,은행코드,계좌번호,수취인,금액,적요\r\n");
|
|
||||||
|
|
||||||
for (PaymentVO p : payments) {
|
|
||||||
sb.append(csv(settleMonth)).append(",");
|
|
||||||
sb.append(csv(p.getBankCode())).append(",");
|
|
||||||
sb.append(csv(p.getAccountNo())).append(","); // 이미 복호화된 값
|
|
||||||
sb.append(csv(agentLabel(p))).append(",");
|
|
||||||
sb.append(p.getPayAmount() != null ? p.getPayAmount().toPlainString() : "0").append(",");
|
|
||||||
sb.append(csv("수수료 정산 " + formatMonth(settleMonth))).append("\r\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
byte[] body = sb.toString().getBytes(StandardCharsets.UTF_8);
|
|
||||||
byte[] result = new byte[UTF8_BOM.length + body.length];
|
|
||||||
System.arraycopy(UTF8_BOM, 0, result, 0, UTF8_BOM.length);
|
|
||||||
System.arraycopy(body, 0, result, UTF8_BOM.length, body.length);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String csv(String val) {
|
|
||||||
if (val == null) return "";
|
|
||||||
if (val.contains(",") || val.contains("\"") || val.contains("\n")) {
|
|
||||||
return "\"" + val.replace("\"", "\"\"") + "\"";
|
|
||||||
}
|
|
||||||
return val;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String agentLabel(PaymentVO p) {
|
|
||||||
return p.getAgentId() != null ? "설계사#" + p.getAgentId() : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "202501" → "2025-01" */
|
|
||||||
private String formatMonth(String settleMonth) {
|
|
||||||
if (settleMonth != null && settleMonth.length() == 6) {
|
|
||||||
return settleMonth.substring(0, 4) + "-" + settleMonth.substring(4, 6);
|
|
||||||
}
|
|
||||||
return settleMonth;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
package com.ga.api.transfer;
|
|
||||||
|
|
||||||
import com.ga.core.vo.settle.PaymentVO;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* KB국민은행(088) 고정폭 텍스트 이체파일 생성기.
|
|
||||||
* 레코드 구조 (총 80바이트):
|
|
||||||
* 은행코드(3) + 계좌번호(20) + 수취인명(20) + 금액(15, 우측정렬) + 적요(20) + CRLF(2)
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
public class KbBankTransferGenerator implements BankTransferFileGenerator {
|
|
||||||
|
|
||||||
private static final String BANK_CODE = "088";
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String getBankCode() {
|
|
||||||
return BANK_CODE;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public String getFileExtension() {
|
|
||||||
return ".dat";
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public byte[] generate(List<PaymentVO> payments, String settleMonth) {
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
|
|
||||||
BigDecimal totalAmount = BigDecimal.ZERO;
|
|
||||||
|
|
||||||
for (PaymentVO p : payments) {
|
|
||||||
BigDecimal amt = p.getPayAmount() != null ? p.getPayAmount() : BigDecimal.ZERO;
|
|
||||||
totalAmount = totalAmount.add(amt);
|
|
||||||
|
|
||||||
sb.append(rpad(BANK_CODE, 3));
|
|
||||||
sb.append(rpad(nvl(p.getAccountNo()), 20)); // 복호화된 계좌번호
|
|
||||||
sb.append(rpad("설계사#" + p.getAgentId(), 20));
|
|
||||||
sb.append(lpad(amt.longValue(), 15));
|
|
||||||
sb.append(rpad("수수료 정산 " + formatMonth(settleMonth), 20));
|
|
||||||
sb.append("\r\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 합계 레코드
|
|
||||||
sb.append(rpad("999", 3));
|
|
||||||
sb.append(rpad("", 20));
|
|
||||||
sb.append(rpad("합계", 20));
|
|
||||||
sb.append(lpad(totalAmount.longValue(), 15));
|
|
||||||
sb.append(rpad("TOTAL:" + payments.size() + "건", 20));
|
|
||||||
sb.append("\r\n");
|
|
||||||
|
|
||||||
return sb.toString().getBytes(StandardCharsets.UTF_8);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String rpad(String val, int width) {
|
|
||||||
if (val == null) val = "";
|
|
||||||
if (val.length() >= width) return val.substring(0, width);
|
|
||||||
return val + " ".repeat(width - val.length());
|
|
||||||
}
|
|
||||||
|
|
||||||
private String lpad(long val, int width) {
|
|
||||||
String s = String.valueOf(val);
|
|
||||||
if (s.length() >= width) return s.substring(s.length() - width);
|
|
||||||
return " ".repeat(width - s.length()) + s;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String nvl(String val) {
|
|
||||||
return val != null ? val : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
private String formatMonth(String settleMonth) {
|
|
||||||
if (settleMonth != null && settleMonth.length() == 6) {
|
|
||||||
return settleMonth.substring(0, 4) + "-" + settleMonth.substring(4, 6);
|
|
||||||
}
|
|
||||||
return settleMonth;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -25,7 +25,3 @@ spring:
|
|||||||
redis:
|
redis:
|
||||||
host: ${REDIS_HOST:localhost}
|
host: ${REDIS_HOST:localhost}
|
||||||
port: ${REDIS_PORT:6379}
|
port: ${REDIS_PORT:6379}
|
||||||
|
|
||||||
# 로컬 검증 시 Redis 미설치면 REDIS_DISABLED=true 로 띄움 → 인메모리 캐시 폴백
|
|
||||||
autoconfigure:
|
|
||||||
exclude: ${REDIS_AUTOCONFIG_EXCLUDE:}
|
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
<?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.api.service.upload.DynamicInsertMapper">
|
|
||||||
|
|
||||||
<!--
|
|
||||||
동적 단건 INSERT.
|
|
||||||
targetTable 은 UploadService 의 whitelist 검증 후 전달한다.
|
|
||||||
row Map 의 key/value 를 동적으로 조립한다.
|
|
||||||
-->
|
|
||||||
<insert id="insertRow">
|
|
||||||
INSERT INTO ${targetTable} (
|
|
||||||
<foreach collection="row" index="col" item="val" separator=",">
|
|
||||||
${col}
|
|
||||||
</foreach>
|
|
||||||
) VALUES (
|
|
||||||
<foreach collection="row" index="col" item="val" separator=",">
|
|
||||||
#{val}
|
|
||||||
</foreach>
|
|
||||||
)
|
|
||||||
</insert>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
동적 배치 INSERT.
|
|
||||||
rows 첫 번째 맵의 key 순서로 컬럼명을 결정한다.
|
|
||||||
-->
|
|
||||||
<insert id="insertBatch">
|
|
||||||
INSERT INTO ${targetTable} (
|
|
||||||
<foreach collection="rows[0]" index="col" item="val" separator=",">
|
|
||||||
${col}
|
|
||||||
</foreach>
|
|
||||||
) VALUES
|
|
||||||
<foreach collection="rows" item="row" separator=",">
|
|
||||||
(
|
|
||||||
<foreach collection="row" index="col" item="val" separator=",">
|
|
||||||
#{val}
|
|
||||||
</foreach>
|
|
||||||
)
|
|
||||||
</foreach>
|
|
||||||
</insert>
|
|
||||||
|
|
||||||
</mapper>
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<?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.api.service.upload.LookupMapper">
|
|
||||||
|
|
||||||
<!--
|
|
||||||
동적 테이블/컬럼 조회.
|
|
||||||
${} 사용 — LookupCache.preload() 에서 whitelist 검증 후 호출한다.
|
|
||||||
-->
|
|
||||||
<select id="selectAll" resultType="java.util.HashMap">
|
|
||||||
SELECT ${sourceField} AS source_value,
|
|
||||||
${targetField} AS target_value
|
|
||||||
FROM ${table}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
</mapper>
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
package com.ga.api.controller.ledger;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.ga.api.service.ledger.LedgerService;
|
|
||||||
import com.ga.api.support.AbstractControllerTest;
|
|
||||||
import com.ga.api.support.MockLoginUser;
|
|
||||||
import com.ga.common.excel.ExcelService;
|
|
||||||
import com.ga.common.menu.PermissionChecker;
|
|
||||||
import com.ga.common.model.PageResponse;
|
|
||||||
import com.ga.core.vo.ledger.ExceptionLedgerResp;
|
|
||||||
import com.ga.core.vo.ledger.ExceptionLedgerSaveReq;
|
|
||||||
import com.ga.core.vo.ledger.LedgerResp;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.DisplayName;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.*;
|
|
||||||
import static org.mockito.BDDMockito.given;
|
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
|
||||||
|
|
||||||
class LedgerControllerTest extends AbstractControllerTest {
|
|
||||||
|
|
||||||
@Autowired ObjectMapper objectMapper;
|
|
||||||
|
|
||||||
@MockBean LedgerService service;
|
|
||||||
@MockBean ExcelService excelService;
|
|
||||||
@MockBean PermissionChecker permissionChecker;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void permitAll() {
|
|
||||||
given(permissionChecker.hasPermission(anyLong(), anyString(), anyString())).willReturn(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 픽스처 ----
|
|
||||||
|
|
||||||
private PageResponse<LedgerResp> singlePage() {
|
|
||||||
LedgerResp r = new LedgerResp();
|
|
||||||
r.setLedgerId(100L);
|
|
||||||
r.setAgentName("홍길동");
|
|
||||||
r.setPolicyNo("POL-001");
|
|
||||||
return PageResponse.<LedgerResp>builder()
|
|
||||||
.list(List.of(r))
|
|
||||||
.pageNum(1).pageSize(10).total(1).pages(1)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private ExceptionLedgerSaveReq validExceptionReq() {
|
|
||||||
ExceptionLedgerSaveReq req = new ExceptionLedgerSaveReq();
|
|
||||||
req.setAgentId(1L);
|
|
||||||
req.setExceptionCode("EXTRA_PAY");
|
|
||||||
req.setSettleMonth("202401");
|
|
||||||
req.setAmount(new BigDecimal("50000"));
|
|
||||||
return req;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 테스트 ----
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("GET /api/ledger/recruit - 200 모집 원장 목록")
|
|
||||||
void listRecruit_ok() throws Exception {
|
|
||||||
given(service.listRecruit(any())).willReturn(singlePage());
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/ledger/recruit").with(MockLoginUser.admin()))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.success").value(true))
|
|
||||||
.andExpect(jsonPath("$.data.list[0].policyNo").value("POL-001"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("GET /api/ledger/maintain - 200 유지 원장 목록")
|
|
||||||
void listMaintain_ok() throws Exception {
|
|
||||||
given(service.listMaintain(any())).willReturn(singlePage());
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/ledger/maintain").with(MockLoginUser.admin()))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.data.total").value(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("GET /api/ledger/exception - 200 예외 원장 목록")
|
|
||||||
void listException_ok() throws Exception {
|
|
||||||
ExceptionLedgerResp er = new ExceptionLedgerResp();
|
|
||||||
er.setLedgerId(200L);
|
|
||||||
er.setExceptionCode("EXTRA_PAY");
|
|
||||||
PageResponse<ExceptionLedgerResp> page = PageResponse.<ExceptionLedgerResp>builder()
|
|
||||||
.list(List.of(er))
|
|
||||||
.pageNum(1).pageSize(10).total(1).pages(1)
|
|
||||||
.build();
|
|
||||||
given(service.listException(any())).willReturn(page);
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/ledger/exception").with(MockLoginUser.admin()))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.data.list[0].exceptionCode").value("EXTRA_PAY"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("POST /api/ledger/exception - 200 예외 원장 등록")
|
|
||||||
void createException_ok() throws Exception {
|
|
||||||
given(service.createException(any())).willReturn(200L);
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/ledger/exception")
|
|
||||||
.with(MockLoginUser.admin())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(validExceptionReq())))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.data").value(200));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("POST /api/ledger/exception - 400 필수값 누락 (agentId null)")
|
|
||||||
void createException_validationFail() throws Exception {
|
|
||||||
ExceptionLedgerSaveReq req = new ExceptionLedgerSaveReq();
|
|
||||||
req.setExceptionCode("EXTRA_PAY");
|
|
||||||
req.setSettleMonth("202401");
|
|
||||||
req.setAmount(new BigDecimal("50000"));
|
|
||||||
// agentId 없음
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/ledger/exception")
|
|
||||||
.with(MockLoginUser.admin())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(req)))
|
|
||||||
.andExpect(status().isBadRequest());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("PUT /api/ledger/exception/{id}/approve - 200 승인 처리")
|
|
||||||
void approveException_ok() throws Exception {
|
|
||||||
mockMvc.perform(put("/api/ledger/exception/200/approve")
|
|
||||||
.with(MockLoginUser.admin())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(Map.of("status", "APPROVED"))))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.success").value(true));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("GET /api/ledger/recruit - 401 인증 없는 요청")
|
|
||||||
void listRecruit_unauthenticated() throws Exception {
|
|
||||||
mockMvc.perform(get("/api/ledger/recruit"))
|
|
||||||
.andExpect(status().isUnauthorized());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
package com.ga.api.controller.org;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.ga.api.service.org.AgentService;
|
|
||||||
import com.ga.api.support.AbstractControllerTest;
|
|
||||||
import com.ga.api.support.MockLoginUser;
|
|
||||||
import com.ga.common.excel.ExcelService;
|
|
||||||
import com.ga.common.menu.PermissionChecker;
|
|
||||||
import com.ga.common.model.PageResponse;
|
|
||||||
import com.ga.core.vo.org.AgentResp;
|
|
||||||
import com.ga.core.vo.org.AgentSaveReq;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.DisplayName;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.*;
|
|
||||||
import static org.mockito.BDDMockito.given;
|
|
||||||
import static org.mockito.BDDMockito.willDoNothing;
|
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
|
||||||
|
|
||||||
class AgentControllerTest extends AbstractControllerTest {
|
|
||||||
|
|
||||||
@Autowired ObjectMapper objectMapper;
|
|
||||||
|
|
||||||
@MockBean AgentService agentService;
|
|
||||||
@MockBean ExcelService excelService;
|
|
||||||
@MockBean PermissionChecker permissionChecker;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void permitAll() {
|
|
||||||
given(permissionChecker.hasPermission(anyLong(), anyString(), anyString())).willReturn(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 픽스처 ----
|
|
||||||
|
|
||||||
private AgentResp sampleAgent() {
|
|
||||||
AgentResp r = new AgentResp();
|
|
||||||
r.setAgentId(1L);
|
|
||||||
r.setAgentName("홍길동");
|
|
||||||
r.setStatus("ACTIVE");
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
private AgentSaveReq validReq() {
|
|
||||||
AgentSaveReq req = new AgentSaveReq();
|
|
||||||
req.setAgentName("홍길동");
|
|
||||||
req.setOrgId(1L);
|
|
||||||
req.setGradeId(1L);
|
|
||||||
return req;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 테스트 ----
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("GET /api/agents - 200 목록 반환")
|
|
||||||
void list_ok() throws Exception {
|
|
||||||
PageResponse<AgentResp> page = PageResponse.<AgentResp>builder()
|
|
||||||
.list(List.of(sampleAgent()))
|
|
||||||
.pageNum(1).pageSize(10).total(1).pages(1)
|
|
||||||
.build();
|
|
||||||
given(agentService.list(any())).willReturn(page);
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/agents").with(MockLoginUser.admin()))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.success").value(true))
|
|
||||||
.andExpect(jsonPath("$.data.total").value(1))
|
|
||||||
.andExpect(jsonPath("$.data.list[0].agentName").value("홍길동"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("GET /api/agents/{id} - 200 상세 반환")
|
|
||||||
void detail_ok() throws Exception {
|
|
||||||
given(agentService.detail(1L)).willReturn(sampleAgent());
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/agents/1").with(MockLoginUser.admin()))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.data.agentId").value(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("POST /api/agents - 200 등록 성공")
|
|
||||||
void create_ok() throws Exception {
|
|
||||||
given(agentService.create(any())).willReturn(1L);
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/agents")
|
|
||||||
.with(MockLoginUser.admin())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(validReq())))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.success").value(true))
|
|
||||||
.andExpect(jsonPath("$.data").value(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("POST /api/agents - 400 필수값 누락 (agentName null)")
|
|
||||||
void create_validationFail() throws Exception {
|
|
||||||
AgentSaveReq req = new AgentSaveReq();
|
|
||||||
req.setOrgId(1L);
|
|
||||||
req.setGradeId(1L);
|
|
||||||
// agentName 없음
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/agents")
|
|
||||||
.with(MockLoginUser.admin())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(req)))
|
|
||||||
.andExpect(status().isBadRequest());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("PUT /api/agents/{id} - 200 수정 성공")
|
|
||||||
void update_ok() throws Exception {
|
|
||||||
willDoNothing().given(agentService).update(eq(1L), any());
|
|
||||||
|
|
||||||
mockMvc.perform(put("/api/agents/1")
|
|
||||||
.with(MockLoginUser.admin())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(validReq())))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.success").value(true));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("GET /api/agents - 401 인증 없는 요청")
|
|
||||||
void list_unauthenticated() throws Exception {
|
|
||||||
mockMvc.perform(get("/api/agents"))
|
|
||||||
.andExpect(status().isUnauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("GET /api/agents - 403 권한 없는 요청")
|
|
||||||
void list_forbidden() throws Exception {
|
|
||||||
given(permissionChecker.hasPermission(anyLong(), eq("ORG_AGENT"), eq("READ"))).willReturn(false);
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/agents").with(MockLoginUser.admin()))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
package com.ga.api.controller.product;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.ga.api.service.product.ContractService;
|
|
||||||
import com.ga.api.support.AbstractControllerTest;
|
|
||||||
import com.ga.api.support.MockLoginUser;
|
|
||||||
import com.ga.common.menu.PermissionChecker;
|
|
||||||
import com.ga.common.model.PageResponse;
|
|
||||||
import com.ga.core.vo.product.ContractResp;
|
|
||||||
import com.ga.core.vo.product.ContractSaveReq;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.DisplayName;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.*;
|
|
||||||
import static org.mockito.BDDMockito.given;
|
|
||||||
import static org.mockito.BDDMockito.willDoNothing;
|
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
|
||||||
|
|
||||||
class ContractControllerTest extends AbstractControllerTest {
|
|
||||||
|
|
||||||
@Autowired ObjectMapper objectMapper;
|
|
||||||
|
|
||||||
@MockBean ContractService service;
|
|
||||||
@MockBean PermissionChecker permissionChecker;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void permitAll() {
|
|
||||||
given(permissionChecker.hasPermission(anyLong(), anyString(), anyString())).willReturn(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 픽스처 ----
|
|
||||||
|
|
||||||
private ContractResp sampleContract() {
|
|
||||||
ContractResp r = new ContractResp();
|
|
||||||
r.setContractId(10L);
|
|
||||||
r.setPolicyNo("POL-2024-001");
|
|
||||||
r.setContractorName("김계약");
|
|
||||||
r.setStatus("ACTIVE");
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
private ContractSaveReq validReq() {
|
|
||||||
ContractSaveReq req = new ContractSaveReq();
|
|
||||||
req.setAgentId(1L);
|
|
||||||
req.setProductId(2L);
|
|
||||||
req.setPolicyNo("POL-2024-002");
|
|
||||||
req.setPremium(new BigDecimal("100000"));
|
|
||||||
req.setContractDate(LocalDate.of(2024, 1, 1));
|
|
||||||
return req;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 테스트 ----
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("GET /api/contracts - 200 목록 반환")
|
|
||||||
void list_ok() throws Exception {
|
|
||||||
PageResponse<ContractResp> page = PageResponse.<ContractResp>builder()
|
|
||||||
.list(List.of(sampleContract()))
|
|
||||||
.pageNum(1).pageSize(10).total(1).pages(1)
|
|
||||||
.build();
|
|
||||||
given(service.list(any())).willReturn(page);
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/contracts").with(MockLoginUser.admin()))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.success").value(true))
|
|
||||||
.andExpect(jsonPath("$.data.total").value(1))
|
|
||||||
.andExpect(jsonPath("$.data.list[0].policyNo").value("POL-2024-001"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("GET /api/contracts/{id} - 200 상세 반환")
|
|
||||||
void detail_ok() throws Exception {
|
|
||||||
given(service.detail(10L)).willReturn(sampleContract());
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/contracts/10").with(MockLoginUser.admin()))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.data.contractId").value(10));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("POST /api/contracts - 200 등록 성공")
|
|
||||||
void create_ok() throws Exception {
|
|
||||||
given(service.create(any())).willReturn(10L);
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/contracts")
|
|
||||||
.with(MockLoginUser.admin())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(validReq())))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.data").value(10));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("POST /api/contracts - 400 policyNo 누락")
|
|
||||||
void create_missingPolicyNo() throws Exception {
|
|
||||||
ContractSaveReq req = new ContractSaveReq();
|
|
||||||
req.setAgentId(1L);
|
|
||||||
req.setProductId(2L);
|
|
||||||
req.setPremium(new BigDecimal("100000"));
|
|
||||||
req.setContractDate(LocalDate.of(2024, 1, 1));
|
|
||||||
// policyNo 없음
|
|
||||||
|
|
||||||
mockMvc.perform(post("/api/contracts")
|
|
||||||
.with(MockLoginUser.admin())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(req)))
|
|
||||||
.andExpect(status().isBadRequest());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("PUT /api/contracts/{id} - 200 수정 성공")
|
|
||||||
void update_ok() throws Exception {
|
|
||||||
willDoNothing().given(service).update(eq(10L), any());
|
|
||||||
|
|
||||||
mockMvc.perform(put("/api/contracts/10")
|
|
||||||
.with(MockLoginUser.admin())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(validReq())))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.success").value(true));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("GET /api/contracts - 401 인증 없는 요청")
|
|
||||||
void list_unauthenticated() throws Exception {
|
|
||||||
mockMvc.perform(get("/api/contracts"))
|
|
||||||
.andExpect(status().isUnauthorized());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("GET /api/contracts - 403 권한 없는 요청")
|
|
||||||
void list_forbidden() throws Exception {
|
|
||||||
given(permissionChecker.hasPermission(anyLong(), eq("CONTRACT_LIST"), eq("READ"))).willReturn(false);
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/contracts").with(MockLoginUser.admin()))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
package com.ga.api.controller.settle;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.ga.api.service.settle.SettleService;
|
|
||||||
import com.ga.api.support.AbstractControllerTest;
|
|
||||||
import com.ga.api.support.MockLoginUser;
|
|
||||||
import com.ga.common.menu.PermissionChecker;
|
|
||||||
import com.ga.common.model.PageResponse;
|
|
||||||
import com.ga.core.vo.settle.SettleMasterResp;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.DisplayName;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.*;
|
|
||||||
import static org.mockito.BDDMockito.given;
|
|
||||||
import static org.mockito.BDDMockito.willDoNothing;
|
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
|
||||||
|
|
||||||
class SettleControllerTest extends AbstractControllerTest {
|
|
||||||
|
|
||||||
@Autowired ObjectMapper objectMapper;
|
|
||||||
|
|
||||||
@MockBean SettleService service;
|
|
||||||
@MockBean PermissionChecker permissionChecker;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void permitAll() {
|
|
||||||
given(permissionChecker.hasPermission(anyLong(), anyString(), anyString())).willReturn(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 픽스처 ----
|
|
||||||
|
|
||||||
private SettleMasterResp sampleSettle() {
|
|
||||||
SettleMasterResp r = new SettleMasterResp();
|
|
||||||
r.setSettleId(50L);
|
|
||||||
r.setAgentId(1L);
|
|
||||||
r.setAgentName("홍길동");
|
|
||||||
r.setSettleMonth("202401");
|
|
||||||
r.setStatus("CALCULATED");
|
|
||||||
r.setNetAmount(new BigDecimal("300000"));
|
|
||||||
return r;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 테스트 ----
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("GET /api/settle - 200 정산 목록 반환")
|
|
||||||
void list_ok() throws Exception {
|
|
||||||
PageResponse<SettleMasterResp> page = PageResponse.<SettleMasterResp>builder()
|
|
||||||
.list(List.of(sampleSettle()))
|
|
||||||
.pageNum(1).pageSize(10).total(1).pages(1)
|
|
||||||
.build();
|
|
||||||
given(service.list(any())).willReturn(page);
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/settle").with(MockLoginUser.admin()))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.success").value(true))
|
|
||||||
.andExpect(jsonPath("$.data.list[0].settleMonth").value("202401"));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("GET /api/settle/summary - 200 월별 요약")
|
|
||||||
void summary_ok() throws Exception {
|
|
||||||
given(service.summary("202401")).willReturn(
|
|
||||||
Map.of("totalAmount", 1000000, "agentCount", 5));
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/settle/summary")
|
|
||||||
.param("settleMonth", "202401")
|
|
||||||
.with(MockLoginUser.admin()))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.data.agentCount").value(5));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("GET /api/settle/{agentId}/months/{settleMonth} - 200 상세")
|
|
||||||
void detail_ok() throws Exception {
|
|
||||||
given(service.detail(1L, "202401")).willReturn(sampleSettle());
|
|
||||||
|
|
||||||
mockMvc.perform(get("/api/settle/1/months/202401").with(MockLoginUser.admin()))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.data.settleId").value(50));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("PUT /api/settle/{id}/confirm - 200 확정 처리")
|
|
||||||
void confirm_ok() throws Exception {
|
|
||||||
willDoNothing().given(service).confirm(50L);
|
|
||||||
|
|
||||||
mockMvc.perform(put("/api/settle/50/confirm").with(MockLoginUser.admin()))
|
|
||||||
.andExpect(status().isOk())
|
|
||||||
.andExpect(jsonPath("$.success").value(true));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("PUT /api/settle/{id}/hold - 200 보류 처리")
|
|
||||||
void hold_ok() throws Exception {
|
|
||||||
willDoNothing().given(service).hold(eq(50L), anyString());
|
|
||||||
|
|
||||||
mockMvc.perform(put("/api/settle/50/hold")
|
|
||||||
.with(MockLoginUser.admin())
|
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
|
||||||
.content(objectMapper.writeValueAsString(Map.of("reason", "검토 필요"))))
|
|
||||||
.andExpect(status().isOk());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("PUT /api/settle/{id}/confirm - 403 APPROVE 권한 없음")
|
|
||||||
void confirm_forbidden() throws Exception {
|
|
||||||
given(permissionChecker.hasPermission(anyLong(), eq("SETTLE_LIST"), eq("APPROVE"))).willReturn(false);
|
|
||||||
|
|
||||||
mockMvc.perform(put("/api/settle/50/confirm").with(MockLoginUser.admin()))
|
|
||||||
.andExpect(status().isForbidden());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("GET /api/settle - 401 인증 없는 요청")
|
|
||||||
void list_unauthenticated() throws Exception {
|
|
||||||
mockMvc.perform(get("/api/settle"))
|
|
||||||
.andExpect(status().isUnauthorized());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user