16 Commits

Author SHA1 Message Date
GA Pro a24f93a39d feat(domain): V22 비즈니스 도메인 6 카테고리 29개 + 사전 매핑 30+
신규 카테고리:

AUDIT (5) — 모든 테이블 공통 감사 컬럼
- DOM_CREATED_BY / DOM_UPDATED_BY (사용자 ID)
- DOM_IP_ADDRESS (IPv4/IPv6 통합)
- DOM_USER_AGENT (HTTP 헤더)
- DOM_VERSION (낙관적 잠금)

WORKFLOW (5) — 결재 / 승인 흐름
- DOM_APPROVE_STATUS (PENDING/APPROVED/REJECTED/CANCELLED)
- DOM_APPROVER_ID / DOM_APPROVE_COMMENT
- DOM_WORKFLOW_STAGE (DRAFT/REVIEW/APPROVED/PUBLISHED/ARCHIVED)
- DOM_PRIORITY (HIGH/MEDIUM/LOW)

SETTLEMENT_BIZ (6) — GA 정산 핵심 비즈
- DOM_RECONCILE_STATUS (대사 상태)
- DOM_RECONCILE_DIFF (보험사-시스템 차액)
- DOM_LAPSE_MONTH (실효 경과월 — 환수 규정 매칭)
- DOM_INSTALLMENT_SEQ / DOM_INSTALLMENT_TOTAL (분할 환수 회차)
- DOM_PERFORMANCE_GRADE (A/B/C/D/F)

FILE (4) — 파일 첨부
- DOM_FILE_ID (file_storage FK)
- DOM_FILE_PATH / DOM_FILE_SIZE / DOM_MIME_TYPE

TAX (4) — 세무
- DOM_TAX_INVOICE_NO (세금계산서)
- DOM_VAT (부가세 10%)
- DOM_INCOME_TAX (소득세 3%) / DOM_LOCAL_TAX (지방세)

BANKING (5) — 은행 / 결제
- DOM_TRANSFER_REF (이체 참조번호)
- DOM_TRANSFER_FEE / DOM_CURRENCY (ISO 4217)
- DOM_FX_RATE (환율 6자리) / DOM_SWIFT_CODE (BIC)

데이터 사전 매핑 30+ 추가:
- exception_ledger (8) / chargeback (7) / reconciliation (7) / batch_job_log (6)
- file_storage (6) / api_access_log (6) / data_change_log (6)
- recruit_ledger·maintain_ledger·settle_master 의 audit 컬럼

검증:
- Flyway V22 적용 35ms
- 전체 도메인 49 → 78 (16 카테고리)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:38:57 +09:00
GA Pro 1c74e192ec fix(upload): target_table 자유입력 → Select (whitelist) + V21 PROFILE/RESUME 도메인
[버그 fix]
- UploadTemplateManager 의 ModalForm 에서 target_table 이 ProFormText 자유입력
  이라 사용자가 잘못 입력 시 ("1" 등) 백엔드 whitelist 검증 실패
  ("허용되지 않은 target_table: 1") 발생
- ProFormSelect 로 변경 + request 로 /api/upload-templates/target-tables 호출
  → 9개 허용 테이블만 옵션 노출 (오타 / 잘못된 입력 원천 차단)

[V21__프로필_이력서_도메인.sql]
도메인 15개 추가:

PROFILE 카테고리 (8개):
- DOM_PROFILE_BIO (자기소개, TEXT)
- DOM_PROFILE_IMAGE (프로필 사진, BIGINT 파일ID)
- DOM_NICKNAME (닉네임, VARCHAR(50))
- DOM_BIRTH_DATE (생년월일, DATE)
- DOM_GENDER (성별, CHAR(1) M/F/U)
- DOM_ADDRESS (주소, VARCHAR(300))
- DOM_ZIP_CODE (우편번호, VARCHAR(10))
- DOM_NATIONALITY (국적, ISO 3166)

RESUME 카테고리 (7개):
- DOM_RESUME_FILE (이력서 파일, BIGINT)
- DOM_CAREER_HISTORY (경력 사항, JSONB 배열)
- DOM_EDUCATION (학력, JSONB 배열)
- DOM_LICENSE_LIST (자격증 목록, JSONB)
- DOM_SKILL_TAGS (스킬 태그, VARCHAR(500))
- DOM_INTRODUCTION (소개 영상 URL, VARCHAR(500))
- DOM_HIRE_TYPE (계약 형태, VARCHAR(20))

데이터 사전 매핑 28개 추가 (V20 누락 보강):
- agent: grade_id/bank_code/leave_date/created_at/updated_at
- users: 7컬럼
- organization: 6컬럼
- insurance_company: 4컬럼
- product: 5컬럼

검증:
- Flyway V21 적용 200ms
- /api/data-domains?domainCategory=PROFILE → 8건
- /api/data-domains?domainCategory=RESUME → 7건
- target-tables 9개 정상 응답
- 프론트 tsc -b 통과

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:34:47 +09:00
GA Pro cece084631 feat: 데이터 도메인 사전 + 데이터 사전 시스템 (V20)
3 agents 병렬 작업 (dba / api / frontend) 결과물 + SQL 버그 2건 fix.

DB (V20__데이터사전.sql):
- data_domain (domain_code PK, category, data_type, length/precision/scale,
  format_pattern, validation_regex, masking_type, encrypted, ...)
- data_dictionary (dict_id PK, table_name, column_name, domain_code FK,
  column_label, is_pk/is_fk/fk_reference, business_rule, sample_value, ...)
- VIEW v_data_dictionary_full: information_schema + pg_description +
  data_dictionary + data_domain 4-way LEFT JOIN
  → ga 스키마 모든 컬럼 자동 노출 (사전 미등록 컬럼도 회색 표시)
- 도메인 30개 초기 데이터 (IDENTIFIER 7 / PII 6 / MONEY 5 / RATE 3 /
  DATE 4 / CODE 4 / TEXT 3 / JSON 2)
- 사전 매핑 50개 (agent 10 / contract 7 / recruit_ledger 9 /
  settle_master 9 / payment 6)
- 메뉴 SYSTEM_DATA_DOMAIN, SYSTEM_DATA_DICT 등록 + 권한

Backend:
- ga-core VO 9개 + Mapper 2개 + XML 2개
- ga-api Service 2 (도메인 삭제 시 사전 참조 검증) + Controller 2
- 엔드포인트:
  · /api/data-domains (CRUD + /options)
  · /api/data-dictionary (CRUD + /tables + /full?tableName=)

Frontend:
- api/dict.ts (13 endpoints)
- pages/system/DataDomainList.tsx — 카테고리 색상 Tag, 마스킹/암호화 표시,
  ModalForm 등록/수정/삭제
- pages/system/DataDictionary.tsx — 좌(테이블 목록 + prefix 그룹핑) +
  우(v_data_dictionary_full 조회, 사전 미등록 컬럼은 회색+점선 추가 버튼)
- App.tsx + MenuIcon.tsx 매핑 추가

Bug fix:
- DataDomainMapper: WHERE is_active = true → 'Y' (CHAR vs boolean)
- DataDictionaryMapper.selectFromView: ORDER BY sort_order 제거
  (view 에 없는 컬럼)

검증:
- ./gradlew :ga-api:bootJar 통과
- Flyway V20 운영 DB 적용 (17초)
- 4 엔드포인트 모두 200 응답
- agent 테이블 15컬럼 사전 자동 노출 + 도메인 매핑 정상

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:29:13 +09:00
GA Pro 2136b38e1b feat(upload): 매핑 UI 4가지 보강 — 자동 채우기 + Select 옵션 + 미리보기 + 좌우 정렬
엑셀 컬럼 매핑이 더 직관적이도록:

Backend:
- TargetFieldMetaService 신규: 9개 target_table 의 사용 가능 필드 메타데이터
  (recruit_ledger 15 / maintain_ledger 11 / contract 10 / agent 10 /
   commission_rate 7 / payout_rule 7 / chargeback_rule 6 / exception_ledger 6 /
   payment 6) — field/label/type/required
- UploadService.extractHeaders(file, headerRow, sheetIndex, previewRows):
  StreamingReader 로 첫 행 헤더 + N행 미리보기 추출, A/B/C 컬럼 라벨 생성
- UploadController 에 3개 엔드포인트 추가:
  · POST /api/upload-templates/excel-headers (multipart) → ExcelHeaderResp
  · GET  /api/upload-templates/target-fields/{targetTable}
  · GET  /api/upload-templates/target-tables

Frontend:
- api/uploadTemplate.ts: extractHeaders / targetFields / targetTables 함수 추가
  + ExcelHeaderResp / TargetFieldMeta 타입
- ColumnEditor 보강:
  1) "샘플 엑셀로 자동 채우기" 버튼 — 사용자가 샘플 파일 올리면 헤더 추출 후
     sourceColumn(A,B,C) + sourceHeader(헤더명) 자동 채움 (기존 매핑 있으면
     Modal.confirm 으로 덮어쓰기 확인)
  2) 대상 필드 Input → Select (target_table 별 메타 옵션) — showSearch +
     선택 시 targetType 자동 설정 (필드 메타의 type 으로)
  3) 헤더에 target_table tag 표시 (→ recruit_ledger)
  4) 자동 채우기 후 첫 5행 샘플 데이터 테이블이 매핑 편집기 위에 표시됨

검증: tsc -b 통과, ga-api 재기동 후 /target-fields 200 (15필드 응답)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:14:45 +09:00
GA Pro 87e4e3d1da feat: 추천 작업 5종 일괄 진행 (4 agents 병렬)
[A] 등록 모달 7페이지 (frontend)
- CompanyList / ProductList / Commission / Payout / Override / Chargeback / ExceptionCode
- ModalForm 580px, Switch Y/N 변환, 날짜 dayjs, PermissionButton
- api/company/product/rule.ts 에 SaveReq + create/update/remove 추가

[B] BatchRun + RoleList 보강 (frontend)
- BatchRun: 정산월/보험사 선택 → 실행, antd Steps 8단계 + 5초 폴링
  (refetchInterval 자동 정지), KPI 4 카드, 최근 이력
- RoleList: 좌(역할 목록) 우(메뉴×6권한 매트릭스 체크박스),
  DIRECTORY 들여쓰기, 전체 토글, Modal.confirm 후 저장

[C] 이체파일 + 은행 어댑터 (api)
- BankTransferFileGenerator 인터페이스 + GenericCsvTransferGenerator
  (UTF-8 BOM, 표준 CSV) + KbBankTransferGenerator (088, 고정폭 + 합계)
- BankTransferFactory (Spring Map 빈)
- TransferFileService: PENDING 조회 → 파일 생성 → file_storage 적재 →
  pay_file_ref 갱신 → status=SENT
- POST /api/payments/transfer-file (DataChangeLog + EXPORT 권한)
- account_no EncryptTypeHandler 자동 복호화 후 파일에 사용
- FileService.saveBytes(byte[]) 오버로드 추가
- PaymentList: 이체파일 생성 ModalForm + 다운로드 컬럼

[D] MockMvc Controller 테스트 (api)
- AbstractControllerTest (SpringBootTest + MockMvc, JwtFilter doAnswer
  로 chain 통과 처리), TestSecurityConfig (filter 빈 override)
- 5 클래스 36 테스트: Agent/Contract/Ledger/Settle/User Controller
- happy path + validation 400 + 권한 403 + 인증 401

검증:
- ./gradlew clean build 성공
- 백엔드 단위테스트 94건 (common 33 + batch 19 + api 36 + 기타 6) 통과
- 프론트 tsc -b / vite build 통과

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:08:32 +09:00
GA Pro a314a95ecf feat: 엑셀 업로드 템플릿 시스템 (V19)
Agent Teams 3명 병렬 작업 (dba / api / frontend) 결과물.

DB (V19__업로드템플릿.sql):
- upload_template / upload_template_column / upload_history 3 테이블
- 인덱스 5종 (category / company_code / template_id / started_at)
- SYSTEM_UPLOAD_TEMPLATE 메뉴 + READ/CREATE/UPDATE/DELETE 권한
- 모든 역할에 권한 부여 (MANAGER 는 READ 만)

Backend:
- ga-core: VO 7개 (Template/Column/History의 VO/Resp/SaveReq/SearchParam) +
  Mapper 3개 + XML 3개 (UploadTemplate/Column/History)
- ga-api: TransformEngine (12 변환 규칙: TRIM/UPPER/LOWER/DATE/DIVIDE/MULTIPLY/
  LOOKUP/CODE_MAP/FIXED/REPLACE/SUBSTRING/CONCAT/DEFAULT) +
  LookupCache (O(1) HashMap 캐시) + UploadService (processUpload/preview) +
  LookupMapper / DynamicInsertMapper (whitelist 기반 동적 SQL)
- ga-api Controller 2개:
  · UploadTemplateController: /api/upload-templates CRUD + columns + history
  · UploadController: POST /api/upload/{templateCode}, /preview

Frontend:
- api/uploadTemplate.ts (10 endpoints)
- pages/system/UploadTemplateManager.tsx — 좌(템플릿 목록) 우(컬럼 매핑 편집기)
  + ModalForm 등록/수정 + 미리보기 Modal + 이력 Drawer
- components/common/TemplateUpload.tsx — 범용 컴포넌트 (Select 템플릿 + Dragger
  + 진행률 + 결과 Modal + 에러 파일 다운로드)
- App.tsx 라우트 추가, MenuIcon.tsx 매핑 추가

검증:
- ./gradlew :ga-api:bootJar 통과
- Flyway V19 운영 DB 적용 성공 (200ms)
- ga-api 부팅 7.8s, /api/upload-templates 200 응답
- 메뉴 트리에 SYSTEM_UPLOAD_TEMPLATE 노출 확인
- 프론트 tsc -b / vite build 통과

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:10:32 +09:00
GA Pro 4e008527b9 feat(frontend): 헤더 단순화 + 등록/수정 모달 도입
헤더 깨짐 수정 (MainLayout):
- logo 를 div 박스 대신 단순 img(/vite.svg) 로 (ProLayout 기본 슬롯 호환)
- avatarProps.render 의 chevron 제거 → ProLayout 기본 dom 만 Dropdown 트리거
- token.header 의 잘못된 키(colorBgRightActionsItemHover) 제거,
  heightLayoutHeader 56 으로 통일
- actionsRender 의 정산월 영역을 Space + 라벨 조합으로 정리

등록/수정 모달 (DrawerForm / ModalForm):
- AgentList: DrawerForm (680px) — 설계사명 / 사번 / 소속(Select) / 직급 /
  전화 / 이메일 / 주민번호(암호화) / 은행 / 계좌(암호화) / 입사일,
  수정 시 detail API 자동 fetch + initialValues 주입,
  액션 컬럼: 수정/퇴사 (Modal.confirm)
- ContractList: ModalForm (680px) — 증권번호 / 설계사(Select) / 상품(Select) /
  계약자 / 피보험자 / 보험료 / 납입주기 / 계약일 / 효력발생일
  toolBarRender 등록 버튼 추가
- ExceptionLedger: ModalForm (620px) — 설계사 / 정산월 / 예외코드 /
  금액 / 증권번호 / 보험사 / 내용
  toolBarRender 등록 버튼 추가, 예외코드 옵션은 ruleApi 에서 동적 로드

api/contract.ts: ContractSaveReq + create/update 추가

검증: tsc -b 통과 / dev 서버 HMR 정상 반영

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:43:57 +09:00
GA Pro f176b9702c feat(frontend): 누락된 메뉴 14개 페이지 일괄 구현
신규 페이지 (모두 ProTable / ProCard 패턴):
- org/OrgTree: 조직 트리 + 선택 시 우측에 소속 설계사 ProTable
- product/CompanyList: 보험사 목록
- product/ProductList: 상품 목록 (보험종 필터)
- rule/CommissionRateList: 수수료율 (보험사 통보)
- rule/PayoutRuleList: 지급율 (직급 × 보험종 × 회차)
- rule/OverrideRuleList: 오버라이드 규정 (직급 페어)
- rule/ChargebackRuleList: 환수 규정 (실효 월수 구간)
- rule/ExceptionCodeList: 예외 코드 마스터
- ledger/MaintainLedger: 유지수수료 원장
- receive/ReceiveData: 보험사 raw 수신 데이터
- receive/ReceiveMapping: 보험사 프로파일 + 필드 매핑 규칙
- system/MenuManage: 메뉴 트리 표시 (Tree)
- system/SystemConfig: 키-값 설정 (그룹별 필터)
- system/SystemLog: 로그인/API/변경 이력 3 탭

신규 API 모듈:
- api/org.ts (조직 트리)
- api/company.ts, api/product.ts
- api/rule.ts (5종 통합)
- api/receive.ts (profile/field/raw/error)
- api/config.ts, api/log.ts

App.tsx: 14 라우트 추가 (org/tree, companies, products, rules/*,
receive/*, ledger/maintain, system/menus, system/config, system/logs)

검증:
- tsc -b 통과
- 12개 백엔드 엔드포인트 200 OK 확인
- dev 서버 HMR 자동 반영

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:36:51 +09:00
GA Pro 020939e563 feat(frontend): Ant Design Pro 패턴 전면 적용
@ant-design/pro-components ^2.8.10 + @ant-design/icons ^6.2 추가.

ProLayout (MainLayout):
- mix 레이아웃 (사이드바 + 헤더 통합), navTheme=light
- 자체 메뉴 트리 렌더링 (children 트리), 동적 메뉴 매핑
- 헤더 actionsRender: 정산월 MonthPicker / 알림 뱃지
- avatarProps: 그라디언트 아바타 + Dropdown 로그아웃
- token: 사이드바/헤더 컬러, pageContainer padding
- footerRender: 버전 표기

PageContainer (Pro):
- @ant-design/pro-layout 의 PageContainer 사용
- 자동 Breadcrumb / Title / extra 슬롯

ProTable 마이그레이션 (6 페이지):
- AgentList: search + valueEnum, request → PageHelper 변환
- ContractList, RecruitLedger, ExceptionLedger, SettleList, PaymentList, UserList
- 검색 폼 자동 생성 (valueType: select/dateMonth 등)
- toolBarRender: 등록/엑셀 버튼
- options: density/fullScreen/reload/setting

Dashboard:
- ProCard + StatisticCard 로 KPI 4종 그리드
- 차트 + 배치 진행 + 알림을 ProCard 로 묶음

검증:
- tsc -b 통과
- vite build 성공 (2.6MB / gzip 811KB)
- 백엔드 76건 단위테스트 영향 없음

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:20:33 +09:00
GA Pro e70589fda8 feat: UI 시각 강화 + 메뉴 트리 구조 + Redis 폴백
UI 시각 강화 (사용자 피드백 — flat 미니멀이 빈약하게 보임):
- MainLayout: 사이드바 로고 영역(그라디언트 #1677FF→#0C447C),
  메뉴 아이콘(Tabler), 푸터, 헤더 breadcrumb + 정산월 라벨,
  사용자 아바타 그라디언트
- PageContainer: 흰 카드 + 좌측 4px 컬러 바 + 그림자, 본문 카드 분리
- Dashboard: KPI 카드 4종(아이콘+컬러+델타), 월별 추이 BarChart(recharts),
  배치 8 Step 진행 상태(pulse), 알림 3종 (대사/파싱/승인)
- CodeBadge: antd preset → 직접 색상 (#0F6E56/#185FA5/#BA7517/#E24B4A
  + 옅은 배경 + 진한 보더, 진한 톤)
- SearchForm: 그라디언트 배경 + 보더 + 패딩 조정
- MenuIcon 신규: 메뉴 코드/경로 → Tabler 아이콘 매핑

메뉴 트리 구조:
- V18 마이그레이션: 25개 평면 PAGE → 9 roots (1 PAGE + 8 DIRECTORY)
  · 조직/인사, 상품/계약, 수수료규정, 데이터수신,
    원장관리, 정산/지급, 리포트, 시스템관리
- 자식 메뉴 sort_order 1~6 정리, DASHBOARD sort_order 10
- 모든 역할에 GRP_* READ 권한 자동 부여

Redis 폴백 (로컬 검증용):
- RedisConfig @ConditionalOnBean(RedisConnectionFactory.class)
- SimpleCacheConfig 신규: ConcurrentMapCacheManager 폴백
  (RedisAutoConfiguration 제외 시 활성)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:09:17 +09:00
GA Pro 24fc187e2e feat(frontend): 09_UI_DESIGN 디자인 시스템 전체 적용
테마 / 폰트 / 아이콘:
- ConfigProvider theme 토큰 30+ 추가 (브랜드 컬러, 폰트, 라운딩, 메뉴/테이블/버튼)
- Pretendard 폰트 link + index.css fontFamily 적용
- @tabler/icons-react 추가, MainLayout 에 적용

레이아웃:
- styles/variables.css 신규 (--ga-* 변수 30+)
- MainLayout: Header 48px 흰색 + bottom border, Sider 220px + 토글,
  Content 1400px 중앙 정렬, 정산월 MonthPicker, 햄버거 토글

공통 컴포넌트:
- CodeBadge: 4단계 색상 매핑 (success/info/warning/danger), 누락 코드 보강
- MoneyText: 양수 #0F6E56, 음수 #E24B4A, 0 #888 (CSS 변수 사용)
- SearchForm: 배경 #fafafa, borderRadius 8, padding 12px 16px
- DataGrid: rowHeight 36, headerHeight 38, AG_GRID_LOCALE_KO,
  합계행 getRowStyle 회색 배경 + 굵게

페이지 페이지네이션 통일:
- utils/tablePagination.ts 신규 (showTotal '총 N건', pageSizeOptions [20/50/100])
- 7개 페이지 (Agent/Contract/Recruit/Exception/Settle/Payment/User) 적용

검증: tsc -b 통과 / vite build 성공 (10s) / 백엔드 단위테스트 76건 영향 없음

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:34:17 +09:00
GA Pro 5b024d9b9c docs: 09_UI_DESIGN.md 추가 (디자인 시스템) + 참조 갱신
- 09_UI_DESIGN.md 신규: Ant Design 테마 토큰, 글로벌 CSS 변수,
  레이아웃 규격(Header 48px / Sider 220px), 페이지 표준 3종(목록/상세/대시보드),
  공통 컴포넌트 스타일(CodeBadge/MoneyText/SearchForm/DataTable/DataGrid),
  등록·수정 폼 규칙(Drawer), 화면별 와이어프레임 6종, 신입 체크리스트
- 00_PROMPTS.md: 프롬프트 3 frontend-developer 가 09_UI_DESIGN.md 도 함께 참조
- 06_FRONTEND_SPEC.md: 상단에 디자인 시스템 참조 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:21:13 +09:00
GA Pro 035175e895 docs: Agent Teams 실행 가이드 + 8개 도메인 spec 추가
00_PROMPTS.md  — Claude Code 실행 프롬프트 1~6
01_DB_SCHEMA.md — V1~V17 Flyway 스키마 + 프로젝트 뼈대
02_COMMON_SPEC.md — ga-common 공통 프레임워크 (모델/예외/MyBatis/AOP/엑셀/공통컴포넌트)
03_CORE_SPEC.md — ga-core VO 설계 원칙 + Mapper 패턴
04_BATCH_SPEC.md — ga-batch 정산 8 Step + DataReceiver/MappingEngine
05_API_SPEC.md — ga-api Controller/Service 표준 패턴 + 엔드포인트 목록
06_FRONTEND_SPEC.md — ga-frontend 13개 화면 + 공통 컴포넌트 사용법
07_INFRA_SPEC.md — Docker/Nginx + README + DEVELOPER_GUIDE
08_DBA_SPEC.md — V13~V15 인덱스/파티셔닝/뷰

실제 구현된 ga-commission-system 기준으로 작성. 코드는 변경 없음.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:15:13 +09:00
GA Pro 936d376794 test: ga-common 서비스 + ga-batch MappingEngine 단위 테스트 37건 추가
- MenuTreeBuilderTest (6): 트리 빌드, sortOrder 정렬, 고아 노드 처리
- PermissionCheckerTest (4): userId null 단락, mapper 위임
- CommonCodeServiceTest (14): 그룹/상세 CRUD, 시스템 코드 잠금, 중복 검출
- MappingEngineTest (13): RECRUIT/MAINTAIN 라우팅, TRIM/UPPER/LOWER/DATE/
  DIVIDE/MULTIPLY/CODE_MAP 변환, 9가지 에러 분기

전체 단위 테스트 76건 (1+2단계 합산).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:53:30 +09:00
GA Pro 8a0483cccc test: ga-common 유틸 + ga-batch CommissionCalculator 단위 테스트 39건
- MoneyUtilTest (10): pct/tax/mul/div HALF_UP, null 안전성
- DateUtilTest (10): 정산월 범위, 윤년/평년, 월 차이/가산
- MaskUtilTest (7): NAME/PHONE/RRN/ACCOUNT/EMAIL 마스킹 규칙
- EncryptUtilTest (6): AES-256 round-trip, 한글, 짧은 키 패딩
- CommissionCalculatorTest (6): 모집/유지수수료 계산 공식, 폴백, agent 누락

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:47:47 +09:00
GA Pro aee9c2c2ca fix(common): EncryptTypeHandler 를 Spring 빈에서 분리
mybatis-spring-boot-starter 가 BaseTypeHandler<String> 빈을 자동 등록하면서
모든 String 컬럼에 적용해버리는 문제 해결. @Component 를 제거하고
EncryptTypeHandlerInitializer (@Configuration) 에서 EncryptUtil 만 정적 주입.
사용처는 mapper XML 에서 컬럼별 typeHandler 속성으로 명시(기존과 동일).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:39:48 +09:00
147 changed files with 16922 additions and 610 deletions
+4
View File
@@ -38,3 +38,7 @@ target/
# Claude Code 개인 설정 (Agent Teams 등)
.claude/
# vite/tsc 빌드 산출물
ga-frontend/tsconfig.tsbuildinfo
ga-frontend/dist/
+182
View File
@@ -0,0 +1,182 @@
# 00_PROMPTS — Claude Code 실행 프롬프트 모음
## 사전 준비
### 1) Agent Teams 활성화 (`~/.claude/settings.json`)
```json
{
"env": {
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
}
}
```
### 2) Git Worktree 격리 (선택)
```bash
git init ga-commission-system && cd ga-commission-system
git commit --allow-empty -m "init"
git worktree add ../ga-wt-common -b feat/common
git worktree add ../ga-wt-core -b feat/core
git worktree add ../ga-wt-batch -b feat/batch
git worktree add ../ga-wt-api -b feat/api
git worktree add ../ga-wt-front -b feat/frontend
git worktree add ../ga-wt-infra -b feat/infra
```
### 3) 본 docs/ 폴더의 모든 파일을 프로젝트 루트에 배치
- `docs/01_DB_SCHEMA.md` ~ `docs/08_DBA_SPEC.md` (8 spec)
- `docs/00_PROMPTS.md` (this file)
> 각 프롬프트 안에 "docs/0N_*.md 읽어줘" 지시가 들어 있어, Claude Code 가
> 자동으로 해당 spec 을 읽고 그대로 구현합니다.
---
## 프롬프트 1: 리더 단독 — 프로젝트 뼈대 + DB
```
GA(법인보험대리점) 수수료 정산 솔루션 프로젝트를 만들어줘.
docs/01_DB_SCHEMA.md 를 읽고 그대로 구현해줘.
핵심 요약:
- Java 17 + Spring Boot 3.2 + MyBatis 3 (JPA 아님!) + PageHelper
- PostgreSQL + Redis + Flyway
- React 18 + Vite + Ant Design 5 + AG Grid Community
- Gradle 멀티모듈: ga-common / ga-core / ga-batch / ga-api / ga-admin / ga-frontend
작업:
1. settings.gradle / build.gradle / 모듈별 build.gradle
2. application.yml (mybatis-config 포함, mapUnderscoreToCamelCase=true)
3. Flyway V1~V17 SQL 전부 작성 (docs/01 의 스키마/초기데이터/인덱스/파티션/뷰/관리자/테스트데이터)
4. 모듈별 빈 패키지 + 진입점 (GaApiApplication 등)
완료 후 ./gradlew build 가 통과해야 함.
```
---
## 프롬프트 2: 리더 단독 — ga-common 공통 프레임워크
```
docs/02_COMMON_SPEC.md 를 읽고 ga-common 모듈을 그대로 구현해줘.
핵심:
- 신입 개발자가 학습하기 쉬운 단순/명시적 코드 우선
- 패키지 구조: model / exception / util / annotation / aop / auth / security /
config / mybatis / grid / code / menu / excel / file / system / notification
- 모든 Controller 가 ApiResponse<T> 형태로 응답
- 검색 파라미터는 SearchParam 상속, 페이징은 PageHelper + PageResponse.of()
- @RequirePermission / @DataChangeLog / @ExcelColumn / @Mask 어노테이션 + AOP
- JWT (HS256) + BCrypt + AES-256 CBC 암호화 (EncryptTypeHandler)
- Excel SXSSF + xlsx-streamer 으로 100만건/70만건 처리
- 공통코드 Redis 캐시 (@Cacheable)
- 메뉴 트리 빌더 + 권한 체커
- ga-frontend 의 공통 컴포넌트 (CodeSelect / CodeBadge / SearchForm /
DataTable / DataGrid / PermissionButton / ExcelExportButton 등) 도 함께 작성
완료 후 ./gradlew :ga-common:build 통과 + 다른 팀원이 ga-common 만 의존하면 즉시 시작 가능해야 함.
```
---
## 프롬프트 3: Agent Teams 5명 병렬 스폰
```
GA 수수료 정산 솔루션의 뼈대 + ga-common 이 완성됐어.
Agent Teams 5명을 병렬로 스폰해줘.
각 팀원은 자기 담당 docs 파일을 읽고 그대로 구현한다:
1) core-architect → docs/03_CORE_SPEC.md → ga-core (VO/Mapper/XML)
2) batch-engineer → docs/04_BATCH_SPEC.md → ga-batch (8 Step)
3) api-developer → docs/05_API_SPEC.md → ga-api (Controller/Service)
4) frontend-developer → docs/06_FRONTEND_SPEC.md
+ docs/09_UI_DESIGN.md → ga-frontend (13 화면 + 디자인 시스템)
5) dba-architect → docs/08_DBA_SPEC.md → V13~V15 + 인덱스/파티션/뷰
의존성:
- core-architect 가 가장 먼저 (다른 팀원이 VO/Mapper 사용)
- batch / api / frontend 는 core 완료 후 병렬 시작
- dba 는 core 의 Mapper XML 검증 후 인덱스/파티셔닝 적용
각 팀원 작업 규칙:
- 담당 모듈만 수정 (다른 팀 코드 건드리지 말기)
- ga-common 의 표준 패턴(ApiResponse, PageResponse, SearchParam, @RequirePermission,
@DataChangeLog) 을 반드시 따른다
- Mapper XML 은 SELECT * 금지 (필드 명시), 목록은 XxxResp 에 매핑
- 신입이 복붙 후 수정만으로 새 화면 만들 수 있도록 동일 패턴 유지
- 완료 시 리더에게 "[팀명] 완료" 메시지
팀을 만들고 작업을 시작해줘.
```
---
## 프롬프트 4: 리더 단독 — 인프라 + 통합 검증
```
모든 팀원 작업 완료. docs/07_INFRA_SPEC.md 를 읽고 마지막 작업을 해줘.
작업:
1. docker-compose.yml: postgres / redis / api / batch / frontend
2. Dockerfile: 멀티스테이지 (Gradle 빌드 → JRE 17 실행)
3. nginx.conf: React SPA + /api → backend:8080 프록시
4. README.md: 시나리오 A(운영 DB) / B(로컬) / C(Docker) 3 가지 실행 가이드
5. DEVELOPER_GUIDE.md: 신입 개발자 온보딩 (새 화면 만들기 step-by-step)
통합 검증:
- ./gradlew clean build 성공
- cd ga-frontend && npm install && npm run build 성공
- docker-compose up -d --build 전체 기동 후 http://localhost:3000 로그인 동작
- Flyway V1~V17 자동 적용 확인
- 단위 테스트 ./gradlew test 통과
문제 있으면 수정 후 v1.0.0-alpha 태그.
```
---
## 프롬프트 5: 리더 단독 — 단위 테스트 (선택)
```
docs/02_COMMON_SPEC.md / 04_BATCH_SPEC.md 를 참고해 단위 테스트를 추가해줘.
대상:
- ga-common 유틸: MoneyUtilTest / DateUtilTest / MaskUtilTest / EncryptUtilTest
- ga-common 서비스: CommonCodeServiceTest / MenuTreeBuilderTest / PermissionCheckerTest
- ga-batch 핵심 로직: CommissionCalculatorTest / MappingEngineTest
규칙:
- JUnit 5 + Mockito + AssertJ (spring-boot-starter-test 에 포함)
- @Cacheable / @CacheEvict 는 단위 테스트에서 동작 안 함 → 비즈니스 로직만 검증
- Mockito strict stubbing 위반 주의 (@BeforeEach 의 stub 은 모든 테스트가 사용해야 함)
- 모든 테스트 통과 후 커밋
```
---
## 프롬프트 6: 리더 단독 — 운영 DB 배포 (선택)
```
운영 DB(192.168.0.60:55432/trading_ai/ga) 에 V1~V17 마이그레이션 + 테스트 데이터를 적용해줘.
방법:
- Python + pg8000 으로 migrate.py 작성 (저장소 외부에 둔다)
- ga 스키마 존재 여부 확인 후 idempotent guard
- application-trading_ai.yml 프로파일 추가 (currentSchema=ga)
- 실행: ./gradlew :ga-api:bootRun --args='--spring.profiles.active=trading_ai'
확인:
- /api/health 200
- /login admin / admin1234! 로 토큰 발급
- 메뉴 트리 / 공통코드 / 설계사 50명 / 계약 200건 조회 OK
```
---
## 비용 절약 팁
- 리더: Opus / 팀원: Sonnet (하이브리드 모델)
- 5명이 적정. 그 이상은 조율 오버헤드 증가
- 작업 범위 명확 + 서로 독립적일수록 효과적
- `--dangerously-skip-permissions` 사용 시 모든 팀원 동일 권한
+313
View File
@@ -0,0 +1,313 @@
# 01_DB_SCHEMA — DB 스키마 + 프로젝트 뼈대
## 1. 기술 스택
| 영역 | 기술 |
|---|---|
| Backend | Java 17, Spring Boot 3.2.5, Spring Security, Spring Batch |
| ORM | **MyBatis 3** + mybatis-spring-boot-starter:3.0.3 (JPA 아님) |
| 페이징 | pagehelper-spring-boot-starter:2.1.0 |
| DB | PostgreSQL 16 |
| Cache | Redis 7 (선택) |
| Frontend | React 18 + Vite + TS + Ant Design 5 + AG Grid Community + React Query + Zustand |
| Build | Gradle 멀티모듈 (Java 17 toolchain) |
| 기타 | Lombok, MapStruct 1.5.5, Flyway, Springdoc OpenAPI 2.3 |
## 2. 프로젝트 구조 (멀티모듈)
```
ga-commission-system/
├── settings.gradle # 모듈 7개 (ga-common/core/api/batch/admin/frontend)
├── build.gradle # subprojects { Lombok, MapStruct, JUnit5 }
├── gradle.properties
├── ga-common/ # 공통 프레임워크 (라이브러리, bootJar disabled)
├── ga-core/ # 도메인 코어 (VO/Mapper/XML, ga-common 의존)
├── ga-api/ # REST API (executable jar, 8080)
├── ga-batch/ # Spring Batch (executable jar, 8081)
├── ga-admin/ # (현재 빈 모듈, 향후 관리자 전용)
├── ga-frontend/ # Vite 빌드, dist/ → nginx
├── docker-compose.yml
├── Dockerfile
└── docs/ # 본 폴더
```
### 모듈 의존성
- `ga-core``ga-common`
- `ga-api``ga-core` (transitively `ga-common`)
- `ga-batch``ga-core`
- `ga-admin``ga-core` (현재 미사용)
### 핵심 build.gradle 설정
```groovy
// 루트 build.gradle subprojects
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.5' apply false
id 'io.spring.dependency-management' version '1.1.4' apply false
}
java { toolchain { languageVersion = JavaLanguageVersion.of(17) } }
compileJava { options.compilerArgs << '-parameters' } // MyBatis 파라미터 이름
// ga-common: 라이브러리 (bootJar disabled, jar enabled)
// ga-api / ga-batch: bootJar enabled, jar disabled
```
### MyBatis 설정 (`ga-common/src/main/resources/mybatis/mybatis-config.xml`)
```xml
<configuration>
<settings>
<setting name="mapUnderscoreToCamelCase" value="true"/>
<setting name="cacheEnabled" value="false"/>
<setting name="callSettersOnNulls" value="true"/>
<setting name="defaultStatementTimeout" value="60"/>
<setting name="jdbcTypeForNull" value="NULL"/>
</settings>
<typeHandlers>
<!-- JsonTypeHandler 만 글로벌. EncryptTypeHandler 는 컬럼별 명시 -->
<typeHandler handler="com.ga.common.mybatis.JsonTypeHandler"/>
</typeHandlers>
</configuration>
```
### application.yml (ga-api)
```yaml
spring:
datasource:
url: jdbc:postgresql://localhost:5432/ga
username: ga
password: ga
hikari: { maximum-pool-size: 20 }
flyway:
locations: classpath:db/migration # ga-common 의 V1~V17
baseline-on-migrate: true
mybatis:
config-location: classpath:mybatis/mybatis-config.xml
mapper-locations: classpath*:mapper/**/*.xml
ga:
jwt:
secret: ${JWT_SECRET:replace-me-256-bit-or-longer-secret-key}
access-ttl-min: 120
refresh-ttl-day: 7
encrypt:
key: ${ENCRYPT_KEY:32byte-key-for-aes-256-cbc-aaaa} # 32 byte
```
---
## 3. Flyway 마이그레이션 V1~V17
위치: `ga-common/src/main/resources/db/migration/`
**모든 금액 `DECIMAL(15,2)`, 모든 수수료율 `DECIMAL(7,4)`. 모든 테이블에 인덱스 부여.**
### V1__조직_인사.sql (4 테이블)
```sql
grade (grade_id PK, grade_name, grade_level, grade_group, description, is_active, sort_order)
organization (org_id PK, parent_org_id FK self, org_name, org_type[HQ/BR/TM], org_level, region,
effective_from, effective_to, is_active, created_at, updated_at, created_by)
agent (agent_id PK, org_id FK, grade_id FK, agent_name, resident_no(AES), license_no,
phone, email, bank_code, account_no(AES), status[ACTIVE/LEAVE/SUSPEND],
join_date, leave_date, created_at, updated_at)
agent_org_history(history_id PK, agent_id FK, from_org_id, to_org_id, from_grade_id, to_grade_id,
change_type[TRANSFER/PROMOTE/DEMOTE/JOIN/LEAVE], change_date, change_reason)
```
인덱스: `idx_org_parent`, `idx_org_type(org_type,is_active)`, `idx_agent_org(org_id,status)`,
`idx_agent_lic(license_no)`, `idx_agent_status`.
### V2__상품_계약.sql (3 테이블)
```sql
insurance_company(company_id PK, company_code UQ, company_name, company_type, biz_no,
contact_name, contact_phone, contact_email, is_active)
product (product_id PK, company_id FK, product_code, product_name, insurance_type,
product_group, pay_period_type, is_active, launch_date, end_date)
contract (contract_id PK, agent_id FK, product_id FK, policy_no UQ, contractor_name,
insured_name, premium DECIMAL(15,2), pay_cycle, pay_period, coverage_period,
status, contract_date, effective_date, lapse_date, revival_date, created_at, updated_at)
```
### V3__수수료규정.sql (5 테이블)
```sql
commission_rate (rate_id PK, product_id FK, commission_year, rate_pct DECIMAL(7,4),
pay_method_cond, min_premium, effective_from, effective_to, version)
payout_rule (rule_id PK, grade_id FK, insurance_type, commission_year, payout_pct DECIMAL(7,4),
pay_method_cond, performance_grade, effective_from, effective_to, version)
override_rule (override_id PK, from_grade, to_grade, override_pct DECIMAL(7,4),
calc_type, insurance_type_cond, cap_amount, effective_from, effective_to)
chargeback_rule (cb_rule_id PK, company_code, insurance_type, lapse_month_from, lapse_month_to,
cb_rate DECIMAL(7,4), include_override, installment_allowed, max_installments,
effective_from, effective_to)
exception_type_code (exception_code PK, exception_name, category, direction[PLUS/MINUS],
auto_calc_yn, approval_required, tax_included, description, is_active)
```
### V4__데이터수신.sql (5 테이블)
```sql
company_profile (company_code PK FK, data_format, receive_method, file_encoding, delimiter,
header_row, data_start_row, sheet_name, date_format, amount_format,
api_url, ftp_host, ftp_path, receive_schedule, is_active)
company_field_mapping (mapping_id PK, company_code FK, source_field, source_col_index,
target_table, target_field, data_type, transform_rule, default_value,
is_required, sort_order, version, effective_from)
code_mapping (code_id PK, company_code, code_type, source_code, source_name,
target_code, target_name, is_active)
raw_commission_data (raw_id PK, company_code, batch_id, settle_month, data_type,
raw_content TEXT, parse_status[PENDING/OK/ERROR], mapped_ledger_id,
mapped_table, file_name, row_number, received_at, parsed_at)
parse_error_log (error_id PK, raw_id FK, company_code, error_type, error_field,
error_value, error_message, resolve_status[OPEN/RESOLVED/IGNORED],
resolved_by, resolve_note, resolved_at, created_at)
```
### V5__원장관리.sql (3 테이블, 동일 컬럼)
```sql
recruit_ledger / maintain_ledger / exception_ledger
:
ledger_id PK, contract_id FK, agent_id FK, policy_no, company_code, product_code,
insurance_type, commission_year, premium DECIMAL(15,2), pay_method,
company_rate DECIMAL(7,4), company_amount DECIMAL(15,2),
payout_rate DECIMAL(7,4), agent_amount DECIMAL(15,2),
tax_amount DECIMAL(15,2),
reconcile_status[PENDING/MATCHED/DIFF], reconcile_diff,
advance_yn, advance_amount, chargeback_yn, settle_month, status[CALCULATED/CONFIRMED/HOLD],
version, created_at, updated_at
exception_ledger :
exception_code, approve_status, approver_id, approve_date,
recurring_yn, recurring_until, recurring_parent_id
```
### V6__정산_지급.sql (4 테이블)
```sql
settle_master (settle_id PK, agent_id FK, settle_month,
recruit_total, maintain_total, exception_plus, exception_minus,
override_total, chargeback_total, gross_amount, tax_amount, net_amount,
status[DRAFT/CONFIRMED/HOLD/PAID], calc_date, confirmed_by, confirmed_at,
hold_reason, UNIQUE(agent_id, settle_month))
override_settle(os_id PK, settle_id FK, from_agent_id, to_agent_id, base_amount,
override_pct, override_amount, calc_basis)
chargeback (cb_id PK, contract_id FK, agent_id FK, lapse_month_diff, base_amount,
cb_rate, cb_amount, installment_seq, installment_total, status, created_at)
payment (payment_id PK, settle_id FK, agent_id FK, bank_code, account_no(AES),
amount, transfer_date, status[PENDING/SENT/SUCCESS/FAIL], result_code, result_msg)
```
### V7__배치_감사.sql (4 테이블)
```sql
batch_job_log (job_log_id PK, job_name, job_params, settle_month, status[REQUESTED/RUNNING/
COMPLETED/FAILED], step_name, total_count, success_count, error_count,
error_message, started_at, ended_at, duration_ms)
reconciliation (recon_id PK, company_code, settle_month, expected_amount, actual_amount,
diff_amount, recon_status, resolved_by, resolved_at)
audit_log (audit_id PK, user_id, action_type, target_table, target_id,
before_data JSONB, after_data JSONB, ip_address, user_agent, created_at)
users (user_id PK, login_id UQ, password(BCrypt), user_name, agent_id FK nullable,
email, phone, status[ACTIVE/LOCKED/EXPIRED], last_login_at,
fail_count, password_changed_at, created_at, updated_at)
```
### V8__초기데이터.sql
- grade 6 건 (FC/SFC/SMGR/DM/GM/SVP)
- exception_type_code 10 건 (ADJUST_PLUS / ADJUST_MINUS / BONUS / PENALTY / ...)
### V9__공통코드.sql (2 테이블)
```sql
common_code_group (group_code VARCHAR(20) PK, group_name, description, is_system CHAR(1),
is_active, sort_order, created_at, created_by, updated_at, updated_by)
common_code (code_id PK, group_code FK, code VARCHAR(30), code_name, code_name_en,
code_desc, attr1, attr2, attr3, ref_code, sort_order, is_active,
created_at, created_by, updated_at, updated_by,
UNIQUE(group_code, code))
```
### V10__메뉴관리.sql (5 테이블)
```sql
menu (menu_id PK, parent_menu_id FK self, menu_code UQ, menu_name,
menu_type[DIRECTORY/PAGE/LINK], menu_path, menu_icon, component_path,
menu_level, sort_order, is_visible, is_active, description)
menu_permission (perm_id PK, menu_id FK, perm_code[READ/CREATE/UPDATE/DELETE/APPROVE/
EXPORT/PRINT], perm_name, sort_order, UNIQUE(menu_id, perm_code))
role (role_id PK, role_code UQ, role_name, role_desc, role_level,
is_system, is_active)
role_menu_permission (rmp_id PK, role_id FK, menu_id FK, perm_code, is_granted,
UNIQUE(role_id, menu_id, perm_code))
user_role (ur_id PK, user_id FK, role_id FK, UNIQUE(user_id, role_id))
```
### V11__시스템관리.sql (6 테이블)
```sql
login_log (log_id PK, user_id, login_id, login_type[PWD/SSO], result[SUCCESS/FAIL],
fail_reason, ip_address, user_agent, login_at)
api_access_log (access_id PK, user_id, request_uri, http_method, params, response_code,
response_time_ms, ip_address, created_at) -- 비동기 저장
data_change_log (change_id PK, user_id, menu_code, table_name, record_id,
before_data JSONB, after_data JSONB, action[CREATE/UPDATE/DELETE], created_at)
file_storage (file_id PK, group_id, original_name, stored_name, file_path, file_size,
content_type, download_count, is_deleted, created_by, created_at)
notification (noti_id PK, user_id, noti_type, title, content, link_url, is_read, read_at, created_at)
system_config (config_key VARCHAR(50) PK, config_value, config_group, description,
is_encrypted, updated_by, updated_at)
```
### V12__공통_초기데이터.sql
- 공통코드 18 그룹 + 상세 42 건
(AGENT_STATUS / CONTRACT_STATUS / SETTLE_STATUS / PAY_CYCLE / INSURANCE_TYPE /
ORG_TYPE / GRADE_GROUP / RECONCILE_STATUS / PARSE_STATUS / EXCEPTION_DIRECTION /
APPROVE_STATUS / PAYMENT_STATUS / NOTI_TYPE / FILE_TYPE / DATA_FORMAT /
RECEIVE_METHOD / TRANSFORM_RULE / CHANGE_TYPE)
- 역할 4 건 (`SUPER_ADMIN` / `ADMIN` / `MANAGER` / `AGENT`)
- 대메뉴 10 건 + 시스템설정 13 건
### V13__인덱스최적화.sql
- `recruit_ledger(settle_month, agent_id)`, `recruit_ledger(policy_no)`
- `maintain_ledger(settle_month, agent_id)`, `maintain_ledger(policy_no)`
- `exception_ledger(settle_month, approve_status)`
- `settle_master(settle_month, status)`
- `contract(agent_id, status)`, `contract(policy_no)`
- `raw_commission_data(company_code, settle_month, parse_status)`
- `api_access_log(created_at)`, `data_change_log(created_at, table_name)`
### V14__파티셔닝.sql
- 헬퍼 함수 `create_monthly_partition(table_name, year_month)` 정의
- `recruit_ledger / maintain_ledger / api_access_log / data_change_log`
`settle_month` 또는 `created_at` 기준 RANGE 파티션으로 변환 (PG 13+)
### V15__뷰.sql
```sql
v_agent_monthly_summary (agent_id, agent_name, org_name, settle_month,
recruit_amt, maintain_amt, exception_plus, exception_minus,
override_amt, chargeback_amt, gross, tax, net, status)
v_org_settle_summary (org_id, org_name, settle_month, agent_count, total_gross, total_net)
v_chargeback_risk (contract_id, policy_no, agent_name, lapse_month_diff,
predicted_cb_amount, risk_grade)
```
### V16__관리자_초기데이터.sql
- `users``admin / admin1234!` (BCrypt) + `SUPER_ADMIN` 역할 매핑
- 메뉴 33건 + 메뉴별 권한 150건 + 역할-메뉴-권한 매트릭스 323건
### V17__테스트데이터.sql
- 보험사 5 (SAMSUNG / HANWHA / KYOBO / DB / KB)
- 상품 20 (보험사당 4)
- 조직 7 (본부 1 / 지점 3 / 팀 3) + 설계사 50
- 계약 200 (ACTIVE 190 / LAPSE 10)
- 수수료 규정: commission_rate 100 / payout_rule 120 / override_rule 10 / chargeback_rule 4
---
## 4. 환경변수 / 시크릿
| 키 | 기본값 | 설명 |
|---|---|---|
| `JWT_SECRET` | `replace-me-...` | HS256 256bit+ |
| `ENCRYPT_KEY` | `32byte-key-...` | AES-256 키 (32 byte) |
| `DB_HOST` / `DB_PORT` / `DB_NAME` / `DB_USERNAME` / `DB_PASSWORD` | localhost/5432/ga/ga/ga | DataSource |
| `REDIS_HOST` / `REDIS_PORT` | localhost / 6379 | 선택 (캐시) |
운영 DB 프로파일 (`application-trading_ai.yml`):
```yaml
spring.datasource:
url: jdbc:postgresql://192.168.0.60:55432/trading_ai?currentSchema=ga
username: kyu
password: ${DB_PASSWORD}
spring.flyway.schemas: ga
```
+503
View File
@@ -0,0 +1,503 @@
# 02_COMMON_SPEC — ga-common 공통 프레임워크
> 모든 다른 모듈이 의존하는 핵심. 신입 개발자가 학습하기 쉬운 단순/명시적 코드 우선.
## 패키지 구조
```
ga-common/src/main/java/com/ga/common/
├── annotation/ # @RequirePermission, @DataChangeLog, @ExcelColumn, @Mask
├── aop/ # PermissionAspect, DataChangeLogAspect, ApiLogAspect
├── auth/ # JwtTokenProvider, JwtAuthFilter, LoginUser
├── code/ # CommonCodeService/Controller/Mapper (Redis 캐시)
├── config/ # RedisConfig, SwaggerConfig, WebMvcConfig, AsyncConfig
├── exception/ # ErrorCode (enum), BizException, GlobalExceptionHandler
├── excel/ # ExcelService (Export 100만, Import 70만)
├── file/ # FileService/Controller (multipart 업로드/다운로드)
├── grid/ # GridSearchParam, GridResponse (AG Grid 서버사이드)
├── menu/ # MenuService/Controller, MenuTreeBuilder, PermissionChecker
├── model/ # ApiResponse<T>, PageResponse<T>, SearchParam, TreeNode
├── mybatis/ # BaseMapper, JsonTypeHandler, EncryptTypeHandler(+Initializer), AuditInterceptor
├── notification/ # NotificationService/Controller
├── security/ # SecurityConfig, JwtAuthEntryPoint, AccessDeniedHandlerImpl
├── system/ # SystemConfigService, DataChangeLogService, ApiAccessLogService, SystemLogController
└── util/ # DateUtil, MoneyUtil, MaskUtil, EncryptUtil, SecurityUtil
ga-common/src/main/resources/
├── mybatis/mybatis-config.xml
└── mapper/common/ # CommonCodeMapper.xml, MenuMapper.xml, FileMapper.xml, SystemConfigMapper.xml
```
build.gradle 핵심: `api 'org.springframework.boot:spring-boot-starter-{web,security,aop,validation,data-redis,cache}'`,
`api 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'`,
`api 'com.github.pagehelper:pagehelper-spring-boot-starter:2.1.0'`,
`api 'io.jsonwebtoken:jjwt-api:0.12.5'`, `api 'org.apache.poi:poi-ooxml:5.2.5'`,
`api 'com.monitorjbl:xlsx-streamer:2.2.0'`, `api 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0'`
---
## 1. 공통 모델 (`model/`)
### `ApiResponse<T>` — 모든 API 응답 표준
```java
public class ApiResponse<T> {
private boolean success;
private String code; // "0000" 성공
private String message;
private T data;
private LocalDateTime timestamp;
public static <T> ApiResponse<T> ok(T data) { ... }
public static <T> ApiResponse<T> ok(T data, String msg) { ... }
public static ApiResponse<Void> ok() { ... }
public static <T> ApiResponse<T> fail(ErrorCode e) { ... }
public static <T> ApiResponse<T> fail(String code, String msg) { ... }
}
```
### `PageResponse<T>` — PageHelper 연동
```java
public class PageResponse<T> {
private List<T> list;
private int pageNum, pageSize, pages;
private long total;
public static <T> PageResponse<T> of(List<T> list) {
PageInfo<T> p = new PageInfo<>(list);
// ...
}
}
```
### `SearchParam` — 공통 검색 파라미터
```java
public class SearchParam {
private int pageNum = 1, pageSize = 20;
private String searchType, searchKeyword;
private String startDate, endDate;
private String status;
private String sortField, sortOrder; // ASC / DESC
public void startPage() {
PageHelper.startPage(pageNum, pageSize);
if (sortField != null) PageHelper.orderBy(sortField + " " + sortOrder);
}
}
```
### `TreeNode` — 트리 응답 공통 (메뉴/조직)
```java
public class TreeNode {
private Object id, parentId;
private String name, type;
private int level, sortOrder;
private List<TreeNode> children = new ArrayList<>();
}
```
### `GridSearchParam extends SearchParam` — AG Grid 서버사이드용
```java
public class GridSearchParam extends SearchParam {
private List<Map<String,Object>> filterModel; // AG Grid 필터
private List<Map<String,String>> sortModel; // AG Grid 정렬
public String buildFilterSql() { ... }
}
```
---
## 2. 예외 처리 (`exception/`)
### `ErrorCode` enum
```java
SUCCESS("0000", 200), BAD_REQUEST("E400", 400),
UNAUTHORIZED("E401", 401), FORBIDDEN("E403", 403),
NOT_FOUND("E404", 404), INVALID_PARAMETER("E411", 400),
INTERNAL_ERROR("E500", 500),
TOKEN_EXPIRED("A001", 401), LOGIN_FAIL("A003", 401), ACCOUNT_LOCKED("A004", 403),
ALREADY_CONFIRMED("B001", 400), RULE_VERSION_CONFLICT("B002", 409),
APPROVAL_REQUIRED("B003", 400), SYSTEM_CODE_LOCKED("B008", 400),
DUPLICATE_DATA("B009", 409), BATCH_RUNNING("B010", 409),
FILE_NOT_FOUND("F001", 404), FILE_UPLOAD_FAIL("F002", 500);
```
### `BizException`
```java
@Getter
public class BizException extends RuntimeException {
private final ErrorCode errorCode;
private final String customMessage;
public BizException(ErrorCode e) { ... }
public BizException(ErrorCode e, String msg) { ... }
}
```
### `GlobalExceptionHandler`
- `BizException``ApiResponse.fail(errorCode)`
- `MethodArgumentNotValidException` → 필드별 에러 목록
- `AccessDeniedException` → 403
- `DuplicateKeyException` → "중복된 데이터입니다"
- `Exception` → 500 + log.error
---
## 3. MyBatis 공통 (`mybatis/`)
### `BaseMapper<T>` (선택적 상속)
```java
public interface BaseMapper<T> {
T selectById(Long id);
List<T> selectList(SearchParam param);
int insert(T entity);
int update(T entity);
int deleteById(Long id);
int countByCondition(SearchParam param);
}
```
### `JsonTypeHandler` — JSONB ↔ JsonNode
```java
@MappedTypes(JsonNode.class)
public class JsonTypeHandler extends BaseTypeHandler<JsonNode> { ... }
```
### `EncryptTypeHandler` — AES-256 컬럼 암호화
- **중요**: `@Component` 등록하면 mybatis-spring-boot-starter 가
자동으로 모든 String 컬럼에 적용해버림 → 일반 클래스로 둠.
- 사용처: `<result property="residentNo" column="resident_no"
typeHandler="com.ga.common.mybatis.EncryptTypeHandler"/>` 형태로 컬럼별 명시.
- `EncryptTypeHandlerInitializer` (`@Configuration`) 가 부팅 시
`EncryptUtil` 을 정적 주입.
- 적용 컬럼: `agent.resident_no`, `agent.account_no`, `payment.account_no`.
### `AuditInterceptor` (Plugin)
- `INSERT` 시 `created_at`, `created_by` 자동
- `UPDATE` 시 `updated_at`, `updated_by` 자동
- 현재 로그인 사용자: `SecurityUtil.currentUserId()`
### 공통 SQL 조각 (`mapper/common/common-sql.xml`)
```xml
<sql id="searchCondition">
<if test="searchKeyword != null and searchKeyword != ''">
<choose>
<when test="searchType == 'name'">AND name LIKE CONCAT('%', #{searchKeyword}, '%')</when>
<when test="searchType == 'code'">AND code = #{searchKeyword}</when>
</choose>
</if>
<if test="startDate != null">AND created_at &gt;= #{startDate}::timestamp</if>
<if test="endDate != null">AND created_at &lt;= #{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 은 모든 테스트가 사용해야 함.
+345
View File
@@ -0,0 +1,345 @@
# 03_CORE_SPEC — ga-core (도메인 VO + Mapper + SQL)
> 22 개 테이블의 용도별 VO + Mapper Interface + Mapper XML.
> ga-common 의 표준 패턴(`SearchParam`, `PageResponse`, `BaseMapper`) 을 사용한다.
## 패키지 구조
```
ga-core/src/main/java/com/ga/core/
├── enums/ # AgentStatus, ContractStatus, SettleStatus 등
├── mapper/
│ ├── org/ # GradeMapper, AgentMapper
│ ├── product/ # InsuranceCompanyMapper, ProductMapper, ContractMapper
│ ├── rule/ # RuleMapper (5 테이블 통합)
│ ├── receive/ # ReceiveMapper (5 테이블 통합)
│ ├── ledger/ # RecruitLedgerMapper, MaintainLedgerMapper, ExceptionLedgerMapper
│ ├── settle/ # SettleMasterMapper, OverrideSettleMapper, ChargebackMapper, PaymentMapper
│ ├── batch/ # BatchJobLogMapper, ReconciliationMapper
│ └── user/ # UserMapper, RoleMapper
├── vo/
│ ├── org/ # GradeVO, OrganizationVO/Resp, AgentVO/Resp/SaveReq/SearchParam/ExcelVO, AgentOrgHistoryVO
│ ├── product/ # InsuranceCompanyVO, ProductVO/Resp, ContractVO/Resp/SaveReq/SearchParam
│ ├── rule/ # CommissionRateVO, PayoutRuleVO, OverrideRuleVO, ChargebackRuleVO, ExceptionTypeCodeVO
│ ├── receive/ # CompanyProfileVO, CompanyFieldMappingVO, CodeMappingVO, RawCommissionDataVO, ParseErrorLogVO
│ ├── ledger/ # LedgerVO, LedgerResp, LedgerSearchParam, LedgerExcelVO (3 테이블 공유)
│ ├── settle/ # SettleMasterVO/Resp/SearchParam, OverrideSettleVO, ChargebackVO, PaymentVO
│ ├── batch/ # BatchJobLogVO, ReconciliationVO
│ └── user/ # UserVO/Resp/SaveReq/SearchParam, RoleVO
└── mapstruct/ # AgentMapstruct, ContractMapstruct (SaveReq ↔ VO)
ga-core/src/main/resources/mapper/
├── org/ # GradeMapper.xml, AgentMapper.xml
├── product/ # ContractMapper.xml, ProductMapper.xml, InsuranceCompanyMapper.xml
├── rule/ # RuleMapper.xml
├── receive/ # ReceiveMapper.xml
├── ledger/ # RecruitLedgerMapper.xml, MaintainLedgerMapper.xml, ExceptionLedgerMapper.xml
├── settle/ # SettleMasterMapper.xml, PaymentMapper.xml, OverrideSettleMapper.xml, ChargebackMapper.xml
├── batch/ # BatchJobLogMapper.xml
└── user/ # UserMapper.xml, RoleMapper.xml
```
build.gradle: `implementation project(':ga-common')`.
---
## 1. VO 설계 원칙 ★ 핵심
> **VO 는 테이블 1:1 이 아니라, 하나의 업무 기능당 용도별 3~4 개를 만든다.**
| VO 종류 | 용도 | 네이밍 예 | 특징 |
|---|---|---|---|
| `XxxVO` | 테이블 1:1 매핑 | `AgentVO` | INSERT/UPDATE 전용, 모든 컬럼 |
| `XxxResp` | API 응답 | `AgentResp` | 조인 결과 포함 (조직명/직급명/집계) — 화면 표시 전용 |
| `XxxSaveReq` | API 요청 | `AgentSaveReq` | 사용자 입력 필드만 + `@Valid` (ID/상태/일시 없음) |
| `XxxSearchParam` | 검색 조건 | `AgentSearchParam` | `SearchParam` 상속, 화면별 필터 |
| `XxxExcelVO` | 엑셀 전용 | `AgentExcelVO` | `@ExcelColumn` 어노테이션, 목록과 다른 구성 |
### 예시: 설계사 (`org/`)
```java
// AgentVO — 테이블 1:1
@Data public class AgentVO {
private Long agentId, orgId, gradeId;
private String agentName, residentNo, licenseNo, phone, email, bankCode, accountNo, status;
private LocalDate joinDate, leaveDate;
private LocalDateTime createdAt, updatedAt;
}
// AgentResp — 조인 결과 (화면 표시용)
@Data public class AgentResp {
private Long agentId, orgId, gradeId;
private String agentName, status;
private String statusName; // 공통코드 변환
private String orgName, gradeName;// 조인
private int contractCount; // 집계
private BigDecimal totalAmt; // settle_master 집계
private String phone; // 마스킹
private String licenseNo;
private LocalDate joinDate;
}
// AgentSaveReq — 등록/수정 입력
@Data public class AgentSaveReq {
@NotBlank private String agentName;
@NotNull private Long orgId;
@NotNull private Long gradeId;
private String licenseNo, phone, email, bankCode;
private String residentNo, accountNo; // 서버에서 EncryptTypeHandler 로 암호화
private LocalDate joinDate;
public AgentVO toVO() { ... MapStruct ... }
}
// AgentSearchParam — 검색
@Data
public class AgentSearchParam extends SearchParam {
private Long orgId, gradeId;
private String licenseNo;
// status 는 SearchParam 에 이미 있음
}
// AgentExcelVO — 엑셀 다운로드 전용
public class AgentExcelVO {
@ExcelColumn(header = "설계사번호", order = 1) private Long agentId;
@ExcelColumn(header = "성명", order = 2) private String agentName;
@ExcelColumn(header = "조직", order = 3) private String orgName;
@ExcelColumn(header = "직급", order = 4) private String gradeName;
@ExcelColumn(header = "상태", order = 5, codeGroup = "AGENT_STATUS") private String status;
@ExcelColumn(header = "주민번호", order = 6, masked = true) private String residentNo;
}
```
### Map 사용이 적절한 경우 (VO 대신)
- 통계/리포트 (동적 컬럼)
- 대시보드 집계 (1회성)
- 동적 피벗 결과
- 공통코드 캐시 (key-value)
---
## 2. Mapper Interface 패턴
```java
@Mapper
public interface AgentMapper {
// 조회
AgentResp selectDetailById(@Param("agentId") Long agentId);
AgentVO selectById(@Param("agentId") Long agentId);
List<AgentResp> selectList(AgentSearchParam param);
Cursor<AgentExcelVO> selectCursorForExcel(AgentSearchParam param); // ★ 엑셀 100만건
// 검증
int existsByLicenseNo(@Param("licenseNo") String licenseNo);
// CUD
int insert(AgentVO vo);
int update(AgentVO vo);
int updateStatus(@Param("agentId") Long id, @Param("status") String status);
int updateOrgGrade(@Param("agentId") Long id, @Param("orgId") Long o, @Param("gradeId") Long g);
// 이력
List<AgentOrgHistoryVO> selectHistory(@Param("agentId") Long agentId);
int insertHistory(AgentOrgHistoryVO vo);
}
```
### `BaseMapper<T>` 상속 (선택)
- 단순 CRUD 만 필요한 Mapper 는 `extends BaseMapper<XxxVO>` 로 짧게.
- 복잡한 조인/집계 가 필요하면 직접 정의.
---
## 3. Mapper XML 패턴 (필수 규칙)
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ga.core.mapper.org.AgentMapper">
<!-- 컬럼 암호화는 명시적으로 -->
<resultMap id="AgentVoMap" type="com.ga.core.vo.org.AgentVO">
<id property="agentId" column="agent_id"/>
<result property="orgId" column="org_id"/>
<result property="gradeId" column="grade_id"/>
<result property="agentName" column="agent_name"/>
<result property="residentNo" column="resident_no"
typeHandler="com.ga.common.mybatis.EncryptTypeHandler"/>
<result property="accountNo" column="account_no"
typeHandler="com.ga.common.mybatis.EncryptTypeHandler"/>
<!-- 나머지는 mapUnderscoreToCamelCase 자동 -->
</resultMap>
<resultMap id="AgentRespMap" type="com.ga.core.vo.org.AgentResp">
<id property="agentId" column="agent_id"/>
<result property="orgName" column="org_name"/>
<result property="gradeName" column="grade_name"/>
<result property="residentNo" column="resident_no"
typeHandler="com.ga.common.mybatis.EncryptTypeHandler"/>
</resultMap>
<!-- 목록: Resp 매핑, 조인 포함 -->
<select id="selectList" parameterType="com.ga.core.vo.org.AgentSearchParam"
resultMap="AgentRespMap">
SELECT a.agent_id, a.agent_name, a.status, a.phone, a.license_no, a.join_date,
o.org_name, g.grade_name,
(SELECT COUNT(*) FROM contract c WHERE c.agent_id = a.agent_id) AS contract_count
FROM agent a
JOIN organization o ON a.org_id = o.org_id
JOIN grade g ON a.grade_id = g.grade_id
WHERE 1=1
<if test="orgId != null"> AND a.org_id = #{orgId}</if>
<if test="gradeId != null"> AND a.grade_id = #{gradeId}</if>
<if test="status != null"> AND a.status = #{status}</if>
<if test="licenseNo != null"> AND a.license_no = #{licenseNo}</if>
<if test="searchKeyword != null and searchKeyword != ''">
AND a.agent_name LIKE CONCAT('%', #{searchKeyword}, '%')
</if>
ORDER BY a.agent_id DESC
</select>
<!-- INSERT: VO 만 받음, 조인 필드는 절대 사용 X -->
<insert id="insert" parameterType="com.ga.core.vo.org.AgentVO"
useGeneratedKeys="true" keyProperty="agentId">
INSERT INTO agent (org_id, grade_id, agent_name, resident_no, license_no, phone, email,
bank_code, account_no, status, join_date)
VALUES (#{orgId}, #{gradeId}, #{agentName},
#{residentNo,typeHandler=com.ga.common.mybatis.EncryptTypeHandler},
#{licenseNo}, #{phone}, #{email}, #{bankCode},
#{accountNo,typeHandler=com.ga.common.mybatis.EncryptTypeHandler},
#{status}, #{joinDate})
</insert>
<!-- 엑셀용 Cursor (절대 List 로 받지 말 것) -->
<select id="selectCursorForExcel" resultType="com.ga.core.vo.org.AgentExcelVO">
SELECT a.agent_id, a.agent_name, o.org_name, g.grade_name, a.status,
a.resident_no
FROM agent a JOIN organization o ON a.org_id = o.org_id
JOIN grade g ON a.grade_id = g.grade_id
ORDER BY a.agent_id
</select>
</mapper>
```
### 필수 규칙
1. **`SELECT *` 금지** — 모든 컬럼 명시
2. **목록 SELECT 는 XxxResp 에 매핑** (조인 포함)
3. **INSERT/UPDATE 는 XxxVO 의 필드만** 사용
4. **암호화 컬럼은 `typeHandler` 명시** (resident_no, account_no, payment.account_no)
5. **동적 WHERE**`<if>` / `<choose>` 적극 사용
6. **신입 학습용 주석** — 복잡한 쿼리에 한글 주석
7. **CTE 재귀** — 조직 트리 (organization), 메뉴 트리 (menu)
### CTE 재귀 예시 (organization)
```xml
<select id="selectTree" resultType="com.ga.core.vo.org.OrganizationResp">
WITH RECURSIVE org_tree AS (
SELECT org_id, parent_org_id, org_name, org_type, org_level, 1 AS depth
FROM organization WHERE parent_org_id IS NULL AND is_active = 'Y'
UNION ALL
SELECT o.org_id, o.parent_org_id, o.org_name, o.org_type, o.org_level, t.depth + 1
FROM organization o JOIN org_tree t ON o.parent_org_id = t.org_id
WHERE o.is_active = 'Y'
)
SELECT org_id, parent_org_id, org_name, org_type, org_level
FROM org_tree ORDER BY org_level, org_name
</select>
```
---
## 4. 모듈별 Mapper 요약
### `org/`
- **`GradeMapper`** : selectAll, selectById, insert/update/delete
- **`AgentMapper`** : (위 예시 — 22 개 메서드)
### `product/`
- **`InsuranceCompanyMapper`** : 5 개 보험사 CRUD
- **`ProductMapper`** : 보험사별 상품, insurance_type 별 통계
- **`ContractMapper`** : 검색 (agent/product/status), `existsByPolicyNo`, `countByAgent(agentId, status)`
### `rule/` — `RuleMapper` (단일 인터페이스, 5 테이블 통합)
```java
// 핵심 조회 (배치 계산에서 호출)
BigDecimal findCompanyRate(@Param("productId") Long, @Param("year") Integer,
@Param("baseDate") LocalDate);
BigDecimal findPayoutRate(@Param("gradeId") Long, @Param("insuranceType") String,
@Param("year") Integer, @Param("baseDate") LocalDate);
BigDecimal findChargebackRate(@Param("companyCode") String, @Param("insuranceType") String,
@Param("lapseMonth") Integer, @Param("baseDate") LocalDate);
List<OverrideRuleVO> selectOverrideRules();
List<ExceptionTypeCodeVO> selectExceptionCodes();
// + 5 개 테이블 각각의 CRUD
```
`effective_from <= baseDate < effective_to` 조건 (NULL = 무한). 같은 base 에 여러 행이면 `version DESC` 최신.
### `receive/` — `ReceiveMapper` (5 테이블 통합)
```java
List<CompanyFieldMappingVO> selectFieldMappings(companyCode, targetTable);
String findTargetCode(companyCode, codeType, sourceCode);
int insertRaw(RawCommissionDataVO);
int insertRawBatch(List<RawCommissionDataVO>);
int updateRawStatus(rawId, parseStatus, mappedLedgerId, mappedTable);
int insertError(ParseErrorLogVO);
int resolveError(errorId, status, note, userId);
```
### `ledger/` — 3 개 Mapper (구조 동일)
- `RecruitLedgerMapper`, `MaintainLedgerMapper`, `ExceptionLedgerMapper`
- 모두 `LedgerVO` 사용 (테이블 컬럼 동일)
- 핵심: `selectList(LedgerSearchParam)`, `selectCursorForExcel(...)`,
`batchInsert(List<LedgerVO>)`, `aggregateByAgent(settleMonth)`,
`sumByAgentMonth(agentId, settleMonth)`, `deleteBySettleMonth(settleMonth)`
- `ExceptionLedgerMapper` 추가: `approve(ledgerId, approverId)`, `reject(ledgerId, reason)`
### `settle/`
- **`SettleMasterMapper`** : `upsert(SettleMasterVO)` (PG `ON CONFLICT(agent_id, settle_month) DO UPDATE`),
`confirm(settleId, userId)`, `hold(settleId, reason)`, `selectMonthlySummary(month)`
- **`OverrideSettleMapper`** : 정산별 오버라이드 내역
- **`ChargebackMapper`** : 환수 내역, 분할 회차 (`installment_seq`)
- **`PaymentMapper`** : 이체 상태 추적, `account_no` AES 암호화
### `batch/`
- **`BatchJobLogMapper`** : `request(jobName, settleMonth)`, `start/complete/fail`,
`selectLatest(jobName)`, `existsRunning(jobName)`
### `user/`
- **`UserMapper`** : `selectByLoginId`, `selectMyMenus`, BCrypt 비밀번호 검증, 잠금/실패 카운트
- **`RoleMapper`** : 역할 목록, 사용자-역할 매핑, 역할-메뉴-권한 매트릭스
---
## 5. Enum + MapStruct
### Enum (코드그룹 대응)
```java
public enum AgentStatus { ACTIVE, LEAVE, SUSPEND;
public boolean isActive() { return this == ACTIVE; }
}
public enum ContractStatus { ACTIVE, LAPSE, REVIVED, EXPIRED, CANCELED; }
public enum SettleStatus { DRAFT, CONFIRMED, HOLD, PAID; }
public enum ParseStatus { PENDING, OK, ERROR; }
public enum ApproveStatus { PENDING, APPROVED, REJECTED; }
```
VO 의 String 필드는 enum 으로 받지 말고 **String 그대로** 둔다 (DB 와 1:1, MyBatis 호환성).
서비스 레이어에서 enum 으로 변환해서 사용.
### MapStruct (`mapstruct/`)
```java
@Mapper(componentModel = "spring")
public interface AgentMapstruct {
AgentVO toVO(AgentSaveReq req);
@Mapping(target = "createdAt", ignore = true)
void update(@MappingTarget AgentVO target, AgentSaveReq req);
}
```
`SaveReq → VO` 변환 정도만 사용. `Resp` 는 SQL ResultMap 이 채우므로 MapStruct 불필요.
---
## 6. 테스트 시나리오 (선택)
`ga-core` 의 Mapper XML 검증은 통상 H2 호환 안되어 **Testcontainers PostgreSQL** 또는
운영 DB 의 read-only 트랜잭션으로 통합 테스트. (현재 미구현, 향후 작업.)
+284
View File
@@ -0,0 +1,284 @@
# 04_BATCH_SPEC — ga-batch (정산 배치 8 Step)
> Spring Batch 기반 월별 정산 잡. ga-core 의 Mapper 를 사용. 별도 실행 모듈 (포트 8081).
## 패키지 구조
```
ga-batch/src/main/java/com/ga/batch/
├── GaBatchApplication.java # @SpringBootApplication, scanBasePackages = "com.ga"
├── config/
│ └── BatchConfig.java # MonthlySettlementJob 정의
├── controller/
│ └── JobLauncherController.java # POST /run, GET /status
└── settlement/
├── SettlementContext.java # @JobScope 공유 컨텍스트
├── calc/
│ └── CommissionCalculator.java # 모집/유지 수수료 계산기
├── receive/
│ ├── DataReceiver.java # 전략 패턴 인터페이스
│ └── ManualReceiver.java # 기본 구현 (수동 업로드)
├── transform/
│ └── MappingEngine.java # raw → 표준 원장
└── step/
├── ReceiveDataStep.java # Step 1
├── TransformDataStep.java # Step 2
├── ReconcileStep.java # Step 3
├── CalcRecruitStep.java # Step 4
├── CalcMaintainStep.java # Step 5
├── ChargebackExceptionStep.java # Step 6
├── CalcOverrideStep.java # Step 7
└── AggregateStep.java # Step 8
```
build.gradle:
```groovy
apply plugin: 'org.springframework.boot'
bootJar { archiveFileName = 'ga-batch.jar'; mainClass = 'com.ga.batch.GaBatchApplication' }
jar.enabled = false
dependencies {
implementation project(':ga-core')
implementation 'org.springframework.boot:spring-boot-starter-batch'
}
```
`application.yml`:
```yaml
server.port: 8081
spring:
batch:
jdbc:
initialize-schema: always # BATCH_JOB_* 메타테이블
job: { enabled: false } # 자동 실행 끄기, 컨트롤러로만 트리거
```
---
## 1. SettlementContext (`@JobScope`)
```java
@Component @JobScope @Getter @Setter
public class SettlementContext {
@Value("#{jobParameters['settleMonth']}") private String settleMonth; // "202605"
@Value("#{jobParameters['companyCode']}") private String companyCode; // 선택 (없으면 전체)
@Value("#{jobParameters['jobLogId']}") private Long jobLogId;
// Step 간 공유 카운트
private int receiveCount, transformOk, transformErr, reconcileOk, reconcileDiff;
private int recruitCount, maintainCount, chargebackCount, exceptionCount, overrideCount;
private int aggregateCount;
}
```
---
## 2. Job 정의 (`BatchConfig`)
```java
@Configuration @RequiredArgsConstructor
public class BatchConfig {
private final JobRepository jobRepo;
private final PlatformTransactionManager txMgr;
@Bean
public Job monthlySettlementJob(
ReceiveDataStep s1, TransformDataStep s2, ReconcileStep s3,
CalcRecruitStep s4, CalcMaintainStep s5,
ChargebackExceptionStep s6, CalcOverrideStep s7, AggregateStep s8) {
return new JobBuilder("monthlySettlementJob", jobRepo)
.start(toStep("receive", s1))
.next(toStep("transform", s2))
.next(toStep("reconcile", s3))
.next(toStep("calcRecruit", s4))
.next(toStep("calcMaintain", s5))
.next(toStep("chargeback", s6))
.next(toStep("calcOverride", s7))
.next(toStep("aggregate", s8))
.build();
}
private Step toStep(String name, Tasklet t) {
return new StepBuilder(name, jobRepo).tasklet(t, txMgr).build();
}
}
```
각 Step 은 `Tasklet` (단순 단계) 또는 `Chunk` 기반.
대용량 (Step 4, 5) 은 `MyBatisCursorItemReader` + `ItemWriter` 청크 처리.
---
## 3. 8 Step 명세
### Step 1 — `receiveData`
- 입력: `companyCode` (또는 전체 활성 보험사)
- 처리: `DataReceiver` 전략 패턴
- `Excel/CSV` (수동 업로드 — 기본): `ManualReceiver`
- `EDI` (FTP), `API` (HTTP) 는 향후 확장
- 각 receiver 가 `raw_commission_data` 에 1 행씩 (`raw_content` JSON) 저장
- 우선 구현: `ManualReceiver` 만. 다른 receiver 는 인터페이스만 두고 stub.
- 출력: `ctx.receiveCount`
```java
public interface DataReceiver {
String getMethod(); // EXCEL / CSV / EDI / API / MANUAL
int receive(String companyCode, String settleMonth);
}
```
### Step 2 — `transformData` (★ 핵심)
- 입력: `raw_commission_data` 에서 `parse_status='PENDING'` 인 행
- 처리: `MappingEngine.transform(raw)` 호출
- `company_field_mapping` 로 source → target 매핑
- 변환 규칙: `TRIM / UPPER / LOWER / DATE / DIVIDE / MULTIPLY / CODE_MAP`
- `policy_no``contract` 매칭 → `contract_id`, `agent_id` 채움
- `data_type` 에 따라 `recruit_ledger` / `maintain_ledger` 에 INSERT
- 실패 시 `parse_error_log` 에 적재
- 9 가지 에러 유형:
`INVALID_INPUT / UNKNOWN_TYPE / NO_MAPPING / JSON_PARSE / REQUIRED /
TRANSFORM_FAIL / NO_POLICY / CONTRACT_NOT_FOUND / CODE_MAP_FAIL`
- 출력: `ctx.transformOk`, `ctx.transformErr`
`MappingEngine` 의 핵심 메서드:
```java
@Transactional
public Long transform(RawCommissionDataVO raw) {
// 1) companyCode/rawContent null check
// 2) data_type → target_table (RECRUIT/MAINTAIN)
// 3) selectFieldMappings(companyCode, target_table)
// 4) JSON 파싱 (ObjectMapper)
// 5) 각 매핑별 applyTransform(value, mapping, companyCode) → mapped Map
// 6) policy_no 로 contract 매칭
// 7) buildLedger(mapped, contract, raw) → recruit/maintain mapper insert
}
```
### Step 3 — `reconcile`
- 입력: 보험사 통보 합계 vs 시스템 계산 합계 (`raw_commission_data` 기준)
- 처리: `reconciliation` 테이블에 `expected/actual/diff` 적재
- 차이 임계값(시스템설정 `RECON_DIFF_THRESHOLD`) 초과 시 `recon_status='DIFF'`
- 출력: `ctx.reconcileOk`, `ctx.reconcileDiff`
### Step 4 — `calcRecruit`
- 입력: `recruit_ledger` 의 status='CALCULATED' 또는 신규 계약 (계약일이 정산월 이내)
- 처리: `CommissionCalculator.calcRecruitLedger(contract, settleMonth)`
- `contractDate` 기준 `commission_rate` (commission_year=1) → `companyAmount`
- `gradeId` + `insuranceType` + `commission_year=1` 기준 `payout_rate``agentAmount`
- `tax = agentAmount × 3.3%` (HALF_UP)
- 출력: `ctx.recruitCount`
```java
public LedgerVO calcRecruitLedger(ContractResp c, String settleMonth) {
AgentVO agent = agentMapper.selectById(c.getAgentId());
if (agent == null) return null;
LocalDate base = c.getContractDate() != null ? c.getContractDate() : LocalDate.now();
BigDecimal companyRate = ruleMapper.findCompanyRate(c.getProductId(), 1, base);
if (companyRate == null) companyRate = BigDecimal.ZERO;
BigDecimal payoutRate = ruleMapper.findPayoutRate(agent.getGradeId(),
c.getInsuranceType(), 1, base);
if (payoutRate == null) payoutRate = BigDecimal.ZERO;
BigDecimal premium = MoneyUtil.zero(c.getPremium());
BigDecimal companyAmt = MoneyUtil.pct(premium, companyRate);
BigDecimal agentAmt = MoneyUtil.pct(companyAmt, payoutRate);
BigDecimal tax = MoneyUtil.tax(agentAmt);
// LedgerVO 필드 채워서 반환
}
```
### Step 5 — `calcMaintain`
- 입력: 유지 계약 (`status='ACTIVE'`, `effective_date` 가 1~5 년차 도래)
- 처리: `CommissionCalculator.calcMaintainLedger(contract, settleMonth, year)`
- `commission_year` 만 1 → year (2~5) 로 다름
- 같은 공식 적용
- 출력: `ctx.maintainCount`
### Step 6 — `chargebackException`
- 입력: 정산월 직전 실효 계약 (`status='LAPSE'`, `lapse_date` 가 정산월 범위)
- 처리:
- `ruleMapper.findChargebackRate(companyCode, insuranceType, lapseMonthDiff, base)`
- 환수 계산: `cb_amount = base × cb_rate`
- 분할 회차(`installment_seq`) 정의 시 N 회로 분할
- 결과를 `chargeback` 에 적재
- 예외 자동 생성: `exception_type_code``auto_calc_yn='Y'` 인 코드별로
`exception_ledger` 에 기록 (수동 승인 대기)
- 출력: `ctx.chargebackCount`, `ctx.exceptionCount`
### Step 7 — `calcOverride`
- 입력: Step 4/5 에서 생성된 모집/유지 수수료 합계 (per agent)
- 처리:
- 조직 트리 상향 탐색 (CTE): 설계사 → 팀장 → 지점장 → 본부장
- 각 단계마다 `override_rule` (from_grade → to_grade) 의 `override_pct` 적용
- `cap_amount` 가 있으면 상한 적용
- `override_settle` 에 (from_agent, to_agent, base, pct, amount) 적재
- 출력: `ctx.overrideCount`
### Step 8 — `aggregate`
- 입력: 모든 원장의 설계사별 합계 + 환수 + 오버라이드 + 예외(PLUS/MINUS)
- 처리:
```
gross = recruit + maintain + override + excPlus - chargeback - excMinus
tax = gross × TAX_RATE (system_config 의 TAX_RATE, 기본 3.3%)
net = gross - tax
```
- `settle_master` 에 `(agent_id, settle_month)` UPSERT (`ON CONFLICT DO UPDATE`)
- `status = 'CALCULATED'` (확정은 별도 API)
- 활성 설계사 누락 보충: `agentMapper.selectList(status=ACTIVE)` 로 0 행도 빈 settle 생성
- 출력: `ctx.aggregateCount`
---
## 4. JobLauncherController
```java
@RestController @RequestMapping("/api/batch/settlement")
public class JobLauncherController {
private final JobLauncher launcher;
private final Job monthlySettlementJob;
private final BatchJobLogMapper logMapper;
@PostMapping("/run")
public ApiResponse<Long> run(@RequestParam String settleMonth,
@RequestParam(required=false) String companyCode) {
if (logMapper.existsRunning("monthlySettlementJob") > 0)
throw new BizException(ErrorCode.BATCH_RUNNING);
Long jobLogId = logMapper.request("monthlySettlementJob", settleMonth);
JobParameters p = new JobParametersBuilder()
.addString("settleMonth", settleMonth)
.addString("companyCode", companyCode)
.addLong ("jobLogId", jobLogId)
.addLong ("ts", System.currentTimeMillis()) // 동일 파라미터 재실행
.toJobParameters();
new Thread(() -> launcher.run(monthlySettlementJob, p)).start();
return ApiResponse.ok(jobLogId);
}
@GetMapping("/status")
public ApiResponse<BatchJobLogVO> status(@RequestParam Long jobLogId) {
return ApiResponse.ok(logMapper.selectById(jobLogId));
}
}
```
ga-api 도 동일 엔드포인트 노출 가능 — 배치 서버가 따로 떠 있을 때는
ga-api 가 `batch_job_log.status='REQUESTED'` 로 적재만 하고, ga-batch 가 폴링/직접 호출.
---
## 5. 단위 테스트 (현재 작성됨)
`ga-batch/src/test/java/com/ga/batch/`:
- `settlement/calc/CommissionCalculatorTest` (6) — 모집/유지/폴백/agent 누락
- `settlement/transform/MappingEngineTest` (13) — RECRUIT/MAINTAIN 라우팅,
TRIM/UPPER/LOWER/DATE/DIVIDE/MULTIPLY/CODE_MAP, 9 가지 에러
---
## 6. 향후 확장 (현재 단순화 부분)
1. **DataReceiver**: EDI / FTP / API 구현 (현재 ManualReceiver 만)
2. **MappingEngine**: REPLACE / SUBSTRING / CONCAT / LOOKUP 등 추가 변환 규칙
3. **이체파일**: 은행별 EBCDIC / CSV 출력 어댑터 (Step 9 으로 분리)
4. **재실행**: 특정 Step 부터 재실행 (`/api/batch/settlement/restart?fromStep=4`)
5. **병렬 처리**: Step 4/5 를 `partitioning` 으로 멀티스레드화
+293
View File
@@ -0,0 +1,293 @@
# 05_API_SPEC — ga-api (REST API)
> 모든 Controller / Service 가 동일한 패턴으로 작성된다.
> 신입이 복붙 후 테이블명/필드명만 바꾸면 새 화면이 완성되도록.
## 패키지 구조
```
ga-api/src/main/java/com/ga/api/
├── GaApiApplication.java # @SpringBootApplication(scanBasePackages = "com.ga")
├── auth/
│ ├── AuthController.java # /api/auth/*
│ └── AuthService.java
├── controller/
│ ├── batch/ BatchTriggerController.java
│ ├── ledger/ LedgerController.java # 모집/유지/예외 통합
│ ├── org/ OrganizationController.java, AgentController.java
│ ├── product/ CompanyController.java, ProductController.java, ContractController.java
│ ├── receive/ ReceiveController.java
│ ├── rule/ RuleController.java
│ ├── settle/ SettleController.java, PaymentController.java
│ └── system/ UserController.java, RoleController.java
└── service/
└── (controller 미러 구조 — XxxService.java)
```
build.gradle:
```groovy
apply plugin: 'org.springframework.boot'
bootJar { archiveFileName = 'ga-api.jar'; mainClass = 'com.ga.api.GaApiApplication' }
jar.enabled = false
dependencies {
implementation project(':ga-core')
}
```
`application.yml``01_DB_SCHEMA.md` 의 yml + `server.port: 8080`.
---
## 1. 표준 Controller 패턴
```java
@RestController
@RequestMapping("/api/agents")
@Tag(name = "설계사 관리")
@RequiredArgsConstructor
public class AgentController {
private final AgentService agentService;
@GetMapping
@RequirePermission(menu = "ORG_AGENT", perm = "READ")
public ApiResponse<PageResponse<AgentResp>> list(AgentSearchParam param) {
return ApiResponse.ok(agentService.list(param));
}
@GetMapping("/{id}")
@RequirePermission(menu = "ORG_AGENT", perm = "READ")
public ApiResponse<AgentResp> detail(@PathVariable Long id) {
return ApiResponse.ok(agentService.detail(id));
}
@PostMapping
@RequirePermission(menu = "ORG_AGENT", perm = "CREATE")
@DataChangeLog(menu = "ORG_AGENT", table = "agent")
public ApiResponse<Long> create(@Valid @RequestBody AgentSaveReq req) {
return ApiResponse.ok(agentService.create(req));
}
@PutMapping("/{id}")
@RequirePermission(menu = "ORG_AGENT", perm = "UPDATE")
@DataChangeLog(menu = "ORG_AGENT", table = "agent")
public ApiResponse<Void> update(@PathVariable Long id, @Valid @RequestBody AgentSaveReq req) {
agentService.update(id, req); return ApiResponse.ok();
}
@PatchMapping("/{id}/status")
@RequirePermission(menu = "ORG_AGENT", perm = "UPDATE")
@DataChangeLog(menu = "ORG_AGENT", table = "agent")
public ApiResponse<Void> updateStatus(@PathVariable Long id, @RequestParam String status) {
agentService.updateStatus(id, status); return ApiResponse.ok();
}
@GetMapping("/export")
@RequirePermission(menu = "ORG_AGENT", perm = "EXPORT")
public void export(AgentSearchParam p, HttpServletResponse res) {
agentService.exportExcel(p, res);
}
}
```
규칙:
1. **클래스 어노테이션**: `@RestController @RequestMapping @Tag @RequiredArgsConstructor`
2. **메서드 어노테이션**: `@RequirePermission` + (CUD 일 때) `@DataChangeLog`
3. **응답**: `ApiResponse<T>` / `ApiResponse<PageResponse<T>>` / `ApiResponse<Void>`
4. **요청 바디**: `@Valid @RequestBody XxxSaveReq`
5. **검색**: `XxxSearchParam` (쿼리 파라미터로 자동 바인딩)
6. **PathVariable**: id 는 `Long`
7. **엑셀 export 는 `void` + `HttpServletResponse` 직접 사용**
---
## 2. 표준 Service 패턴
```java
@Service @RequiredArgsConstructor
public class AgentService {
private final AgentMapper mapper;
private final CommonCodeService codeService;
private final AgentMapstruct mapstruct;
private final ExcelService excelService;
public PageResponse<AgentResp> list(AgentSearchParam param) {
param.startPage();
return PageResponse.of(mapper.selectList(param));
}
public AgentResp detail(Long id) {
AgentResp r = mapper.selectDetailById(id);
if (r == null) throw new BizException(ErrorCode.NOT_FOUND);
return r;
}
@Transactional
public Long create(AgentSaveReq req) {
if (req.getLicenseNo() != null && mapper.existsByLicenseNo(req.getLicenseNo()) > 0)
throw new BizException(ErrorCode.DUPLICATE_DATA);
AgentVO vo = mapstruct.toVO(req);
vo.setStatus("ACTIVE");
mapper.insert(vo);
return vo.getAgentId();
}
@Transactional
public void update(Long id, AgentSaveReq req) {
AgentVO vo = mapper.selectById(id);
if (vo == null) throw new BizException(ErrorCode.NOT_FOUND);
mapstruct.update(vo, req);
mapper.update(vo);
}
@Transactional
public void updateStatus(Long id, String status) {
codeService.validateCode("AGENT_STATUS", status);
mapper.updateStatus(id, status);
}
public void exportExcel(AgentSearchParam p, HttpServletResponse res) {
excelService.exportLargeExcel("설계사목록", AgentExcelVO.class,
() -> mapper.selectCursorForExcel(p), res);
}
}
```
규칙:
1. **Service 는 Mapper + 다른 Service 만 의존** (Controller 직접 호출 X)
2. **CUD 메서드는 `@Transactional`**
3. **공통코드 검증은 `codeService.validateCode(group, code)`**
4. **중복 검증 → `DUPLICATE_DATA`, 미존재 → `NOT_FOUND`**
5. **엑셀은 `excelService.exportLargeExcel(fileName, ExcelVO.class, cursorSupplier, response)`**
---
## 3. API 엔드포인트 목록 (현재 구현된 것)
### 인증 (`AuthController` — `/api/auth`)
- `POST /login``{loginId, password}``{accessToken, refreshToken, user}` + `login_log` 적재
- `POST /refresh` — refresh 토큰으로 access 갱신
- `POST /logout` — 클라이언트 토큰 폐기 (서버는 로그만)
- `GET /me` — 현재 사용자 + 역할 + 메뉴 트리
### 조직 / 설계사
- `OrganizationController /api/orgs`
- `GET /tree` — 트리 (CTE 재귀)
- `GET /{id}` / `POST` / `PUT /{id}` / `DELETE /{id}`
- `AgentController /api/agents`
- `GET` (검색+페이징) / `GET /{id}` / `POST` / `PUT /{id}`
- `PATCH /{id}/status` / `PATCH /{id}/transfer` (조직/직급 변경)
- `GET /{id}/history` (조직/직급 이력)
- `GET /export` (엑셀 다운로드)
### 보험상품 / 계약
- `CompanyController /api/companies` — 보험사 5 건 CRUD
- `ProductController /api/products` — 보험사 × 상품
- `ContractController /api/contracts` — 검색/CRUD/엑셀, `policy_no` 중복검증
### 수수료 규정
- `RuleController /api/rules`
- `GET /commission-rates`, `POST`, `PUT /{id}`, `DELETE /{id}`
- `GET /payout-rules`, ... 동일 패턴
- `GET /override-rules`, `GET /chargeback-rules`, `GET /exception-codes`
### 데이터 수신 / 매핑
- `ReceiveController /api/receive`
- `POST /upload?companyCode=&dataType=` — multipart 업로드 → `raw_commission_data` 적재
- `POST /process?companyCode=&settleMonth=``MappingEngine` 으로 변환 호출
- `GET /status?companyCode=&settleMonth=` — 진행 현황
- `GET /errors?companyCode=&resolveStatus=` — 에러 목록
- `PATCH /errors/{errorId}/resolve` — 에러 해결 처리
- 매핑 관리: `GET /mapping/{companyCode}/fields`, `POST/PUT/DELETE`
### 원장 (`LedgerController /api/ledger` — 통합)
- `GET /recruit` (검색+페이징) / `GET /recruit/export` (엑셀)
- `GET /maintain`, `GET /exception` — 동일 패턴
- `POST /exception` — 예외금액 등록 (수동)
- `PATCH /exception/{id}/approve` — 승인 (`@RequirePermission perm=APPROVE`)
- `PATCH /exception/{id}/reject` — 반려
### 정산
- `SettleController /api/settle`
- `GET` (월별 검색) / `GET /{id}` / `GET /summary?settleMonth=`
- `PATCH /{id}/confirm` (`@RequirePermission APPROVE`) — `@Transactional`,
`version` 체크 → `RULE_VERSION_CONFLICT` 가능
- `PATCH /{id}/hold` `{reason}`
- `PaymentController /api/payments`
- `GET` 검색 / `POST` 이체 생성 (`settle_master.status='CONFIRMED'` 인 것만)
- `POST /{id}/transfer-file` 이체파일 생성
- `PATCH /{id}/success`, `PATCH /{id}/fail`
### 배치 트리거 (`BatchTriggerController /api/batch`)
- `POST /settlement/run?settleMonth=&companyCode=``batch_job_log` 적재 + ga-batch 트리거
- `GET /settlement/status?jobLogId=` — 진행 상태
- `POST /settlement/restart?jobLogId=&fromStep=` — 재실행 (선택 구현)
### 시스템관리
- `UserController /api/system/users`
- `GET` / `POST` / `PUT /{id}` / `DELETE /{id}`
- `PATCH /{id}/reset-password` — 임시 비밀번호 발급 (이메일/알림)
- `PATCH /{id}/unlock` — 잠금 해제
- `PUT /{id}/roles` — 역할 매핑
- `RoleController /api/system/roles`
- 역할 CRUD + `GET /{id}/permissions` (메뉴 × 권한 매트릭스)
- `PUT /{id}/permissions` (전체 매트릭스 갱신)
### 공통 (ga-common 에 위치하지만 ga-api 가 노출)
- `MenuController /api/menus``GET /my`, `GET /tree`, CRUD
- `CommonCodeController /api/common/codes` — 그룹/상세 CRUD
- `FileController /api/files` — 업로드/다운로드/목록
- `NotificationController /api/notifications` — 목록/읽음
- `SystemConfigController /api/system/config` — 시스템 설정
- `SystemLogController /api/system/logs` — 로그인/API/변경 이력
---
## 4. 인증 / 권한 흐름
1. **로그인**: `POST /api/auth/login` → JWT 발급
2. **헤더**: 모든 요청에 `Authorization: Bearer <token>`
3. **`JwtAuthFilter`**: SecurityContext 에 `LoginUser` 주입
4. **`@RequirePermission`**: `PermissionAspect``role_menu_permission` 조회 → 없으면 `FORBIDDEN`
5. **`@DataChangeLog`**: 변경 전/후 데이터 비교 후 `data_change_log` JSONB 비동기 적재
6. **`ApiLogAspect`**: 모든 요청 `api_access_log` 비동기 적재 (민감정보 마스킹)
---
## 5. 응답 표준
### 성공
```json
{
"success": true, "code": "0000", "message": "OK",
"data": { ... }, "timestamp": "2026-05-10T12:34:56"
}
```
### 페이징
```json
{
"success": true, "code": "0000",
"data": {
"list": [...], "pageNum": 1, "pageSize": 20, "total": 123, "pages": 7
}
}
```
### 실패
```json
{
"success": false, "code": "B009", "message": "중복된 데이터입니다",
"data": null, "timestamp": "..."
}
```
---
## 6. 테스트 (선택)
- MockMvc 기반 Controller 테스트
- `@WebMvcTest(AgentController.class)` + `@MockBean AgentService`
- 인증/권한 검증 (403 응답)
- 요청 바디 검증 (`@Valid` 실패 시 400)
- WireMock 으로 ga-batch 호출 stub
+307
View File
@@ -0,0 +1,307 @@
# 06_FRONTEND_SPEC — ga-frontend (React 18)
> Vite + React 18 + TypeScript + Ant Design 5 + AG Grid Community + React Query + Zustand.
> ga-common 에서 작성한 공통 컴포넌트(02_COMMON_SPEC §10)를 반드시 재사용.
> **디자인 시스템**: `docs/09_UI_DESIGN.md` 함께 참조 (테마 토큰, 레이아웃 규격, 화면별 와이어프레임)
## 패키지 구조
```
ga-frontend/
├── package.json # AG Grid Community ^31.3, antd ^5.18, react-query ^5.40
├── vite.config.ts # /api → http://localhost:8080 proxy
├── tsconfig.json # strict: true
├── index.html
└── src/
├── main.tsx # ReactDOM.createRoot, QueryClientProvider, ConfigProvider(ko)
├── App.tsx # 라우터 + 전역 메시지/모달
├── api/
│ ├── request.ts # axios + 인터셉터 + ApiResponse 타입
│ ├── auth.ts, agent.ts, contract.ts, ledger.ts, settle.ts, payment.ts,
│ ├── system.ts, menu.ts, code.ts, file.ts, batch.ts
├── components/
│ └── common/
│ ├── CodeSelect.tsx, CodeBadge.tsx, MoneyText.tsx
│ ├── SearchForm.tsx, DataGrid.tsx
│ ├── PermissionButton.tsx, ExcelExportButton.tsx, PageContainer.tsx
├── hooks/
│ ├── useCommonCodes.ts, useMenuTree.ts, usePermission.ts, useSearchParam.ts
├── layouts/
│ └── MainLayout.tsx # Sider + Header + Content + Breadcrumb
├── pages/
│ ├── Dashboard.tsx
│ ├── LoginPage.tsx
│ ├── org/ # AgentList / AgentDetail / AgentForm
│ ├── product/ # ContractList
│ ├── ledger/ # RecruitLedger / ExceptionLedger
│ ├── settle/ # SettleList / BatchRun / PaymentList
│ └── system/ # UserList / RoleList / CodeList
├── router/
│ └── DynamicRouter.tsx # /api/menus/my → React.lazy 매핑
├── stores/
│ ├── authStore.ts (Zustand) # accessToken, user, login/logout
│ ├── menuStore.ts # 메뉴 트리 캐시
│ └── notificationStore.ts # 미읽음 카운트
├── types/
│ └── api.ts, domain.ts
└── utils/
└── formatMoney.ts, formatDate.ts, downloadFile.ts
```
`vite.config.ts`:
```typescript
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } }
}
});
```
---
## 1. 공통 컴포넌트 / 훅
상세 시그니처는 `02_COMMON_SPEC.md §10` 참고. 핵심:
```tsx
// 공통코드 셀렉트
<CodeSelect groupCode="AGENT_STATUS" allowClear />
<CodeBadge groupCode="SETTLE_STATUS" value="CONFIRMED" />
<MoneyText value={123456} sign /> // +123,456 (음수 빨강)
<MonthPicker value={month} onChange={setMonth} />
// 검색폼 — conditions 배열로 자동 렌더링
<SearchForm
conditions={[
{ type: 'text', name: 'searchKeyword', label: '검색어' },
{ type: 'code', name: 'status', label: '상태', groupCode: 'AGENT_STATUS' },
{ type: 'dateRange', name: 'dateRange', label: '기간' },
{ type: 'month', name: 'settleMonth', label: '정산월' },
]}
onSearch={setParam} onReset={resetParam}
/>
// 권한 버튼 — perm 없으면 안 보임
<PermissionButton menuCode="ORG_AGENT" permCode="CREATE" type="primary"
onClick={() => navigate('new')}></PermissionButton>
// 엑셀 다운로드 — 100만건 진행률 표시
<ExcelExportButton url="/api/agents/export" params={searchParam}
fileName="설계사목록" showProgress />
// AG Grid 서버사이드
<DataGrid columnDefs={cols} dataSource={(p) => agentApi.list(p)}
totalRow rowSelection="multiple" />
```
훅:
```tsx
const { data: codes } = useCommonCodes('AGENT_STATUS'); // React Query staleTime 10분
const { canCreate, canExport, canApprove } = usePermission('ORG_AGENT');
const [param, setParam] = useSearchParam({ status: '' }); // URL 동기화
```
---
## 2. AG Grid vs DataTable 사용 기준
| 화면 | 컴포넌트 | 이유 |
|---|---|---|
| 설계사 목록, 사용자 목록 | Ant DataTable | 단순 CRUD, < 100 행 |
| 계약 목록 | Ant DataTable | 검색 위주 |
| **모집/유지/예외 원장** | **AG Grid** | 1만~100만 행, 합계행, 셀 편집 |
| **정산 결과** | **AG Grid** | 설계사 × 항목, 합계행 |
| 수수료율 편집 | AG Grid | 인라인 셀 편집 |
| 지급 관리 | Ant DataTable | 단순 상태 추적 |
판단 규칙:
- 100 건 이상 → AG Grid
- 셀 편집 필요 → AG Grid
- 합계행 (`pinnedBottomRowData`) → AG Grid
- 그 외 단순 목록 → Ant DataTable
---
## 3. 동적 라우터 (`router/DynamicRouter.tsx`)
```tsx
export function DynamicRouter() {
const { menuTree } = useMenuTree(); // /api/menus/my
const flat = flatten(menuTree).filter(m => m.menuType === 'PAGE' && m.componentPath);
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<MainLayout />}>
<Route path="/" element={<Dashboard />} />
{flat.map(m => {
const Page = lazy(() => import(`../pages/${m.componentPath}.tsx`));
return (
<Route key={m.menuId} path={m.menuPath}
element={<Suspense fallback={<Spin />}><Page /></Suspense>} />
);
})}
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
);
}
```
- 로그인 → `/api/menus/my` 호출 → Sider 메뉴 + 라우터 동적 등록
- `menu.component_path``org/AgentList``pages/org/AgentList.tsx` 로 lazy import
- 권한 없는 URL 진입 시 403
---
## 4. 화면 13개 (현재 구현됨)
### 1) `Dashboard.tsx` (`/`)
- 정산 요약 카드 4개: 모집 / 유지 / 예외 / 환수 (월간 합계)
- 배치 진행 상황 (최근 monthlySettlementJob)
- 미해결 대사 오류 알림
- 월별 추이 (recharts `LineChart`)
### 2) `LoginPage.tsx` (`/login`)
- 로그인 폼 (`loginId`, `password`)
- 성공 시 토큰 저장 + 메뉴 트리 fetch + `/` 리다이렉트
### 3~5) `org/AgentList.tsx`, `AgentDetail.tsx`, `AgentForm.tsx` (`/org/agents`)
- 목록 (DataTable, 50명 더미 표시)
- SearchForm: 이름/상태/조직
- 상세 모달 또는 페이지 (탭: 기본정보/조직이력/계약/정산이력)
- 등록/수정 폼 (`AgentSaveReq`, Ant Form + `<Form.Item rules>`)
- 엑셀 다운로드 (`ExcelExportButton`)
### 6) `product/ContractList.tsx` (`/contracts`)
- 200건 더미 표시
- 필터: 상태(ACTIVE/LAPSE), 보험사, 보험종류, 기간
### 7) `ledger/RecruitLedger.tsx` (`/ledger/recruit`)
- AG Grid (서버사이드 페이징/정렬/필터)
- 합계행 (`pinnedBottomRowData`)
- 컬럼: 정산월, 증권번호, 설계사, 보험사, 보험료, 수수료율, 수수료, 세액
- 엑셀 다운로드
### 8) `ledger/ExceptionLedger.tsx` (`/ledger/exception`)
- AG Grid + 등록 모달 (수동 등록)
- 행 액션: 승인 / 반려 (`@RequirePermission perm=APPROVE`)
### 9) `settle/SettleList.tsx` (`/settle`)
- 월별 정산 요약
- 설계사별 카드 / AG Grid (모집/유지/예외/오버라이드/환수/총액/세금/실지급)
- 행 액션: 확정 / 보류
### 10) `settle/BatchRun.tsx` (`/settle/batch`)
- 정산월 선택 → 배치 실행 버튼
- 진행률 폴링 (5초 간격, `GET /api/batch/settlement/status`)
- 8 Step 별 진행 카운트 표시
### 11) `settle/PaymentList.tsx` (`/payments`)
- 지급 상태 (PENDING / SENT / SUCCESS / FAIL)
- 이체파일 생성 / 결과 등록
### 12) `system/UserList.tsx` (`/system/users`)
- 사용자 CRUD, 역할 할당 체크박스
- 비밀번호 초기화 / 잠금 해제 액션
### 13) `system/RoleList.tsx` (`/system/roles`)
- 역할 CRUD + 권한 매트릭스 (메뉴 × 6 권한 체크박스 그리드)
### `system/CodeList.tsx` (`/system/codes`)
- 좌측: 그룹 리스트 (DataTable)
- 우측: 선택한 그룹의 상세 코드 (DataTable)
- `is_system='Y'` 인 그룹은 수정/삭제 disable
---
## 5. 색상 / 디자인 규칙
- **상태 뱃지 색** (`CodeBadge` 자동):
- 초록: CONFIRMED / APPROVED / ACTIVE / PAID / SUCCESS
- 파랑: PENDING / CALCULATED / PROCESSING / SENT
- 빨강: HOLD / SUSPEND / REJECTED / FAIL / LAPSE / ERROR
- 회색: DRAFT / NEW / OPEN / IGNORED
- **금액**: `MoneyText` 사용 (양수 검정 / 음수 빨강 / null `-`)
- **마스킹**: 주민번호/계좌번호는 응답에서 마스킹돼 옴 (`@Mask` 어노테이션)
- **빈 상태**: `<Empty />` (Ant Design)
- **로딩**: `<Spin />`
---
## 6. Zustand Store 패턴
```typescript
interface AuthState {
accessToken: string | null;
user: { userId: number; loginId: string; userName: string; roleCodes: string[] } | null;
login: (token: string, user: any) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
accessToken: localStorage.getItem('accessToken'),
user: JSON.parse(localStorage.getItem('user') ?? 'null'),
login: (token, user) => {
localStorage.setItem('accessToken', token);
localStorage.setItem('user', JSON.stringify(user));
set({ accessToken: token, user });
},
logout: () => {
localStorage.removeItem('accessToken');
localStorage.removeItem('user');
set({ accessToken: null, user: null });
location.href = '/login';
},
}));
```
---
## 7. 신입 개발자 5분 컷 패턴
```tsx
export default function AgentList() {
const { canCreate, canExport } = usePermission('ORG_AGENT');
const [param, setParam] = useSearchParam({ status: '' });
const { data, isLoading } = useQuery({
queryKey: ['agents', param],
queryFn: () => agentApi.list(param),
});
return (
<PageContainer title="설계사 관리">
<SearchForm conditions={[
{ type: 'text', name: 'searchKeyword', label: '설계사명' },
{ type: 'code', name: 'status', label: '상태', groupCode: 'AGENT_STATUS' },
]} onSearch={setParam} />
<Space style={{ marginBottom: 16, justifyContent: 'flex-end' }}>
<PermissionButton menuCode="ORG_AGENT" permCode="CREATE" type="primary"
onClick={() => navigate('/org/agents/new')}></PermissionButton>
{canExport && <ExcelExportButton url="/api/agents/export" params={param}
fileName="설계사목록" showProgress />}
</Space>
<DataTable
loading={isLoading}
dataSource={data?.list ?? []}
rowKey="agentId"
pagination={{ total: data?.total ?? 0, current: param.pageNum, pageSize: param.pageSize }}
columns={[
{ title: '설계사명', dataIndex: 'agentName' },
{ title: '소속', dataIndex: 'orgName' },
{ title: '직급', dataIndex: 'gradeName' },
{ title: '상태', dataIndex: 'status',
render: v => <CodeBadge groupCode="AGENT_STATUS" value={v} /> },
{ title: '계약수', dataIndex: 'contractCount', align: 'right' },
{ title: '수수료', dataIndex: 'totalAmt',
render: v => <MoneyText value={v} />, align: 'right' },
]}
onRow={(r) => ({ onClick: () => navigate(`/org/agents/${r.agentId}`) })}
/>
</PageContainer>
);
}
```
+279
View File
@@ -0,0 +1,279 @@
# 07_INFRA_SPEC — Docker / Nginx / 문서
## 1. docker-compose.yml
루트에 위치. 5 서비스: `postgres / redis / api / batch / frontend`.
```yaml
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: ga
POSTGRES_USER: ga
POSTGRES_PASSWORD: ga
TZ: Asia/Seoul
ports: ["5432:5432"]
volumes: [ga-postgres-data:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ga -d ga"]
interval: 10s
redis:
image: redis:7-alpine
ports: ["6379:6379"]
volumes: [ga-redis-data:/data]
api:
build:
context: .
dockerfile: Dockerfile
args: { MODULE: ga-api }
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_started }
environment:
SPRING_PROFILES_ACTIVE: docker
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/ga
SPRING_DATASOURCE_USERNAME: ga
SPRING_DATASOURCE_PASSWORD: ga
SPRING_DATA_REDIS_HOST: redis
JWT_SECRET: ${JWT_SECRET:-please-change-this-jwt-secret-must-be-256-bits}
ENCRYPT_KEY: ${ENCRYPT_KEY:-please-change-this-32-byte-key!}
ports: ["8080:8080"]
batch:
build: { context: ., dockerfile: Dockerfile, args: { MODULE: ga-batch } }
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_started }
api: { condition: service_started }
environment: # api 와 동일
...
ports: ["8081:8081"]
frontend:
build: { context: ./ga-frontend, dockerfile: Dockerfile }
depends_on: [api]
ports: ["3000:80"]
volumes: { ga-postgres-data: , ga-redis-data: }
```
---
## 2. Dockerfile (루트, ga-api/ga-batch 공용)
멀티스테이지: Gradle 빌드 → 슬림 JRE.
```dockerfile
# stage 1: builder
FROM gradle:8.6-jdk17 AS builder
WORKDIR /workspace
COPY settings.gradle build.gradle gradle.properties ./
COPY ga-common/build.gradle ga-common/
COPY ga-core/build.gradle ga-core/
COPY ga-api/build.gradle ga-api/
COPY ga-batch/build.gradle ga-batch/
COPY ga-admin/build.gradle ga-admin/
RUN gradle dependencies --no-daemon || true
COPY . .
ARG MODULE=ga-api
RUN gradle :${MODULE}:bootJar --no-daemon -x test
RUN cp /workspace/${MODULE}/build/libs/${MODULE}.jar /workspace/app.jar
# stage 2: runtime
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
RUN apk add --no-cache tzdata && cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime
COPY --from=builder /workspace/app.jar /app/app.jar
EXPOSE 8080 8081
ENTRYPOINT ["java","-XX:+UseContainerSupport","-XX:MaxRAMPercentage=75","-jar","/app/app.jar"]
```
---
## 3. ga-frontend/Dockerfile + nginx.conf
```dockerfile
# ga-frontend/Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci || npm install
COPY . .
RUN npm run build # → /app/dist
FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
```
```nginx
# ga-frontend/nginx.conf
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# API 프록시
location /api/ {
proxy_pass http://api:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 100m;
proxy_read_timeout 300s; # 엑셀 대용량 다운로드 대비
}
# 정적 자원 캐시
location ~* \.(?:css|js|woff2?|ttf|svg|png|jpg|jpeg|gif|ico)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800, immutable";
}
}
```
---
## 4. README.md (루트)
작성 항목 (현재 README.md 가 이미 갖춘 형태):
- 기술 스택 요약 표
- 모듈 구조 다이어그램
- **시나리오 A — 운영 DB(`192.168.0.60:55432/trading_ai/ga`) 연결**
- Gradle wrapper 생성 → `bootJar``bootRun --args='--spring.profiles.active=trading_ai'`
- 환경변수 `DB_PASSWORD`, `REDIS_HOST` 오버라이드
- **시나리오 B — 로컬 PostgreSQL** (개발용)
- `docker compose up -d postgres redis``bootRun`
- **시나리오 C — Docker Compose 전체** (운영 흐름)
- `docker compose up -d --build`
- DB 마이그레이션 직접 실행 (Python `migrate.py`)
- 데이터 확인 표 (보험사 5 / 상품 20 / 설계사 50 / 계약 200 / ...)
- 트러블슈팅 FAQ
- `gradle wrapper --gradle-version 8.6`
- `Failed to determine a suitable driver class` → 프로파일 확인
- `NoSuchKey: ga``currentSchema=ga`
- 401 토큰 만료 → 재로그인
- AG Grid 빈 화면 → CSS import 확인
- 주요 기능 (백엔드/도메인/배치 8 Step 요약)
- 보안 (JWT/AES/RBAC/감사로그)
- 진행 상황 체크리스트
- 향후 확장
- 외부 시스템 연결 정보
---
## 5. DEVELOPER_GUIDE.md (신입 개발자 온보딩)
### 1. 환경 설정
- JDK 17 (`java -version` 확인)
- Node 20 + npm
- PostgreSQL 14+ (운영 또는 Docker)
- Redis 7+ (선택)
- IntelliJ IDEA Community + Lombok 플러그인 (자동 설치 권장)
### 2. 프로젝트 구조 한 장 다이어그램
```
ga-common ─┬─> ga-core ─┬─> ga-api (REST API, 8080)
│ ├─> ga-batch (Spring Batch, 8081)
│ └─> ga-admin (예약 모듈)
└─> 다른 모듈 모두 의존 (web/security/aop/cache/mybatis/excel...)
ga-frontend (독립 빌드, dist → nginx)
```
### 3. 새 화면 만들기 Step-by-Step (예: "공지사항")
**Step 1 — DB**: `notice` 테이블 + 메뉴/권한 등록 (V18 마이그레이션)
**Step 2 — VO** (`ga-core/vo/notice/`):
- `NoticeVO.java` (테이블 1:1)
- `NoticeResp.java` (조인 결과)
- `NoticeSaveReq.java` (`@Valid` 검증)
- `NoticeSearchParam.java extends SearchParam`
**Step 3 — Mapper**:
- `mapper/notice/NoticeMapper.java` (`@Mapper`)
- `resources/mapper/notice/NoticeMapper.xml` (기존 AgentMapper.xml 복사 후 수정)
**Step 4 — Service** (`ga-api/service/notice/`):
- `NoticeService.java` (단순 CRUD 패턴 + `@Transactional`)
**Step 5 — Controller**:
- `NoticeController.java``@RequirePermission(menu="NOTICE", perm="...")`
- CUD 메서드에 `@DataChangeLog`
**Step 6 — 프론트** (`ga-frontend/src/`):
- `api/notice.ts` — 함수 5개 (list/detail/create/update/delete)
- `pages/notice/NoticeList.tsx``06_FRONTEND_SPEC.md §7` 패턴
- `pages/notice/NoticeForm.tsx` — Ant Form
**Step 7 — 메뉴 등록**:
- `system/menus` 화면에서 메뉴/권한 등록 → 사용자 역할에 권한 부여
### 4. 자주 쓰는 패턴
- **공통코드 드롭다운**: `<CodeSelect groupCode="..." />`
- **엑셀 다운로드**: `<ExcelExportButton url={...} fileName={...} showProgress />`
- **상태 뱃지**: `<CodeBadge groupCode="..." value={status} />`
- **권한 버튼**: `<PermissionButton menuCode permCode>...</PermissionButton>`
- **AG Grid 서버사이드**: `<DataGrid dataSource={p => api.list(p)} />`
### 5. 코드 컨벤션
- VO 네이밍: `XxxVO / XxxSearchParam / XxxSaveReq / XxxResp / XxxExcelVO`
- Mapper XML: `XxxMapper.xml` (resources/mapper/{도메인}/)
- API URL: `/api/{리소스}s` (복수형, kebab-case)
- 금액: 항상 `BigDecimal` + `MoneyUtil` 헬퍼
- 날짜: `LocalDate / LocalDateTime` (java.util.Date 금지)
- 예외: `BizException(ErrorCode.XXX)` (RuntimeException 직접 throw 금지)
- 로그: `lombok @Slf4j` + `log.info("msg, key={}", value)` (한글 메시지 OK)
### 6. 트러블슈팅 FAQ
- **권한 에러**: `@RequirePermission` 의 menuCode 가 `menu_permission` 에 등록되어 있는지 + 사용자 역할에 권한 부여됐는지
- **공통코드 캐시 안 맞음**: `CommonCodeService.refreshCache(groupCode)` 또는 Redis FLUSH
- **MyBatis 매핑 에러**: ResultMap 의 column / property 명 + `mapUnderscoreToCamelCase` 확인
- **암호화 컬럼 평문 노출**: `EncryptTypeHandler` 가 컬럼별 명시(typeHandler 속성)되어 있는지
- **JSONB null**: `JsonTypeHandler``@MappedTypes(JsonNode.class)` 로 등록됐는지
- **PageHelper 안 먹음**: `param.startPage()` 호출 후 SELECT 1회만 + 같은 트랜잭션
- **AG Grid 빈 화면**: `@ag-grid-community/styles/ag-theme-quartz.css` import 확인
---
## 6. 통합 검증 체크리스트
```bash
# 1) 빌드
./gradlew clean build # 모든 모듈 컴파일 + 단위 테스트 통과
cd ga-frontend && npm install && npm run build # → dist/
# 2) Docker 전체 기동
docker compose up -d --build
# 3) Smoke
curl http://localhost:8080/api/health # {"status":"UP"}
curl http://localhost:3000/ # nginx 200
# 브라우저: admin / admin1234! 로그인 → 메뉴 트리 보임 → 설계사 50 행 표시
# 4) 정산 배치
curl -X POST -H "Authorization: Bearer $TOKEN" \
"http://localhost:8081/api/batch/settlement/run?settleMonth=202605"
# 5) 태그
git tag v1.0.0-alpha
```
기대 결과:
- Flyway V1~V17 자동 적용
- ga-api 8080 / ga-batch 8081 / frontend 3000 모두 OK
- 단위테스트 76+ 건 통과
- 운영 DB 시나리오 A 도 동일 동작 (`--spring.profiles.active=trading_ai`)
+252
View File
@@ -0,0 +1,252 @@
# 08_DBA_SPEC — DB 성능 (인덱스 / 파티셔닝 / 뷰)
> core-architect 의 Mapper XML 작성 완료 후, EXPLAIN ANALYZE 로 검증하고
> V13~V15 마이그레이션을 추가한다.
## 1. 검토 절차
1. core-architect 가 작성한 모든 Mapper XML 의 SELECT 쿼리를 추출
2. 운영 DB 또는 테스트 DB 에서 `EXPLAIN ANALYZE` 실행
3. `Seq Scan` 발견 시 인덱스 후보 정리
4. 통계/리포트 쿼리는 View 또는 Materialized View 로 분리
5. 대용량 테이블은 파티셔닝 검토 (recruit_ledger, api_access_log, data_change_log)
---
## 2. V13__인덱스최적화.sql
이미 생성된 V1~V12 의 인덱스 외에 추가:
```sql
-- settle_master: 정산월 기준 빈번한 조회
CREATE INDEX IF NOT EXISTS idx_sm_month_status ON settle_master(settle_month, status);
CREATE INDEX IF NOT EXISTS idx_sm_agent_month ON settle_master(agent_id, settle_month);
-- recruit_ledger / maintain_ledger: 정산월 + 설계사
CREATE INDEX IF NOT EXISTS idx_rl_month_agent ON recruit_ledger(settle_month, agent_id);
CREATE INDEX IF NOT EXISTS idx_ml_month_agent ON maintain_ledger(settle_month, agent_id);
CREATE INDEX IF NOT EXISTS idx_rl_company ON recruit_ledger(company_code, settle_month);
CREATE INDEX IF NOT EXISTS idx_ml_company ON maintain_ledger(company_code, settle_month);
-- exception_ledger: 정산월 + 승인상태
CREATE INDEX IF NOT EXISTS idx_el_month_approve ON exception_ledger(settle_month, approve_status);
-- contract: 만기/실효 처리용 (부분 인덱스)
CREATE INDEX IF NOT EXISTS idx_contract_lapse ON contract(lapse_date) WHERE lapse_date IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_contract_status_agent ON contract(status, agent_id);
-- chargeback / payment
CREATE INDEX IF NOT EXISTS idx_cb_month ON chargeback(settle_month, status);
CREATE INDEX IF NOT EXISTS idx_pay_month ON payment(pay_date, pay_status);
-- raw / 에러 / 매핑 (부분 인덱스 적극 사용)
CREATE INDEX IF NOT EXISTS idx_raw_status ON raw_commission_data(parse_status, settle_month);
CREATE INDEX IF NOT EXISTS idx_pe_company ON parse_error_log(company_code, resolve_status);
CREATE INDEX IF NOT EXISTS idx_codemap_lookup ON code_mapping(company_code, code_type, source_code)
WHERE is_active = 'Y';
-- 로그 테이블 (DESC 인덱스로 최신순 조회 최적화)
CREATE INDEX IF NOT EXISTS idx_dcl_table_recent ON data_change_log(table_name, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_aal_url_recent ON api_access_log(url, created_at DESC);
```
### 인덱스 설계 원칙
- **WHERE 자주 쓰이는 컬럼 + ORDER BY 컬럼** 을 복합 인덱스로
- **선택도(cardinality) 낮은 컬럼은 후순위** (`status` 보다 `agent_id` 가 앞)
- **부분 인덱스**(`WHERE`) — 활성 데이터만 인덱싱하여 크기 축소
- **DESC 인덱스** — 최신순 조회가 빈번한 로그 테이블
- **외래키는 자동 인덱스 X** — PG 는 FK 컬럼에 자동 인덱스 안 만듦. 직접 추가.
---
## 3. V14__파티셔닝.sql
대용량/시계열 테이블을 RANGE 파티셔닝으로 변환.
### 헬퍼 함수
```sql
CREATE OR REPLACE FUNCTION create_monthly_partition(
parent_table TEXT,
year_month TEXT -- 'YYYYMM'
) RETURNS VOID AS $$
DECLARE
partition_name TEXT;
start_date DATE;
end_date DATE;
BEGIN
partition_name := parent_table || '_p' || year_month;
start_date := to_date(year_month || '01', 'YYYYMMDD');
end_date := start_date + INTERVAL '1 month';
EXECUTE format(
'CREATE TABLE IF NOT EXISTS %I PARTITION OF %I FOR VALUES FROM (%L) TO (%L)',
partition_name, parent_table, start_date, end_date
);
END $$ LANGUAGE plpgsql;
```
### 적용 테이블
- **`recruit_ledger`, `maintain_ledger`**: `settle_month` 기준 (VARCHAR 'YYYYMM')
- 변환 절차: 임시 테이블로 데이터 이전 → 파티션 마스터 생성 → 월별 파티션 생성
- 신규 시스템이면 처음부터 파티션 테이블로 정의 (V5 변경)
- **`api_access_log`, `data_change_log`**: `created_at` (TIMESTAMP) 기준 월별
- 90일 이상 자료는 자동 분리 (별도 cron 으로 detach)
### 자동 파티션 생성 (예약)
```sql
-- 매월 1일 차월 파티션 생성 (pg_cron 또는 외부 스케줄러)
SELECT create_monthly_partition('recruit_ledger', to_char(now() + interval '1 month', 'YYYYMM'));
SELECT create_monthly_partition('api_access_log', to_char(now() + interval '1 month', 'YYYYMM'));
```
### 주의사항
- 파티션 테이블은 글로벌 UNIQUE INDEX 못 만듦 → 파티션 키 포함 UNIQUE 만 가능
- ON CONFLICT 사용 시 모든 파티션에 동일 인덱스 필요
- Flyway 적용 시 운영 데이터가 있으면 별도 데이터 이관 스크립트 필요 (개발 환경에서는 빈 테이블 가정)
---
## 4. V15__뷰.sql
### `v_agent_monthly_summary` — 설계사별 월 실적
```sql
CREATE OR REPLACE VIEW v_agent_monthly_summary AS
SELECT sm.agent_id, a.agent_name, o.org_id, o.org_name, g.grade_name,
sm.settle_month,
sm.recruit_total, sm.maintain_total,
sm.exception_plus, sm.exception_minus,
sm.override_total, sm.chargeback_total,
sm.gross_amount, sm.tax_amount, sm.net_amount, sm.status
FROM settle_master sm
JOIN agent a ON a.agent_id = sm.agent_id
LEFT JOIN organization o ON o.org_id = a.org_id
LEFT JOIN grade g ON g.grade_id = a.grade_id;
```
### `v_org_settle_summary` — 조직별 월 합계
```sql
CREATE OR REPLACE VIEW v_org_settle_summary AS
SELECT a.org_id, o.org_name, o.org_type, sm.settle_month,
COUNT(sm.settle_id) AS agent_count,
COALESCE(SUM(sm.recruit_total), 0) AS recruit_total,
COALESCE(SUM(sm.maintain_total), 0) AS maintain_total,
COALESCE(SUM(sm.gross_amount), 0) AS gross_amount,
COALESCE(SUM(sm.tax_amount), 0) AS tax_amount,
COALESCE(SUM(sm.net_amount), 0) AS net_amount
FROM settle_master sm
JOIN agent a ON a.agent_id = sm.agent_id
JOIN organization o ON o.org_id = a.org_id
GROUP BY a.org_id, o.org_name, o.org_type, sm.settle_month;
```
### `v_chargeback_risk` — 환수 위험 계약
```sql
CREATE OR REPLACE VIEW v_chargeback_risk AS
SELECT cb.cb_id, cb.contract_id, cb.agent_id, a.agent_name, o.org_name,
cb.policy_no, cb.lapse_month, cb.cb_rate, cb.cb_amount,
cb.paid_amount, cb.remain_amount, cb.settle_month, cb.status
FROM chargeback cb
JOIN agent a ON a.agent_id = cb.agent_id
LEFT JOIN organization o ON o.org_id = a.org_id;
```
### Materialized View 후보 (대시보드 차트)
- `mv_monthly_total` — 월별 전사 합계 (`REFRESH MATERIALIZED VIEW` 일 1회)
- `mv_agent_top10` — 설계사 실적 상위 10
---
## 5. 테스트 데이터 (V17 — `infra-reviewer` 가 아닌 dba-architect 가 생성)
| 테이블 | 건수 | 설명 |
|---|---:|---|
| insurance_company | 5 | SAMSUNG / HANWHA / KYOBO / DB / KB |
| product | 20 | 보험사당 4 (생명/건강/저축/연금) |
| organization | 7 | 본부 1 / 지점 3 / 팀 3 |
| agent | 50 | GM 3 / DM 3 / SMGR 6 / SFC 12 / FC 26 |
| contract | 200| ACTIVE 190 / LAPSE 10 |
| commission_rate | 100| 상품 × 1~5년차 |
| payout_rule | 120| grade × insurance_type × year |
| override_rule | 10 | grade pair |
| chargeback_rule | 4 | 보험종 × 실효 월수 |
생성 SQL 패턴:
```sql
-- 50명 설계사 자동 생성
INSERT INTO agent (org_id, grade_id, agent_name, license_no, phone, status, join_date)
SELECT
-- org/grade 분배
CASE WHEN n <= 3 THEN 1 WHEN n <= 6 THEN 2 ... END,
CASE WHEN n <= 3 THEN 5 WHEN n <= 6 THEN 4 ... END,
'설계사_' || LPAD(n::text, 3, '0'),
'L' || LPAD(n::text, 5, '0'),
'010-' || LPAD((1000 + n)::text, 4, '0') || '-' || LPAD(n::text, 4, '0'),
'ACTIVE',
CURRENT_DATE - (n * 30)
FROM generate_series(1, 50) n;
```
---
## 6. PostgreSQL 설정 권장값 (`postgresql.conf`)
```ini
# 메모리 (8GB 서버 기준)
shared_buffers = 2GB # 25%
effective_cache_size = 6GB # 75%
work_mem = 32MB
maintenance_work_mem = 512MB
# WAL / 체크포인트 (대량 INSERT 대비)
wal_buffers = 16MB
checkpoint_completion_target = 0.9
max_wal_size = 4GB
# 병렬
max_parallel_workers_per_gather = 4
max_worker_processes = 8
# 통계 (쿼리 플래너 정확도)
default_statistics_target = 100
# autovacuum (대용량 로그 테이블 대비)
autovacuum_vacuum_scale_factor = 0.05
autovacuum_analyze_scale_factor = 0.025
```
---
## 7. 대량 배치 쿼리 튜닝
### MyBatis 배치 INSERT (현재 구현)
```xml
<insert id="batchInsert" parameterType="list">
INSERT INTO recruit_ledger (contract_id, agent_id, policy_no, ...)
VALUES
<foreach collection="list" item="item" separator=",">
(#{item.contractId}, #{item.agentId}, #{item.policyNo}, ...)
</foreach>
</insert>
```
- 1000 건씩 끊어서 호출 (ExcelService.importLargeExcel 의 batchSize)
- 70만건 INSERT: 약 700 회 호출 → 약 1~2 분 소요
### COPY 명령 활용 (10만건+ 시 10배 빠름)
```java
// JDBC CopyManager 직접 사용
CopyManager copy = new CopyManager((BaseConnection) conn);
copy.copyIn("COPY recruit_ledger FROM STDIN WITH (FORMAT csv)", reader);
```
- 트랜잭션 격리 / 트리거 / 제약 조건 주의
- 향후 70만건 이상 단발성 적재용 옵션으로 추가
---
## 8. 검토 체크리스트 (core-architect 와 협업)
- [ ] 모든 SELECT 쿼리에 `ORDER BY` + `LIMIT/OFFSET`(또는 PageHelper) 적용
- [ ] `LIKE '%키워드%'` 가 자주 쓰이는 컬럼은 `pg_trgm` GIN 인덱스 검토
- [ ] JSONB 컬럼 검색은 `jsonb_path_ops` GIN 인덱스
- [ ] 날짜 범위 검색은 BTREE 인덱스 + WHERE `created_at >= ...`
- [ ] N+1 SELECT 가 발생하지 않도록 ResultMap 의 `<collection>` 검토
- [ ] 정산 배치 Step 4/5 의 SELECT 가 `idx_rl_month_agent` 를 쓰는지 EXPLAIN
+625
View File
@@ -0,0 +1,625 @@
# UI 디자인 시스템 (UI_DESIGN.md)
> GA 수수료 정산 솔루션의 UI 표준. 모든 화면은 이 규칙을 따른다.
> 기술: React 18 + Ant Design 5.x + AG Grid Community
> 신입 개발자가 복붙으로 일관된 UI를 만들 수 있도록 설계.
---
## 1. Ant Design 테마 커스터마이징
### ConfigProvider 설정 (App.tsx)
```tsx
import { ConfigProvider } from 'antd';
import koKR from 'antd/locale/ko_KR';
const theme = {
token: {
// 브랜드 컬러
colorPrimary: '#1677FF',
colorSuccess: '#0F6E56',
colorWarning: '#BA7517',
colorError: '#E24B4A',
colorInfo: '#1677FF',
// 폰트
fontFamily: "'Pretendard', -apple-system, sans-serif",
fontSize: 13,
fontSizeHeading1: 22,
fontSizeHeading2: 18,
fontSizeHeading3: 16,
// 라운딩
borderRadius: 6,
borderRadiusLG: 8,
// 간격
padding: 16,
paddingLG: 24,
margin: 16,
// 배경
colorBgContainer: '#ffffff',
colorBgLayout: '#f5f5f5',
colorBgElevated: '#ffffff',
// 테이블
tablePaddingHorizontal: 12,
tablePaddingVertical: 10,
tableHeaderBg: '#fafafa',
tableRowHoverBg: '#f5f7fa',
tableBorderColor: '#f0f0f0',
},
components: {
// 사이드 메뉴
Menu: {
itemHeight: 40,
itemMarginInline: 8,
itemBorderRadius: 6,
subMenuItemBg: 'transparent',
itemSelectedBg: '#e6f1fb',
itemSelectedColor: '#185fa5',
},
// 테이블
Table: {
headerBg: '#fafafa',
rowHoverBg: '#f5f7fa',
cellPaddingBlock: 10,
cellPaddingInline: 12,
headerSplitColor: '#f0f0f0',
},
// 버튼
Button: {
controlHeight: 32,
controlHeightSM: 28,
paddingInline: 16,
},
// 입력
Input: {
controlHeight: 32,
paddingInline: 10,
},
Select: {
controlHeight: 32,
},
// 카드
Card: {
paddingLG: 16,
},
},
};
<ConfigProvider theme={theme} locale={koKR}>
<App />
</ConfigProvider>
```
### 폰트 설치 (index.html)
```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css" />
```
### 아이콘: Tabler Icons
```bash
pnpm add @tabler/icons-react
```
```tsx
import { IconUsers, IconCalculator, IconDownload } from '@tabler/icons-react';
<IconUsers size={16} />
```
---
## 2. 글로벌 CSS 변수 (styles/variables.css)
```css
:root {
/* 브랜드 */
--ga-primary: #1677FF;
--ga-primary-light: #E6F1FB;
--ga-primary-dark: #0C447C;
/* 상태 색상 (CodeBadge 등) */
--ga-success: #0F6E56;
--ga-success-bg: #E1F5EE;
--ga-warning: #BA7517;
--ga-warning-bg: #FAEEDA;
--ga-danger: #E24B4A;
--ga-danger-bg: #FCEBEB;
--ga-info: #185FA5;
--ga-info-bg: #E6F1FB;
/* 금액 */
--ga-money-plus: #0F6E56;
--ga-money-minus: #E24B4A;
/* 레이아웃 */
--ga-sider-width: 220px;
--ga-sider-collapsed-width: 64px;
--ga-header-height: 48px;
--ga-content-padding: 20px;
--ga-page-max-width: 1400px;
/* 간격 단위 */
--ga-spacing-xs: 4px;
--ga-spacing-sm: 8px;
--ga-spacing-md: 12px;
--ga-spacing-lg: 16px;
--ga-spacing-xl: 24px;
/* 그림자 (최소한만 사용) */
--ga-shadow-card: 0 1px 2px rgba(0,0,0,0.03);
--ga-shadow-dropdown: 0 4px 12px rgba(0,0,0,0.08);
}
```
---
## 3. 레이아웃 규칙
### MainLayout 구조
```
┌─────────────────────────────────────────────┐
│ Header (48px) │
│ [☰] GA 정산시스템 [2026-05 ▼] [🔔3] [관리자]│
├──────┬──────────────────────────────────────┤
│Sider │ Content │
│220px │ Breadcrumb │
│ │ ┌─────────────────────────────────┐ │
│ 메뉴 │ │ 페이지 내용 │ │
│ │ │ │ │
│ │ └─────────────────────────────────┘ │
└──────┴──────────────────────────────────────┘
```
### Header
- 높이: 48px, 배경: 흰색, 하단 border
- 왼쪽: 햄버거 메뉴(접기/펼치기) + 로고 "GA 정산시스템"
- 오른쪽: 정산월 MonthPicker + 알림벨(미읽음 숫자뱃지) + 사용자 Dropdown
### Sider (사이드 메뉴)
- 너비: 220px (접으면 64px, 아이콘만)
- 배경: 흰색
- 메뉴 아이템: 높이 40px, 좌측 아이콘 16px + 텍스트 13px
- 활성 메뉴: 배경 #E6F1FB, 텍스트 #185FA5, font-weight 500
- 서브메뉴: 좌측 30px 인덴트
- 메뉴 데이터: API에서 동적 로드 (GET /api/menus/my)
### Content
- Breadcrumb: 상단 (홈 > 조직관리 > 설계사목록)
- 패딩: 20px
- 최대 너비: 1400px (중앙 정렬)
---
## 4. 페이지 표준 레이아웃 (3가지)
### 타입 A: 목록 페이지 (가장 많이 사용)
```
┌──── 검색 폼 (SearchForm) ─────────────────┐
│ [설계사명 ___] [상태 ▼전체] [기간 ___~___] [조회] [초기화] │
└────────────────────────────────────────────┘
┌──── 버튼 영역 ─────────────────────────────┐
│ [+ 등록] [⬇ 엑셀] │
└────────────────────────────────────────────┘
┌──── 데이터 테이블 ─────────────────────────┐
│ □ 설계사명 소속 직급 수수료합계 상태│
│ □ 김영수 서울1팀 수석 12,450,000 위촉│
│ □ 이미영 경기2팀 설계사 8,320,000 위촉│
├────────────────────────────────────────────┤
│ 총 1,234건 1 2 3 ...
└────────────────────────────────────────────┘
```
- 검색 폼: 회색 배경(#fafafa), border-radius 8px, padding 12px 16px
- 검색 조건: 한 줄에 3~4개, 버튼은 우측
- 버튼 영역: 우측 정렬, 테이블 바로 위 margin-bottom 12px
- 등록 버튼: Primary(파란색), 엑셀: Default(흰색)
- 테이블: antd Table 또는 AG Grid
### 타입 B: 상세 페이지
```
┌──── 기본 정보 카드 ─────────────────────────┐
│ 설계사명: 김영수 소속: 서울1팀 상태: [위촉] │
│ 전화번호: 010-****-5678 이메일: kim@ga.com │
│ [수정] [삭제] │
└─────────────────────────────────────────────┘
┌──── 탭 영역 ────────────────────────────────┐
│ [기본정보] [실적현황] [소속이력] [변경이력] │
├─────────────────────────────────────────────┤
│ 탭 내용... │
└─────────────────────────────────────────────┘
```
- 상단 카드: 흰색 배경, border, border-radius 8px
- 탭: antd Tabs, 카드 안에 포함
- 버튼: 카드 우상단
### 타입 C: 대시보드
```
┌──── 지표 카드 (4열) ───────────────────────┐
│ [모집 2.4억] [유지 8,200만] [환수 1,350만] [순 3.1억]│
└────────────────────────────────────────────┘
┌─── 차트 (2:1) ──────┬─── 현황 ────────────┐
│ 월별 추이 (recharts) │ 배치 진행 상태 │
│ ▆▆▆▇█ │ ● 수신 완료 │
│ │ ● 계산 진행중... │
└─────────────────────┴──────────────────────┘
┌──── 알림/오류 ──────────────────────────────┐
│ [대사불일치] 삼성생명 5월 3건 차이 10분 전 │
│ [파싱에러] 한화생명 12행 형식 오류 1시간 전 │
└────────────────────────────────────────────┘
```
- 지표 카드: 배경 #fafafa, border-radius 8px, padding 12px
- 숫자: 20~22px, weight 500
- 증감: ▲ 초록(양수), ▼ 빨강(음수)
- 차트: recharts, 높이 200~240px
---
## 5. 공통 컴포넌트 스타일 규칙
### CodeBadge (상태 뱃지)
```tsx
// 상태값 → 색상 자동 매핑
const COLOR_MAP = {
// 초록 (긍정적 완료 상태)
CONFIRMED: 'success', APPROVED: 'success', ACTIVE: 'success',
PAID: 'success', COMPLETED: 'success', MATCH: 'success',
// 파랑 (진행 중)
CALCULATED: 'info', PENDING: 'info', PROCESSING: 'info',
REVIEWED: 'info', NEW: 'info',
// 주황 (주의)
HOLD: 'warning', SUSPEND: 'warning', MISMATCH: 'warning',
PARTIAL: 'warning',
// 빨강 (부정적)
REJECTED: 'danger', LEAVE: 'danger', CANCEL: 'danger',
FAILED: 'danger', LAPSED: 'danger',
// 회색 (기본)
DRAFT: 'default', READY: 'default',
};
// 렌더링
<Tag color={COLOR_MAP[value] || 'default'}>{codeName}</Tag>
```
### MoneyText (금액 표시)
```tsx
// 양수: 초록, 음수: 빨강, 0: 회색
// 항상 천 단위 콤마, 정수 표시
<span style={{ color: amount > 0 ? '#0F6E56' : amount < 0 ? '#E24B4A' : '#888' }}>
{amount >= 0 ? '' : '-'}{Math.abs(amount).toLocaleString()}
</span>
```
### SearchForm (검색 폼)
```tsx
// 스타일 규칙
// 배경: #fafafa, border-radius: 8px, padding: 12px 16px
// 조건: Row + Col (span 기준)
// - 텍스트 입력: span=6 (4개/행)
// - Select: span=4 (6개/행까지)
// - DateRange: span=8
// - 버튼: 우측 정렬
<div style={{
background: '#fafafa',
borderRadius: 8,
padding: '12px 16px',
marginBottom: 16,
}}>
<Row gutter={[12, 12]} align="bottom">
<Col span={6}>
<div style={{ fontSize: 12, color: '#999', marginBottom: 4 }}></div>
<Input placeholder="이름 검색" />
</Col>
<Col span={4}>
<div style={{ fontSize: 12, color: '#999', marginBottom: 4 }}></div>
<CodeSelect groupCode="AGENT_STATUS" allowClear placeholder="전체" />
</Col>
<Col span={4} style={{ marginLeft: 'auto' }}>
<Space>
<Button type="primary" icon={<IconSearch size={14}/>}></Button>
<Button type="link"></Button>
</Space>
</Col>
</Row>
</div>
```
### DataTable (목록 테이블)
```tsx
// 스타일 규칙
// 행 높이: 42px (compact)
// 헤더: 배경 #fafafa, font-weight 500, font-size 12px
// 본문: font-size 13px
// hover: 배경 #f5f7fa
// 금액 컬럼: 우측 정렬, MoneyText
// 상태 컬럼: 중앙 정렬, CodeBadge
// 이름 컬럼: font-weight 500, 클릭 시 상세 이동
// 페이지네이션: 우측, "총 N건" 좌측
<Table
size="small"
rowSelection={{ type: 'checkbox' }}
pagination={{
showTotal: (total) => `${total.toLocaleString()}`,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
defaultPageSize: 20,
}}
/>
```
### DataGrid (AG Grid 래퍼)
```tsx
// AG Grid 공통 설정
const defaultColDef = {
sortable: true,
resizable: true,
filter: true,
minWidth: 80,
};
const gridOptions = {
rowHeight: 36,
headerHeight: 38,
// 한글 로케일
localeText: AG_GRID_LOCALE_KO,
// 합계행
pinnedBottomRowData: [summaryRow],
// 셀 스타일
getRowStyle: (params) => {
if (params.node.rowPinned) return { background: '#fafafa', fontWeight: 500 };
},
};
// AG Grid 사용 화면: 원장관리, 정산결과, 수수료율편집, 대사현황
// 나머지는 Ant Design Table
```
---
## 6. 등록/수정 폼 규칙
```tsx
// 폼 레이아웃 규칙
// 2열 배치 (Row > Col span=12)
// 레이블: 12px, color #999, 필수 표시 빨간 *
// 입력: height 32px
// Select: 공통코드는 CodeSelect 사용
// 하단 버튼: 우측 정렬 [취소] [저장]
<Card>
<Form layout="vertical" form={form}>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="설계사명" name="agentName" rules={[{ required: true }]}>
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="소속" name="orgId" rules={[{ required: true }]}>
<TreeSelect treeData={orgTree} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="직급" name="gradeId">
<CodeSelect groupCode="GRADE" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="상태" name="status">
<CodeSelect groupCode="AGENT_STATUS" />
</Form.Item>
</Col>
</Row>
<div style={{ textAlign: 'right', marginTop: 16 }}>
<Space>
<Button onClick={onCancel}></Button>
<Button type="primary" onClick={onSubmit}></Button>
</Space>
</div>
</Form>
</Card>
```
---
## 7. 모달/드로워 규칙
```
// 등록/수정 폼 → Drawer (우측에서 슬라이드)
// 너비: 600px (2열 폼), 400px (단순 폼)
// 제목: "설계사 등록" / "설계사 수정"
// 하단: 취소 + 저장 버튼
// 상세 보기 → Modal
// 너비: 720px
// Descriptions 컴포넌트로 상세 표시
// 확인/경고 → Modal.confirm()
// "정산을 확정하시겠습니까?" [취소] [확정]
// 삭제 → Modal.confirm({ type: 'warning' })
// "삭제하면 복구할 수 없습니다" [취소] [삭제]
```
---
## 8. 화면별 상세 와이어프레임
### 8.1 대시보드 (/)
```
레이아웃: 타입 C (대시보드)
├── 지표 카드 4열: 모집수수료, 유지수수료, 환수금, 순지급액
│ - 이번 달 금액 (억/만 단위 자동)
│ - 전월 대비 증감률 (▲초록/▼빨강)
│ - 카드 클릭 → 해당 원장 페이지 이동
├── 2열 영역
│ ├── 왼쪽(60%): 월별 추이 차트 (recharts BarChart)
│ │ - X축: 최근 6개월, Y축: 금액
│ │ - 시리즈: 모집(파랑), 유지(초록), 환수(빨강)
│ └── 오른쪽(40%): 배치 현황 카드
│ - Step별 상태: ● 완료(초록), ● 진행중(파랑+pulse), ○ 대기(회색)
│ - 각 Step: 이름 + 완료시간/진행시간
│ - 에러 시: ● 빨강 + 에러 메시지
└── 알림/오류 패널
- 대사 불일치, 파싱 에러, 승인 대기 등
- 뱃지 색상: 에러=빨강, 경고=주황, 정보=파랑
- 클릭 → 해당 화면 이동
```
### 8.2 조직관리 (/org)
```
레이아웃: 2열 (좌 300px 트리 + 우 나머지 목록)
├── 좌측: 조직 트리 (antd Tree)
│ - 본사 > 지역본부 > 지점 > 팀
│ - 드래그앤드롭 정렬 (관리자만)
│ - 노드 클릭 → 우측 설계사 목록 필터링
│ - 우클릭 → 추가/수정/삭제 컨텍스트메뉴
└── 우측: 설계사 목록 (타입 A)
- 검색: 이름, 상태, 직급
- 테이블 컬럼: 설계사명, 직급, 상태, 위촉일, 수수료합계
- 행 클릭 → 상세 (타입 B, 탭: 기본정보/실적/이력)
```
### 8.3 원장관리 (/ledger)
```
레이아웃: 탭 3개 (모집/유지/예외)
├── 공통 검색: 정산월, 보험사, 설계사, 상태
├── AG Grid (DataGrid) 사용 ← 대량 데이터
│ - 서버사이드 페이징/정렬/필터
│ - 컬럼 고정: 왼쪽(증권번호, 설계사명), 오른쪽(수수료합계)
│ - 합계행: 하단 고정 (pinnedBottomRow)
│ - 금액 셀: 우측 정렬, MoneyText 스타일
│ - 상태 셀: CodeBadge
├── 예외금액 탭 추가 기능:
│ - [+ 등록] → Drawer (예외유형, 금액, 방향, 설명)
│ - 승인 워크플로우: 작성→대기→승인/반려
│ - 승인 버튼: PermissionButton(permCode="APPROVE")
└── 엑셀 다운로드: ExcelExportButton (100만건, 진행률)
```
### 8.4 정산관리 (/settle)
```
레이아웃: 탭 4개 (배치실행/정산결과/정산상세/대사)
[배치실행 탭]
├── 정산월 선택 + [정산실행] 버튼
├── 진행 상태: Steps 컴포넌트 (8단계)
│ - 각 Step: 이름 + 처리건수 + 소요시간 + 상태아이콘
│ - 진행 중: 폴링으로 실시간 업데이트 (3초 간격)
│ - 에러: Step 클릭 → 에러 상세 모달
└── 완료 시: 결과 요약 카드 (총건수, 성공, 에러, 소요시간)
[정산결과 탭]
├── AG Grid: 설계사별 정산 결과
│ - 컬럼: 설계사명, 소속, 모집, 유지, 예외가산, 예외차감, 오버라이드, 환수, 세전, 세금, 순지급
│ - 합계행
│ - 상태별 필터: 계산완료/검토/확정/지급/보류
│ - 행 클릭 → 정산상세 탭으로 이동
└── 버튼: [일괄확정] [엑셀다운로드]
[정산상세 탭]
├── 설계사 선택 (상단)
├── 요약 카드 4열: 모집합계, 유지합계, 예외합계, 순지급액
└── 탭 5개: 모집수수료 / 유지수수료 / 예외금액 / 오버라이드 / 환수
- 각 탭 AG Grid로 상세 내역 표시
[대사 탭]
├── 보험사별 대사 결과 테이블
│ - 보험사, 보험사건수, 시스템건수, 차이, 보험사금액, 시스템금액, 차이, 상태
│ - 상태: MATCH(초록), MISMATCH(빨강)
└── 불일치 행 클릭 → 상세 모달 (건별 차이 목록)
```
### 8.5 수수료규정 (/rules)
```
레이아웃: 탭 4개 (수수료율/지급률/오버라이드/환수)
각 탭 공통:
├── 검색: 보험사/보험종류/적용기간
├── AG Grid: 셀 편집 가능 (인라인 수정)
│ - 수정된 셀: 노란색 배경 강조
│ - 하단: [저장] [취소] 버튼
├── 버전 관리:
│ - 규정 수정 시 → version 자동 증가 + 기존 규정 effective_to 설정
│ - [이력보기] 버튼 → 버전별 변경 내역 타임라인
└── 신규 등록: [+ 규정 등록] → Drawer
```
### 8.6 시스템관리 (/system)
#### 역할관리 (/system/roles)
```
├── 역할 목록 테이블
└── 역할 클릭 → 권한 매트릭스
┌──────────┬──────┬──────┬──────┬──────┬──────┬──────┐
│ 메뉴 │ 조회 │ 등록 │ 수정 │ 삭제 │ 승인 │ 엑셀 │
├──────────┼──────┼──────┼──────┼──────┼──────┼──────┤
│ 조직관리 │ ☑ │ ☑ │ ☑ │ ☐ │ ☐ │ ☑ │
│ 정산관리 │ ☑ │ ☐ │ ☐ │ ☐ │ ☑ │ ☑ │
│ 시스템관리 │ ☑ │ ☑ │ ☑ │ ☑ │ ☐ │ ☐ │
└──────────┴──────┴──────┴──────┴──────┴──────┴──────┘
[전체선택/해제 토글] [저장]
```
#### 공통코드관리 (/system/codes)
```
2열 레이아웃:
├── 좌측 (300px): 코드 그룹 리스트
│ - 선택 시 우측에 상세 코드 표시
│ - 시스템 코드: 🔒 아이콘 + 수정 불가
└── 우측: 상세 코드 테이블
- 코드값, 코드명, 영문명, 정렬순서, 활성여부
- 인라인 편집 가능 (시스템코드 제외)
```
#### 업로드 템플릿 관리 (/system/upload-templates)
```
2열 레이아웃:
├── 좌측 (300px): 템플릿 목록 (카테고리별 필터)
└── 우측: 컬럼 매핑 편집기
- 각 행: [엑셀 컬럼 입력] → [DB 필드 Select] → [변환규칙 Select] → [파라미터]
- 드래그앤드롭 순서 변경
- [+ 행 추가] [미리보기] [저장]
- 미리보기: 샘플 엑셀 업로드 → 첫 5행 매핑 결과 테이블
```
---
## 9. 반응형 규칙
```
// 이 솔루션은 데스크톱 전용 (1280px 이상)
// 모바일 대응 불필요 (사내 관리 시스템)
// 최소 해상도: 1280 x 720
// 권장 해상도: 1920 x 1080
// 1280px 미만: Sider 자동 접힘 (아이콘만)
// 1920px 이상: Content 최대 너비 1400px 중앙 정렬
```
---
## 10. 신입 개발자 UI 체크리스트
새 화면 만들 때 아래 항목 확인:
- [ ] 검색 폼: SearchForm 컴포넌트 사용, 배경 #fafafa
- [ ] 목록: 단순이면 DataTable, 대량/편집이면 DataGrid
- [ ] 상태 컬럼: CodeBadge 사용 (하드코딩 색상 금지)
- [ ] 금액 컬럼: MoneyText 사용 (양수 초록, 음수 빨강)
- [ ] Select: CodeSelect 사용 (공통코드 연동)
- [ ] 버튼: PermissionButton 사용 (권한 제어)
- [ ] 엑셀: ExcelExportButton 사용
- [ ] 등록/수정: Drawer 사용 (모달 아님)
- [ ] 삭제: Modal.confirm 사용
- [ ] 폼: antd Form + 2열(Col span=12) + layout="vertical"
- [ ] 금액 입력: InputNumber + formatter (천단위 콤마)
- [ ] 날짜: DatePicker + dayjs
- [ ] 정산월: MonthPicker 공통 컴포넌트
+1
View File
@@ -10,4 +10,5 @@ jar.enabled = false
dependencies {
implementation project(':ga-core')
testImplementation 'org.springframework.security:spring-security-test'
}
@@ -0,0 +1,76 @@
package com.ga.api.controller.dict;
import com.ga.api.service.dict.DataDictionaryService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.dict.DataDictionaryFullResp;
import com.ga.core.vo.dict.DataDictionaryResp;
import com.ga.core.vo.dict.DataDictionarySaveReq;
import com.ga.core.vo.dict.DataDictionarySearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "데이터 사전")
@RestController
@RequestMapping("/api/data-dictionary")
@RequiredArgsConstructor
public class DataDictionaryController {
private final DataDictionaryService dataDictionaryService;
@GetMapping
@RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "READ")
public ApiResponse<PageResponse<DataDictionaryResp>> list(@ModelAttribute DataDictionarySearchParam param) {
return ApiResponse.ok(dataDictionaryService.list(param));
}
@GetMapping("/tables")
@RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "READ")
public ApiResponse<List<String>> tables() {
return ApiResponse.ok(dataDictionaryService.tables());
}
@GetMapping("/full")
@RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "READ")
public ApiResponse<List<DataDictionaryFullResp>> full(
@RequestParam(required = false) String tableName) {
return ApiResponse.ok(dataDictionaryService.full(tableName));
}
@GetMapping("/{dictId}")
@RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "READ")
public ApiResponse<DataDictionaryResp> detail(@PathVariable Long dictId) {
return ApiResponse.ok(dataDictionaryService.detail(dictId));
}
@PostMapping
@RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "CREATE")
@DataChangeLog(menu = "SYSTEM_DATA_DICT", table = "data_dictionary")
public ApiResponse<Void> create(@Valid @RequestBody DataDictionarySaveReq req) {
dataDictionaryService.create(req);
return ApiResponse.ok();
}
@PutMapping("/{dictId}")
@RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "UPDATE")
@DataChangeLog(menu = "SYSTEM_DATA_DICT", table = "data_dictionary")
public ApiResponse<Void> update(@PathVariable Long dictId,
@Valid @RequestBody DataDictionarySaveReq req) {
dataDictionaryService.update(dictId, req);
return ApiResponse.ok();
}
@DeleteMapping("/{dictId}")
@RequirePermission(menu = "SYSTEM_DATA_DICT", perm = "DELETE")
@DataChangeLog(menu = "SYSTEM_DATA_DICT", table = "data_dictionary")
public ApiResponse<Void> delete(@PathVariable Long dictId) {
dataDictionaryService.delete(dictId);
return ApiResponse.ok();
}
}
@@ -0,0 +1,69 @@
package com.ga.api.controller.dict;
import com.ga.api.service.dict.DataDomainService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
import com.ga.common.model.PageResponse;
import com.ga.core.vo.dict.DataDomainOptionResp;
import com.ga.core.vo.dict.DataDomainResp;
import com.ga.core.vo.dict.DataDomainSaveReq;
import com.ga.core.vo.dict.DataDomainSearchParam;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "데이터 도메인 사전")
@RestController
@RequestMapping("/api/data-domains")
@RequiredArgsConstructor
public class DataDomainController {
private final DataDomainService dataDomainService;
@GetMapping
@RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "READ")
public ApiResponse<PageResponse<DataDomainResp>> list(@ModelAttribute DataDomainSearchParam param) {
return ApiResponse.ok(dataDomainService.list(param));
}
@GetMapping("/options")
@RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "READ")
public ApiResponse<List<DataDomainOptionResp>> options() {
return ApiResponse.ok(dataDomainService.options());
}
@GetMapping("/{domainCode}")
@RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "READ")
public ApiResponse<DataDomainResp> detail(@PathVariable String domainCode) {
return ApiResponse.ok(dataDomainService.detail(domainCode));
}
@PostMapping
@RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "CREATE")
@DataChangeLog(menu = "SYSTEM_DATA_DOMAIN", table = "data_domain")
public ApiResponse<Void> create(@Valid @RequestBody DataDomainSaveReq req) {
dataDomainService.create(req);
return ApiResponse.ok();
}
@PutMapping("/{domainCode}")
@RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "UPDATE")
@DataChangeLog(menu = "SYSTEM_DATA_DOMAIN", table = "data_domain")
public ApiResponse<Void> update(@PathVariable String domainCode,
@Valid @RequestBody DataDomainSaveReq req) {
dataDomainService.update(domainCode, req);
return ApiResponse.ok();
}
@DeleteMapping("/{domainCode}")
@RequirePermission(menu = "SYSTEM_DATA_DOMAIN", perm = "DELETE")
@DataChangeLog(menu = "SYSTEM_DATA_DOMAIN", table = "data_domain")
public ApiResponse<Void> delete(@PathVariable String domainCode) {
dataDomainService.delete(domainCode);
return ApiResponse.ok();
}
}
@@ -1,5 +1,6 @@
package com.ga.api.controller.settle;
import com.ga.api.service.transfer.TransferFileService;
import com.ga.common.annotation.DataChangeLog;
import com.ga.common.annotation.RequirePermission;
import com.ga.common.model.ApiResponse;
@@ -7,6 +8,8 @@ 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.SettleSearchParam;
import com.ga.core.vo.settle.TransferFileResp;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@@ -20,6 +23,7 @@ import java.util.Map;
public class PaymentController {
private final PaymentMapper mapper;
private final TransferFileService transferFileService;
@GetMapping
@RequirePermission(menu = "PAYMENT", perm = "READ")
@@ -35,4 +39,14 @@ public class PaymentController {
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(
@RequestParam String settleMonth,
@RequestParam String bankCode) {
return ApiResponse.ok(transferFileService.generate(settleMonth, bankCode));
}
}
@@ -0,0 +1,76 @@
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 lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.Map;
@Tag(name = "엑셀 업로드")
@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,
@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());
}
}
@@ -0,0 +1,140 @@
package com.ga.api.controller.upload;
import com.ga.common.annotation.DataChangeLog;
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.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 io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "업로드 템플릿 관리")
@RestController
@RequestMapping("/api/upload-templates")
@RequiredArgsConstructor
public class UploadTemplateController {
private final UploadTemplateMapper templateMapper;
private final UploadColumnMapper columnMapper;
private final UploadHistoryMapper historyMapper;
@Operation(summary = "템플릿 목록 조회")
@GetMapping
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ")
public ApiResponse<PageResponse<UploadTemplateResp>> list(@ModelAttribute UploadTemplateSearchParam param) {
param.startPage();
return ApiResponse.ok(PageResponse.of(templateMapper.selectList(param)));
}
@Operation(summary = "템플릿 상세 조회 (컬럼 포함)")
@GetMapping("/{templateId}")
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "READ")
public ApiResponse<UploadTemplateResp> detail(@PathVariable Long templateId) {
UploadTemplateResp resp = templateMapper.selectById(templateId);
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
resp.setColumns(columnMapper.selectByTemplateId(templateId));
return ApiResponse.ok(resp);
}
@Operation(summary = "템플릿 등록")
@PostMapping
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "CREATE")
@DataChangeLog(menu = "SYSTEM_UPLOAD_TEMPLATE", table = "upload_template")
@Transactional
public ApiResponse<Long> create(@Valid @RequestBody 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 ApiResponse.ok(vo.getTemplateId());
}
@Operation(summary = "템플릿 수정")
@PutMapping("/{templateId}")
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "UPDATE")
@DataChangeLog(menu = "SYSTEM_UPLOAD_TEMPLATE", table = "upload_template")
@Transactional
public ApiResponse<Void> update(@PathVariable Long templateId,
@Valid @RequestBody 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());
}
}
return ApiResponse.ok();
}
@Operation(summary = "템플릿 삭제")
@DeleteMapping("/{templateId}")
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "DELETE")
@DataChangeLog(menu = "SYSTEM_UPLOAD_TEMPLATE", table = "upload_template")
@Transactional
public ApiResponse<Void> delete(@PathVariable Long templateId) {
if (templateMapper.selectById(templateId) == null) {
throw new BizException(ErrorCode.NOT_FOUND);
}
columnMapper.deleteByTemplateId(templateId);
templateMapper.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(columnMapper.selectByTemplateId(templateId));
}
@Operation(summary = "템플릿 컬럼 전체 갱신 (기존 삭제 후 재등록)")
@PutMapping("/{templateId}/columns")
@RequirePermission(menu = "SYSTEM_UPLOAD_TEMPLATE", perm = "UPDATE")
@DataChangeLog(menu = "SYSTEM_UPLOAD_TEMPLATE", table = "upload_template_column")
@Transactional
public ApiResponse<Void> updateColumns(@PathVariable Long templateId,
@RequestBody 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);
}
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) {
param.startPage();
return ApiResponse.ok(PageResponse.of(historyMapper.selectByTemplate(templateId, param)));
}
}
@@ -0,0 +1,73 @@
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);
}
}
}
@@ -0,0 +1,68 @@
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);
}
}
}
@@ -0,0 +1,59 @@
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;
@Transactional
public TransferFileResp generate(String settleMonth, String bankCode) {
// 1) 해당 정산월 + 은행의 PENDING payment 조회 (accountNo는 EncryptTypeHandler로 자동복호화됨)
List<PaymentVO> payments = paymentMapper.selectPendingByMonthAndBank(settleMonth, bankCode);
if (payments.isEmpty()) {
throw new BizException(ErrorCode.NOT_FOUND, "이체 대상 지급 건이 없습니다 (settleMonth=" + settleMonth + ", bankCode=" + bankCode + ")");
}
// 2) 은행별 생성기로 byte[] 생성 (accountNo는 이미 복호화된 상태)
BankTransferFileGenerator generator = factory.get(bankCode);
byte[] fileContent = generator.generate(payments, settleMonth);
// 3) FileService로 file_storage 적재 → fileId 반환
String fileName = "transfer_" + settleMonth + "_" + bankCode + generator.getFileExtension();
Long fileId = fileService.saveBytes(fileContent, fileName, "TRANSFER_FILE", "application/octet-stream");
// 4) payment들의 transfer_file_id 갱신 + status=SENT
List<Long> paymentIds = payments.stream()
.map(PaymentVO::getPaymentId)
.collect(Collectors.toList());
paymentMapper.updateTransferFile(paymentIds, fileId);
// 5) 합계 금액 계산
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);
}
}
@@ -0,0 +1,23 @@
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);
}
@@ -0,0 +1,105 @@
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 변환 규칙 최적화 캐시.
*
* processUpload() 시작 시 LOOKUP 컬럼의 참조 테이블을 한 번에 로드하여
* 행 처리마다 DB 조회하는 것을 방지한다 (70만건 × DB 조회 = 지옥 → HashMap.get() = 즉시).
*
* 캐시 구조: 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}$");
// 요청 범위 캐시 (processUpload 한 번에만 유효)
private final Map<String, Map<String, Object>> cache = new HashMap<>();
/**
* LOOKUP 컬럼 목록에서 필요한 참조 데이터를 한 번에 preload.
*/
public void preload(List<UploadTemplateColumnVO> columns) {
cache.clear();
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());
}
});
}
/**
* O(1) 조회.
*/
public Object lookup(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);
}
}
}
@@ -0,0 +1,21 @@
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);
}
@@ -0,0 +1,137 @@
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());
}
}
@@ -0,0 +1,293 @@
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 템플릿 컬럼 정의
* @return { targetField → 변환된 값 }
*/
public Map<String, Object> transform(Row row, List<UploadTemplateColumnVO> columns) {
Map<String, Object> result = new LinkedHashMap<>();
for (UploadTemplateColumnVO col : columns) {
try {
Object rawValue = extractRaw(row, col);
Object transformed = applyTransform(rawValue, col, row, columns);
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) {
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);
case "CODE_MAP" -> applyCodeMap(value, col);
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) {
if (value == null) return null;
Object result = lookupCache.lookup(
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) {
if (value == null) return null;
Object mapped = lookupCache.lookup(
"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);
}
}
}
@@ -0,0 +1,44 @@
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();
}
}
}
@@ -0,0 +1,459 @@
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.UploadHistoryMapper;
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.transaction.annotation.Transactional;
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 없음)
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UploadService {
private final UploadTemplateMapper templateMapper;
private final UploadColumnMapper columnMapper;
private final UploadHistoryMapper historyMapper;
private final TransformEngine transformEngine;
private final LookupCache lookupCache;
private final DynamicInsertMapper dynamicInsertMapper;
private final FileService fileService;
/** 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"
);
// ============================================================
// 메인: 업로드 처리
// ============================================================
@Transactional
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 캐시 사전 로드 (70만건 × DB 조회 방지)
lookupCache.preload(columns);
// 3. history 레코드 생성 (PROCESSING 상태)
UploadHistoryVO history = initHistory(template, file);
historyMapper.insert(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);
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);
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) {
dynamicInsertMapper.insertBatch(template.getTargetTable(), batch);
successCount += batch.size();
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()) {
dynamicInsertMapper.insertBatch(template.getTargetTable(), batch);
successCount += batch.size();
batch.clear();
}
} catch (BizException e) {
updateHistoryFail(history.getHistoryId(), e.getDisplayMessage());
throw e;
} catch (Exception e) {
log.error("Upload processing error: templateCode={}", templateCode, e);
updateHistoryFail(history.getHistoryId(), e.getMessage());
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());
}
historyMapper.updateStatus(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());
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));
} 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 updateHistoryFail(Long historyId, String message) {
historyMapper.updateStatus(historyId, "FAIL", message,
0, 0, 0, 0, null, 0);
}
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);
}
}
}
}
@@ -0,0 +1,27 @@
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"));
}
}
@@ -0,0 +1,24 @@
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);
}
@@ -0,0 +1,69 @@
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;
}
}
@@ -0,0 +1,82 @@
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,3 +25,7 @@ spring:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
# 로컬 검증 시 Redis 미설치면 REDIS_DISABLED=true 로 띄움 → 인메모리 캐시 폴백
autoconfigure:
exclude: ${REDIS_AUTOCONFIG_EXCLUDE:}
@@ -0,0 +1,42 @@
<?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>
@@ -0,0 +1,16 @@
<?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>
@@ -0,0 +1,150 @@
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());
}
}
@@ -0,0 +1,143 @@
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());
}
}
@@ -0,0 +1,147 @@
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());
}
}
@@ -0,0 +1,129 @@
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());
}
}
@@ -0,0 +1,149 @@
package com.ga.api.controller.system;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ga.api.service.system.UserService;
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.user.UserResp;
import com.ga.core.vo.user.UserSaveReq;
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 UserControllerTest extends AbstractControllerTest {
@Autowired ObjectMapper objectMapper;
@MockBean UserService service;
@MockBean PermissionChecker permissionChecker;
@BeforeEach
void permitAll() {
given(permissionChecker.hasPermission(anyLong(), anyString(), anyString())).willReturn(true);
}
// ---- 픽스처 ----
private UserResp sampleUser() {
UserResp r = new UserResp();
r.setUserId(99L);
r.setLoginId("admin");
r.setUserName("관리자");
r.setStatus("ACTIVE");
return r;
}
private UserSaveReq validReq() {
UserSaveReq req = new UserSaveReq();
req.setLoginId("newuser");
req.setUserName("신규사용자");
req.setEmail("new@example.com");
return req;
}
// ---- 테스트 ----
@Test
@DisplayName("GET /api/system/users - 200 사용자 목록")
void list_ok() throws Exception {
PageResponse<UserResp> page = PageResponse.<UserResp>builder()
.list(List.of(sampleUser()))
.pageNum(1).pageSize(10).total(1).pages(1)
.build();
given(service.list(any())).willReturn(page);
mockMvc.perform(get("/api/system/users").with(MockLoginUser.admin()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.list[0].loginId").value("admin"));
}
@Test
@DisplayName("GET /api/system/users/{id} - 200 사용자 상세")
void detail_ok() throws Exception {
given(service.detail(99L)).willReturn(sampleUser());
mockMvc.perform(get("/api/system/users/99").with(MockLoginUser.admin()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.userId").value(99));
}
@Test
@DisplayName("POST /api/system/users - 200 사용자 등록 성공")
void create_ok() throws Exception {
given(service.create(any())).willReturn(99L);
mockMvc.perform(post("/api/system/users")
.with(MockLoginUser.admin())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(validReq())))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data").value(99));
}
@Test
@DisplayName("POST /api/system/users - 400 loginId 누락")
void create_missingLoginId() throws Exception {
UserSaveReq req = new UserSaveReq();
req.setUserName("신규사용자");
// loginId 없음
mockMvc.perform(post("/api/system/users")
.with(MockLoginUser.admin())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(req)))
.andExpect(status().isBadRequest());
}
@Test
@DisplayName("PUT /api/system/users/{id}/reset-password - 200 비밀번호 초기화")
void resetPassword_ok() throws Exception {
willDoNothing().given(service).resetPassword(99L);
mockMvc.perform(put("/api/system/users/99/reset-password").with(MockLoginUser.admin()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true));
}
@Test
@DisplayName("PUT /api/system/users/{id}/unlock - 200 계정 잠금 해제")
void unlock_ok() throws Exception {
willDoNothing().given(service).unlock(99L);
mockMvc.perform(put("/api/system/users/99/unlock").with(MockLoginUser.admin()))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true));
}
@Test
@DisplayName("GET /api/system/users - 401 인증 없는 요청")
void list_unauthenticated() throws Exception {
mockMvc.perform(get("/api/system/users"))
.andExpect(status().isUnauthorized());
}
@Test
@DisplayName("POST /api/system/users - 403 권한 없는 요청")
void create_forbidden() throws Exception {
given(permissionChecker.hasPermission(anyLong(), eq("SYSTEM_USER"), eq("CREATE"))).willReturn(false);
mockMvc.perform(post("/api/system/users")
.with(MockLoginUser.admin())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(validReq())))
.andExpect(status().isForbidden());
}
}
@@ -0,0 +1,133 @@
package com.ga.api.support;
import com.ga.common.auth.JwtAuthFilter;
import com.ga.common.auth.JwtTokenProvider;
import com.ga.common.code.CommonCodeMapper;
import com.ga.common.file.FileMapper;
import com.ga.common.menu.MenuMapper;
import com.ga.common.notification.NotificationMapper;
import com.ga.common.system.ApiAccessLogService;
import com.ga.common.system.DataChangeLogService;
import com.ga.common.system.SystemConfigMapper;
import com.ga.common.system.SystemLogMapper;
import com.ga.core.mapper.ledger.ExceptionLedgerMapper;
import com.ga.core.mapper.ledger.MaintainLedgerMapper;
import com.ga.core.mapper.ledger.RecruitLedgerMapper;
import com.ga.core.mapper.org.AgentMapper;
import com.ga.core.mapper.org.GradeMapper;
import com.ga.core.mapper.org.OrganizationMapper;
import com.ga.core.mapper.product.ContractMapper;
import com.ga.core.mapper.product.InsuranceCompanyMapper;
import com.ga.core.mapper.product.ProductMapper;
import com.ga.core.mapper.receive.ReceiveMapper;
import com.ga.core.mapper.rule.RuleMapper;
import com.ga.core.mapper.settle.BatchJobLogMapper;
import com.ga.core.mapper.settle.ChargebackMapper;
import com.ga.core.mapper.settle.OverrideSettleMapper;
import com.ga.core.mapper.settle.PaymentMapper;
import com.ga.core.mapper.settle.ReconciliationMapper;
import com.ga.core.mapper.settle.SettleMasterMapper;
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.mapper.user.RoleMapper;
import com.ga.core.mapper.user.UserMapper;
import com.ga.api.service.upload.LookupMapper;
import com.ga.api.service.upload.DynamicInsertMapper;
import jakarta.servlet.FilterChain;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
/**
* Controller 테스트 베이스.
*
* @SpringBootTest + @AutoConfigureMockMvc 로 전체 Spring MVC 컨텍스트 사용.
* application-test.yml 로 DB/Redis/Cache 자동설정 제외.
* 모든 MyBatis Mapper 를 @MockBean 으로 등록하여 SqlSessionFactory 없이 동작.
*
* 각 테스트는:
* - 사용할 Service 를 @MockBean 으로 추가
* - PermissionChecker 를 @MockBean 으로 추가 후 hasPermission() stub
* - MockLoginUser.admin() 으로 인증된 요청 실행
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Import(TestSecurityConfig.class)
public abstract class AbstractControllerTest {
@Autowired protected MockMvc mockMvc;
// ---- 인증 ----
@MockBean protected JwtAuthFilter jwtAuthFilter;
@MockBean protected JwtTokenProvider jwtTokenProvider;
/**
* JwtAuthFilter 는 OncePerRequestFilter 를 상속한다.
* @MockBean 으로 교체하면 doFilter() 가 chain.doFilter() 를 호출하지 않아
* 요청이 DispatcherServlet 에 도달하지 못한다.
* 이를 해결하기 위해 public doFilter() 를 pass-through 로 stub 한다.
*/
@BeforeEach
void stubJwtFilter() throws Exception {
doAnswer(inv -> {
jakarta.servlet.ServletRequest req = inv.getArgument(0);
jakarta.servlet.ServletResponse res = inv.getArgument(1);
FilterChain chain = inv.getArgument(2);
chain.doFilter(req, res);
return null;
}).when(jwtAuthFilter).doFilter(
any(jakarta.servlet.ServletRequest.class),
any(jakarta.servlet.ServletResponse.class),
any(FilterChain.class));
}
// ---- 로그 서비스 ----
@MockBean protected DataChangeLogService dataChangeLogService;
@MockBean protected ApiAccessLogService apiAccessLogService;
// ---- 공통 Mapper ----
@MockBean protected CommonCodeMapper commonCodeMapper;
@MockBean protected FileMapper fileMapper;
@MockBean protected MenuMapper menuMapper;
@MockBean protected NotificationMapper notificationMapper;
@MockBean protected SystemConfigMapper systemConfigMapper;
@MockBean protected SystemLogMapper systemLogMapper;
// ---- 도메인 Mapper ----
@MockBean protected AgentMapper agentMapper;
@MockBean protected GradeMapper gradeMapper;
@MockBean protected OrganizationMapper organizationMapper;
@MockBean protected ContractMapper contractMapper;
@MockBean protected InsuranceCompanyMapper insuranceCompanyMapper;
@MockBean protected ProductMapper productMapper;
@MockBean protected RecruitLedgerMapper recruitLedgerMapper;
@MockBean protected MaintainLedgerMapper maintainLedgerMapper;
@MockBean protected ExceptionLedgerMapper exceptionLedgerMapper;
@MockBean protected ReceiveMapper receiveMapper;
@MockBean protected RuleMapper ruleMapper;
@MockBean protected SettleMasterMapper settleMasterMapper;
@MockBean protected PaymentMapper paymentMapper;
@MockBean protected ChargebackMapper chargebackMapper;
@MockBean protected OverrideSettleMapper overrideSettleMapper;
@MockBean protected ReconciliationMapper reconciliationMapper;
@MockBean protected BatchJobLogMapper batchJobLogMapper;
@MockBean protected UploadHistoryMapper uploadHistoryMapper;
@MockBean protected UploadTemplateMapper uploadTemplateMapper;
@MockBean protected UploadColumnMapper uploadColumnMapper;
@MockBean protected UserMapper userMapper;
@MockBean protected RoleMapper roleMapper;
// ---- ga-api 내부 Mapper ----
@MockBean protected LookupMapper lookupMapper;
@MockBean protected DynamicInsertMapper dynamicInsertMapper;
}
@@ -0,0 +1,37 @@
package com.ga.api.support;
import com.ga.common.auth.LoginUser;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors;
import org.springframework.test.web.servlet.request.RequestPostProcessor;
import java.util.List;
/**
* 테스트에서 LoginUser 를 SecurityContext 에 직접 주입하는 헬퍼.
*
* 사용 예:
* mockMvc.perform(get("/api/agents").with(MockLoginUser.admin()))
*/
public final class MockLoginUser {
private MockLoginUser() {}
/** userId=1, roleCode=SUPER_ADMIN 인 관리자 사용자 */
public static RequestPostProcessor admin() {
return asUser(1L, "admin", List.of("SUPER_ADMIN"));
}
public static RequestPostProcessor asUser(Long userId, String loginId, List<String> roles) {
LoginUser loginUser = LoginUser.builder()
.userId(userId)
.loginId(loginId)
.userName("테스트사용자")
.roleCodes(roles)
.build();
Authentication auth = new UsernamePasswordAuthenticationToken(
loginUser, null, loginUser.getAuthorities());
return SecurityMockMvcRequestPostProcessors.authentication(auth);
}
}
@@ -0,0 +1,53 @@
package com.ga.api.support;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
/**
* 테스트용 보안 설정.
* - JWT 필터 없이 인증 여부만 체크
* - 미인증 요청 → 401
* - WebMvcConfig 를 포함하지 않으므로 Jackson 기본 설정 사용
*/
@TestConfiguration
public class TestSecurityConfig {
/**
* JavaTimeModule 포함한 ObjectMapper. WebMvcConfig 대신 사용.
* @WebMvcTest 에서 LocalDateTime 직렬화 실패를 방지한다.
*/
@Bean
@Primary
public ObjectMapper testObjectMapper() {
return new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
}
/**
* SecurityConfig.filter() 를 오버라이드한다.
* spring.main.allow-bean-definition-overriding=true 가 필요하다.
*/
@Bean("filter")
public SecurityFilterChain testFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.exceptionHandling(eh -> eh
.authenticationEntryPoint((req, res, ex) ->
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized")))
.authorizeHttpRequests(a -> a.anyRequest().authenticated());
return http.build();
}
}
@@ -0,0 +1,32 @@
spring:
application:
name: ga-api-test
main:
allow-bean-definition-overriding: true
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
- org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration
- org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration
- org.mybatis.spring.boot.autoconfigure.MybatisAutoConfiguration
- org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration
- org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration
- org.springframework.boot.autoconfigure.data.redis.RedisReactiveAutoConfiguration
- org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration
ga:
jwt:
secret: test-secret-key-for-unit-tests-only-256bits-long-placeholder
access-token-expire: 7200000
refresh-token-expire: 604800000
encrypt:
key: 0123456789abcdef0123456789abcdef
mybatis:
mapper-locations: classpath*:mapper/**/*.xml
logging:
level:
com.ga: WARN
org.springframework.security: WARN
org.springframework.web: WARN
@@ -0,0 +1,160 @@
package com.ga.batch.settlement.calc;
import com.ga.core.mapper.org.AgentMapper;
import com.ga.core.mapper.rule.RuleMapper;
import com.ga.core.vo.ledger.LedgerVO;
import com.ga.core.vo.org.AgentVO;
import com.ga.core.vo.product.ContractResp;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CommissionCalculatorTest {
@Mock RuleMapper ruleMapper;
@Mock AgentMapper agentMapper;
@InjectMocks CommissionCalculator calculator;
private ContractResp contract;
@BeforeEach
void setUp() {
contract = new ContractResp();
contract.setContractId(100L);
contract.setAgentId(10L);
contract.setProductId(1L);
contract.setPolicyNo("POL-0001");
contract.setCompanyCode("SAMSUNG");
contract.setProductCode("PRD001");
contract.setInsuranceType("WHOLE_LIFE");
contract.setPremium(new BigDecimal("1000000"));
contract.setPayCycle("MONTHLY");
contract.setContractDate(LocalDate.of(2024, 1, 15));
AgentVO agent = new AgentVO();
agent.setAgentId(10L);
agent.setGradeId(5L);
when(agentMapper.selectById(10L)).thenReturn(agent);
}
@Test
@DisplayName("calcRecruitLedger: premium × company_rate × payout_rate, 세금은 3.3%")
void recruit_basic() {
when(ruleMapper.findCompanyRate(eq(1L), eq(1), any(LocalDate.class)))
.thenReturn(new BigDecimal("7.5"));
when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(1), any(LocalDate.class)))
.thenReturn(new BigDecimal("60.0"));
LedgerVO led = calculator.calcRecruitLedger(contract, "202405");
assertThat(led).isNotNull();
assertThat(led.getCommissionYear()).isEqualTo(1);
assertThat(led.getSettleMonth()).isEqualTo("202405");
assertThat(led.getStatus()).isEqualTo("CALCULATED");
assertThat(led.getPremium()).isEqualByComparingTo("1000000");
assertThat(led.getCompanyRate()).isEqualByComparingTo("7.5");
assertThat(led.getPayoutRate()).isEqualByComparingTo("60.0");
// 1,000,000 × 7.5% = 75,000.00
assertThat(led.getCompanyAmount()).isEqualByComparingTo("75000.00");
// 75,000 × 60% = 45,000.00
assertThat(led.getAgentAmount()).isEqualByComparingTo("45000.00");
// 45,000 × 3.3% = 1,485.00
assertThat(led.getTaxAmount()).isEqualByComparingTo("1485.00");
assertThat(led.getContractId()).isEqualTo(100L);
assertThat(led.getAgentId()).isEqualTo(10L);
assertThat(led.getPolicyNo()).isEqualTo("POL-0001");
assertThat(led.getCompanyCode()).isEqualTo("SAMSUNG");
assertThat(led.getProductCode()).isEqualTo("PRD001");
assertThat(led.getInsuranceType()).isEqualTo("WHOLE_LIFE");
assertThat(led.getPayMethod()).isEqualTo("MONTHLY");
}
@Test
@DisplayName("calcMaintainLedger: 회차에 따라 commission_year 가 다름")
void maintain_year3() {
when(ruleMapper.findCompanyRate(eq(1L), eq(3), any(LocalDate.class)))
.thenReturn(new BigDecimal("3.0"));
when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(3), any(LocalDate.class)))
.thenReturn(new BigDecimal("50.0"));
LedgerVO led = calculator.calcMaintainLedger(contract, "202405", 3);
assertThat(led.getCommissionYear()).isEqualTo(3);
// 1,000,000 × 3% = 30,000
assertThat(led.getCompanyAmount()).isEqualByComparingTo("30000.00");
// 30,000 × 50% = 15,000
assertThat(led.getAgentAmount()).isEqualByComparingTo("15000.00");
// 15,000 × 3.3% = 495
assertThat(led.getTaxAmount()).isEqualByComparingTo("495.00");
}
@Test
@DisplayName("agent 가 존재하지 않으면 null 반환")
void agentNotFound() {
when(agentMapper.selectById(10L)).thenReturn(null);
LedgerVO led = calculator.calcRecruitLedger(contract, "202405");
assertThat(led).isNull();
}
@Test
@DisplayName("규정 누락 시 0 으로 폴백 → 모든 금액 0")
void missingRates_fallbackZero() {
when(ruleMapper.findCompanyRate(any(), any(), any())).thenReturn(null);
when(ruleMapper.findPayoutRate(any(), any(), any(), any())).thenReturn(null);
LedgerVO led = calculator.calcRecruitLedger(contract, "202405");
assertThat(led).isNotNull();
assertThat(led.getCompanyRate()).isEqualByComparingTo("0");
assertThat(led.getPayoutRate()).isEqualByComparingTo("0");
assertThat(led.getCompanyAmount()).isEqualByComparingTo("0.00");
assertThat(led.getAgentAmount()).isEqualByComparingTo("0.00");
assertThat(led.getTaxAmount()).isEqualByComparingTo("0.00");
}
@Test
@DisplayName("contractDate 가 null 이면 오늘 날짜로 대체 (예외 없음)")
void nullContractDate_usesToday() {
contract.setContractDate(null);
when(ruleMapper.findCompanyRate(eq(1L), eq(1), any(LocalDate.class)))
.thenReturn(new BigDecimal("5"));
when(ruleMapper.findPayoutRate(eq(5L), eq("WHOLE_LIFE"), eq(1), any(LocalDate.class)))
.thenReturn(new BigDecimal("100"));
LedgerVO led = calculator.calcRecruitLedger(contract, "202405");
assertThat(led).isNotNull();
// 1,000,000 × 5% × 100% = 50,000
assertThat(led.getAgentAmount()).isEqualByComparingTo("50000.00");
}
@Test
@DisplayName("premium 이 null 이면 0 으로 처리 (HALF_UP 0.00)")
void nullPremium() {
contract.setPremium(null);
when(ruleMapper.findCompanyRate(any(), any(), any())).thenReturn(new BigDecimal("7.5"));
when(ruleMapper.findPayoutRate(any(), any(), any(), any())).thenReturn(new BigDecimal("60"));
LedgerVO led = calculator.calcRecruitLedger(contract, "202405");
assertThat(led.getPremium()).isEqualByComparingTo("0");
assertThat(led.getCompanyAmount()).isEqualByComparingTo("0.00");
assertThat(led.getAgentAmount()).isEqualByComparingTo("0.00");
}
}
@@ -0,0 +1,382 @@
package com.ga.batch.settlement.transform;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ga.core.mapper.ledger.MaintainLedgerMapper;
import com.ga.core.mapper.ledger.RecruitLedgerMapper;
import com.ga.core.mapper.product.ContractMapper;
import com.ga.core.mapper.receive.ReceiveMapper;
import com.ga.core.vo.ledger.LedgerVO;
import com.ga.core.vo.product.ContractResp;
import com.ga.core.vo.product.ContractSearchParam;
import com.ga.core.vo.product.ContractVO;
import com.ga.core.vo.receive.CompanyFieldMappingVO;
import com.ga.core.vo.receive.ParseErrorLogVO;
import com.ga.core.vo.receive.RawCommissionDataVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class MappingEngineTest {
private ReceiveMapper receiveMapper;
private ContractMapper contractMapper;
private RecruitLedgerMapper recruitMapper;
private MaintainLedgerMapper maintainMapper;
private MappingEngine engine;
@BeforeEach
void setUp() {
receiveMapper = mock(ReceiveMapper.class);
contractMapper = mock(ContractMapper.class);
recruitMapper = mock(RecruitLedgerMapper.class);
maintainMapper = mock(MaintainLedgerMapper.class);
engine = new MappingEngine(receiveMapper, contractMapper, recruitMapper, maintainMapper, new ObjectMapper());
}
/** 매핑 정의 1건 생성 헬퍼 */
private CompanyFieldMappingVO mapping(String src, String tgt, String dataType, String rule, String defVal, String required) {
CompanyFieldMappingVO m = new CompanyFieldMappingVO();
m.setSourceField(src);
m.setTargetField(tgt);
m.setDataType(dataType);
m.setTransformRule(rule);
m.setDefaultValue(defVal);
m.setIsRequired(required);
return m;
}
/** raw 1건 + 정상 mapping 정의 + 계약 매칭까지 갖춘 행복 경로 셋업 */
private RawCommissionDataVO basicRecruitRaw(String json) {
RawCommissionDataVO raw = new RawCommissionDataVO();
raw.setRawId(1000L);
raw.setCompanyCode("SAMSUNG");
raw.setSettleMonth("202405");
raw.setDataType("RECRUIT");
raw.setRawContent(json);
return raw;
}
private void setupContractMatch(String policyNo, Long contractId, Long agentId) {
ContractResp resp = new ContractResp();
resp.setContractId(contractId);
when(contractMapper.selectList(any(ContractSearchParam.class)))
.thenReturn(List.of(resp));
ContractVO vo = new ContractVO();
vo.setContractId(contractId);
vo.setAgentId(agentId);
vo.setPolicyNo(policyNo);
vo.setPremium(new BigDecimal("500000"));
vo.setPayCycle("MONTHLY");
when(contractMapper.selectById(contractId)).thenReturn(vo);
}
@Test
@DisplayName("RECRUIT 행복경로: TRIM/DATE/CODE_MAP 모두 적용 + recruitMapper.insert 호출")
void recruit_happyPath() {
String json = """
{"policyNo":" POL-001 ", "premium":"1000000", "amt":"75000",
"agentAmt":"45000", "compRate":"7.5", "payRate":"60",
"type":"WL_RAW"}
""";
RawCommissionDataVO raw = basicRecruitRaw(json);
when(receiveMapper.selectFieldMappings("SAMSUNG", "recruit_ledger")).thenReturn(List.of(
mapping("policyNo", "policy_no", "STRING", "TRIM", null, "Y"),
mapping("premium", "premium", "DECIMAL", null, null, "N"),
mapping("amt", "company_amount", "DECIMAL", null, null, "N"),
mapping("agentAmt", "agent_amount", "DECIMAL", null, null, "N"),
mapping("compRate", "company_rate", "DECIMAL", null, null, "N"),
mapping("payRate", "payout_rate", "DECIMAL", null, null, "N"),
mapping("type", "insurance_type", "STRING", "CODE_MAP", "INSURANCE_TYPE", "N")
));
when(receiveMapper.findTargetCode("SAMSUNG", "INSURANCE_TYPE", "WL_RAW")).thenReturn("WHOLE_LIFE");
setupContractMatch("POL-001", 100L, 10L);
Long ledgerId = engine.transform(raw);
// recruit insert 호출 / maintain 미호출
ArgumentCaptor<LedgerVO> cap = ArgumentCaptor.forClass(LedgerVO.class);
verify(recruitMapper).insert(cap.capture());
verify(maintainMapper, never()).insert(any());
LedgerVO led = cap.getValue();
assertThat(led.getContractId()).isEqualTo(100L);
assertThat(led.getAgentId()).isEqualTo(10L);
assertThat(led.getPolicyNo()).isEqualTo("POL-001"); // TRIM
assertThat(led.getCompanyCode()).isEqualTo("SAMSUNG");
assertThat(led.getInsuranceType()).isEqualTo("WHOLE_LIFE"); // CODE_MAP 변환
assertThat(led.getPremium()).isEqualByComparingTo("1000000");
assertThat(led.getCompanyAmount()).isEqualByComparingTo("75000");
assertThat(led.getAgentAmount()).isEqualByComparingTo("45000");
assertThat(led.getCompanyRate()).isEqualByComparingTo("7.5");
assertThat(led.getPayoutRate()).isEqualByComparingTo("60");
// 세금은 agent_amount × 3.3% 자동 계산: 45000 × 3.3% = 1485
assertThat(led.getTaxAmount()).isEqualByComparingTo("1485.00");
assertThat(led.getSettleMonth()).isEqualTo("202405");
assertThat(led.getStatus()).isEqualTo("CALCULATED");
assertThat(led.getReconcileStatus()).isEqualTo("PENDING");
// ledger 의 ID 는 insert 후 채워지지만, 반환값은 mapped LedgerId 그대로
assertThat(ledgerId).isEqualTo(led.getLedgerId());
}
@Test
@DisplayName("MAINTAIN 데이터타입은 maintainMapper.insert 로 라우팅")
void maintain_routedToMaintainMapper() {
String json = """
{"policyNo":"POL-002", "premium":"500000"}
""";
RawCommissionDataVO raw = new RawCommissionDataVO();
raw.setRawId(2000L);
raw.setCompanyCode("HANWHA");
raw.setSettleMonth("202405");
raw.setDataType("MAINTAIN");
raw.setRawContent(json);
when(receiveMapper.selectFieldMappings("HANWHA", "maintain_ledger")).thenReturn(List.of(
mapping("policyNo", "policy_no", "STRING", null, null, "Y"),
mapping("premium", "premium", "DECIMAL", null, null, "N")
));
setupContractMatch("POL-002", 200L, 20L);
engine.transform(raw);
verify(maintainMapper).insert(any(LedgerVO.class));
verify(recruitMapper, never()).insert(any());
}
@Test
@DisplayName("UPPER/LOWER/DIVIDE/MULTIPLY 변환 규칙 검증")
void multipleTransformRules() {
String json = """
{"upper":"hello", "lower":"WORLD",
"div":"100000", "mul":"5"}
""";
RawCommissionDataVO raw = basicRecruitRaw(json);
// policy_no 도 필수이므로 추가
String j2 = json.replace("{", "{\"pol\":\"POL-X\",");
raw.setRawContent(j2);
when(receiveMapper.selectFieldMappings("SAMSUNG", "recruit_ledger")).thenReturn(List.of(
mapping("pol", "policy_no", "STRING", "TRIM", null, "Y"),
// attr1 등 표준 외 필드는 mapped 에 들어가지만 LedgerVO 에 매핑되진 않음 — 변환만 검증
mapping("upper", "product_code", "STRING", "UPPER", null, "N"),
mapping("lower", "pay_method", "STRING", "LOWER", null, "N"),
mapping("div", "agent_amount", "DECIMAL", "DIVIDE", "100", "N"),
mapping("mul", "company_amount", "DECIMAL", "MULTIPLY", "1000","N")
));
setupContractMatch("POL-X", 300L, 30L);
engine.transform(raw);
ArgumentCaptor<LedgerVO> cap = ArgumentCaptor.forClass(LedgerVO.class);
verify(recruitMapper).insert(cap.capture());
LedgerVO led = cap.getValue();
assertThat(led.getProductCode()).isEqualTo("HELLO"); // UPPER
assertThat(led.getPayMethod()).isEqualTo("world"); // LOWER
// DIVIDE: 100000 / 100 = 1000.00
assertThat(led.getAgentAmount()).isEqualByComparingTo("1000.00");
// MULTIPLY: 5 × 1000 = 5000
assertThat(led.getCompanyAmount()).isEqualByComparingTo("5000");
}
@Test
@DisplayName("DATE 변환: defaultValue 의 패턴으로 파싱")
void dateTransform() {
// mapped 의 "contract_date" 등은 LedgerVO 에 직접 안 들어가므로,
// 변환만 일어나고 buildLedger 에서 기본값을 쓰는지 확인하기 어려움.
// 대신 변환 자체가 실패해서 에러가 안 나는지로 확인.
String json = """
{"pol":"POL-D", "dateField":"20240515"}
""";
RawCommissionDataVO raw = basicRecruitRaw(json);
raw.setRawContent(json);
when(receiveMapper.selectFieldMappings("SAMSUNG", "recruit_ledger")).thenReturn(List.of(
mapping("pol", "policy_no", "STRING", "TRIM", null, "Y"),
mapping("dateField", "contract_date", "DATE", "DATE", "yyyyMMdd", "N")
));
setupContractMatch("POL-D", 400L, 40L);
Long id = engine.transform(raw);
// 정상 처리 (예외 없음)
verify(recruitMapper).insert(any(LedgerVO.class));
verify(receiveMapper, never()).insertError(any());
}
@Test
@DisplayName("INVALID_INPUT: company_code 가 null 이면 에러 로그 + null 반환")
void invalidInput_nullCompanyCode() {
RawCommissionDataVO raw = new RawCommissionDataVO();
raw.setRawId(1L);
raw.setRawContent("{}");
raw.setDataType("RECRUIT");
// companyCode 누락
Long id = engine.transform(raw);
assertThat(id).isNull();
ArgumentCaptor<ParseErrorLogVO> cap = ArgumentCaptor.forClass(ParseErrorLogVO.class);
verify(receiveMapper).insertError(cap.capture());
assertThat(cap.getValue().getErrorType()).isEqualTo("INVALID_INPUT");
verify(recruitMapper, never()).insert(any());
}
@Test
@DisplayName("UNKNOWN_TYPE: 지원하지 않는 data_type")
void unknownDataType() {
RawCommissionDataVO raw = new RawCommissionDataVO();
raw.setRawId(2L);
raw.setCompanyCode("SAMSUNG");
raw.setRawContent("{}");
raw.setDataType("STRANGE");
Long id = engine.transform(raw);
assertThat(id).isNull();
ArgumentCaptor<ParseErrorLogVO> cap = ArgumentCaptor.forClass(ParseErrorLogVO.class);
verify(receiveMapper).insertError(cap.capture());
assertThat(cap.getValue().getErrorType()).isEqualTo("UNKNOWN_TYPE");
}
@Test
@DisplayName("NO_MAPPING: 매핑 정의가 비어있음")
void noMapping() {
RawCommissionDataVO raw = basicRecruitRaw("{}");
when(receiveMapper.selectFieldMappings("SAMSUNG", "recruit_ledger"))
.thenReturn(new ArrayList<>());
Long id = engine.transform(raw);
assertThat(id).isNull();
ArgumentCaptor<ParseErrorLogVO> cap = ArgumentCaptor.forClass(ParseErrorLogVO.class);
verify(receiveMapper).insertError(cap.capture());
assertThat(cap.getValue().getErrorType()).isEqualTo("NO_MAPPING");
}
@Test
@DisplayName("JSON_PARSE: 잘못된 raw_content")
void jsonParseError() {
RawCommissionDataVO raw = basicRecruitRaw("{not-json");
when(receiveMapper.selectFieldMappings(any(), any())).thenReturn(List.of(
mapping("a", "policy_no", "STRING", null, null, "Y")
));
Long id = engine.transform(raw);
assertThat(id).isNull();
ArgumentCaptor<ParseErrorLogVO> cap = ArgumentCaptor.forClass(ParseErrorLogVO.class);
verify(receiveMapper).insertError(cap.capture());
assertThat(cap.getValue().getErrorType()).isEqualTo("JSON_PARSE");
}
@Test
@DisplayName("REQUIRED: 필수 필드가 raw 에 없음")
void requiredMissing() {
RawCommissionDataVO raw = basicRecruitRaw("{\"other\":\"x\"}");
when(receiveMapper.selectFieldMappings(any(), any())).thenReturn(List.of(
mapping("policyNo", "policy_no", "STRING", "TRIM", null, "Y") // 필수
));
Long id = engine.transform(raw);
assertThat(id).isNull();
ArgumentCaptor<ParseErrorLogVO> cap = ArgumentCaptor.forClass(ParseErrorLogVO.class);
verify(receiveMapper).insertError(cap.capture());
assertThat(cap.getValue().getErrorType()).isEqualTo("REQUIRED");
assertThat(cap.getValue().getErrorField()).isEqualTo("policy_no");
}
@Test
@DisplayName("NO_POLICY: policy_no 매핑이 아예 정의되지 않음")
void noPolicyMapping() {
RawCommissionDataVO raw = basicRecruitRaw("{\"premium\":\"100\"}");
when(receiveMapper.selectFieldMappings(any(), any())).thenReturn(List.of(
mapping("premium", "premium", "DECIMAL", null, null, "N")
));
Long id = engine.transform(raw);
assertThat(id).isNull();
ArgumentCaptor<ParseErrorLogVO> cap = ArgumentCaptor.forClass(ParseErrorLogVO.class);
verify(receiveMapper).insertError(cap.capture());
assertThat(cap.getValue().getErrorType()).isEqualTo("NO_POLICY");
}
@Test
@DisplayName("CONTRACT_NOT_FOUND: policy_no 로 계약 매칭 실패")
void contractNotFound() {
RawCommissionDataVO raw = basicRecruitRaw("{\"policyNo\":\"POL-NONE\"}");
when(receiveMapper.selectFieldMappings(any(), any())).thenReturn(List.of(
mapping("policyNo", "policy_no", "STRING", "TRIM", null, "Y")
));
when(contractMapper.selectList(any(ContractSearchParam.class))).thenReturn(List.of());
Long id = engine.transform(raw);
assertThat(id).isNull();
ArgumentCaptor<ParseErrorLogVO> cap = ArgumentCaptor.forClass(ParseErrorLogVO.class);
verify(receiveMapper).insertError(cap.capture());
assertThat(cap.getValue().getErrorType()).isEqualTo("CONTRACT_NOT_FOUND");
assertThat(cap.getValue().getErrorValue()).isEqualTo("POL-NONE");
}
@Test
@DisplayName("CODE_MAP: 매칭 실패 시 원본 값 그대로 사용")
void codeMap_fallback() {
String json = """
{"pol":"POL-CM", "type":"UNKNOWN_TYPE"}
""";
RawCommissionDataVO raw = basicRecruitRaw(json);
when(receiveMapper.selectFieldMappings(any(), any())).thenReturn(List.of(
mapping("pol", "policy_no", "STRING", "TRIM", null, "Y"),
mapping("type", "insurance_type", "STRING", "CODE_MAP", "INSURANCE_TYPE", "N")
));
when(receiveMapper.findTargetCode("SAMSUNG", "INSURANCE_TYPE", "UNKNOWN_TYPE")).thenReturn(null);
setupContractMatch("POL-CM", 500L, 50L);
engine.transform(raw);
ArgumentCaptor<LedgerVO> cap = ArgumentCaptor.forClass(LedgerVO.class);
verify(recruitMapper).insert(cap.capture());
// 매핑 미존재 → 원본 그대로
assertThat(cap.getValue().getInsuranceType()).isEqualTo("UNKNOWN_TYPE");
}
@Test
@DisplayName("계약값 폴백: premium/pay_method 가 mapped 에 없으면 contract 값 사용")
void buildLedger_fallbackToContract() {
// premium, pay_method 매핑 없음 → contract 의 값(500000, MONTHLY) 사용
RawCommissionDataVO raw = basicRecruitRaw("{\"pol\":\"POL-FB\"}");
when(receiveMapper.selectFieldMappings(any(), any())).thenReturn(List.of(
mapping("pol", "policy_no", "STRING", "TRIM", null, "Y")
));
setupContractMatch("POL-FB", 600L, 60L);
engine.transform(raw);
ArgumentCaptor<LedgerVO> cap = ArgumentCaptor.forClass(LedgerVO.class);
verify(recruitMapper).insert(cap.capture());
LedgerVO led = cap.getValue();
assertThat(led.getPremium()).isEqualByComparingTo("500000"); // contract.premium
assertThat(led.getPayMethod()).isEqualTo("MONTHLY"); // contract.payCycle
assertThat(led.getInsuranceType()).isEqualTo("WHOLE_LIFE"); // 기본값
assertThat(led.getCommissionYear()).isEqualTo(1); // 기본값
}
}
@@ -1,6 +1,7 @@
package com.ga.common.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
@@ -15,8 +16,13 @@ import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
/**
* Redis 가 사용 가능한 경우(RedisConnectionFactory 빈 존재)에만 활성화.
* Redis 없으면 SimpleCacheConfig 가 ConcurrentMapCacheManager 로 폴백.
*/
@Configuration
@EnableCaching
@ConditionalOnBean(RedisConnectionFactory.class)
public class RedisConfig {
@Bean
@@ -0,0 +1,24 @@
package com.ga.common.config;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
/**
* Redis 미연결 환경(개발/검증)용 인메모리 캐시 폴백.
* RedisConnectionFactory 빈이 없을 때만 활성화 (RedisAutoConfiguration 제외 시).
*/
@Configuration
@EnableCaching
@ConditionalOnMissingBean(RedisConnectionFactory.class)
public class SimpleCacheConfig {
@Bean
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager("commonCode", "userPerm");
}
}
@@ -65,6 +65,39 @@ public class FileService {
}
}
/**
* byte[] 콘텐츠를 파일로 저장하고 fileId를 반환한다.
* MultipartFile 없이 프로그래밍 방식으로 파일을 생성할 때 사용.
*/
@Transactional
public Long saveBytes(byte[] content, String fileName, String fileGroup, String contentType) {
String ext = extension(fileName);
String stored = UUID.randomUUID() + (ext.isEmpty() ? "" : "." + ext);
String relPath = LocalDate.now().toString().replace("-", "/");
Path dir = Paths.get(uploadDir, relPath);
try {
Files.createDirectories(dir);
Path dest = dir.resolve(stored);
Files.write(dest, content);
FileVO vo = new FileVO();
vo.setFileGroup(fileGroup);
vo.setOriginalName(fileName);
vo.setStoredName(stored);
vo.setFilePath(dest.toAbsolutePath().toString());
vo.setFileSize((long) content.length);
vo.setContentType(contentType);
vo.setExtension(ext);
vo.setCreatedBy(SecurityUtil.getCurrentUserId());
vo.setCreatedAt(java.time.LocalDateTime.now());
mapper.insert(vo);
return vo.getFileId();
} catch (IOException e) {
log.error("File save error", e);
throw new BizException(ErrorCode.FILE_UPLOAD_FAIL, e.getMessage());
}
}
public FileVO download(Long fileId) {
FileVO vo = mapper.selectById(fileId);
if (vo == null) throw new BizException(ErrorCode.FILE_NOT_FOUND);
@@ -3,10 +3,6 @@ package com.ga.common.mybatis;
import com.ga.common.util.EncryptUtil;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
@@ -16,23 +12,22 @@ import java.sql.SQLException;
/**
* AES 암호화 컬럼용 TypeHandler.
*
* @MappedTypes 를 의도적으로 빼서 String 전체 자동 적용을 막는다.
* 사용 시 컬럼별로 명시:
* 중요: Spring 빈으로 등록하면 mybatis-spring-boot-starter 가 자동으로
* 모든 String 컬럼에 적용해 버린다 (BaseTypeHandler<String> 의 generic 때문).
* 따라서 @Component 를 붙이지 않고, EncryptUtil 만 정적 주입한다.
*
* 사용은 컬럼별 명시만 허용:
* <result property="residentNo" column="resident_no"
* typeHandler="com.ga.common.mybatis.EncryptTypeHandler"/>
*
* EncryptUtil 주입은 EncryptTypeHandlerInitializer (별도 @Configuration) 에서 수행.
*/
@Component
public class EncryptTypeHandler extends BaseTypeHandler<String> implements ApplicationContextAware {
public class EncryptTypeHandler extends BaseTypeHandler<String> {
private static EncryptUtil encryptUtil;
@Override
public void setApplicationContext(ApplicationContext ctx) {
EncryptTypeHandler.encryptUtil = ctx.getBean(EncryptUtil.class);
}
@Autowired(required = false)
public void setEncryptUtil(EncryptUtil util) {
/** 부팅 시 EncryptTypeHandlerInitializer 가 1회 호출 */
public static void setEncryptUtil(EncryptUtil util) {
EncryptTypeHandler.encryptUtil = util;
}
@@ -0,0 +1,16 @@
package com.ga.common.mybatis;
import com.ga.common.util.EncryptUtil;
import org.springframework.context.annotation.Configuration;
/**
* EncryptTypeHandler 가 Spring 빈이 아닌 일반 클래스이므로,
* 부팅 시 EncryptUtil 인스턴스를 정적으로 주입해주는 초기화 빈.
*/
@Configuration
public class EncryptTypeHandlerInitializer {
public EncryptTypeHandlerInitializer(EncryptUtil util) {
EncryptTypeHandler.setEncryptUtil(util);
}
}
@@ -0,0 +1,65 @@
-- V18: 메뉴 트리 구조 (대메뉴 8개 신설 + 25개 PAGE 를 자식으로 매핑)
-- 1) 대메뉴 (DIRECTORY 타입) 추가 -- 기존 menu_code 충돌 회피
INSERT INTO menu (menu_code, menu_name, menu_type, menu_icon, menu_level, sort_order, is_visible, is_active, parent_menu_id, description)
VALUES
('GRP_ORG', '조직 / 인사', 'DIRECTORY', 'users-group', 1, 20, 'Y', 'Y', NULL, '조직 트리 / 설계사 관리'),
('GRP_PRD', '상품 / 계약', 'DIRECTORY', 'briefcase', 1, 30, 'Y', 'Y', NULL, '보험사 / 상품 / 계약 관리'),
('GRP_RULE', '수수료 규정', 'DIRECTORY', 'receipt', 1, 40, 'Y', 'Y', NULL, '수수료율 / 지급율 / 오버라이드 / 환수'),
('GRP_RECEIVE', '데이터 수신', 'DIRECTORY', 'database', 1, 50, 'Y', 'Y', NULL, '보험사 데이터 수신 / 매핑'),
('GRP_LEDGER', '원장 관리', 'DIRECTORY', 'file-invoice', 1, 60, 'Y', 'Y', NULL, '모집 / 유지 / 예외 원장'),
('GRP_SETTLE', '정산 / 지급', 'DIRECTORY', 'calculator', 1, 70, 'Y', 'Y', NULL, '정산 결과 / 배치 / 지급'),
('GRP_REPORT', '리포트', 'DIRECTORY', 'chart-bar', 1, 80, 'Y', 'Y', NULL, '실적 / 조직 / 환수 위험'),
('GRP_SYSTEM', '시스템 관리', 'DIRECTORY', 'settings', 1, 99, 'Y', 'Y', NULL, '사용자 / 권한 / 코드 / 로그')
ON CONFLICT (menu_code) DO NOTHING;
-- 2) 자식 PAGE 메뉴들의 parent_menu_id + menu_level 갱신
UPDATE menu SET parent_menu_id = (SELECT menu_id FROM menu WHERE menu_code = 'GRP_ORG'), menu_level = 2 WHERE menu_code IN ('ORG_TREE', 'ORG_AGENT');
UPDATE menu SET parent_menu_id = (SELECT menu_id FROM menu WHERE menu_code = 'GRP_PRD'), menu_level = 2 WHERE menu_code IN ('COMPANY', 'PRODUCT', 'CONTRACT_LIST');
UPDATE menu SET parent_menu_id = (SELECT menu_id FROM menu WHERE menu_code = 'GRP_RULE'), menu_level = 2 WHERE menu_code IN ('RULE_COMMISSION', 'RULE_PAYOUT', 'RULE_OVERRIDE', 'RULE_CHARGEBACK', 'RULE_EXCEPTION');
UPDATE menu SET parent_menu_id = (SELECT menu_id FROM menu WHERE menu_code = 'GRP_RECEIVE'), menu_level = 2 WHERE menu_code IN ('RECEIVE_MAPPING', 'RECEIVE_DATA');
UPDATE menu SET parent_menu_id = (SELECT menu_id FROM menu WHERE menu_code = 'GRP_LEDGER'), menu_level = 2 WHERE menu_code IN ('LEDGER_RECRUIT', 'LEDGER_MAINTAIN', 'LEDGER_EXCEPTION');
UPDATE menu SET parent_menu_id = (SELECT menu_id FROM menu WHERE menu_code = 'GRP_SETTLE'), menu_level = 2 WHERE menu_code IN ('SETTLE_LIST', 'BATCH_RUN', 'PAYMENT');
UPDATE menu SET parent_menu_id = (SELECT menu_id FROM menu WHERE menu_code = 'GRP_SYSTEM'), menu_level = 2 WHERE menu_code IN ('SYSTEM_USER', 'SYSTEM_ROLE', 'SYSTEM_MENU', 'SYSTEM_CODE', 'SYSTEM_CONFIG', 'SYSTEM_LOG');
-- 3) 대시보드는 root, sort_order 10 으로 최상단
UPDATE menu SET sort_order = 10 WHERE menu_code = 'DASHBOARD';
-- 4) 자식 PAGE 의 그룹 내 sort_order 정렬
UPDATE menu SET sort_order = 1 WHERE menu_code = 'ORG_TREE';
UPDATE menu SET sort_order = 2 WHERE menu_code = 'ORG_AGENT';
UPDATE menu SET sort_order = 1 WHERE menu_code = 'COMPANY';
UPDATE menu SET sort_order = 2 WHERE menu_code = 'PRODUCT';
UPDATE menu SET sort_order = 3 WHERE menu_code = 'CONTRACT_LIST';
UPDATE menu SET sort_order = 1 WHERE menu_code = 'RULE_COMMISSION';
UPDATE menu SET sort_order = 2 WHERE menu_code = 'RULE_PAYOUT';
UPDATE menu SET sort_order = 3 WHERE menu_code = 'RULE_OVERRIDE';
UPDATE menu SET sort_order = 4 WHERE menu_code = 'RULE_CHARGEBACK';
UPDATE menu SET sort_order = 5 WHERE menu_code = 'RULE_EXCEPTION';
UPDATE menu SET sort_order = 1 WHERE menu_code = 'RECEIVE_DATA';
UPDATE menu SET sort_order = 2 WHERE menu_code = 'RECEIVE_MAPPING';
UPDATE menu SET sort_order = 1 WHERE menu_code = 'LEDGER_RECRUIT';
UPDATE menu SET sort_order = 2 WHERE menu_code = 'LEDGER_MAINTAIN';
UPDATE menu SET sort_order = 3 WHERE menu_code = 'LEDGER_EXCEPTION';
UPDATE menu SET sort_order = 1 WHERE menu_code = 'SETTLE_LIST';
UPDATE menu SET sort_order = 2 WHERE menu_code = 'BATCH_RUN';
UPDATE menu SET sort_order = 3 WHERE menu_code = 'PAYMENT';
UPDATE menu SET sort_order = 1 WHERE menu_code = 'SYSTEM_USER';
UPDATE menu SET sort_order = 2 WHERE menu_code = 'SYSTEM_ROLE';
UPDATE menu SET sort_order = 3 WHERE menu_code = 'SYSTEM_MENU';
UPDATE menu SET sort_order = 4 WHERE menu_code = 'SYSTEM_CODE';
UPDATE menu SET sort_order = 5 WHERE menu_code = 'SYSTEM_CONFIG';
UPDATE menu SET sort_order = 6 WHERE menu_code = 'SYSTEM_LOG';
-- 5) 대메뉴(DIRECTORY) 에 READ 권한 정의
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
SELECT menu_id, 'READ', '조회', 1 FROM menu WHERE menu_code LIKE 'GRP_%'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
-- 6) 모든 역할에 대메뉴 READ 권한 부여 (자식 메뉴 권한이 있어야 자식이 보이지만, 부모 expand 가능)
INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
SELECT r.role_id, m.menu_id, 'READ', 'Y'
FROM role r CROSS JOIN menu m
WHERE r.role_code IN ('SUPER_ADMIN', 'ADMIN', 'MANAGER', 'AGENT')
AND m.menu_code LIKE 'GRP_%'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
@@ -0,0 +1,127 @@
-- V19: 엑셀 업로드 매핑 템플릿 시스템
-- 1. upload_template (마스터)
CREATE TABLE upload_template (
template_id BIGSERIAL PRIMARY KEY,
template_code VARCHAR(50) NOT NULL UNIQUE,
template_name VARCHAR(100) NOT NULL,
category VARCHAR(30) NOT NULL, -- RECRUIT/MAINTAIN/CONTRACT/AGENT/CUSTOM
target_table VARCHAR(50) NOT NULL,
company_code VARCHAR(20), -- NULL이면 범용
header_row INT NOT NULL DEFAULT 1,
data_start_row INT NOT NULL DEFAULT 2,
sheet_index INT NOT NULL DEFAULT 0,
sheet_name VARCHAR(50),
file_encoding VARCHAR(20) DEFAULT 'UTF-8',
delimiter VARCHAR(5),
skip_empty_row CHAR(1) DEFAULT 'Y',
max_error_count INT DEFAULT 1000,
batch_size INT DEFAULT 1000,
description VARCHAR(200),
is_active CHAR(1) DEFAULT 'Y',
created_at TIMESTAMP DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
updated_by BIGINT
);
CREATE INDEX idx_upload_tpl_category ON upload_template(category);
CREATE INDEX idx_upload_tpl_company ON upload_template(company_code) WHERE company_code IS NOT NULL;
-- 2. upload_template_column (컬럼 매핑 규칙)
CREATE TABLE upload_template_column (
column_id BIGSERIAL PRIMARY KEY,
template_id BIGINT NOT NULL REFERENCES upload_template(template_id) ON DELETE CASCADE,
sort_order INT NOT NULL,
source_type VARCHAR(10) NOT NULL DEFAULT 'COLUMN', -- COLUMN/FIXED/EXPRESSION
source_column VARCHAR(10), -- 엑셀 컬럼 (A,B,C 또는 0,1,2)
source_header VARCHAR(100),
target_field VARCHAR(50) NOT NULL,
target_type VARCHAR(20) NOT NULL DEFAULT 'STRING', -- STRING/NUMBER/DECIMAL/DATE/INTEGER
transform_rule VARCHAR(30), -- TRIM/UPPER/LOWER/DATE/DIVIDE/MULTIPLY/LOOKUP/CODE_MAP/FIXED/REPLACE/SUBSTRING/CONCAT/DEFAULT/REQUIRED
transform_param VARCHAR(200), -- JSON 또는 단순값
date_format VARCHAR(30),
number_divide INT,
number_multiply INT,
fixed_value VARCHAR(200),
default_value VARCHAR(200),
lookup_table VARCHAR(50),
lookup_source_field VARCHAR(50),
lookup_target_field VARCHAR(50),
code_map_group VARCHAR(50),
is_required CHAR(1) DEFAULT 'N',
validation_regex VARCHAR(200),
validation_message VARCHAR(200),
description VARCHAR(200),
UNIQUE (template_id, sort_order)
);
CREATE INDEX idx_upload_tpl_col ON upload_template_column(template_id);
-- 3. upload_history (업로드 이력)
CREATE TABLE upload_history (
history_id BIGSERIAL PRIMARY KEY,
template_id BIGINT NOT NULL REFERENCES upload_template(template_id),
template_code VARCHAR(50) NOT NULL,
file_name VARCHAR(300) NOT NULL,
file_size BIGINT,
total_count INT NOT NULL DEFAULT 0,
success_count INT NOT NULL DEFAULT 0,
error_count INT NOT NULL DEFAULT 0,
skip_count INT NOT NULL DEFAULT 0,
error_file_id BIGINT, -- file_storage 느슨한 참조 (제약 없음)
status VARCHAR(10) NOT NULL DEFAULT 'PROCESSING', -- PROCESSING/COMPLETED/FAILED/CANCELLED
error_message TEXT,
started_at TIMESTAMP NOT NULL DEFAULT NOW(),
finished_at TIMESTAMP,
duration_sec INT,
created_by BIGINT
);
CREATE INDEX idx_upload_history_tpl ON upload_history(template_id, started_at DESC);
CREATE INDEX idx_upload_history_started ON upload_history(started_at DESC);
-- 4. 메뉴 등록 (GRP_SYSTEM 하위, sort_order 7)
INSERT INTO menu (menu_code, menu_name, menu_type, menu_path, component_path, menu_icon,
menu_level, sort_order, is_visible, is_active, parent_menu_id, description)
VALUES ('SYSTEM_UPLOAD_TEMPLATE', '엑셀 업로드 템플릿', 'PAGE', '/system/upload-templates',
'system/UploadTemplateManager', 'file-spreadsheet', 2, 7, 'Y', 'Y',
(SELECT menu_id FROM menu WHERE menu_code = 'GRP_SYSTEM'),
'보험사별 엑셀 업로드 양식 매핑 관리')
ON CONFLICT (menu_code) DO NOTHING;
-- 5. 메뉴 권한 4건 정의
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
SELECT menu_id, 'READ', '조회', 1 FROM menu WHERE menu_code = 'SYSTEM_UPLOAD_TEMPLATE'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
SELECT menu_id, 'CREATE', '등록', 2 FROM menu WHERE menu_code = 'SYSTEM_UPLOAD_TEMPLATE'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
SELECT menu_id, 'UPDATE', '수정', 3 FROM menu WHERE menu_code = 'SYSTEM_UPLOAD_TEMPLATE'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
SELECT menu_id, 'DELETE', '삭제', 4 FROM menu WHERE menu_code = 'SYSTEM_UPLOAD_TEMPLATE'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
-- 6. SUPER_ADMIN / ADMIN : READ + CREATE + UPDATE + DELETE
INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
SELECT r.role_id, m.menu_id, p.perm_code, 'Y'
FROM role r
CROSS JOIN menu m
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE')) AS p(perm_code)
WHERE r.role_code IN ('SUPER_ADMIN', 'ADMIN')
AND m.menu_code = 'SYSTEM_UPLOAD_TEMPLATE'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- 7. MANAGER : READ 만
INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
SELECT r.role_id, m.menu_id, 'READ', 'Y'
FROM role r
CROSS JOIN menu m
WHERE r.role_code = 'MANAGER'
AND m.menu_code = 'SYSTEM_UPLOAD_TEMPLATE'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
@@ -0,0 +1,355 @@
-- V20: 데이터 도메인 사전 + 데이터 사전 시스템
-- ============================================================
-- 1. data_domain (도메인 마스터)
-- ============================================================
CREATE TABLE ga.data_domain (
domain_code VARCHAR(50) NOT NULL,
domain_name VARCHAR(100) NOT NULL,
domain_category VARCHAR(30) NOT NULL, -- IDENTIFIER/PII/MONEY/RATE/DATE/CODE/TEXT/JSON/BOOLEAN
data_type VARCHAR(30) NOT NULL, -- VARCHAR/DECIMAL/INTEGER/DATE/TIMESTAMP/TEXT/JSONB/CHAR/BIGINT
length INT,
precision INT,
scale INT,
format_pattern VARCHAR(100),
validation_regex VARCHAR(200),
example_value VARCHAR(200),
masking_type VARCHAR(20), -- NAME/PHONE/RRN/ACCOUNT/EMAIL/NONE
encrypted CHAR(1) NOT NULL DEFAULT 'N',
description TEXT,
is_active CHAR(1) NOT NULL DEFAULT 'Y',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
updated_by BIGINT,
CONSTRAINT pk_data_domain PRIMARY KEY (domain_code)
);
CREATE INDEX idx_data_domain_category ON ga.data_domain (domain_category, is_active);
COMMENT ON TABLE ga.data_domain IS '데이터 도메인 마스터';
COMMENT ON COLUMN ga.data_domain.domain_code IS '도메인 코드 (PK)';
COMMENT ON COLUMN ga.data_domain.domain_name IS '도메인 한글명';
COMMENT ON COLUMN ga.data_domain.domain_category IS '도메인 분류 (IDENTIFIER/PII/MONEY/RATE/DATE/CODE/TEXT/JSON/BOOLEAN)';
COMMENT ON COLUMN ga.data_domain.data_type IS '물리 데이터 타입 (VARCHAR/DECIMAL/INTEGER/DATE/TIMESTAMP/TEXT/JSONB/CHAR/BIGINT)';
COMMENT ON COLUMN ga.data_domain.length IS 'VARCHAR 길이';
COMMENT ON COLUMN ga.data_domain.precision IS 'DECIMAL 정밀도';
COMMENT ON COLUMN ga.data_domain.scale IS 'DECIMAL 스케일';
COMMENT ON COLUMN ga.data_domain.format_pattern IS '표시 형식 패턴 (예: yyyy-MM-dd, #,##0.00)';
COMMENT ON COLUMN ga.data_domain.validation_regex IS '입력값 검증 정규식';
COMMENT ON COLUMN ga.data_domain.example_value IS '예시 값';
COMMENT ON COLUMN ga.data_domain.masking_type IS '마스킹 유형 (NAME/PHONE/RRN/ACCOUNT/EMAIL/NONE)';
COMMENT ON COLUMN ga.data_domain.encrypted IS 'AES 암호화 여부 (Y/N)';
COMMENT ON COLUMN ga.data_domain.is_active IS '사용여부 (Y/N)';
-- ============================================================
-- 2. data_dictionary (컬럼-도메인 매핑 + 한글명)
-- ============================================================
CREATE TABLE ga.data_dictionary (
dict_id BIGSERIAL NOT NULL,
table_name VARCHAR(100) NOT NULL,
column_name VARCHAR(100) NOT NULL,
domain_code VARCHAR(50) REFERENCES ga.data_domain (domain_code),
column_label VARCHAR(100),
description TEXT,
is_pk CHAR(1) NOT NULL DEFAULT 'N',
is_fk CHAR(1) NOT NULL DEFAULT 'N',
fk_reference VARCHAR(150), -- 'agent.agent_id' 형태
is_required CHAR(1) NOT NULL DEFAULT 'N',
default_value VARCHAR(200),
business_rule TEXT,
sample_value VARCHAR(200),
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_by BIGINT,
updated_at TIMESTAMP,
updated_by BIGINT,
CONSTRAINT pk_data_dictionary PRIMARY KEY (dict_id),
CONSTRAINT uq_dict_table_column UNIQUE (table_name, column_name)
);
CREATE INDEX idx_dict_table ON ga.data_dictionary (table_name);
CREATE INDEX idx_dict_domain ON ga.data_dictionary (domain_code);
COMMENT ON TABLE ga.data_dictionary IS '컬럼-도메인 매핑 + 한글명';
COMMENT ON COLUMN ga.data_dictionary.dict_id IS '사전 ID (PK)';
COMMENT ON COLUMN ga.data_dictionary.table_name IS '테이블명';
COMMENT ON COLUMN ga.data_dictionary.column_name IS '컬럼명';
COMMENT ON COLUMN ga.data_dictionary.domain_code IS '도메인 코드 (FK → data_domain)';
COMMENT ON COLUMN ga.data_dictionary.column_label IS '컬럼 한글명';
COMMENT ON COLUMN ga.data_dictionary.is_pk IS 'PK 여부 (Y/N)';
COMMENT ON COLUMN ga.data_dictionary.is_fk IS 'FK 여부 (Y/N)';
COMMENT ON COLUMN ga.data_dictionary.fk_reference IS 'FK 참조 (테이블.컬럼)';
COMMENT ON COLUMN ga.data_dictionary.business_rule IS '비즈니스 규칙 (검증/계산)';
COMMENT ON COLUMN ga.data_dictionary.sample_value IS '샘플 값';
-- ============================================================
-- 3. View: v_data_dictionary_full
-- ============================================================
CREATE OR REPLACE VIEW ga.v_data_dictionary_full AS
SELECT
c.table_name,
c.column_name,
c.ordinal_position,
c.data_type,
c.character_maximum_length AS max_length,
c.numeric_precision AS num_precision,
c.numeric_scale AS num_scale,
c.is_nullable,
c.column_default,
obj_description(
(c.table_schema || '.' || c.table_name)::regclass, 'pg_class'
) AS table_comment,
col_description(
((c.table_schema || '.' || c.table_name)::regclass)::oid,
c.ordinal_position
) AS column_comment,
dd.column_label,
dd.description AS dict_description,
dd.domain_code,
d.domain_name,
d.domain_category,
d.format_pattern,
d.masking_type,
d.encrypted,
dd.is_pk,
dd.is_fk,
dd.fk_reference,
dd.business_rule,
dd.sample_value
FROM information_schema.columns c
LEFT JOIN ga.data_dictionary dd
ON dd.table_name = c.table_name
AND dd.column_name = c.column_name
LEFT JOIN ga.data_domain d
ON d.domain_code = dd.domain_code
WHERE c.table_schema = 'ga'
AND c.table_name NOT LIKE 'flyway_%'
AND c.table_name NOT LIKE 'BATCH_%'
ORDER BY c.table_name, c.ordinal_position;
-- ============================================================
-- 4. 초기 도메인 데이터 (~30개)
-- ============================================================
-- IDENTIFIER (식별자)
INSERT INTO ga.data_domain (domain_code, domain_name, domain_category, data_type, length, precision, scale, format_pattern, validation_regex, example_value, masking_type, encrypted, description) VALUES
('DOM_ID_BIGINT', 'ID 식별자', 'IDENTIFIER', 'BIGINT', NULL, NULL, NULL, NULL, NULL, '12345', 'NONE', 'N', '자동증가 대리키 (PK용)'),
('DOM_LICENSE_NO', '사번/면허번호','IDENTIFIER', 'VARCHAR', 50, NULL, NULL, NULL, NULL, 'GA20230001', 'NONE', 'N', '설계사 사번 또는 보험업 면허번호'),
('DOM_POLICY_NO', '증권번호', 'IDENTIFIER', 'VARCHAR', 50, NULL, NULL, NULL, NULL, '20230100012345','NONE', 'N', '보험사 발급 증권번호'),
('DOM_COMPANY_CODE', '보험사 코드', 'IDENTIFIER', 'VARCHAR', 20, NULL, NULL, NULL, NULL, 'KDB', 'NONE', 'N', '보험사 식별 코드'),
('DOM_PRODUCT_CODE', '상품 코드', 'IDENTIFIER', 'VARCHAR', 50, NULL, NULL, NULL, NULL, 'PRD-2023-001', 'NONE', 'N', '보험 상품 코드'),
('DOM_SETTLE_MONTH', '정산월', 'IDENTIFIER', 'CHAR', 6, NULL, NULL, 'YYYYMM', '^\d{6}$', '202306', 'NONE', 'N', '정산 기준 연월 (YYYYMM)'),
('DOM_BATCH_ID', '배치 ID', 'IDENTIFIER', 'VARCHAR', 50, NULL, NULL, NULL, NULL, 'BATCH-20230601','NONE', 'N', '배치 작업 식별자');
-- PII (개인정보)
INSERT INTO ga.data_domain (domain_code, domain_name, domain_category, data_type, length, precision, scale, format_pattern, validation_regex, example_value, masking_type, encrypted, description) VALUES
('DOM_RRN', '주민번호', 'PII', 'VARCHAR', 200, NULL, NULL, 'YYYYMMDD-NNNNNNN', '^\d{6}-\d{7}$', '800101-1234567', 'RRN', 'Y', 'AES 암호화 + RRN 마스킹'),
('DOM_NAME', '성명', 'PII', 'VARCHAR', 50, NULL, NULL, NULL, NULL, '홍길동', 'NAME', 'N', '고객/설계사 실명'),
('DOM_PHONE', '전화번호', 'PII', 'VARCHAR', 20, NULL, NULL, NULL, '^\d{2,3}-\d{3,4}-\d{4}$', '010-1234-5678', 'PHONE', 'N', '연락처'),
('DOM_EMAIL', '이메일', 'PII', 'VARCHAR', 100, NULL, NULL, NULL, NULL, 'user@example.com','EMAIL', 'N', '이메일 주소'),
('DOM_ACCOUNT_NO', '계좌번호', 'PII', 'VARCHAR', 200, NULL, NULL, NULL, NULL, '123-456-789012', 'ACCOUNT', 'Y', 'AES 암호화 + 계좌 마스킹'),
('DOM_BANK_CODE', '은행코드', 'PII', 'VARCHAR', 10, NULL, NULL, NULL, NULL, '004', 'NONE', 'N', '금융기관 코드 (은행연합회 기준)');
-- MONEY (금액) — DECIMAL(15,2)
INSERT INTO ga.data_domain (domain_code, domain_name, domain_category, data_type, length, precision, scale, format_pattern, validation_regex, example_value, masking_type, encrypted, description) VALUES
('DOM_AMOUNT', '금액', 'MONEY', 'DECIMAL', NULL, 15, 2, '#,##0', NULL, '1,000,000', 'NONE', 'N', '일반 금액'),
('DOM_PREMIUM', '보험료', 'MONEY', 'DECIMAL', NULL, 15, 2, '#,##0', NULL, '150,000', 'NONE', 'N', '월 납입 보험료'),
('DOM_COMMISSION', '수수료', 'MONEY', 'DECIMAL', NULL, 15, 2, '#,##0', NULL, '45,000', 'NONE', 'N', '설계사 수수료 금액'),
('DOM_TAX', '세액', 'MONEY', 'DECIMAL', NULL, 15, 2, '#,##0', NULL, '1,485', 'NONE', 'N', '원천징수 세액 (소득세 + 지방소득세)'),
('DOM_NET_AMOUNT', '순지급액', 'MONEY', 'DECIMAL', NULL, 15, 2, '#,##0', NULL, '43,515', 'NONE', 'N', '세액 공제 후 실지급액');
-- RATE (율) — DECIMAL(7,4)
INSERT INTO ga.data_domain (domain_code, domain_name, domain_category, data_type, length, precision, scale, format_pattern, validation_regex, example_value, masking_type, encrypted, description) VALUES
('DOM_RATE_PCT', '수수료율(%)', 'RATE', 'DECIMAL', NULL, 7, 4, '#,##0.0000', NULL, '30.0000', 'NONE', 'N', '보험사 지급 수수료율'),
('DOM_PAYOUT_PCT', '지급율(%)', 'RATE', 'DECIMAL', NULL, 7, 4, '#,##0.0000', NULL, '80.0000', 'NONE', 'N', 'GA 내부 설계사 지급율'),
('DOM_TAX_RATE', '세율(%)', 'RATE', 'DECIMAL', NULL, 7, 4, '#,##0.0000', NULL, '3.3000', 'NONE', 'N', '원천세율 (소득세 3% + 지방소득세 0.3%)');
-- DATE (날짜)
INSERT INTO ga.data_domain (domain_code, domain_name, domain_category, data_type, length, precision, scale, format_pattern, validation_regex, example_value, masking_type, encrypted, description) VALUES
('DOM_DATE', '날짜', 'DATE', 'DATE', NULL, NULL, NULL, 'yyyy-MM-dd', NULL, '2023-06-01', 'NONE', 'N', '일반 날짜'),
('DOM_DATETIME', '일시', 'DATE', 'TIMESTAMP', NULL, NULL, NULL, 'yyyy-MM-dd HH:mm:ss', NULL, '2023-06-01 09:00:00','NONE', 'N', '일반 일시'),
('DOM_CONTRACT_DATE', '계약일','DATE', 'DATE', NULL, NULL, NULL, 'yyyy-MM-dd', NULL, '2023-06-01', 'NONE', 'N', '보험 계약 체결일'),
('DOM_EFFECTIVE_DATE', '효력일','DATE', 'DATE', NULL, NULL, NULL, 'yyyy-MM-dd', NULL, '2023-07-01', 'NONE', 'N', '보험 효력 개시일');
-- CODE (코드)
INSERT INTO ga.data_domain (domain_code, domain_name, domain_category, data_type, length, precision, scale, format_pattern, validation_regex, example_value, masking_type, encrypted, description) VALUES
('DOM_STATUS', '상태', 'CODE', 'VARCHAR', 20, NULL, NULL, NULL, NULL, 'ACTIVE', 'NONE', 'N', '레코드 상태 코드'),
('DOM_YN_FLAG', '여부 플래그','CODE', 'CHAR', 1, NULL, NULL, NULL, '^[YN]$', 'Y', 'NONE', 'N', 'Y/N 이진 플래그'),
('DOM_INSURANCE_TYPE', '보험종류', 'CODE', 'VARCHAR', 30, NULL, NULL, NULL, NULL, 'WHOLE_LIFE','NONE','N', '보험 종류 코드'),
('DOM_PAY_CYCLE', '납입주기', 'CODE', 'VARCHAR', 20, NULL, NULL, NULL, NULL, 'MONTHLY', 'NONE', 'N', '보험료 납입 주기 코드');
-- TEXT (텍스트)
INSERT INTO ga.data_domain (domain_code, domain_name, domain_category, data_type, length, precision, scale, format_pattern, validation_regex, example_value, masking_type, encrypted, description) VALUES
('DOM_TEXT_SHORT', '짧은 텍스트', 'TEXT', 'VARCHAR', 200, NULL, NULL, NULL, NULL, '간단한 메모', 'NONE', 'N', '200자 이내 단문'),
('DOM_TEXT_LONG', '긴 텍스트', 'TEXT', 'TEXT', NULL, NULL, NULL, NULL, NULL, '상세 내용...', 'NONE', 'N', '제한 없는 장문'),
('DOM_DESCRIPTION', '설명', 'TEXT', 'VARCHAR', 500, NULL, NULL, NULL, NULL, '비고 내용', 'NONE', 'N', '500자 이내 설명/비고');
-- JSON / 기타
INSERT INTO ga.data_domain (domain_code, domain_name, domain_category, data_type, length, precision, scale, format_pattern, validation_regex, example_value, masking_type, encrypted, description) VALUES
('DOM_JSONB', 'JSON 데이터', 'JSON', 'JSONB', NULL, NULL, NULL, NULL, NULL, '{"key":"value"}', 'NONE', 'N', 'PostgreSQL JSONB 타입'),
('DOM_AUDIT_USER', '감사 사용자 ID', 'IDENTIFIER', 'BIGINT', NULL, NULL, NULL, NULL, NULL, '1', 'NONE', 'N', '생성자/수정자 user_id');
-- ============================================================
-- 5. 초기 데이터 사전 매핑 (~50개)
-- ============================================================
INSERT INTO ga.data_dictionary
(table_name, column_name, domain_code, column_label, is_pk, is_fk, fk_reference, is_required)
VALUES
-- agent
('agent', 'agent_id', 'DOM_ID_BIGINT', '설계사ID', 'Y', 'N', NULL, 'Y'),
('agent', 'org_id', 'DOM_ID_BIGINT', '조직ID', 'N', 'Y', 'organization.org_id', 'Y'),
('agent', 'agent_name', 'DOM_NAME', '설계사명', 'N', 'N', NULL, 'Y'),
('agent', 'resident_no', 'DOM_RRN', '주민번호', 'N', 'N', NULL, 'N'),
('agent', 'license_no', 'DOM_LICENSE_NO', '사번', 'N', 'N', NULL, 'N'),
('agent', 'phone', 'DOM_PHONE', '전화번호', 'N', 'N', NULL, 'N'),
('agent', 'email', 'DOM_EMAIL', '이메일', 'N', 'N', NULL, 'N'),
('agent', 'account_no', 'DOM_ACCOUNT_NO', '계좌번호', 'N', 'N', NULL, 'N'),
('agent', 'status', 'DOM_STATUS', '상태', 'N', 'N', NULL, 'Y'),
('agent', 'join_date', 'DOM_DATE', '입사일', 'N', 'N', NULL, 'N'),
-- contract
('contract', 'contract_id', 'DOM_ID_BIGINT', '계약ID', 'Y', 'N', NULL, 'Y'),
('contract', 'agent_id', 'DOM_ID_BIGINT', '설계사ID','N', 'Y', 'agent.agent_id', 'Y'),
('contract', 'product_id', 'DOM_ID_BIGINT', '상품ID', 'N', 'Y', 'product.product_id','Y'),
('contract', 'policy_no', 'DOM_POLICY_NO', '증권번호','N', 'N', NULL, 'Y'),
('contract', 'premium', 'DOM_PREMIUM', '보험료', 'N', 'N', NULL, 'N'),
('contract', 'pay_cycle', 'DOM_PAY_CYCLE', '납입주기','N', 'N', NULL, 'N'),
('contract', 'contract_date', 'DOM_CONTRACT_DATE', '계약일', 'N', 'N', NULL, 'N'),
-- recruit_ledger
('recruit_ledger', 'ledger_id', 'DOM_ID_BIGINT', '원장ID', 'Y', 'N', NULL, 'Y'),
('recruit_ledger', 'agent_id', 'DOM_ID_BIGINT', '설계사ID', 'N', 'Y', 'agent.agent_id', 'Y'),
('recruit_ledger', 'policy_no', 'DOM_POLICY_NO', '증권번호', 'N', 'N', NULL, 'Y'),
('recruit_ledger', 'premium', 'DOM_PREMIUM', '보험료', 'N', 'N', NULL, 'N'),
('recruit_ledger', 'company_rate', 'DOM_RATE_PCT', '회사율', 'N', 'N', NULL, 'N'),
('recruit_ledger', 'company_amount', 'DOM_COMMISSION', '회사수수료', 'N', 'N', NULL, 'N'),
('recruit_ledger', 'payout_rate', 'DOM_PAYOUT_PCT', '지급율', 'N', 'N', NULL, 'N'),
('recruit_ledger', 'agent_amount', 'DOM_COMMISSION', '설계사수수료','N','N', NULL, 'N'),
('recruit_ledger', 'tax_amount', 'DOM_TAX', '세액', 'N', 'N', NULL, 'N'),
('recruit_ledger', 'settle_month', 'DOM_SETTLE_MONTH','정산월', 'N', 'N', NULL, 'Y'),
('recruit_ledger', 'status', 'DOM_STATUS', '상태', 'N', 'N', NULL, 'Y'),
-- settle_master
('settle_master', 'settle_id', 'DOM_ID_BIGINT', '정산ID', 'Y', 'N', NULL, 'Y'),
('settle_master', 'agent_id', 'DOM_ID_BIGINT', '설계사ID', 'N', 'Y', 'agent.agent_id', 'Y'),
('settle_master', 'settle_month', 'DOM_SETTLE_MONTH','정산월', 'N', 'N', NULL, 'Y'),
('settle_master', 'recruit_total', 'DOM_COMMISSION', '모집합계', 'N', 'N', NULL, 'N'),
('settle_master', 'maintain_total', 'DOM_COMMISSION', '유지합계', 'N', 'N', NULL, 'N'),
('settle_master', 'gross_amount', 'DOM_AMOUNT', '총액', 'N', 'N', NULL, 'N'),
('settle_master', 'tax_amount', 'DOM_TAX', '세액', 'N', 'N', NULL, 'N'),
('settle_master', 'net_amount', 'DOM_NET_AMOUNT', '순지급액', 'N', 'N', NULL, 'N'),
('settle_master', 'status', 'DOM_STATUS', '상태', 'N', 'N', NULL, 'Y'),
-- payment
('payment', 'payment_id', 'DOM_ID_BIGINT', '지급ID', 'Y', 'N', NULL, 'Y'),
('payment', 'settle_id', 'DOM_ID_BIGINT', '정산ID', 'N', 'Y', 'settle_master.settle_id', 'Y'),
('payment', 'agent_id', 'DOM_ID_BIGINT', '설계사ID','N', 'Y', 'agent.agent_id', 'Y'),
('payment', 'bank_code', 'DOM_BANK_CODE', '은행코드','N', 'N', NULL, 'N'),
('payment', 'account_no', 'DOM_ACCOUNT_NO', '계좌번호','N', 'N', NULL, 'N'),
('payment', 'pay_amount', 'DOM_AMOUNT', '지급액', 'N', 'N', NULL, 'N')
ON CONFLICT (table_name, column_name) DO NOTHING;
-- ============================================================
-- 6. 메뉴 등록 (GRP_SYSTEM 하위)
-- ============================================================
INSERT INTO menu (menu_code, menu_name, menu_type, menu_path, component_path, menu_icon,
menu_level, sort_order, is_visible, is_active, parent_menu_id, description)
VALUES
(
'SYSTEM_DATA_DOMAIN',
'데이터 도메인 사전',
'PAGE',
'/system/data-domains',
'system/DataDomainList',
'database',
2,
8,
'Y',
'Y',
(SELECT menu_id FROM menu WHERE menu_code = 'GRP_SYSTEM'),
'비즈니스 도메인 정의 (주민번호/금액/율 등)'
),
(
'SYSTEM_DATA_DICT',
'데이터 사전',
'PAGE',
'/system/data-dictionary',
'system/DataDictionary',
'book',
2,
9,
'Y',
'Y',
(SELECT menu_id FROM menu WHERE menu_code = 'GRP_SYSTEM'),
'테이블/컬럼별 한글명 + 도메인 매핑'
)
ON CONFLICT (menu_code) DO NOTHING;
-- ============================================================
-- 7. 메뉴 권한 정의
-- ============================================================
-- SYSTEM_DATA_DOMAIN : READ / CREATE / UPDATE / DELETE
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
SELECT menu_id, 'READ', '조회', 1 FROM menu WHERE menu_code = 'SYSTEM_DATA_DOMAIN'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
SELECT menu_id, 'CREATE', '등록', 2 FROM menu WHERE menu_code = 'SYSTEM_DATA_DOMAIN'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
SELECT menu_id, 'UPDATE', '수정', 3 FROM menu WHERE menu_code = 'SYSTEM_DATA_DOMAIN'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
SELECT menu_id, 'DELETE', '삭제', 4 FROM menu WHERE menu_code = 'SYSTEM_DATA_DOMAIN'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
-- SYSTEM_DATA_DICT : READ / CREATE / UPDATE (사전은 DELETE 없음)
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
SELECT menu_id, 'READ', '조회', 1 FROM menu WHERE menu_code = 'SYSTEM_DATA_DICT'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
SELECT menu_id, 'CREATE', '등록', 2 FROM menu WHERE menu_code = 'SYSTEM_DATA_DICT'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
SELECT menu_id, 'UPDATE', '수정', 3 FROM menu WHERE menu_code = 'SYSTEM_DATA_DICT'
ON CONFLICT (menu_id, perm_code) DO NOTHING;
-- ============================================================
-- 8. 역할-메뉴 권한 부여
-- ============================================================
-- SUPER_ADMIN / ADMIN : SYSTEM_DATA_DOMAIN 모든 권한 (READ/CREATE/UPDATE/DELETE)
INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
SELECT r.role_id, m.menu_id, p.perm_code, 'Y'
FROM role r
CROSS JOIN menu m
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE')) AS p(perm_code)
WHERE r.role_code IN ('SUPER_ADMIN', 'ADMIN')
AND m.menu_code = 'SYSTEM_DATA_DOMAIN'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- SUPER_ADMIN / ADMIN : SYSTEM_DATA_DICT 모든 권한 (READ/CREATE/UPDATE)
INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
SELECT r.role_id, m.menu_id, p.perm_code, 'Y'
FROM role r
CROSS JOIN menu m
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE')) AS p(perm_code)
WHERE r.role_code IN ('SUPER_ADMIN', 'ADMIN')
AND m.menu_code = 'SYSTEM_DATA_DICT'
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
-- MANAGER : 두 메뉴 모두 READ 만
INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
SELECT r.role_id, m.menu_id, 'READ', 'Y'
FROM role r
CROSS JOIN menu m
WHERE r.role_code = 'MANAGER'
AND m.menu_code IN ('SYSTEM_DATA_DOMAIN', 'SYSTEM_DATA_DICT')
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
@@ -0,0 +1,127 @@
-- V21: 프로필 / 이력서 / 인적사항 관련 도메인 추가 (data_domain)
-- + 기존 agent / users 테이블의 누락 컬럼들 데이터 사전 매핑 보강
-- ============================================================
-- 1) 프로필 / 이력서 카테고리 도메인 추가
-- ============================================================
INSERT INTO ga.data_domain
(domain_code, domain_name, domain_category, data_type, length, precision, scale,
format_pattern, validation_regex, example_value, masking_type, encrypted, description, is_active)
VALUES
-- 프로필 (PROFILE) — 사용자/설계사 프로필 정보
('DOM_PROFILE_BIO', '자기소개', 'PROFILE', 'TEXT', NULL, NULL, NULL,
NULL, NULL, '15년차 라이프 플래너...', 'NONE', 'N',
'설계사 / 사용자의 자기소개 글', 'Y'),
('DOM_PROFILE_IMAGE', '프로필 사진', 'PROFILE', 'BIGINT', NULL, NULL, NULL,
NULL, NULL, '12345', 'NONE', 'N',
'file_storage 테이블의 file_id (프로필 이미지)', 'Y'),
('DOM_NICKNAME', '닉네임', 'PROFILE', 'VARCHAR', 50, NULL, NULL,
NULL, '^[가-힣A-Za-z0-9_]{2,20}$', '베스트설계사', 'NONE', 'N',
'표시명 / 별명', 'Y'),
('DOM_BIRTH_DATE', '생년월일', 'PROFILE', 'DATE', NULL, NULL, NULL,
'yyyy-MM-dd', NULL, '1985-03-15', 'NONE', 'N',
'생년월일 (만 나이 계산용)', 'Y'),
('DOM_GENDER', '성별', 'PROFILE', 'CHAR', 1, NULL, NULL,
NULL, '^[MFU]$', 'M', 'NONE', 'N',
'성별 코드 — M(남) / F(여) / U(미지정)', 'Y'),
('DOM_ADDRESS', '주소', 'PROFILE', 'VARCHAR', 300, NULL, NULL,
NULL, NULL, '서울특별시 강남구 테헤란로 123', 'NONE', 'N',
'주소 (도로명 또는 지번)', 'Y'),
('DOM_ZIP_CODE', '우편번호', 'PROFILE', 'VARCHAR', 10, NULL, NULL,
NULL, '^\d{5,6}$', '06234', 'NONE', 'N',
'우편번호', 'Y'),
('DOM_NATIONALITY', '국적', 'PROFILE', 'VARCHAR', 10, NULL, NULL,
NULL, '^[A-Z]{2,3}$', 'KR', 'NONE', 'N',
'ISO 3166 국가 코드 (예: KR, US)', 'Y'),
-- 이력서 (RESUME)
('DOM_RESUME_FILE', '이력서 파일', 'RESUME', 'BIGINT', NULL, NULL, NULL,
NULL, NULL, '67890', 'NONE', 'N',
'file_storage 의 file_id (PDF/DOCX 이력서)', 'Y'),
('DOM_CAREER_HISTORY', '경력 사항', 'RESUME', 'JSONB', NULL, NULL, NULL,
NULL, NULL,
'[{"company":"삼성생명","period":"2015-01~2020-12","role":"FC"}]',
'NONE', 'N',
'경력 이력 JSONB 배열 (회사/기간/역할)', 'Y'),
('DOM_EDUCATION', '학력', 'RESUME', 'JSONB', NULL, NULL, NULL,
NULL, NULL,
'[{"school":"서울대학교","major":"경영학","year":2010,"degree":"학사"}]',
'NONE', 'N',
'학력 이력 JSONB 배열 (학교/전공/연도)', 'Y'),
('DOM_LICENSE_LIST', '자격증 목록', 'RESUME', 'JSONB', NULL, NULL, NULL,
NULL, NULL,
'[{"name":"생명보험설계사","issuedAt":"2015-03-01","number":"L-12345"}]',
'NONE', 'N',
'자격증 / 인증 JSONB 배열', 'Y'),
('DOM_SKILL_TAGS', '스킬 태그', 'RESUME', 'VARCHAR', 500, NULL, NULL,
NULL, NULL, '#종신보험,#건강보험,#연금', 'NONE', 'N',
'쉼표 / 해시태그 구분 스킬', 'Y'),
('DOM_INTRODUCTION', '소개 영상 URL', 'RESUME', 'VARCHAR', 500, NULL, NULL,
NULL, '^https?://.+', 'https://youtu.be/abc123', 'NONE', 'N',
'소개 영상 외부 URL', 'Y'),
('DOM_HIRE_TYPE', '계약 형태', 'RESUME', 'VARCHAR', 20, NULL, NULL,
NULL, NULL, 'FULL_TIME', 'NONE', 'N',
'FULL_TIME / PART_TIME / CONTRACT / FREELANCE', 'Y')
ON CONFLICT (domain_code) DO NOTHING;
-- ============================================================
-- 2) 기존 테이블에 도메인 매핑 보강 (V20 에서 누락된 부분)
-- ============================================================
INSERT INTO ga.data_dictionary
(table_name, column_name, domain_code, column_label, is_pk, is_fk, fk_reference, is_required, description)
VALUES
-- agent 추가 컬럼 (V20 에서 10개 매핑됐음 — 누락 보강)
('agent', 'grade_id', 'DOM_ID_BIGINT', '직급ID', 'N', 'Y', 'grade.grade_id', 'Y', '소속 직급'),
('agent', 'bank_code', 'DOM_BANK_CODE', '은행코드', 'N', 'N', NULL, 'N', '계좌의 은행 코드'),
('agent', 'leave_date', 'DOM_DATE', '퇴사일', 'N', 'N', NULL, 'N', '퇴사 시 입력'),
('agent', 'created_at', 'DOM_DATETIME', '등록일시', 'N', 'N', NULL, 'Y', '레코드 생성 시각'),
('agent', 'updated_at', 'DOM_DATETIME', '수정일시', 'N', 'N', NULL, 'N', '레코드 수정 시각'),
-- users 테이블 (시스템 사용자) — 신규 매핑
('users', 'user_id', 'DOM_ID_BIGINT', '사용자ID', 'Y', 'N', NULL, 'Y', NULL),
('users', 'login_id', 'DOM_LICENSE_NO', '로그인 ID', 'N', 'N', NULL, 'Y', '로그인 식별자 (UNIQUE)'),
('users', 'password', 'DOM_TEXT_SHORT', '비밀번호', 'N', 'N', NULL, 'Y', 'BCrypt 해시'),
('users', 'agent_id', 'DOM_ID_BIGINT', '연결 설계사', 'N', 'Y', 'agent.agent_id', 'N', NULL),
('users', 'role', 'DOM_STATUS', '역할', 'N', 'N', NULL, 'Y', 'SUPER_ADMIN / ADMIN / MANAGER / AGENT'),
('users', 'is_active', 'DOM_YN_FLAG', '활성여부', 'N', 'N', NULL, 'Y', NULL),
('users', 'last_login_at','DOM_DATETIME', '마지막 로그인일시', 'N', 'N', NULL, 'N', NULL),
-- organization 테이블
('organization', 'org_id', 'DOM_ID_BIGINT', '조직ID', 'Y', 'N', NULL, 'Y', NULL),
('organization', 'parent_org_id', 'DOM_ID_BIGINT', '상위조직ID', 'N', 'Y', 'organization.org_id', 'N', NULL),
('organization', 'org_name', 'DOM_NAME', '조직명', 'N', 'N', NULL, 'Y', NULL),
('organization', 'org_type', 'DOM_STATUS', '조직유형', 'N', 'N', NULL, 'Y', 'HQ / BR / TM'),
('organization', 'org_level', 'DOM_ID_BIGINT', '조직레벨', 'N', 'N', NULL, 'Y', NULL),
('organization', 'effective_from', 'DOM_DATE', '적용시작일', 'N', 'N', NULL, 'Y', NULL),
-- insurance_company 테이블
('insurance_company', 'company_id', 'DOM_ID_BIGINT', '보험사ID', 'Y', 'N', NULL, 'Y', NULL),
('insurance_company', 'company_code', 'DOM_COMPANY_CODE','보험사코드','N','N', NULL, 'Y', 'UNIQUE'),
('insurance_company', 'company_name', 'DOM_NAME', '회사명', 'N', 'N', NULL, 'Y', NULL),
('insurance_company', 'biz_no', 'DOM_LICENSE_NO', '사업자번호','N','N', NULL, 'N', NULL),
-- product 테이블
('product', 'product_id', 'DOM_ID_BIGINT', '상품ID', 'Y', 'N', NULL, 'Y', NULL),
('product', 'company_id', 'DOM_ID_BIGINT', '보험사ID', 'N', 'Y', 'insurance_company.company_id', 'Y', NULL),
('product', 'product_code', 'DOM_PRODUCT_CODE', '상품코드', 'N', 'N', NULL, 'Y', NULL),
('product', 'product_name', 'DOM_NAME', '상품명', 'N', 'N', NULL, 'Y', NULL),
('product', 'insurance_type', 'DOM_INSURANCE_TYPE','보험종류', 'N', 'N', NULL, 'Y', NULL)
ON CONFLICT (table_name, column_name) DO NOTHING;
@@ -0,0 +1,196 @@
-- V22: GA 정산 솔루션의 비즈니스 도메인 확장 (6 카테고리 29개 + 사전 매핑 보강)
INSERT INTO ga.data_domain
(domain_code, domain_name, domain_category, data_type, length, precision, scale,
format_pattern, validation_regex, example_value, masking_type, encrypted, description, is_active)
VALUES
-- ============================================================
-- AUDIT (감사) — 모든 테이블의 공통 감사 컬럼 표준화
-- ============================================================
('DOM_CREATED_BY', '등록자 ID', 'AUDIT', 'BIGINT', NULL, NULL, NULL,
NULL, NULL, '1001', 'NONE', 'N',
'users.user_id 참조 (생성한 사용자)', 'Y'),
('DOM_UPDATED_BY', '수정자 ID', 'AUDIT', 'BIGINT', NULL, NULL, NULL,
NULL, NULL, '1001', 'NONE', 'N',
'users.user_id 참조 (마지막 수정자)', 'Y'),
('DOM_IP_ADDRESS', 'IP 주소', 'AUDIT', 'VARCHAR', 45, NULL, NULL,
NULL, '^([0-9]{1,3}\.){3}[0-9]{1,3}$|^([0-9a-fA-F:]+)$', '192.168.0.60', 'NONE', 'N',
'IPv4(15) 또는 IPv6(45)', 'Y'),
('DOM_USER_AGENT', '클라이언트 정보', 'AUDIT', 'VARCHAR', 500, NULL, NULL,
NULL, NULL, 'Mozilla/5.0 ...', 'NONE', 'N',
'HTTP User-Agent 헤더', 'Y'),
('DOM_VERSION', '낙관적 잠금 버전','AUDIT', 'INTEGER', NULL, NULL, NULL,
NULL, NULL, '3', 'NONE', 'N',
'동시 수정 충돌 감지용 (UPDATE 시 +1)', 'Y'),
-- ============================================================
-- WORKFLOW (결재 / 승인)
-- ============================================================
('DOM_APPROVE_STATUS', '승인 상태', 'WORKFLOW', 'VARCHAR', 20, NULL, NULL,
NULL, NULL, 'PENDING', 'NONE', 'N',
'PENDING / APPROVED / REJECTED / CANCELLED', 'Y'),
('DOM_APPROVER_ID', '승인자 ID', 'WORKFLOW', 'BIGINT', NULL, NULL, NULL,
NULL, NULL, '1001', 'NONE', 'N',
'users.user_id 참조', 'Y'),
('DOM_APPROVE_COMMENT', '승인/반려 사유','WORKFLOW', 'TEXT', NULL, NULL, NULL,
NULL, NULL, '예외 사유 검토 후 승인', 'NONE', 'N',
'결재 메모 (반려 시 필수)', 'Y'),
('DOM_WORKFLOW_STAGE', '워크플로 단계', 'WORKFLOW', 'VARCHAR', 30, NULL, NULL,
NULL, NULL, 'REVIEW', 'NONE', 'N',
'DRAFT / REVIEW / APPROVED / PUBLISHED / ARCHIVED', 'Y'),
('DOM_PRIORITY', '우선순위', 'WORKFLOW', 'VARCHAR', 10, NULL, NULL,
NULL, '^(HIGH|MEDIUM|LOW)$', 'HIGH', 'NONE', 'N',
'HIGH / MEDIUM / LOW', 'Y'),
-- ============================================================
-- SETTLEMENT_BIZ (정산 비즈니스 — GA 솔루션 핵심)
-- ============================================================
('DOM_RECONCILE_STATUS', '대사 상태', 'SETTLEMENT_BIZ', 'VARCHAR', 20, NULL, NULL,
NULL, NULL, 'MATCH', 'NONE', 'N',
'PENDING / MATCH / MISMATCH / DIFF', 'Y'),
('DOM_RECONCILE_DIFF', '대사 차액', 'SETTLEMENT_BIZ', 'DECIMAL', NULL, 15, 2,
'#,##0', NULL, '12500.00', 'NONE', 'N',
'보험사 통보액 - 시스템 계산액 (음수 가능)', 'Y'),
('DOM_LAPSE_MONTH', '실효 경과월', 'SETTLEMENT_BIZ', 'INTEGER', NULL, NULL, NULL,
NULL, '^[0-9]{1,3}$', '13', 'NONE', 'N',
'계약 효력일~실효일 사이 개월 수 (환수 규정 매칭)', 'Y'),
('DOM_INSTALLMENT_SEQ', '분할 회차', 'SETTLEMENT_BIZ', 'INTEGER', NULL, NULL, NULL,
NULL, NULL, '2', 'NONE', 'N',
'환수 분할 시 N차 (1~installment_total)', 'Y'),
('DOM_INSTALLMENT_TOTAL','분할 총 회차', 'SETTLEMENT_BIZ', 'INTEGER', NULL, NULL, NULL,
NULL, NULL, '6', 'NONE', 'N',
'환수 분할 총 회차 (보통 1/3/6/12)', 'Y'),
('DOM_PERFORMANCE_GRADE','실적 등급', 'SETTLEMENT_BIZ', 'VARCHAR', 10, NULL, NULL,
NULL, '^[A-F]$|^GOLD$|^SILVER$|^BRONZE$', 'A', 'NONE', 'N',
'A/B/C/D/F 또는 GOLD/SILVER/BRONZE — 지급율 차등', 'Y'),
-- ============================================================
-- FILE (파일 / 첨부)
-- ============================================================
('DOM_FILE_ID', '파일 ID', 'FILE', 'BIGINT', NULL, NULL, NULL,
NULL, NULL, '12345', 'NONE', 'N',
'file_storage.file_id 참조', 'Y'),
('DOM_FILE_PATH', '파일 경로', 'FILE', 'VARCHAR', 500, NULL, NULL,
NULL, NULL, '/upload/2026/05/abc.pdf', 'NONE', 'N',
'서버 저장 경로 (또는 S3 키)', 'Y'),
('DOM_FILE_SIZE', '파일 크기', 'FILE', 'BIGINT', NULL, NULL, NULL,
NULL, NULL, '1048576', 'NONE', 'N',
'바이트 단위 (1MB = 1048576)', 'Y'),
('DOM_MIME_TYPE', 'MIME 타입', 'FILE', 'VARCHAR', 100, NULL, NULL,
NULL, NULL, 'application/pdf', 'NONE', 'N',
'application/pdf, image/png 등', 'Y'),
-- ============================================================
-- TAX (세무)
-- ============================================================
('DOM_TAX_INVOICE_NO', '세금계산서 번호', 'TAX', 'VARCHAR', 50, NULL, NULL,
NULL, NULL, '202605-00123', 'NONE', 'N',
'국세청 발행 번호 또는 내부 일련번호', 'Y'),
('DOM_VAT', '부가세', 'TAX', 'DECIMAL', NULL, 15, 2,
'#,##0', NULL, '300000.00', 'NONE', 'N',
'부가가치세 (10%)', 'Y'),
('DOM_INCOME_TAX', '소득세', 'TAX', 'DECIMAL', NULL, 15, 2,
'#,##0', NULL, '99000.00', 'NONE', 'N',
'원천징수 소득세 (3%)', 'Y'),
('DOM_LOCAL_TAX', '지방세', 'TAX', 'DECIMAL', NULL, 15, 2,
'#,##0', NULL, '9900.00', 'NONE', 'N',
'지방소득세 (소득세의 10%)', 'Y'),
-- ============================================================
-- BANKING (은행 / 결제)
-- ============================================================
('DOM_TRANSFER_REF', '이체 참조번호', 'BANKING', 'VARCHAR', 50, NULL, NULL,
NULL, NULL, 'TRX-20260510-0001', 'NONE', 'N',
'은행/PG 측 이체 추적 번호', 'Y'),
('DOM_TRANSFER_FEE', '이체 수수료', 'BANKING', 'DECIMAL', NULL, 15, 2,
'#,##0', NULL, '500.00', 'NONE', 'N',
'건당 이체 수수료 (지급액에서 차감 가능)', 'Y'),
('DOM_CURRENCY', '통화 코드', 'BANKING', 'CHAR', 3, NULL, NULL,
NULL, '^[A-Z]{3}$', 'KRW', 'NONE', 'N',
'ISO 4217 (KRW / USD / EUR / JPY)', 'Y'),
('DOM_FX_RATE', '환율', 'BANKING', 'DECIMAL', NULL, 15, 6,
'#,##0.000000', NULL, '1350.123456', 'NONE', 'N',
'기준 통화 → 거래 통화 (소수점 6자리)', 'Y'),
('DOM_SWIFT_CODE', 'SWIFT BIC', 'BANKING', 'VARCHAR', 11, NULL, NULL,
NULL, '^[A-Z]{6}[A-Z0-9]{2,5}$', 'KOEXKRSE', 'NONE', 'N',
'국제 송금용 은행 식별 코드', 'Y')
ON CONFLICT (domain_code) DO NOTHING;
-- ============================================================
-- 데이터 사전 매핑 보강 (V20 / V21 누락분 + 신규 매핑)
-- ============================================================
INSERT INTO ga.data_dictionary
(table_name, column_name, domain_code, column_label, is_pk, is_fk, fk_reference, is_required, description)
VALUES
-- exception_ledger 핵심
('exception_ledger', 'ledger_id', 'DOM_ID_BIGINT', '원장ID', 'Y', 'N', NULL, 'Y', NULL),
('exception_ledger', 'agent_id', 'DOM_ID_BIGINT', '설계사ID', 'N', 'Y', 'agent.agent_id', 'Y', NULL),
('exception_ledger', 'exception_code', 'DOM_STATUS', '예외코드', 'N', 'Y', 'exception_type_code.exception_code', 'Y', NULL),
('exception_ledger', 'amount', 'DOM_AMOUNT', '금액', 'N', 'N', NULL, 'Y', NULL),
('exception_ledger', 'settle_month', 'DOM_SETTLE_MONTH', '정산월', 'N', 'N', NULL, 'Y', NULL),
('exception_ledger', 'approve_status', 'DOM_APPROVE_STATUS', '승인상태', 'N', 'N', NULL, 'Y', 'PENDING/APPROVED/REJECTED'),
('exception_ledger', 'approver_id', 'DOM_APPROVER_ID', '승인자', 'N', 'Y', 'users.user_id', 'N', NULL),
('exception_ledger', 'approve_date', 'DOM_DATETIME', '승인일시', 'N', 'N', NULL, 'N', NULL),
-- chargeback 핵심
('chargeback', 'cb_id', 'DOM_ID_BIGINT', '환수ID', 'Y', 'N', NULL, 'Y', NULL),
('chargeback', 'agent_id', 'DOM_ID_BIGINT', '설계사ID', 'N', 'Y', 'agent.agent_id', 'Y', NULL),
('chargeback', 'lapse_month', 'DOM_LAPSE_MONTH', '실효경과월', 'N', 'N', NULL, 'Y', NULL),
('chargeback', 'cb_rate', 'DOM_RATE_PCT', '환수율', 'N', 'N', NULL, 'Y', NULL),
('chargeback', 'cb_amount', 'DOM_COMMISSION', '환수금액', 'N', 'N', NULL, 'Y', NULL),
('chargeback', 'current_installment','DOM_INSTALLMENT_SEQ','분할회차', 'N', 'N', NULL, 'N', NULL),
('chargeback', 'settle_month', 'DOM_SETTLE_MONTH', '정산월', 'N', 'N', NULL, 'Y', NULL),
-- reconciliation
('reconciliation', 'recon_id', 'DOM_ID_BIGINT', '대사ID', 'Y', 'N', NULL, 'Y', NULL),
('reconciliation', 'company_code', 'DOM_COMPANY_CODE', '보험사', 'N', 'N', NULL, 'Y', NULL),
('reconciliation', 'settle_month', 'DOM_SETTLE_MONTH', '정산월', 'N', 'N', NULL, 'Y', NULL),
('reconciliation', 'company_amount', 'DOM_AMOUNT', '보험사통보액','N','N', NULL, 'Y', NULL),
('reconciliation', 'system_amount', 'DOM_AMOUNT', '시스템계산액','N','N', NULL, 'Y', NULL),
('reconciliation', 'diff_amount', 'DOM_RECONCILE_DIFF', '차액', 'N', 'N', NULL, 'Y', NULL),
('reconciliation', 'status', 'DOM_RECONCILE_STATUS', '대사상태', 'N', 'N', NULL, 'Y', NULL),
-- batch_job_log
('batch_job_log', 'job_id', 'DOM_ID_BIGINT', '배치ID', 'Y', 'N', NULL, 'Y', NULL),
('batch_job_log', 'batch_id', 'DOM_BATCH_ID', '배치코드', 'N', 'N', NULL, 'Y', 'monthlySettlementJob 등'),
('batch_job_log', 'settle_month', 'DOM_SETTLE_MONTH','정산월', 'N', 'N', NULL, 'N', NULL),
('batch_job_log', 'status', 'DOM_STATUS', '상태', 'N', 'N', NULL, 'Y', 'REQUESTED/RUNNING/COMPLETED/FAILED'),
('batch_job_log', 'started_at', 'DOM_DATETIME', '시작일시', 'N', 'N', NULL, 'N', NULL),
('batch_job_log', 'finished_at', 'DOM_DATETIME', '종료일시', 'N', 'N', NULL, 'N', NULL),
-- file_storage
('file_storage', 'file_id', 'DOM_FILE_ID', '파일ID', 'Y', 'N', NULL, 'Y', NULL),
('file_storage', 'original_name', 'DOM_TEXT_SHORT', '원본파일명', 'N', 'N', NULL, 'Y', NULL),
('file_storage', 'stored_name', 'DOM_TEXT_SHORT', '저장파일명', 'N', 'N', NULL, 'Y', NULL),
('file_storage', 'file_path', 'DOM_FILE_PATH', '파일경로', 'N', 'N', NULL, 'Y', NULL),
('file_storage', 'file_size', 'DOM_FILE_SIZE', '파일크기', 'N', 'N', NULL, 'N', NULL),
('file_storage', 'mime_type', 'DOM_MIME_TYPE', 'MIME타입', 'N', 'N', NULL, 'N', NULL),
-- api_access_log
('api_access_log', 'log_id', 'DOM_ID_BIGINT', '로그ID', 'Y', 'N', NULL, 'Y', NULL),
('api_access_log', 'user_id', 'DOM_ID_BIGINT', '사용자ID', 'N', 'Y', 'users.user_id', 'N', NULL),
('api_access_log', 'request_url', 'DOM_TEXT_SHORT', '요청URL', 'N', 'N', NULL, 'Y', NULL),
('api_access_log', 'response_status', 'DOM_ID_BIGINT', 'HTTP상태', 'N', 'N', NULL, 'N', NULL),
('api_access_log', 'response_time', 'DOM_ID_BIGINT', '응답시간', 'N', 'N', NULL, 'N', '단위: ms'),
('api_access_log', 'ip_address', 'DOM_IP_ADDRESS', 'IP', 'N', 'N', NULL, 'N', NULL),
-- data_change_log
('data_change_log', 'log_id', 'DOM_ID_BIGINT', '로그ID', 'Y', 'N', NULL, 'Y', NULL),
('data_change_log', 'table_name', 'DOM_TEXT_SHORT', '테이블명', 'N', 'N', NULL, 'Y', NULL),
('data_change_log', 'record_id', 'DOM_TEXT_SHORT', 'PK', 'N', 'N', NULL, 'N', NULL),
('data_change_log', 'action_type', 'DOM_STATUS', '액션', 'N', 'N', NULL, 'Y', 'CREATE/UPDATE/DELETE'),
('data_change_log', 'change_data', 'DOM_JSONB', '변경 데이터','N', 'N', NULL, 'N', 'before/after JSON'),
('data_change_log', 'user_id', 'DOM_ID_BIGINT', '사용자ID', 'N', 'Y', 'users.user_id', 'N', NULL),
-- 모든 정산/원장 테이블의 created_by/updated_by 매핑 (감사 표준화)
('recruit_ledger', 'created_at', 'DOM_DATETIME', '등록일시', 'N', 'N', NULL, 'Y', NULL),
('recruit_ledger', 'updated_at', 'DOM_DATETIME', '수정일시', 'N', 'N', NULL, 'N', NULL),
('maintain_ledger', 'created_at', 'DOM_DATETIME', '등록일시', 'N', 'N', NULL, 'Y', NULL),
('maintain_ledger', 'updated_at', 'DOM_DATETIME', '수정일시', 'N', 'N', NULL, 'N', NULL),
('settle_master', 'confirmed_by','DOM_APPROVER_ID','확정자', 'N', 'Y', 'users.user_id', 'N', NULL),
('settle_master', 'confirmed_at','DOM_DATETIME', '확정일시', 'N', 'N', NULL, 'N', NULL)
ON CONFLICT (table_name, column_name) DO NOTHING;
@@ -0,0 +1,209 @@
package com.ga.common.code;
import com.ga.common.exception.BizException;
import com.ga.common.exception.ErrorCode;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* CommonCodeService 단위 테스트.
*
* 주의: @Cacheable / @CacheEvict 는 Spring 프록시 단계에서 동작하므로
* 단위 테스트(직접 인스턴스 호출)에서는 캐시가 끼지 않는다 — 비즈니스 로직만 검증.
*/
@ExtendWith(MockitoExtension.class)
class CommonCodeServiceTest {
@Mock CommonCodeMapper mapper;
@InjectMocks CommonCodeService service;
private CommonCodeVO code(String groupCode, String code, String name) {
CommonCodeVO c = new CommonCodeVO();
c.setGroupCode(groupCode);
c.setCode(code);
c.setCodeName(name);
return c;
}
private CommonCodeGroupVO group(String groupCode, String isSystem) {
CommonCodeGroupVO g = new CommonCodeGroupVO();
g.setGroupCode(groupCode);
g.setIsSystem(isSystem);
return g;
}
/** 필요한 테스트에서만 호출 (strict stubbing) */
private void stubGenderGroup() {
when(mapper.selectActiveCodes("GENDER")).thenReturn(List.of(
code("GENDER", "M", "남자"),
code("GENDER", "F", "여자")
));
}
@Test
@DisplayName("getCodeName: 매칭 성공")
void getCodeName_hit() {
stubGenderGroup();
assertThat(service.getCodeName("GENDER", "M")).isEqualTo("남자");
assertThat(service.getCodeName("GENDER", "F")).isEqualTo("여자");
}
@Test
@DisplayName("getCodeName: 미존재 코드 → null")
void getCodeName_miss() {
stubGenderGroup();
assertThat(service.getCodeName("GENDER", "X")).isNull();
}
@Test
@DisplayName("getCodeName: code null → null (DB 조회 안함)")
void getCodeName_nullCode() {
assertThat(service.getCodeName("GENDER", null)).isNull();
verify(mapper, never()).selectActiveCodes(any());
}
@Test
@DisplayName("validateCode: 미존재 시 INVALID_PARAMETER 예외")
void validateCode_invalid() {
stubGenderGroup();
assertThatThrownBy(() -> service.validateCode("GENDER", "X"))
.isInstanceOf(BizException.class)
.extracting(e -> ((BizException) e).getErrorCode())
.isEqualTo(ErrorCode.INVALID_PARAMETER);
}
@Test
@DisplayName("validateCode: 존재하면 예외 없음")
void validateCode_ok() {
stubGenderGroup();
service.validateCode("GENDER", "M"); // no throw
}
@Test
@DisplayName("getGroup: 미존재 시 NOT_FOUND")
void getGroup_notFound() {
when(mapper.selectGroup("MISSING")).thenReturn(null);
assertThatThrownBy(() -> service.getGroup("MISSING"))
.isInstanceOf(BizException.class)
.extracting(e -> ((BizException) e).getErrorCode())
.isEqualTo(ErrorCode.NOT_FOUND);
}
@Test
@DisplayName("createGroup: 중복 그룹 코드 → DUPLICATE_DATA")
void createGroup_duplicate() {
when(mapper.selectGroup("DUP")).thenReturn(group("DUP", "N"));
CommonCodeGroupVO vo = group("DUP", "N");
assertThatThrownBy(() -> service.createGroup(vo))
.isInstanceOf(BizException.class)
.extracting(e -> ((BizException) e).getErrorCode())
.isEqualTo(ErrorCode.DUPLICATE_DATA);
verify(mapper, never()).insertGroup(any());
}
@Test
@DisplayName("createGroup: 신규 → insertGroup 호출")
void createGroup_ok() {
when(mapper.selectGroup("NEW")).thenReturn(null);
CommonCodeGroupVO vo = group("NEW", "N");
service.createGroup(vo);
verify(mapper).insertGroup(vo);
}
@Test
@DisplayName("updateGroup: 시스템 그룹은 SYSTEM_CODE_LOCKED")
void updateGroup_systemLocked() {
when(mapper.selectGroup("SYS")).thenReturn(group("SYS", "Y"));
CommonCodeGroupVO vo = group("SYS", "Y");
assertThatThrownBy(() -> service.updateGroup(vo))
.isInstanceOf(BizException.class)
.extracting(e -> ((BizException) e).getErrorCode())
.isEqualTo(ErrorCode.SYSTEM_CODE_LOCKED);
verify(mapper, never()).updateGroup(any());
}
@Test
@DisplayName("deleteGroup: 일반 그룹은 삭제 가능")
void deleteGroup_ok() {
when(mapper.selectGroup("USER_GRP")).thenReturn(group("USER_GRP", "N"));
service.deleteGroup("USER_GRP");
verify(mapper).deleteGroup("USER_GRP");
}
@Test
@DisplayName("deleteGroup: 시스템 그룹은 SYSTEM_CODE_LOCKED")
void deleteGroup_systemLocked() {
when(mapper.selectGroup("SYS")).thenReturn(group("SYS", "Y"));
assertThatThrownBy(() -> service.deleteGroup("SYS"))
.isInstanceOf(BizException.class)
.extracting(e -> ((BizException) e).getErrorCode())
.isEqualTo(ErrorCode.SYSTEM_CODE_LOCKED);
verify(mapper, never()).deleteGroup(any());
}
@Test
@DisplayName("createCode: 동일 (group, code) 중복 → DUPLICATE_DATA")
void createCode_duplicate() {
when(mapper.existsByGroupAndCode("GENDER", "M")).thenReturn(1);
assertThatThrownBy(() -> service.createCode(code("GENDER", "M", "남자")))
.isInstanceOf(BizException.class);
verify(mapper, never()).insertCode(any());
}
@Test
@DisplayName("updateCode: 시스템 그룹의 code/groupCode 는 강제로 원본 유지")
void updateCode_systemForcesIdentity() {
CommonCodeVO old = code("GENDER", "M", "남자");
old.setCodeId(1L);
when(mapper.selectCode(1L)).thenReturn(old);
when(mapper.selectGroup("GENDER")).thenReturn(group("GENDER", "Y")); // 시스템 그룹
// 사용자가 임의로 code/groupCode 바꾸려 시도
CommonCodeVO update = code("OTHER", "X", "변경시도");
update.setCodeId(1L);
service.updateCode(update);
// 시스템 그룹이므로 code/groupCode 가 원본으로 강제 복원됨
assertThat(update.getCode()).isEqualTo("M");
assertThat(update.getGroupCode()).isEqualTo("GENDER");
verify(mapper).updateCode(update);
}
@Test
@DisplayName("deleteCode: 시스템 그룹은 SYSTEM_CODE_LOCKED")
void deleteCode_systemLocked() {
CommonCodeVO c = code("GENDER", "M", "남자");
c.setCodeId(1L);
when(mapper.selectCode(1L)).thenReturn(c);
when(mapper.selectGroup("GENDER")).thenReturn(group("GENDER", "Y"));
assertThatThrownBy(() -> service.deleteCode(1L))
.isInstanceOf(BizException.class)
.extracting(e -> ((BizException) e).getErrorCode())
.isEqualTo(ErrorCode.SYSTEM_CODE_LOCKED);
verify(mapper, never()).deleteCode(any());
}
}
@@ -0,0 +1,113 @@
package com.ga.common.menu;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class MenuTreeBuilderTest {
private MenuVO menu(Long id, Long parentId, String code, int sort) {
MenuVO m = new MenuVO();
m.setMenuId(id);
m.setParentMenuId(parentId);
m.setMenuCode(code);
m.setMenuName(code + "-name");
m.setMenuType("PAGE");
m.setSortOrder(sort);
m.setIsActive("Y");
return m;
}
@Test
@DisplayName("부모-자식 연결: parent_menu_id 가 null 이면 root")
void buildTree_basic() {
List<MenuVO> flat = List.of(
menu(1L, null, "ROOT_A", 1),
menu(2L, 1L, "A_CHILD_1", 2),
menu(3L, 1L, "A_CHILD_2", 1),
menu(4L, null, "ROOT_B", 2)
);
List<MenuResp> roots = MenuTreeBuilder.build(flat, Map.of());
assertThat(roots).hasSize(2);
// root 도 sortOrder 로 정렬됨
assertThat(roots.get(0).getMenuCode()).isEqualTo("ROOT_A");
assertThat(roots.get(1).getMenuCode()).isEqualTo("ROOT_B");
// ROOT_A 의 자식이 sortOrder 순으로 정렬되었는지
List<MenuResp> aChildren = roots.get(0).getChildren();
assertThat(aChildren).hasSize(2);
assertThat(aChildren.get(0).getMenuCode()).isEqualTo("A_CHILD_2"); // sort 1
assertThat(aChildren.get(1).getMenuCode()).isEqualTo("A_CHILD_1"); // sort 2
assertThat(roots.get(1).getChildren()).isEmpty();
}
@Test
@DisplayName("permissionsByMenuId: menuId 별 권한 코드 주입")
void permissions_injected() {
List<MenuVO> flat = List.of(menu(10L, null, "M", 1));
Map<Long, List<String>> perms = Map.of(10L, List.of("READ", "CREATE", "UPDATE"));
List<MenuResp> roots = MenuTreeBuilder.build(flat, perms);
assertThat(roots.get(0).getPermissions()).containsExactly("READ", "CREATE", "UPDATE");
}
@Test
@DisplayName("permissionsByMenuId 없는 메뉴는 빈 리스트")
void permissions_emptyDefault() {
List<MenuVO> flat = List.of(menu(10L, null, "M", 1));
List<MenuResp> roots = MenuTreeBuilder.build(flat, Map.of());
assertThat(roots.get(0).getPermissions()).isEmpty();
}
@Test
@DisplayName("부모가 목록에 없는 고아 메뉴는 root 로 끌어올림")
void orphan_promotedToRoot() {
List<MenuVO> flat = List.of(
menu(1L, null, "ROOT", 1),
menu(99L, 999L, "ORPHAN", 1) // 부모 999가 목록에 없음
);
List<MenuResp> roots = MenuTreeBuilder.build(flat, Map.of());
// 둘 다 root 로 처리되어야 함
assertThat(roots).hasSize(2);
assertThat(roots).extracting(MenuResp::getMenuCode).containsExactlyInAnyOrder("ROOT", "ORPHAN");
}
@Test
@DisplayName("3단계 계층: 손자까지 연결되고 각 레벨 sortOrder 정렬")
void threeLevels() {
List<MenuVO> flat = List.of(
menu(1L, null, "L1", 1),
menu(2L, 1L, "L2", 1),
menu(3L, 2L, "L3_B", 2),
menu(4L, 2L, "L3_A", 1)
);
List<MenuResp> roots = MenuTreeBuilder.build(flat, Map.of());
MenuResp l2 = roots.get(0).getChildren().get(0);
assertThat(l2.getMenuCode()).isEqualTo("L2");
assertThat(l2.getChildren()).extracting(MenuResp::getMenuCode)
.containsExactly("L3_A", "L3_B"); // sortOrder 정렬
}
@Test
@DisplayName("permissionsByMenuId 가 null 이면 권한 주입 스킵")
void permissionsByMenuId_null_skipsInjection() {
List<MenuVO> flat = List.of(menu(10L, null, "M", 1));
List<MenuResp> roots = MenuTreeBuilder.build(flat, null);
// 기본값(빈 리스트) 그대로 유지
assertThat(roots.get(0).getPermissions()).isEmpty();
}
}
@@ -0,0 +1,57 @@
package com.ga.common.menu;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PermissionCheckerTest {
@Mock MenuMapper menuMapper;
@InjectMocks PermissionChecker checker;
@Test
@DisplayName("userId null 이면 즉시 false 반환 (DB 조회 안함)")
void nullUser_returnsFalse() {
boolean ok = checker.hasPermission(null, "MENU", "READ");
assertThat(ok).isFalse();
verify(menuMapper, never()).hasPermission(any(), any(), any());
}
@Test
@DisplayName("Mapper 가 1 이상 반환 → true")
void hasPermission_grant() {
when(menuMapper.hasPermission(1L, "AGENT", "READ")).thenReturn(1);
assertThat(checker.hasPermission(1L, "AGENT", "READ")).isTrue();
}
@Test
@DisplayName("Mapper 가 0 반환 → false")
void hasPermission_deny() {
when(menuMapper.hasPermission(1L, "AGENT", "DELETE")).thenReturn(0);
assertThat(checker.hasPermission(1L, "AGENT", "DELETE")).isFalse();
}
@Test
@DisplayName("Mapper 호출 인자가 그대로 전달됨")
void argumentsForwarded() {
when(menuMapper.hasPermission(anyLong(), any(), any())).thenReturn(1);
checker.hasPermission(42L, "SETTLE", "APPROVE");
verify(menuMapper).hasPermission(42L, "SETTLE", "APPROVE");
}
}
@@ -0,0 +1,92 @@
package com.ga.common.util;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class DateUtilTest {
@Test
@DisplayName("getMonthRange: 평월")
void monthRange_normal() {
String[] r = DateUtil.getMonthRange("202605");
assertThat(r).containsExactly("2026-05-01", "2026-05-31");
}
@Test
@DisplayName("getMonthRange: 윤년 2월")
void monthRange_leap() {
String[] r = DateUtil.getMonthRange("202402");
assertThat(r).containsExactly("2024-02-01", "2024-02-29");
}
@Test
@DisplayName("getMonthRange: 평년 2월")
void monthRange_normalFeb() {
String[] r = DateUtil.getMonthRange("202302");
assertThat(r).containsExactly("2023-02-01", "2023-02-28");
}
@Test
@DisplayName("calcMonthDiff: yyyymm 문자열")
void monthDiff_yyyymm() {
assertThat(DateUtil.calcMonthDiff("202401", "202405")).isEqualTo(4);
assertThat(DateUtil.calcMonthDiff("202312", "202401")).isEqualTo(1);
assertThat(DateUtil.calcMonthDiff("202405", "202401")).isEqualTo(-4);
assertThat(DateUtil.calcMonthDiff("202401", "202401")).isZero();
}
@Test
@DisplayName("calcMonthDiff: LocalDate")
void monthDiff_date() {
// 같은 달의 다른 일자는 0개월
assertThat(DateUtil.calcMonthDiff(LocalDate.of(2024, 1, 1), LocalDate.of(2024, 1, 31))).isZero();
assertThat(DateUtil.calcMonthDiff(LocalDate.of(2024, 1, 31), LocalDate.of(2024, 2, 1))).isEqualTo(1);
assertThat(DateUtil.calcMonthDiff(LocalDate.of(2023, 1, 1), LocalDate.of(2024, 1, 1))).isEqualTo(12);
}
@Test
@DisplayName("addMonth: 월 가산/연도 넘김")
void addMonth() {
assertThat(DateUtil.addMonth("202401", 3)).isEqualTo("202404");
assertThat(DateUtil.addMonth("202312", 1)).isEqualTo("202401");
assertThat(DateUtil.addMonth("202403", -3)).isEqualTo("202312");
}
@Test
@DisplayName("parseDate: 패턴 일치/실패")
void parseDate() {
assertThat(DateUtil.parseDate("2026-05-10", "yyyy-MM-dd")).isEqualTo(LocalDate.of(2026, 5, 10));
assertThat(DateUtil.parseDate("20260510", "yyyyMMdd")).isEqualTo(LocalDate.of(2026, 5, 10));
assertThat(DateUtil.parseDate(null, "yyyy-MM-dd")).isNull();
assertThat(DateUtil.parseDate("", "yyyy-MM-dd")).isNull();
assertThat(DateUtil.parseDate("invalid", "yyyy-MM-dd")).isNull();
}
@Test
@DisplayName("format: 패턴 적용")
void format() {
LocalDate d = LocalDate.of(2026, 5, 10);
assertThat(DateUtil.format(d, "yyyy-MM-dd")).isEqualTo("2026-05-10");
assertThat(DateUtil.format(d, "yyyyMMdd")).isEqualTo("20260510");
assertThat(DateUtil.format(null, "yyyy-MM-dd")).isNull();
}
@Test
@DisplayName("getMonthRange: 잘못된 yyyymm 은 예외")
void monthRange_invalid() {
assertThatThrownBy(() -> DateUtil.getMonthRange("2026"))
.isInstanceOf(java.time.format.DateTimeParseException.class);
}
@Test
@DisplayName("getSettleMonth(LocalDate): yyyyMM")
void settleMonthOfDate() {
assertThat(DateUtil.getSettleMonth(LocalDate.of(2026, 5, 10))).isEqualTo("202605");
assertThat(DateUtil.getSettleMonth(LocalDate.of(2024, 12, 31))).isEqualTo("202412");
}
}
@@ -0,0 +1,63 @@
package com.ga.common.util;
import com.ga.common.exception.BizException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class EncryptUtilTest {
private final EncryptUtil util = new EncryptUtil("ga-test-key-1234567890-abcdefghi");
@Test
@DisplayName("encrypt → decrypt round-trip")
void roundTrip() {
String plain = "9001011234567"; // 주민번호 형태
String enc = util.encrypt(plain);
assertThat(enc).isNotEqualTo(plain);
assertThat(enc).isBase64();
assertThat(util.decrypt(enc)).isEqualTo(plain);
}
@Test
@DisplayName("동일 평문 → 동일 암호문 (고정 IV)")
void deterministic() {
String enc1 = util.encrypt("hello");
String enc2 = util.encrypt("hello");
assertThat(enc1).isEqualTo(enc2);
}
@Test
@DisplayName("null/empty 는 그대로 반환")
void nullEmpty() {
assertThat(util.encrypt(null)).isNull();
assertThat(util.encrypt("")).isEmpty();
assertThat(util.decrypt(null)).isNull();
assertThat(util.decrypt("")).isEmpty();
}
@Test
@DisplayName("키가 32바이트 미만이면 0 패딩 → 정상 동작")
void shortKey() {
EncryptUtil shortKey = new EncryptUtil("short");
String enc = shortKey.encrypt("data");
assertThat(shortKey.decrypt(enc)).isEqualTo("data");
}
@Test
@DisplayName("잘못된 암호문 복호화 시 BizException")
void decryptInvalid() {
assertThatThrownBy(() -> util.decrypt("not-base64-!!"))
.isInstanceOf(BizException.class);
}
@Test
@DisplayName("한글/특수문자 round-trip")
void multiByte() {
String plain = "홍길동@한국#$% áéíóú";
assertThat(util.decrypt(util.encrypt(plain))).isEqualTo(plain);
}
}
@@ -0,0 +1,71 @@
package com.ga.common.util;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class MaskUtilTest {
@Test
@DisplayName("maskName: 길이별 분기")
void maskName() {
assertThat(MaskUtil.maskName("")).isEqualTo(""); // 1자: 그대로
assertThat(MaskUtil.maskName("홍길")).isEqualTo("홍*"); // 2자: 마지막
assertThat(MaskUtil.maskName("홍길동")).isEqualTo("홍*동"); // 3자: 가운데
assertThat(MaskUtil.maskName("홍길동이")).isEqualTo("홍**이"); // 4자
}
@Test
@DisplayName("maskPhone: 11자리 / 10자리")
void maskPhone() {
assertThat(MaskUtil.maskPhone("010-1234-5678")).isEqualTo("010-****-5678");
assertThat(MaskUtil.maskPhone("01012345678")).isEqualTo("010-****-5678");
assertThat(MaskUtil.maskPhone("031-123-4567")).isEqualTo("031-***-4567"); // 10자리
assertThat(MaskUtil.maskPhone("02-123-4567")).isEqualTo("02-123-4567"); // 9자리는 원본
}
@Test
@DisplayName("maskRrn: 13자리만 마스킹, 그 외 원본")
void maskRrn() {
assertThat(MaskUtil.maskRrn("880101-1234567")).isEqualTo("880101-*******");
assertThat(MaskUtil.maskRrn("8801011234567")).isEqualTo("880101-*******");
// 13자리가 아니면 원본 반환
assertThat(MaskUtil.maskRrn("88010")).isEqualTo("88010");
}
@Test
@DisplayName("maskAccount: 하이픈 포함 / 미포함")
void maskAccount() {
assertThat(MaskUtil.maskAccount("110-123-456789")).isEqualTo("110-***-******");
// 하이픈 없는 경우, 4자리 노출 후 마스킹
assertThat(MaskUtil.maskAccount("110123456789")).isEqualTo("1101********");
assertThat(MaskUtil.maskAccount("123")).isEqualTo("123"); // 4자 이하는 원본
}
@Test
@DisplayName("maskEmail: 첫 글자만 노출")
void maskEmail() {
assertThat(MaskUtil.maskEmail("aaa@bbb.com")).isEqualTo("a**@bbb.com");
assertThat(MaskUtil.maskEmail("longusername@domain.io")).isEqualTo("l***********@domain.io");
// @ 위치가 0 또는 1이면 마스킹 불가 → 원본
assertThat(MaskUtil.maskEmail("@bbb.com")).isEqualTo("@bbb.com");
assertThat(MaskUtil.maskEmail("a@bbb.com")).isEqualTo("a@bbb.com");
}
@Test
@DisplayName("mask: enum 디스패치")
void mask_enum() {
assertThat(MaskUtil.mask("홍길동", MaskUtil.MaskType.NAME)).isEqualTo("홍*동");
assertThat(MaskUtil.mask("010-1234-5678", MaskUtil.MaskType.PHONE)).isEqualTo("010-****-5678");
assertThat(MaskUtil.mask("aaa@bbb.com", MaskUtil.MaskType.EMAIL)).isEqualTo("a**@bbb.com");
}
@Test
@DisplayName("mask: null/blank 안전")
void mask_nullSafe() {
assertThat(MaskUtil.mask(null, MaskUtil.MaskType.NAME)).isNull();
assertThat(MaskUtil.mask("", MaskUtil.MaskType.NAME)).isEmpty();
assertThat(MaskUtil.mask(" ", MaskUtil.MaskType.NAME)).isEqualTo(" ");
}
}
@@ -0,0 +1,101 @@
package com.ga.common.util;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
class MoneyUtilTest {
@Test
@DisplayName("zero: null 은 0 으로 치환")
void zero_null() {
assertThat(MoneyUtil.zero(null)).isEqualTo(BigDecimal.ZERO);
assertThat(MoneyUtil.zero(new BigDecimal("12.34"))).isEqualByComparingTo("12.34");
}
@Test
@DisplayName("add/sub: null 안전, 정상 합/차")
void add_sub() {
assertThat(MoneyUtil.add(null, new BigDecimal("100"))).isEqualByComparingTo("100");
assertThat(MoneyUtil.add(new BigDecimal("100"), null)).isEqualByComparingTo("100");
assertThat(MoneyUtil.add(new BigDecimal("100"), new BigDecimal("50.50"))).isEqualByComparingTo("150.50");
assertThat(MoneyUtil.sub(new BigDecimal("100"), new BigDecimal("30"))).isEqualByComparingTo("70");
assertThat(MoneyUtil.sub(null, null)).isEqualByComparingTo("0");
}
@Test
@DisplayName("mul: 소수점 2자리 HALF_UP")
void mul_halfUp() {
// 1.235 → 1.24 (HALF_UP)
assertThat(MoneyUtil.mul(new BigDecimal("0.5"), new BigDecimal("2.47")))
.isEqualByComparingTo("1.24");
assertThat(MoneyUtil.mul(null, new BigDecimal("100"))).isEqualByComparingTo("0.00");
}
@Test
@DisplayName("div: 0 으로 나누면 0 반환, null 안전")
void div_byZero() {
assertThat(MoneyUtil.div(new BigDecimal("100"), BigDecimal.ZERO)).isEqualByComparingTo("0");
assertThat(MoneyUtil.div(new BigDecimal("100"), null)).isEqualByComparingTo("0");
assertThat(MoneyUtil.div(new BigDecimal("100"), new BigDecimal("3")))
.isEqualByComparingTo("33.33");
}
@Test
@DisplayName("pct: amount × rate / 100, 2자리 HALF_UP")
void pct_basic() {
// 1,000,000 × 7.5% = 75,000.00
assertThat(MoneyUtil.pct(new BigDecimal("1000000"), new BigDecimal("7.5")))
.isEqualByComparingTo("75000.00");
// 100 × 33.33% = 33.33
assertThat(MoneyUtil.pct(new BigDecimal("100"), new BigDecimal("33.33")))
.isEqualByComparingTo("33.33");
}
@Test
@DisplayName("pct: null 입력은 0")
void pct_null() {
assertThat(MoneyUtil.pct(null, new BigDecimal("10"))).isEqualByComparingTo("0");
assertThat(MoneyUtil.pct(new BigDecimal("100"), null)).isEqualByComparingTo("0");
}
@Test
@DisplayName("tax: 기본 3.3% 원천징수")
void tax_default() {
// 1,000,000 × 3.3% = 33,000.00
assertThat(MoneyUtil.tax(new BigDecimal("1000000")))
.isEqualByComparingTo("33000.00");
}
@Test
@DisplayName("tax: 사용자 지정 세율")
void tax_customRate() {
assertThat(MoneyUtil.tax(new BigDecimal("1000"), new BigDecimal("10")))
.isEqualByComparingTo("100.00");
}
@Test
@DisplayName("isZero/isPositive/isNegative")
void signChecks() {
assertThat(MoneyUtil.isZero(null)).isTrue();
assertThat(MoneyUtil.isZero(BigDecimal.ZERO)).isTrue();
assertThat(MoneyUtil.isZero(new BigDecimal("0.00"))).isTrue();
assertThat(MoneyUtil.isPositive(new BigDecimal("0.01"))).isTrue();
assertThat(MoneyUtil.isPositive(null)).isFalse();
assertThat(MoneyUtil.isNegative(new BigDecimal("-1"))).isTrue();
assertThat(MoneyUtil.isNegative(BigDecimal.ZERO)).isFalse();
}
@Test
@DisplayName("fmt/fmtDec/fmtSign")
void formatting() {
assertThat(MoneyUtil.fmt(new BigDecimal("1234567"))).isEqualTo("1,234,567");
assertThat(MoneyUtil.fmt(null)).isEmpty();
assertThat(MoneyUtil.fmtDec(new BigDecimal("1234.5"))).isEqualTo("1,234.50");
assertThat(MoneyUtil.fmtSign(new BigDecimal("100"))).isEqualTo("+100");
assertThat(MoneyUtil.fmtSign(new BigDecimal("-100"))).isEqualTo("-100");
}
}
@@ -0,0 +1,37 @@
package com.ga.core.mapper.dict;
import com.ga.core.vo.dict.DataDictionaryFullResp;
import com.ga.core.vo.dict.DataDictionaryResp;
import com.ga.core.vo.dict.DataDictionarySearchParam;
import com.ga.core.vo.dict.DataDictionaryVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface DataDictionaryMapper {
/** 목록 조회 (data_dictionary + data_domain 조인, 페이징) */
List<DataDictionaryResp> selectList(DataDictionarySearchParam param);
/** 특정 테이블의 모든 컬럼 (sort_order 순, LEFT JOIN data_domain) */
List<DataDictionaryResp> selectByTable(@Param("tableName") String tableName);
/** VIEW v_data_dictionary_full 조회. tableName null 이면 전체 */
List<DataDictionaryFullResp> selectFromView(@Param("tableName") String tableName);
/** 사전에 등록된 distinct table_name 리스트 */
List<String> selectTables();
DataDictionaryResp selectById(@Param("dictId") Long dictId);
int insert(DataDictionaryVO vo);
int update(DataDictionaryVO vo);
int delete(@Param("dictId") Long dictId);
/** domain_code 존재 여부 확인 */
int countDomainByCode(@Param("domainCode") String domainCode);
}
@@ -0,0 +1,30 @@
package com.ga.core.mapper.dict;
import com.ga.core.vo.dict.DataDomainOptionResp;
import com.ga.core.vo.dict.DataDomainResp;
import com.ga.core.vo.dict.DataDomainSearchParam;
import com.ga.core.vo.dict.DataDomainVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface DataDomainMapper {
List<DataDomainResp> selectList(DataDomainSearchParam param);
DataDomainResp selectByCode(@Param("domainCode") String domainCode);
/** 드롭다운 Select 옵션용 전체 목록 (활성 도메인) */
List<DataDomainOptionResp> selectAll();
int insert(DataDomainVO vo);
int update(DataDomainVO vo);
int delete(@Param("domainCode") String domainCode);
/** data_dictionary 에서 해당 domainCode 를 참조하는 컬럼 수 */
int countUsageByCode(@Param("domainCode") String domainCode);
}
@@ -19,4 +19,7 @@ public interface PaymentMapper {
@Param("failReason") String failReason);
int updateTransferFile(@Param("paymentIds") List<Long> paymentIds,
@Param("transferFileId") Long transferFileId);
List<PaymentVO> selectPendingByMonthAndBank(@Param("settleMonth") String settleMonth,
@Param("bankCode") String bankCode);
}
@@ -0,0 +1,18 @@
package com.ga.core.mapper.upload;
import com.ga.core.vo.upload.UploadTemplateColumnVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface UploadColumnMapper {
List<UploadTemplateColumnVO> selectByTemplateId(@Param("templateId") Long templateId);
int insertBatch(@Param("templateId") Long templateId,
@Param("columns") List<UploadTemplateColumnVO> columns);
int deleteByTemplateId(@Param("templateId") Long templateId);
}
@@ -0,0 +1,30 @@
package com.ga.core.mapper.upload;
import com.ga.core.vo.upload.UploadHistoryResp;
import com.ga.core.vo.upload.UploadHistoryVO;
import com.ga.core.vo.upload.UploadTemplateSearchParam;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface UploadHistoryMapper {
List<UploadHistoryResp> selectList(UploadTemplateSearchParam param);
List<UploadHistoryResp> selectByTemplate(@Param("templateId") Long templateId,
UploadTemplateSearchParam param);
int insert(UploadHistoryVO vo);
int updateStatus(@Param("historyId") Long historyId,
@Param("status") String status,
@Param("errorMessage") String errorMessage,
@Param("successCount") Integer successCount,
@Param("errorCount") Integer errorCount,
@Param("skipCount") Integer skipCount,
@Param("totalCount") Integer totalCount,
@Param("errorFileId") Long errorFileId,
@Param("durationSec") Integer durationSec);
}
@@ -0,0 +1,27 @@
package com.ga.core.mapper.upload;
import com.ga.core.vo.upload.UploadTemplateResp;
import com.ga.core.vo.upload.UploadTemplateSearchParam;
import com.ga.core.vo.upload.UploadTemplateVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface UploadTemplateMapper {
List<UploadTemplateResp> selectList(UploadTemplateSearchParam param);
UploadTemplateResp selectById(@Param("templateId") Long templateId);
UploadTemplateVO selectByCode(@Param("templateCode") String templateCode);
int existsByCode(@Param("templateCode") String templateCode);
int insert(UploadTemplateVO vo);
int update(UploadTemplateVO vo);
int delete(@Param("templateId") Long templateId);
}
@@ -0,0 +1,42 @@
package com.ga.core.vo.dict;
import lombok.Data;
/**
* VIEW v_data_dictionary_full 조회 결과.
* data_dictionary + data_domain + information_schema.COLUMNS 결합.
*/
@Data
public class DataDictionaryFullResp {
// data_dictionary
private Long dictId;
private String tableName;
private String columnName;
private String domainCode;
private String columnLabel;
private String description;
private Boolean isPk;
private Boolean isFk;
private String fkReference;
private Boolean isRequired;
private String defaultValue;
private String businessRule;
private String sampleValue;
private Integer sortOrder;
// data_domain
private String domainName;
private String domainCategory;
private String maskingType;
private Boolean encrypted;
// information_schema.COLUMNS
private String physicalDataType;
private Long physicalLength;
private Integer physicalPrecision;
private Integer physicalScale;
private String physicalNullable;
private String physicalDefault;
private String physicalComment;
private Long ordinalPosition;
}
@@ -0,0 +1,39 @@
package com.ga.core.vo.dict;
import lombok.Data;
import java.time.LocalDateTime;
/**
* data_dictionary + data_domain 조인 결과
*/
@Data
public class DataDictionaryResp {
// data_dictionary 컬럼
private Long dictId;
private String tableName;
private String columnName;
private String domainCode;
private String columnLabel;
private String description;
private Boolean isPk;
private Boolean isFk;
private String fkReference;
private Boolean isRequired;
private String defaultValue;
private String businessRule;
private String sampleValue;
private Integer sortOrder;
private LocalDateTime createdAt;
private String createdBy;
private LocalDateTime updatedAt;
private String updatedBy;
// data_domain 조인 컬럼
private String domainName;
private String domainCategory;
private String dataType;
private Integer length;
private String maskingType;
private Boolean encrypted;
}
@@ -0,0 +1,63 @@
package com.ga.core.vo.dict;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class DataDictionarySaveReq {
@NotBlank(message = "테이블명은 필수입니다")
@Size(max = 100)
private String tableName;
@NotBlank(message = "컬럼명은 필수입니다")
@Size(max = 100)
private String columnName;
@Size(max = 50)
private String domainCode;
@Size(max = 200)
private String columnLabel;
@Size(max = 500)
private String description;
private Boolean isPk;
private Boolean isFk;
@Size(max = 200)
private String fkReference;
private Boolean isRequired;
@Size(max = 200)
private String defaultValue;
@Size(max = 1000)
private String businessRule;
@Size(max = 200)
private String sampleValue;
private Integer sortOrder;
public DataDictionaryVO toVO() {
DataDictionaryVO vo = new DataDictionaryVO();
vo.setTableName(tableName);
vo.setColumnName(columnName);
vo.setDomainCode(domainCode);
vo.setColumnLabel(columnLabel);
vo.setDescription(description);
vo.setIsPk(isPk != null ? isPk : Boolean.FALSE);
vo.setIsFk(isFk != null ? isFk : Boolean.FALSE);
vo.setFkReference(fkReference);
vo.setIsRequired(isRequired != null ? isRequired : Boolean.FALSE);
vo.setDefaultValue(defaultValue);
vo.setBusinessRule(businessRule);
vo.setSampleValue(sampleValue);
vo.setSortOrder(sortOrder != null ? sortOrder : 0);
return vo;
}
}
@@ -0,0 +1,12 @@
package com.ga.core.vo.dict;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class DataDictionarySearchParam extends SearchParam {
private String tableName;
private String domainCode;
}
@@ -0,0 +1,27 @@
package com.ga.core.vo.dict;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class DataDictionaryVO {
private Long dictId;
private String tableName;
private String columnName;
private String domainCode;
private String columnLabel;
private String description;
private Boolean isPk;
private Boolean isFk;
private String fkReference;
private Boolean isRequired;
private String defaultValue;
private String businessRule;
private String sampleValue;
private Integer sortOrder;
private LocalDateTime createdAt;
private String createdBy;
private LocalDateTime updatedAt;
private String updatedBy;
}
@@ -0,0 +1,13 @@
package com.ga.core.vo.dict;
import lombok.Data;
/**
* 드롭다운 Select 옵션용 경량 VO (selectAll 전용)
*/
@Data
public class DataDomainOptionResp {
private String domainCode;
private String domainName;
private String domainCategory;
}
@@ -0,0 +1,27 @@
package com.ga.core.vo.dict;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class DataDomainResp {
private String domainCode;
private String domainName;
private String domainCategory;
private String dataType;
private Integer length;
private Integer precision;
private Integer scale;
private String formatPattern;
private String validationRegex;
private String exampleValue;
private String maskingType;
private Boolean encrypted;
private String description;
private Boolean isActive;
private LocalDateTime createdAt;
private String createdBy;
private LocalDateTime updatedAt;
private String updatedBy;
}
@@ -0,0 +1,67 @@
package com.ga.core.vo.dict;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class DataDomainSaveReq {
@NotBlank(message = "도메인코드는 필수입니다")
@Size(max = 50)
private String domainCode;
@NotBlank(message = "도메인명은 필수입니다")
@Size(max = 100)
private String domainName;
@NotBlank(message = "도메인 분류는 필수입니다")
@Size(max = 50)
private String domainCategory;
@NotBlank(message = "데이터 타입은 필수입니다")
@Size(max = 30)
private String dataType;
private Integer length;
private Integer precision;
private Integer scale;
@Size(max = 100)
private String formatPattern;
@Size(max = 500)
private String validationRegex;
@Size(max = 200)
private String exampleValue;
@Size(max = 30)
private String maskingType;
private Boolean encrypted;
@Size(max = 500)
private String description;
private Boolean isActive;
public DataDomainVO toVO() {
DataDomainVO vo = new DataDomainVO();
vo.setDomainCode(domainCode);
vo.setDomainName(domainName);
vo.setDomainCategory(domainCategory);
vo.setDataType(dataType);
vo.setLength(length);
vo.setPrecision(precision);
vo.setScale(scale);
vo.setFormatPattern(formatPattern);
vo.setValidationRegex(validationRegex);
vo.setExampleValue(exampleValue);
vo.setMaskingType(maskingType);
vo.setEncrypted(encrypted != null ? encrypted : Boolean.FALSE);
vo.setDescription(description);
vo.setIsActive(isActive != null ? isActive : Boolean.TRUE);
return vo;
}
}
@@ -0,0 +1,12 @@
package com.ga.core.vo.dict;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class DataDomainSearchParam extends SearchParam {
private String domainCategory;
private Boolean isActive;
}
@@ -0,0 +1,27 @@
package com.ga.core.vo.dict;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class DataDomainVO {
private String domainCode;
private String domainName;
private String domainCategory;
private String dataType;
private Integer length;
private Integer precision;
private Integer scale;
private String formatPattern;
private String validationRegex;
private String exampleValue;
private String maskingType;
private Boolean encrypted;
private String description;
private Boolean isActive;
private LocalDateTime createdAt;
private String createdBy;
private LocalDateTime updatedAt;
private String updatedBy;
}
@@ -0,0 +1,17 @@
package com.ga.core.vo.settle;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TransferFileResp {
private Long fileId;
private String fileName;
private int paymentCount;
private BigDecimal totalAmount;
}
@@ -0,0 +1,31 @@
package com.ga.core.vo.upload;
import lombok.Data;
import java.time.LocalDateTime;
/**
* upload_history 응답 VO (목록/상세용).
*/
@Data
public class UploadHistoryResp {
private Long historyId;
private Long templateId;
private String templateCode;
private String templateName;
private String fileName;
private Long fileSize;
private Integer totalCount;
private Integer successCount;
private Integer errorCount;
private Integer skipCount;
private Long errorFileId;
private String status;
private String errorMessage;
private LocalDateTime startedAt;
private LocalDateTime finishedAt;
private Integer durationSec;
private Long createdBy;
private String createdByName;
}
@@ -0,0 +1,32 @@
package com.ga.core.vo.upload;
import lombok.Data;
import java.time.LocalDateTime;
/**
* upload_history 테이블 1:1 VO.
*/
@Data
public class UploadHistoryVO {
private Long historyId;
private Long templateId;
private String templateCode;
private String fileName;
private Long fileSize;
private Integer totalCount;
private Integer successCount;
private Integer errorCount;
private Integer skipCount;
private Long errorFileId;
/** PROCESSING / SUCCESS / PARTIAL / FAIL */
private String status;
private String errorMessage;
private LocalDateTime startedAt;
private LocalDateTime finishedAt;
private Integer durationSec;
private Long createdBy;
}
@@ -0,0 +1,39 @@
package com.ga.core.vo.upload;
import lombok.Data;
/**
* upload_template_column 테이블 1:1 VO.
*/
@Data
public class UploadTemplateColumnVO {
private Long columnId;
private Long templateId;
private Integer sortOrder;
/** COLUMN / FIXED / EXPRESSION */
private String sourceType;
private Integer sourceColumn;
private String sourceHeader;
private String targetField;
/** STRING / NUMBER / DECIMAL / DATE / INTEGER */
private String targetType;
private String transformRule;
private String transformParam;
private String dateFormat;
private Integer numberDivide;
private Integer numberMultiply;
private String fixedValue;
private String defaultValue;
private String lookupTable;
private String lookupSourceField;
private String lookupTargetField;
private String codeMappingGroup;
private String isRequired;
private String validationRegex;
private String validationMessage;
private String description;
}
@@ -0,0 +1,37 @@
package com.ga.core.vo.upload;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* upload_template 목록/상세 응답 VO.
* 상세 조회 columns 채워진다.
*/
@Data
public class UploadTemplateResp {
private Long templateId;
private String templateCode;
private String templateName;
private String category;
private String targetTable;
private String companyCode;
private Integer headerRow;
private Integer dataStartRow;
private Integer sheetIndex;
private String sheetName;
private String fileEncoding;
private String delimiter;
private String skipEmptyRow;
private Integer maxErrorCount;
private Integer batchSize;
private String description;
private String isActive;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/** 상세 조회 시 포함 (목록에서는 null) */
private List<UploadTemplateColumnVO> columns;
}
@@ -0,0 +1,63 @@
package com.ga.core.vo.upload;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
/**
* 업로드 템플릿 등록/수정 요청.
*/
@Data
public class UploadTemplateSaveReq {
@NotBlank
private String templateCode;
@NotBlank
private String templateName;
private String category;
@NotBlank
private String targetTable;
private String companyCode;
private Integer headerRow;
private Integer dataStartRow;
private Integer sheetIndex;
private String sheetName;
private String fileEncoding;
private String delimiter;
private String skipEmptyRow;
private Integer maxErrorCount;
private Integer batchSize;
private String description;
private String isActive;
@Valid
private List<UploadTemplateColumnVO> columns;
public UploadTemplateVO toVO() {
UploadTemplateVO vo = new UploadTemplateVO();
vo.setTemplateCode(this.templateCode);
vo.setTemplateName(this.templateName);
vo.setCategory(this.category);
vo.setTargetTable(this.targetTable);
vo.setCompanyCode(this.companyCode);
vo.setHeaderRow(this.headerRow != null ? this.headerRow : 1);
vo.setDataStartRow(this.dataStartRow != null ? this.dataStartRow : 2);
vo.setSheetIndex(this.sheetIndex != null ? this.sheetIndex : 0);
vo.setSheetName(this.sheetName);
vo.setFileEncoding(this.fileEncoding != null ? this.fileEncoding : "UTF-8");
vo.setDelimiter(this.delimiter);
vo.setSkipEmptyRow(this.skipEmptyRow != null ? this.skipEmptyRow : "Y");
vo.setMaxErrorCount(this.maxErrorCount != null ? this.maxErrorCount : 100);
vo.setBatchSize(this.batchSize != null ? this.batchSize : 1000);
vo.setDescription(this.description);
vo.setIsActive(this.isActive != null ? this.isActive : "Y");
return vo;
}
}
@@ -0,0 +1,17 @@
package com.ga.core.vo.upload;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 업로드 템플릿 검색 파라미터.
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class UploadTemplateSearchParam extends SearchParam {
private String category;
private String companyCode;
private String isActive;
}
@@ -0,0 +1,34 @@
package com.ga.core.vo.upload;
import lombok.Data;
import java.time.LocalDateTime;
/**
* upload_template 테이블 1:1 VO.
*/
@Data
public class UploadTemplateVO {
private Long templateId;
private String templateCode;
private String templateName;
private String category;
private String targetTable;
private String companyCode;
private Integer headerRow;
private Integer dataStartRow;
private Integer sheetIndex;
private String sheetName;
private String fileEncoding;
private String delimiter;
private String skipEmptyRow;
private Integer maxErrorCount;
private Integer batchSize;
private String description;
private String isActive;
private LocalDateTime createdAt;
private Long createdBy;
private LocalDateTime updatedAt;
private Long updatedBy;
}
@@ -0,0 +1,167 @@
<?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.dict.DataDictionaryMapper">
<resultMap id="DataDictionaryRespMap" type="com.ga.core.vo.dict.DataDictionaryResp">
<id property="dictId" column="dict_id"/>
<result property="tableName" column="table_name"/>
<result property="columnName" column="column_name"/>
<result property="domainCode" column="domain_code"/>
<result property="columnLabel" column="column_label"/>
<result property="description" column="description"/>
<result property="isPk" column="is_pk"/>
<result property="isFk" column="is_fk"/>
<result property="fkReference" column="fk_reference"/>
<result property="isRequired" column="is_required"/>
<result property="defaultValue" column="default_value"/>
<result property="businessRule" column="business_rule"/>
<result property="sampleValue" column="sample_value"/>
<result property="sortOrder" column="sort_order"/>
<result property="createdAt" column="created_at"/>
<result property="createdBy" column="created_by"/>
<result property="updatedAt" column="updated_at"/>
<result property="updatedBy" column="updated_by"/>
<!-- data_domain join -->
<result property="domainName" column="domain_name"/>
<result property="domainCategory" column="domain_category"/>
<result property="dataType" column="data_type"/>
<result property="length" column="length"/>
<result property="maskingType" column="masking_type"/>
<result property="encrypted" column="encrypted"/>
</resultMap>
<resultMap id="DataDictionaryFullRespMap" type="com.ga.core.vo.dict.DataDictionaryFullResp">
<id property="dictId" column="dict_id"/>
<result property="tableName" column="table_name"/>
<result property="columnName" column="column_name"/>
<result property="domainCode" column="domain_code"/>
<result property="columnLabel" column="column_label"/>
<result property="description" column="description"/>
<result property="isPk" column="is_pk"/>
<result property="isFk" column="is_fk"/>
<result property="fkReference" column="fk_reference"/>
<result property="isRequired" column="is_required"/>
<result property="defaultValue" column="default_value"/>
<result property="businessRule" column="business_rule"/>
<result property="sampleValue" column="sample_value"/>
<result property="sortOrder" column="sort_order"/>
<!-- data_domain -->
<result property="domainName" column="domain_name"/>
<result property="domainCategory" column="domain_category"/>
<result property="maskingType" column="masking_type"/>
<result property="encrypted" column="encrypted"/>
<!-- information_schema -->
<result property="physicalDataType" column="physical_data_type"/>
<result property="physicalLength" column="physical_length"/>
<result property="physicalPrecision" column="physical_precision"/>
<result property="physicalScale" column="physical_scale"/>
<result property="physicalNullable" column="physical_nullable"/>
<result property="physicalDefault" column="physical_default"/>
<result property="physicalComment" column="physical_comment"/>
<result property="ordinalPosition" column="ordinal_position"/>
</resultMap>
<sql id="dictDomainJoinCols">
d.dict_id, d.table_name, d.column_name, d.domain_code,
d.column_label, d.description, d.is_pk, d.is_fk, d.fk_reference,
d.is_required, d.default_value, d.business_rule, d.sample_value, d.sort_order,
d.created_at, d.created_by, d.updated_at, d.updated_by,
dom.domain_name, dom.domain_category, dom.data_type,
dom.length, dom.masking_type, dom.encrypted
</sql>
<select id="selectList" resultMap="DataDictionaryRespMap">
SELECT <include refid="dictDomainJoinCols"/>
FROM data_dictionary d
LEFT JOIN data_domain dom ON dom.domain_code = d.domain_code
<where>
<if test="tableName != null and tableName != ''">
AND d.table_name = #{tableName}
</if>
<if test="domainCode != null and domainCode != ''">
AND d.domain_code = #{domainCode}
</if>
<if test="searchKeyword != null and searchKeyword != ''">
AND (d.column_name LIKE CONCAT('%', #{searchKeyword}, '%')
OR d.column_label LIKE CONCAT('%', #{searchKeyword}, '%'))
</if>
</where>
ORDER BY d.table_name, d.sort_order, d.column_name
</select>
<!-- 특정 테이블의 모든 컬럼 (도메인 미등록 컬럼도 노출) -->
<select id="selectByTable" resultMap="DataDictionaryRespMap">
SELECT <include refid="dictDomainJoinCols"/>
FROM data_dictionary d
LEFT JOIN data_domain dom ON dom.domain_code = d.domain_code
WHERE d.table_name = #{tableName}
ORDER BY d.sort_order, d.column_name
</select>
<!-- v_data_dictionary_full VIEW 조회. View 에는 sort_order 가 없음 -->
<select id="selectFromView" resultMap="DataDictionaryFullRespMap">
SELECT *
FROM v_data_dictionary_full
<where>
<if test="tableName != null and tableName != ''">
AND table_name = #{tableName}
</if>
</where>
ORDER BY table_name, ordinal_position
</select>
<select id="selectTables" resultType="string">
SELECT DISTINCT table_name
FROM data_dictionary
ORDER BY table_name
</select>
<select id="selectById" resultMap="DataDictionaryRespMap">
SELECT <include refid="dictDomainJoinCols"/>
FROM data_dictionary d
LEFT JOIN data_domain dom ON dom.domain_code = d.domain_code
WHERE d.dict_id = #{dictId}
</select>
<insert id="insert" parameterType="com.ga.core.vo.dict.DataDictionaryVO" useGeneratedKeys="true" keyProperty="dictId">
INSERT INTO data_dictionary (
table_name, column_name, domain_code, column_label, description,
is_pk, is_fk, fk_reference, is_required, default_value,
business_rule, sample_value, sort_order, created_at, created_by
) VALUES (
#{tableName}, #{columnName}, #{domainCode}, #{columnLabel}, #{description},
#{isPk}, #{isFk}, #{fkReference}, #{isRequired}, #{defaultValue},
#{businessRule}, #{sampleValue}, #{sortOrder}, NOW(), #{createdBy}
)
</insert>
<update id="update" parameterType="com.ga.core.vo.dict.DataDictionaryVO">
UPDATE data_dictionary SET
table_name = #{tableName},
column_name = #{columnName},
domain_code = #{domainCode},
column_label = #{columnLabel},
description = #{description},
is_pk = #{isPk},
is_fk = #{isFk},
fk_reference = #{fkReference},
is_required = #{isRequired},
default_value = #{defaultValue},
business_rule = #{businessRule},
sample_value = #{sampleValue},
sort_order = #{sortOrder},
updated_at = NOW(),
updated_by = #{updatedBy}
WHERE dict_id = #{dictId}
</update>
<delete id="delete">
DELETE FROM data_dictionary WHERE dict_id = #{dictId}
</delete>
<select id="countDomainByCode" resultType="int">
SELECT COUNT(*) FROM data_domain WHERE domain_code = #{domainCode}
</select>
</mapper>
@@ -0,0 +1,89 @@
<?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.dict.DataDomainMapper">
<resultMap id="DataDomainRespMap" type="com.ga.core.vo.dict.DataDomainResp"/>
<resultMap id="DataDomainOptionMap" type="com.ga.core.vo.dict.DataDomainOptionResp"/>
<sql id="domainCols">
domain_code, domain_name, domain_category, data_type,
length, precision, scale, format_pattern, validation_regex,
example_value, masking_type, encrypted, description, is_active,
created_at, created_by, updated_at, updated_by
</sql>
<select id="selectList" resultMap="DataDomainRespMap">
SELECT <include refid="domainCols"/>
FROM data_domain
<where>
<if test="domainCategory != null and domainCategory != ''">
AND domain_category = #{domainCategory}
</if>
<if test="isActive != null">
AND is_active = #{isActive}
</if>
<if test="searchKeyword != null and searchKeyword != ''">
AND (domain_code LIKE CONCAT('%', #{searchKeyword}, '%')
OR domain_name LIKE CONCAT('%', #{searchKeyword}, '%'))
</if>
</where>
ORDER BY domain_code ASC
</select>
<select id="selectByCode" resultMap="DataDomainRespMap">
SELECT <include refid="domainCols"/>
FROM data_domain
WHERE domain_code = #{domainCode}
</select>
<select id="selectAll" resultMap="DataDomainOptionMap">
SELECT domain_code, domain_name, domain_category
FROM data_domain
WHERE is_active = 'Y'
ORDER BY domain_category, domain_name
</select>
<insert id="insert" parameterType="com.ga.core.vo.dict.DataDomainVO">
INSERT INTO data_domain (
domain_code, domain_name, domain_category, data_type,
length, precision, scale, format_pattern, validation_regex,
example_value, masking_type, encrypted, description, is_active,
created_at, created_by
) VALUES (
#{domainCode}, #{domainName}, #{domainCategory}, #{dataType},
#{length}, #{precision}, #{scale}, #{formatPattern}, #{validationRegex},
#{exampleValue}, #{maskingType}, #{encrypted}, #{description}, #{isActive},
NOW(), #{createdBy}
)
</insert>
<update id="update" parameterType="com.ga.core.vo.dict.DataDomainVO">
UPDATE data_domain SET
domain_name = #{domainName},
domain_category = #{domainCategory},
data_type = #{dataType},
length = #{length},
precision = #{precision},
scale = #{scale},
format_pattern = #{formatPattern},
validation_regex = #{validationRegex},
example_value = #{exampleValue},
masking_type = #{maskingType},
encrypted = #{encrypted},
description = #{description},
is_active = #{isActive},
updated_at = NOW(),
updated_by = #{updatedBy}
WHERE domain_code = #{domainCode}
</update>
<delete id="delete">
DELETE FROM data_domain WHERE domain_code = #{domainCode}
</delete>
<select id="countUsageByCode" resultType="int">
SELECT COUNT(*) FROM data_dictionary WHERE domain_code = #{domainCode}
</select>
</mapper>
@@ -69,6 +69,18 @@
WHERE payment_id = #{paymentId}
</update>
<select id="selectPendingByMonthAndBank" resultMap="VOMap">
SELECT p.*
FROM payment p
JOIN settle_master s ON s.settle_id = p.settle_id
WHERE p.pay_status = 'PENDING'
AND s.settle_month = #{settleMonth}
<if test="bankCode != null and bankCode != '' and bankCode != 'GENERIC_CSV'">
AND p.bank_code = #{bankCode}
</if>
ORDER BY p.payment_id
</select>
<update id="updateTransferFile">
UPDATE payment SET transfer_file_id = #{transferFileId}, pay_status = 'SENT', updated_at = NOW()
WHERE payment_id IN
@@ -0,0 +1,67 @@
<?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.upload.UploadColumnMapper">
<resultMap id="ColumnMap" type="com.ga.core.vo.upload.UploadTemplateColumnVO">
<id property="columnId" column="column_id"/>
<result property="templateId" column="template_id"/>
<result property="sortOrder" column="sort_order"/>
<result property="sourceType" column="source_type"/>
<result property="sourceColumn" column="source_column"/>
<result property="sourceHeader" column="source_header"/>
<result property="targetField" column="target_field"/>
<result property="targetType" column="target_type"/>
<result property="transformRule" column="transform_rule"/>
<result property="transformParam" column="transform_param"/>
<result property="dateFormat" column="date_format"/>
<result property="numberDivide" column="number_divide"/>
<result property="numberMultiply" column="number_multiply"/>
<result property="fixedValue" column="fixed_value"/>
<result property="defaultValue" column="default_value"/>
<result property="lookupTable" column="lookup_table"/>
<result property="lookupSourceField" column="lookup_source_field"/>
<result property="lookupTargetField" column="lookup_target_field"/>
<result property="codeMappingGroup" column="code_map_group"/>
<result property="isRequired" column="is_required"/>
<result property="validationRegex" column="validation_regex"/>
<result property="validationMessage" column="validation_message"/>
<result property="description" column="description"/>
</resultMap>
<select id="selectByTemplateId" resultMap="ColumnMap">
SELECT column_id, template_id, sort_order, source_type, source_column, source_header,
target_field, target_type, transform_rule, transform_param, date_format,
number_divide, number_multiply, fixed_value, default_value,
lookup_table, lookup_source_field, lookup_target_field, code_map_group,
is_required, validation_regex, validation_message, description
FROM upload_template_column
WHERE template_id = #{templateId}
ORDER BY sort_order ASC
</select>
<!-- forEach 로 다건 INSERT -->
<insert id="insertBatch">
INSERT INTO upload_template_column (
template_id, sort_order, source_type, source_column, source_header,
target_field, target_type, transform_rule, transform_param, date_format,
number_divide, number_multiply, fixed_value, default_value,
lookup_table, lookup_source_field, lookup_target_field, code_map_group,
is_required, validation_regex, validation_message, description
) VALUES
<foreach collection="columns" item="c" separator=",">
(
#{templateId}, #{c.sortOrder}, #{c.sourceType}, #{c.sourceColumn}, #{c.sourceHeader},
#{c.targetField}, #{c.targetType}, #{c.transformRule}, #{c.transformParam}, #{c.dateFormat},
#{c.numberDivide}, #{c.numberMultiply}, #{c.fixedValue}, #{c.defaultValue},
#{c.lookupTable}, #{c.lookupSourceField}, #{c.lookupTargetField}, #{c.codeMappingGroup},
#{c.isRequired}, #{c.validationRegex}, #{c.validationMessage}, #{c.description}
)
</foreach>
</insert>
<delete id="deleteByTemplateId">
DELETE FROM upload_template_column WHERE template_id = #{templateId}
</delete>
</mapper>
@@ -0,0 +1,101 @@
<?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.upload.UploadHistoryMapper">
<resultMap id="HistoryRespMap" type="com.ga.core.vo.upload.UploadHistoryResp">
<id property="historyId" column="history_id"/>
<result property="templateId" column="template_id"/>
<result property="templateCode" column="template_code"/>
<result property="templateName" column="template_name"/>
<result property="fileName" column="file_name"/>
<result property="fileSize" column="file_size"/>
<result property="totalCount" column="total_count"/>
<result property="successCount" column="success_count"/>
<result property="errorCount" column="error_count"/>
<result property="skipCount" column="skip_count"/>
<result property="errorFileId" column="error_file_id"/>
<result property="status" column="status"/>
<result property="errorMessage" column="error_message"/>
<result property="startedAt" column="started_at"/>
<result property="finishedAt" column="finished_at"/>
<result property="durationSec" column="duration_sec"/>
<result property="createdBy" column="created_by"/>
<result property="createdByName" column="created_by_name"/>
</resultMap>
<sql id="historyCols">
h.history_id, h.template_id, h.template_code,
t.template_name,
h.file_name, h.file_size, h.total_count, h.success_count, h.error_count,
h.skip_count, h.error_file_id, h.status, h.error_message,
h.started_at, h.finished_at, h.duration_sec, h.created_by,
u.login_id AS created_by_name
</sql>
<select id="selectList" parameterType="com.ga.core.vo.upload.UploadTemplateSearchParam"
resultMap="HistoryRespMap">
SELECT <include refid="historyCols"/>
FROM upload_history h
LEFT JOIN upload_template t ON t.template_id = h.template_id
LEFT JOIN users u ON u.user_id = h.created_by
<where>
<if test="searchKeyword != null and searchKeyword != ''">
AND (h.template_code LIKE CONCAT('%', #{searchKeyword}, '%')
OR h.file_name LIKE CONCAT('%', #{searchKeyword}, '%'))
</if>
<if test="status != null and status != ''">
AND h.status = #{status}
</if>
<if test="startDate != null and startDate != ''">
AND h.started_at >= #{startDate}::timestamp
</if>
<if test="endDate != null and endDate != ''">
AND h.started_at &lt; (#{endDate}::date + INTERVAL '1 day')
</if>
</where>
ORDER BY h.history_id DESC
</select>
<select id="selectByTemplate" resultMap="HistoryRespMap">
SELECT <include refid="historyCols"/>
FROM upload_history h
LEFT JOIN upload_template t ON t.template_id = h.template_id
LEFT JOIN users u ON u.user_id = h.created_by
WHERE h.template_id = #{templateId}
<if test="param.status != null and param.status != ''">
AND h.status = #{param.status}
</if>
ORDER BY h.history_id DESC
</select>
<insert id="insert" parameterType="com.ga.core.vo.upload.UploadHistoryVO"
useGeneratedKeys="true" keyProperty="historyId">
INSERT INTO upload_history (
template_id, template_code, file_name, file_size,
total_count, success_count, error_count, skip_count,
error_file_id, status, error_message, started_at, finished_at,
duration_sec, created_by
) VALUES (
#{templateId}, #{templateCode}, #{fileName}, #{fileSize},
#{totalCount}, #{successCount}, #{errorCount}, #{skipCount},
#{errorFileId}, #{status}, #{errorMessage}, #{startedAt}, #{finishedAt},
#{durationSec}, #{createdBy}
)
</insert>
<update id="updateStatus">
UPDATE upload_history SET
status = #{status},
error_message = #{errorMessage},
success_count = #{successCount},
error_count = #{errorCount},
skip_count = #{skipCount},
total_count = #{totalCount},
error_file_id = #{errorFileId},
duration_sec = #{durationSec},
finished_at = NOW()
WHERE history_id = #{historyId}
</update>
</mapper>
@@ -0,0 +1,141 @@
<?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.upload.UploadTemplateMapper">
<resultMap id="UploadTemplateVOMap" type="com.ga.core.vo.upload.UploadTemplateVO">
<id property="templateId" column="template_id"/>
<result property="templateCode" column="template_code"/>
<result property="templateName" column="template_name"/>
<result property="category" column="category"/>
<result property="targetTable" column="target_table"/>
<result property="companyCode" column="company_code"/>
<result property="headerRow" column="header_row"/>
<result property="dataStartRow" column="data_start_row"/>
<result property="sheetIndex" column="sheet_index"/>
<result property="sheetName" column="sheet_name"/>
<result property="fileEncoding" column="file_encoding"/>
<result property="delimiter" column="delimiter"/>
<result property="skipEmptyRow" column="skip_empty_row"/>
<result property="maxErrorCount" column="max_error_count"/>
<result property="batchSize" column="batch_size"/>
<result property="description" column="description"/>
<result property="isActive" column="is_active"/>
<result property="createdAt" column="created_at"/>
<result property="createdBy" column="created_by"/>
<result property="updatedAt" column="updated_at"/>
<result property="updatedBy" column="updated_by"/>
</resultMap>
<resultMap id="UploadTemplateRespMap" type="com.ga.core.vo.upload.UploadTemplateResp">
<id property="templateId" column="template_id"/>
<result property="templateCode" column="template_code"/>
<result property="templateName" column="template_name"/>
<result property="category" column="category"/>
<result property="targetTable" column="target_table"/>
<result property="companyCode" column="company_code"/>
<result property="headerRow" column="header_row"/>
<result property="dataStartRow" column="data_start_row"/>
<result property="sheetIndex" column="sheet_index"/>
<result property="sheetName" column="sheet_name"/>
<result property="fileEncoding" column="file_encoding"/>
<result property="delimiter" column="delimiter"/>
<result property="skipEmptyRow" column="skip_empty_row"/>
<result property="maxErrorCount" column="max_error_count"/>
<result property="batchSize" column="batch_size"/>
<result property="description" column="description"/>
<result property="isActive" column="is_active"/>
<result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/>
</resultMap>
<sql id="templateCols">
t.template_id, t.template_code, t.template_name, t.category, t.target_table,
t.company_code, t.header_row, t.data_start_row, t.sheet_index, t.sheet_name,
t.file_encoding, t.delimiter, t.skip_empty_row, t.max_error_count, t.batch_size,
t.description, t.is_active, t.created_at, t.updated_at
</sql>
<select id="selectList" parameterType="com.ga.core.vo.upload.UploadTemplateSearchParam"
resultMap="UploadTemplateRespMap">
SELECT <include refid="templateCols"/>
FROM upload_template t
<where>
<if test="category != null and category != ''">
AND t.category = #{category}
</if>
<if test="companyCode != null and companyCode != ''">
AND t.company_code = #{companyCode}
</if>
<if test="isActive != null and isActive != ''">
AND t.is_active = #{isActive}
</if>
<if test="searchKeyword != null and searchKeyword != ''">
AND (t.template_code LIKE CONCAT('%', #{searchKeyword}, '%')
OR t.template_name LIKE CONCAT('%', #{searchKeyword}, '%'))
</if>
</where>
ORDER BY t.template_id DESC
</select>
<select id="selectById" resultMap="UploadTemplateRespMap">
SELECT <include refid="templateCols"/>
FROM upload_template t
WHERE t.template_id = #{templateId}
</select>
<select id="selectByCode" resultMap="UploadTemplateVOMap">
SELECT t.template_id, t.template_code, t.template_name, t.category, t.target_table,
t.company_code, t.header_row, t.data_start_row, t.sheet_index, t.sheet_name,
t.file_encoding, t.delimiter, t.skip_empty_row, t.max_error_count, t.batch_size,
t.description, t.is_active, t.created_at, t.created_by, t.updated_at, t.updated_by
FROM upload_template t
WHERE t.template_code = #{templateCode}
</select>
<select id="existsByCode" resultType="int">
SELECT COUNT(*) FROM upload_template WHERE template_code = #{templateCode}
</select>
<insert id="insert" parameterType="com.ga.core.vo.upload.UploadTemplateVO"
useGeneratedKeys="true" keyProperty="templateId">
INSERT INTO upload_template (
template_code, template_name, category, target_table, company_code,
header_row, data_start_row, sheet_index, sheet_name, file_encoding,
delimiter, skip_empty_row, max_error_count, batch_size,
description, is_active, created_at, created_by
) VALUES (
#{templateCode}, #{templateName}, #{category}, #{targetTable}, #{companyCode},
#{headerRow}, #{dataStartRow}, #{sheetIndex}, #{sheetName}, #{fileEncoding},
#{delimiter}, #{skipEmptyRow}, #{maxErrorCount}, #{batchSize},
#{description}, #{isActive}, NOW(), #{createdBy}
)
</insert>
<update id="update" parameterType="com.ga.core.vo.upload.UploadTemplateVO">
UPDATE upload_template SET
template_name = #{templateName},
category = #{category},
target_table = #{targetTable},
company_code = #{companyCode},
header_row = #{headerRow},
data_start_row = #{dataStartRow},
sheet_index = #{sheetIndex},
sheet_name = #{sheetName},
file_encoding = #{fileEncoding},
delimiter = #{delimiter},
skip_empty_row = #{skipEmptyRow},
max_error_count = #{maxErrorCount},
batch_size = #{batchSize},
description = #{description},
is_active = #{isActive},
updated_at = NOW(),
updated_by = #{updatedBy}
WHERE template_id = #{templateId}
</update>
<delete id="delete">
DELETE FROM upload_template WHERE template_id = #{templateId}
</delete>
</mapper>
+1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css" />
<title>GA 수수료 정산 솔루션</title>
</head>
<body>
+734 -4
View File
@@ -12,6 +12,9 @@
"@ag-grid-community/core": "^31.3.2",
"@ag-grid-community/react": "^31.3.2",
"@ag-grid-community/styles": "^31.3.2",
"@ant-design/icons": "^6.2.2",
"@ant-design/pro-components": "^2.8.10",
"@tabler/icons-react": "^3.5.0",
"@tanstack/react-query": "^5.40.0",
"antd": "^5.18.0",
"axios": "^1.7.2",
@@ -125,6 +128,69 @@
}
},
"node_modules/@ant-design/icons": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.2.2.tgz",
"integrity": "sha512-zlJtE7AMbG12TeYVPhtBXwNpFInNy8mjLzcIm+0BPw16/b8ODG87YJ1G37VIF5VFscdgfsf6EweAFPTobu/3iQ==",
"license": "MIT",
"dependencies": {
"@ant-design/colors": "^8.0.1",
"@ant-design/icons-svg": "^4.4.2",
"@rc-component/util": "^1.10.1",
"clsx": "^2.1.1"
},
"engines": {
"node": ">=8"
},
"peerDependencies": {
"react": ">=16.0.0",
"react-dom": ">=16.0.0"
}
},
"node_modules/@ant-design/icons-svg": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz",
"integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==",
"license": "MIT"
},
"node_modules/@ant-design/icons/node_modules/@ant-design/colors": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-8.0.1.tgz",
"integrity": "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==",
"license": "MIT",
"dependencies": {
"@ant-design/fast-color": "^3.0.0"
}
},
"node_modules/@ant-design/icons/node_modules/@ant-design/fast-color": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-3.0.1.tgz",
"integrity": "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==",
"license": "MIT",
"engines": {
"node": ">=8.x"
}
},
"node_modules/@ant-design/pro-card": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@ant-design/pro-card/-/pro-card-2.10.0.tgz",
"integrity": "sha512-sLONn1odmE0Wkbse8pol4WiaEzBV8JU5s3FAMflPpycfUcbSaa1ktXzQ7LCo2SAvOS7gkfmpFjBPtrfbigKh4g==",
"license": "MIT",
"dependencies": {
"@ant-design/cssinjs": "^1.21.1",
"@ant-design/icons": "^5.0.0",
"@ant-design/pro-provider": "2.16.2",
"@ant-design/pro-utils": "2.18.0",
"@babel/runtime": "^7.18.0",
"classnames": "^2.3.2",
"rc-resize-observer": "^1.0.0",
"rc-util": "^5.4.0"
},
"peerDependencies": {
"antd": "^4.24.15 || ^5.11.2",
"react": ">=17.0.0"
}
},
"node_modules/@ant-design/pro-card/node_modules/@ant-design/icons": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz",
"integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==",
@@ -144,12 +210,409 @@
"react-dom": ">=16.0.0"
}
},
"node_modules/@ant-design/icons-svg": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz",
"integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==",
"node_modules/@ant-design/pro-components": {
"version": "2.8.10",
"resolved": "https://registry.npmjs.org/@ant-design/pro-components/-/pro-components-2.8.10.tgz",
"integrity": "sha512-QHnnIXdmC5GTAtm6i8eeJy5yT9npPlFyxpDm+duiDrTRKRFaAQBduArxlH3DA/hoRCCypzPONxfK9BQNIhIyZA==",
"license": "MIT",
"dependencies": {
"@ant-design/pro-card": "2.10.0",
"@ant-design/pro-descriptions": "2.6.10",
"@ant-design/pro-field": "3.1.0",
"@ant-design/pro-form": "2.32.0",
"@ant-design/pro-layout": "7.22.7",
"@ant-design/pro-list": "2.6.10",
"@ant-design/pro-provider": "2.16.2",
"@ant-design/pro-skeleton": "2.2.1",
"@ant-design/pro-table": "3.21.0",
"@ant-design/pro-utils": "2.18.0",
"@babel/runtime": "^7.16.3"
},
"peerDependencies": {
"antd": "^4.24.15 || ^5.11.2",
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@ant-design/pro-descriptions": {
"version": "2.6.10",
"resolved": "https://registry.npmjs.org/@ant-design/pro-descriptions/-/pro-descriptions-2.6.10.tgz",
"integrity": "sha512-+4MbiOfumnWlW0Awm4m8JML5o3lR649FD24AaivCmr8BQvIAAXdTITnDMXEg8BqvdP4KOvNsStZrvYfqoev33A==",
"license": "MIT",
"dependencies": {
"@ant-design/pro-field": "3.1.0",
"@ant-design/pro-form": "2.32.0",
"@ant-design/pro-provider": "2.16.2",
"@ant-design/pro-skeleton": "2.2.1",
"@ant-design/pro-utils": "2.18.0",
"@babel/runtime": "^7.18.0",
"rc-resize-observer": "^0.2.3",
"rc-util": "^5.0.6"
},
"peerDependencies": {
"antd": "^4.24.15 || ^5.11.2",
"react": ">=17.0.0"
}
},
"node_modules/@ant-design/pro-descriptions/node_modules/rc-resize-observer": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-0.2.6.tgz",
"integrity": "sha512-YX6nYnd6fk7zbuvT6oSDMKiZjyngjHoy+fz+vL3Tez38d/G5iGdaDJa2yE7345G6sc4Mm1IGRUIwclvltddhmA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.10.1",
"classnames": "^2.2.1",
"rc-util": "^5.0.0",
"resize-observer-polyfill": "^1.5.1"
},
"peerDependencies": {
"react": ">=16.9.0",
"react-dom": ">=16.9.0"
}
},
"node_modules/@ant-design/pro-field": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@ant-design/pro-field/-/pro-field-3.1.0.tgz",
"integrity": "sha512-+Dgp31WjD+iwg9KIRAMgNkfQivkJKMcYBrIBmho1e8ep/O0HgWSp48g70tBIWi/Lfem/Ky2schF7O8XCFouczw==",
"license": "MIT",
"dependencies": {
"@ant-design/icons": "^5.0.0",
"@ant-design/pro-provider": "2.16.2",
"@ant-design/pro-utils": "2.18.0",
"@babel/runtime": "^7.18.0",
"@chenshuai2144/sketch-color": "^1.0.8",
"classnames": "^2.3.2",
"dayjs": "^1.11.10",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"rc-util": "^5.4.0",
"swr": "^2.0.0"
},
"peerDependencies": {
"antd": "^4.24.15 || ^5.11.2",
"react": ">=17.0.0"
}
},
"node_modules/@ant-design/pro-field/node_modules/@ant-design/icons": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz",
"integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==",
"license": "MIT",
"dependencies": {
"@ant-design/colors": "^7.0.0",
"@ant-design/icons-svg": "^4.4.0",
"@babel/runtime": "^7.24.8",
"classnames": "^2.2.6",
"rc-util": "^5.31.1"
},
"engines": {
"node": ">=8"
},
"peerDependencies": {
"react": ">=16.0.0",
"react-dom": ">=16.0.0"
}
},
"node_modules/@ant-design/pro-form": {
"version": "2.32.0",
"resolved": "https://registry.npmjs.org/@ant-design/pro-form/-/pro-form-2.32.0.tgz",
"integrity": "sha512-GZnVAMeYv+YHJb17lJ7rX5PYuQPvEA6EotQnPbHi9tGLN3PfexcAd21rqzuO+OrulU2x7TEMDIxtY9MzvvOGbg==",
"license": "MIT",
"dependencies": {
"@ant-design/icons": "^5.0.0",
"@ant-design/pro-field": "3.1.0",
"@ant-design/pro-provider": "2.16.2",
"@ant-design/pro-utils": "2.18.0",
"@babel/runtime": "^7.18.0",
"@chenshuai2144/sketch-color": "^1.0.7",
"@umijs/use-params": "^1.0.9",
"classnames": "^2.3.2",
"dayjs": "^1.11.10",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"rc-resize-observer": "^1.1.0",
"rc-util": "^5.0.6"
},
"peerDependencies": {
"antd": "^4.24.15 || ^5.11.2",
"rc-field-form": ">=1.22.0",
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@ant-design/pro-form/node_modules/@ant-design/icons": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz",
"integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==",
"license": "MIT",
"dependencies": {
"@ant-design/colors": "^7.0.0",
"@ant-design/icons-svg": "^4.4.0",
"@babel/runtime": "^7.24.8",
"classnames": "^2.2.6",
"rc-util": "^5.31.1"
},
"engines": {
"node": ">=8"
},
"peerDependencies": {
"react": ">=16.0.0",
"react-dom": ">=16.0.0"
}
},
"node_modules/@ant-design/pro-layout": {
"version": "7.22.7",
"resolved": "https://registry.npmjs.org/@ant-design/pro-layout/-/pro-layout-7.22.7.tgz",
"integrity": "sha512-fvmtNA1r9SaasVIQIQt611VSlNxtVxDbQ3e+1GhYQza3tVJi/3gCZuDyfMfTnbLmf3PaW/YvLkn7MqDbzAzoLA==",
"license": "MIT",
"dependencies": {
"@ant-design/cssinjs": "^1.21.1",
"@ant-design/icons": "^5.0.0",
"@ant-design/pro-provider": "2.16.2",
"@ant-design/pro-utils": "2.18.0",
"@babel/runtime": "^7.18.0",
"@umijs/route-utils": "^4.0.0",
"@umijs/use-params": "^1.0.9",
"classnames": "^2.3.2",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"path-to-regexp": "8.2.0",
"rc-resize-observer": "^1.1.0",
"rc-util": "^5.0.6",
"swr": "^2.0.0",
"warning": "^4.0.3"
},
"peerDependencies": {
"antd": "^4.24.15 || ^5.11.2",
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@ant-design/pro-layout/node_modules/@ant-design/icons": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz",
"integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==",
"license": "MIT",
"dependencies": {
"@ant-design/colors": "^7.0.0",
"@ant-design/icons-svg": "^4.4.0",
"@babel/runtime": "^7.24.8",
"classnames": "^2.2.6",
"rc-util": "^5.31.1"
},
"engines": {
"node": ">=8"
},
"peerDependencies": {
"react": ">=16.0.0",
"react-dom": ">=16.0.0"
}
},
"node_modules/@ant-design/pro-list": {
"version": "2.6.10",
"resolved": "https://registry.npmjs.org/@ant-design/pro-list/-/pro-list-2.6.10.tgz",
"integrity": "sha512-xSWwnqCr+hPEYR4qY7nFUaxO5RQBxNlFaPNmobP2i+Im31slk9JuAusgWeIYO0mNhLJuLbxd8CCma2AZij3fBQ==",
"license": "MIT",
"dependencies": {
"@ant-design/cssinjs": "^1.21.1",
"@ant-design/icons": "^5.0.0",
"@ant-design/pro-card": "2.10.0",
"@ant-design/pro-field": "3.1.0",
"@ant-design/pro-table": "3.21.0",
"@ant-design/pro-utils": "2.18.0",
"@babel/runtime": "^7.18.0",
"classnames": "^2.3.2",
"dayjs": "^1.11.10",
"rc-resize-observer": "^1.0.0",
"rc-util": "^4.19.0"
},
"peerDependencies": {
"antd": "^4.24.15 || ^5.11.2",
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@ant-design/pro-list/node_modules/@ant-design/icons": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz",
"integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==",
"license": "MIT",
"dependencies": {
"@ant-design/colors": "^7.0.0",
"@ant-design/icons-svg": "^4.4.0",
"@babel/runtime": "^7.24.8",
"classnames": "^2.2.6",
"rc-util": "^5.31.1"
},
"engines": {
"node": ">=8"
},
"peerDependencies": {
"react": ">=16.0.0",
"react-dom": ">=16.0.0"
}
},
"node_modules/@ant-design/pro-list/node_modules/@ant-design/icons/node_modules/rc-util": {
"version": "5.44.4",
"resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz",
"integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"react-is": "^18.2.0"
},
"peerDependencies": {
"react": ">=16.9.0",
"react-dom": ">=16.9.0"
}
},
"node_modules/@ant-design/pro-list/node_modules/@ant-design/icons/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/@ant-design/pro-list/node_modules/rc-util": {
"version": "4.21.1",
"resolved": "https://registry.npmjs.org/rc-util/-/rc-util-4.21.1.tgz",
"integrity": "sha512-Z+vlkSQVc1l8O2UjR3WQ+XdWlhj5q9BMQNLk2iOBch75CqPfrJyGtcWMcnhRlNuDu0Ndtt4kLVO8JI8BrABobg==",
"license": "MIT",
"dependencies": {
"add-dom-event-listener": "^1.1.0",
"prop-types": "^15.5.10",
"react-is": "^16.12.0",
"react-lifecycles-compat": "^3.0.4",
"shallowequal": "^1.1.0"
}
},
"node_modules/@ant-design/pro-provider": {
"version": "2.16.2",
"resolved": "https://registry.npmjs.org/@ant-design/pro-provider/-/pro-provider-2.16.2.tgz",
"integrity": "sha512-0KmCH1EaOND787Jz6VRMYtLNZmqfT0JPjdUfxhyOxFfnBRfrjyfZgIa6CQoAJLEUMWv57PccWS8wRHVUUk2Yiw==",
"license": "MIT",
"dependencies": {
"@ant-design/cssinjs": "^1.21.1",
"@babel/runtime": "^7.18.0",
"@ctrl/tinycolor": "^3.4.0",
"dayjs": "^1.11.10",
"rc-util": "^5.0.1",
"swr": "^2.0.0"
},
"peerDependencies": {
"antd": "^4.24.15 || ^5.11.2",
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@ant-design/pro-skeleton": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@ant-design/pro-skeleton/-/pro-skeleton-2.2.1.tgz",
"integrity": "sha512-3M2jNOZQZWEDR8pheY00OkHREfb0rquvFZLCa6DypGmiksiuuYuR9Y4iA82ZF+mva2FmpHekdwbje/GpbxqBeg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.0"
},
"peerDependencies": {
"antd": "^4.24.15 || ^5.11.2",
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@ant-design/pro-table": {
"version": "3.21.0",
"resolved": "https://registry.npmjs.org/@ant-design/pro-table/-/pro-table-3.21.0.tgz",
"integrity": "sha512-sI81d3FYRv5sXamUc+M5CsHZ9CchuUQgOAPzo5H4oPAVL5h+mkYGRsBzPsxQX7khTNpWjrAtPoRm5ipx3vvWog==",
"license": "MIT",
"dependencies": {
"@ant-design/cssinjs": "^1.21.1",
"@ant-design/icons": "^5.0.0",
"@ant-design/pro-card": "2.10.0",
"@ant-design/pro-field": "3.1.0",
"@ant-design/pro-form": "2.32.0",
"@ant-design/pro-provider": "2.16.2",
"@ant-design/pro-utils": "2.18.0",
"@babel/runtime": "^7.18.0",
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/modifiers": "^6.0.1",
"@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/utilities": "^3.2.1",
"classnames": "^2.3.2",
"dayjs": "^1.11.10",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"rc-resize-observer": "^1.0.0",
"rc-util": "^5.0.1"
},
"peerDependencies": {
"antd": "^4.24.15 || ^5.11.2",
"rc-field-form": ">=1.22.0",
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@ant-design/pro-table/node_modules/@ant-design/icons": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz",
"integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==",
"license": "MIT",
"dependencies": {
"@ant-design/colors": "^7.0.0",
"@ant-design/icons-svg": "^4.4.0",
"@babel/runtime": "^7.24.8",
"classnames": "^2.2.6",
"rc-util": "^5.31.1"
},
"engines": {
"node": ">=8"
},
"peerDependencies": {
"react": ">=16.0.0",
"react-dom": ">=16.0.0"
}
},
"node_modules/@ant-design/pro-utils": {
"version": "2.18.0",
"resolved": "https://registry.npmjs.org/@ant-design/pro-utils/-/pro-utils-2.18.0.tgz",
"integrity": "sha512-8+ikyrN8L8a8Ph4oeHTOJEiranTj18+9+WHCHjKNdEfukI7Rjn8xpYdLJWb2AUJkb9d4eoAqjd5+k+7w81Df0w==",
"license": "MIT",
"dependencies": {
"@ant-design/icons": "^5.0.0",
"@ant-design/pro-provider": "2.16.2",
"@babel/runtime": "^7.18.0",
"classnames": "^2.3.2",
"dayjs": "^1.11.10",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"rc-util": "^5.0.6",
"safe-stable-stringify": "^2.4.3",
"swr": "^2.0.0"
},
"peerDependencies": {
"antd": "^4.24.15 || ^5.11.2",
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@ant-design/pro-utils/node_modules/@ant-design/icons": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz",
"integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==",
"license": "MIT",
"dependencies": {
"@ant-design/colors": "^7.0.0",
"@ant-design/icons-svg": "^4.4.0",
"@babel/runtime": "^7.24.8",
"classnames": "^2.2.6",
"rc-util": "^5.31.1"
},
"engines": {
"node": ">=8"
},
"peerDependencies": {
"react": ">=16.0.0",
"react-dom": ">=16.0.0"
}
},
"node_modules/@ant-design/react-slick": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.1.2.tgz",
@@ -457,6 +920,95 @@
"node": ">=6.9.0"
}
},
"node_modules/@chenshuai2144/sketch-color": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@chenshuai2144/sketch-color/-/sketch-color-1.0.9.tgz",
"integrity": "sha512-obzSy26cb7Pm7OprWyVpgMpIlrZpZ0B7vbrU0RMbvRg0YAI890S5Xy02Aj1Nhl4+KTbi1lVYHt6HQP8Hm9s+1w==",
"license": "MIT",
"dependencies": {
"reactcss": "^1.2.3",
"tinycolor2": "^1.4.2"
},
"peerDependencies": {
"react": ">=16.12.0"
}
},
"node_modules/@ctrl/tinycolor": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
"integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/modifiers": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-6.0.1.tgz",
"integrity": "sha512-rbxcsg3HhzlcMHVHWDuh9LCjpOVAgqbV78wLGI8tziXY3+qcMQ61qVXIvNKQFuhj75dSfD+o+PYZQ/NUk2A23A==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.1",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.0.6",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-7.0.2.tgz",
"integrity": "sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.0",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.0.7",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emotion/hash": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz",
@@ -1057,6 +1609,26 @@
"react-dom": ">=16.9.0"
}
},
"node_modules/@rc-component/util": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.10.1.tgz",
"integrity": "sha512-q++9S6rUa5Idb/xIBNz6jtvumw5+O5YV5V0g4iK9mn9jWs4oGJheE3ZN1kAnE723AXyaD8v95yeOASmdk8Jnng==",
"license": "MIT",
"dependencies": {
"is-mobile": "^5.0.0",
"react-is": "^18.2.0"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
}
},
"node_modules/@rc-component/util/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/@remix-run/router": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
@@ -1462,6 +2034,32 @@
"win32"
]
},
"node_modules/@tabler/icons": {
"version": "3.44.0",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.44.0.tgz",
"integrity": "sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
}
},
"node_modules/@tabler/icons-react": {
"version": "3.44.0",
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.44.0.tgz",
"integrity": "sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==",
"license": "MIT",
"dependencies": {
"@tabler/icons": "3.44.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
},
"peerDependencies": {
"react": ">= 16"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.100.9",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.9.tgz",
@@ -1631,6 +2229,21 @@
"@types/react": "^18.0.0"
}
},
"node_modules/@umijs/route-utils": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/@umijs/route-utils/-/route-utils-4.0.3.tgz",
"integrity": "sha512-zPEcYhl1cSfkSRDzzGgoD1mDvGjxoOTJFvkn55srfgdQ3NZe2ZMCScCU6DEnOxuKP1XDVf8pqyqCDVd2+RCQIw==",
"license": "MIT"
},
"node_modules/@umijs/use-params": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@umijs/use-params/-/use-params-1.0.9.tgz",
"integrity": "sha512-QlN0RJSBVQBwLRNxbxjQ5qzqYIGn+K7USppMoIOVlf7fxXHsnQZ2bEsa6Pm74bt6DVQxpUE8HqvdStn6Y9FV1w==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -1652,6 +2265,15 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/add-dom-event-listener": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/add-dom-event-listener/-/add-dom-event-listener-1.1.0.tgz",
"integrity": "sha512-WCxx1ixHT0GQU9hb0KI/mhgRQhnU+U3GvwY6ZvVjYq8rsihIGoaIOUbY0yMPBxLH5MDtr0kz3fisWGNcbWW7Jw==",
"license": "MIT",
"dependencies": {
"object-assign": "4.x"
}
},
"node_modules/antd": {
"version": "5.29.3",
"resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz",
@@ -1717,6 +2339,26 @@
"react-dom": ">=16.9.0"
}
},
"node_modules/antd/node_modules/@ant-design/icons": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz",
"integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==",
"license": "MIT",
"dependencies": {
"@ant-design/colors": "^7.0.0",
"@ant-design/icons-svg": "^4.4.0",
"@babel/runtime": "^7.24.8",
"classnames": "^2.2.6",
"rc-util": "^5.31.1"
},
"engines": {
"node": ">=8"
},
"peerDependencies": {
"react": ">=16.0.0",
"react-dom": ">=16.0.0"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -2030,6 +2672,15 @@
"node": ">=0.4.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
@@ -2337,6 +2988,12 @@
"node": ">=12"
}
},
"node_modules/is-mobile": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-5.0.0.tgz",
"integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==",
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -2384,6 +3041,12 @@
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -2478,6 +3141,15 @@
"node": ">=0.10.0"
}
},
"node_modules/path-to-regexp": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
"integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -3177,6 +3849,12 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-lifecycles-compat": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
"license": "MIT"
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -3250,6 +3928,15 @@
"react-dom": ">=16.6.0"
}
},
"node_modules/reactcss": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz",
"integrity": "sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==",
"license": "MIT",
"dependencies": {
"lodash": "^4.0.1"
}
},
"node_modules/recharts": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
@@ -3339,6 +4026,15 @@
"fsevents": "~2.3.2"
}
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -3367,6 +4063,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/shallowequal": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
"integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -3389,6 +4091,19 @@
"integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
"license": "MIT"
},
"node_modules/swr": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.4.1.tgz",
"integrity": "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/throttle-debounce": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
@@ -3404,6 +4119,12 @@
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinycolor2": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
"integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
"license": "MIT"
},
"node_modules/toggle-selection": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz",
@@ -3552,6 +4273,15 @@
}
}
},
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+3
View File
@@ -14,6 +14,9 @@
"@ag-grid-community/core": "^31.3.2",
"@ag-grid-community/react": "^31.3.2",
"@ag-grid-community/styles": "^31.3.2",
"@ant-design/icons": "^6.2.2",
"@ant-design/pro-components": "^2.8.10",
"@tabler/icons-react": "^3.5.0",
"@tanstack/react-query": "^5.40.0",
"antd": "^5.18.0",
"axios": "^1.7.2",
+48 -4
View File
@@ -3,18 +3,41 @@ import LoginPage from '@/pages/LoginPage';
import MainLayout from '@/layouts/MainLayout';
import Dashboard from '@/pages/Dashboard';
import OrgTree from '@/pages/org/OrgTree';
import AgentList from '@/pages/org/AgentList';
import AgentDetail from '@/pages/org/AgentDetail';
import AgentForm from '@/pages/org/AgentForm';
import CompanyList from '@/pages/product/CompanyList';
import ProductList from '@/pages/product/ProductList';
import ContractList from '@/pages/product/ContractList';
import CommissionRateList from '@/pages/rule/CommissionRateList';
import PayoutRuleList from '@/pages/rule/PayoutRuleList';
import OverrideRuleList from '@/pages/rule/OverrideRuleList';
import ChargebackRuleList from '@/pages/rule/ChargebackRuleList';
import ExceptionCodeList from '@/pages/rule/ExceptionCodeList';
import ReceiveData from '@/pages/receive/ReceiveData';
import ReceiveMapping from '@/pages/receive/ReceiveMapping';
import RecruitLedger from '@/pages/ledger/RecruitLedger';
import MaintainLedger from '@/pages/ledger/MaintainLedger';
import ExceptionLedger from '@/pages/ledger/ExceptionLedger';
import SettleList from '@/pages/settle/SettleList';
import PaymentList from '@/pages/settle/PaymentList';
import BatchRun from '@/pages/settle/BatchRun';
import PaymentList from '@/pages/settle/PaymentList';
import UserList from '@/pages/system/UserList';
import RoleList from '@/pages/system/RoleList';
import MenuManage from '@/pages/system/MenuManage';
import CodeList from '@/pages/system/CodeList';
import SystemConfig from '@/pages/system/SystemConfig';
import SystemLog from '@/pages/system/SystemLog';
import UploadTemplateManager from '@/pages/system/UploadTemplateManager';
import DataDomainList from '@/pages/system/DataDomainList';
import DataDictionary from '@/pages/system/DataDictionary';
function RequireAuth({ children }: { children: React.ReactNode }) {
const token = localStorage.getItem('accessToken');
@@ -28,20 +51,35 @@ export default function App() {
<Route path="/" element={<RequireAuth><MainLayout /></RequireAuth>}>
<Route index element={<Dashboard />} />
{/* 조직 / 설계사 */}
{/* 조직 / 사 */}
<Route path="org/tree" element={<OrgTree />} />
<Route path="org/agents" element={<AgentList />} />
<Route path="org/agents/new" element={<AgentForm />} />
<Route path="org/agents/:id" element={<AgentDetail />} />
<Route path="org/agents/:id/edit" element={<AgentForm />} />
{/* 계약 */}
{/* 상품 / 계약 */}
<Route path="companies" element={<CompanyList />} />
<Route path="products" element={<ProductList />} />
<Route path="contracts" element={<ContractList />} />
{/* 수수료 규정 */}
<Route path="rules/commission" element={<CommissionRateList />} />
<Route path="rules/payout" element={<PayoutRuleList />} />
<Route path="rules/override" element={<OverrideRuleList />} />
<Route path="rules/chargeback" element={<ChargebackRuleList />} />
<Route path="rules/exceptions" element={<ExceptionCodeList />} />
{/* 데이터 수신 */}
<Route path="receive/data" element={<ReceiveData />} />
<Route path="receive/mapping" element={<ReceiveMapping />} />
{/* 원장 */}
<Route path="ledger/recruit" element={<RecruitLedger />} />
<Route path="ledger/maintain" element={<MaintainLedger />} />
<Route path="ledger/exception" element={<ExceptionLedger />} />
{/* 정산 / 지급 / 배치 */}
{/* 정산 / 지급 */}
<Route path="settle" element={<SettleList />} />
<Route path="settle/batch" element={<BatchRun />} />
<Route path="payments" element={<PaymentList />} />
@@ -49,7 +87,13 @@ export default function App() {
{/* 시스템관리 */}
<Route path="system/users" element={<UserList />} />
<Route path="system/roles" element={<RoleList />} />
<Route path="system/menus" element={<MenuManage />} />
<Route path="system/codes" element={<CodeList />} />
<Route path="system/config" element={<SystemConfig />} />
<Route path="system/logs" element={<SystemLog />} />
<Route path="system/upload-templates" element={<UploadTemplateManager />} />
<Route path="system/data-domains" element={<DataDomainList />} />
<Route path="system/data-dictionary" element={<DataDictionary />} />
</Route>
</Routes>
);
+47
View File
@@ -0,0 +1,47 @@
import api, { unwrap } from './request';
export interface BatchRunResp {
jobLogId: number;
}
export interface BatchJobLogResp {
jobLogId: number;
jobName: string;
status: 'REQUESTED' | 'RUNNING' | 'COMPLETED' | 'FAILED';
stepName?: string;
totalCount?: number;
successCount?: number;
errorCount?: number;
startedAt?: string;
endedAt?: string;
durationMs?: number;
errorMessage?: string;
}
export interface BatchHistoryResp {
jobLogId: number;
jobName: string;
status: string;
stepName?: string;
startedAt?: string;
endedAt?: string;
durationMs?: number;
errorMessage?: string;
}
export const batchRunApi = {
run: (settleMonth: string, companyCode?: string) =>
unwrap<number>(
api.post('/api/batch/settlement/run', null, {
params: { settleMonth, companyCode },
}),
),
status: (jobLogId: number) =>
unwrap<BatchJobLogResp>(
api.get('/api/batch/settlement/status', { params: { jobLogId } }),
),
history: (size = 10) =>
unwrap<BatchHistoryResp[]>(
api.get('/api/batch/settlement/history', { params: { size } }),
),
};
+40
View File
@@ -0,0 +1,40 @@
import api, { PageResponse, unwrap } from './request';
export interface CompanyResp {
companyId: number;
companyCode: string;
companyName: string;
companyType?: string;
bizNo?: string;
contactName?: string;
contactPhone?: string;
contactEmail?: string;
isActive: string;
}
export interface CompanySaveReq {
companyCode: string;
companyName: string;
companyType?: string;
bizNo?: string;
contactName?: string;
contactPhone?: string;
contactEmail?: string;
isActive: string;
}
export interface CompanySearchParam {
pageNum?: number;
pageSize?: number;
searchKeyword?: string;
isActive?: string;
}
export const companyApi = {
list: (p: CompanySearchParam) =>
unwrap<PageResponse<CompanyResp>>(api.get('/api/companies', { params: p })),
create: (req: CompanySaveReq) =>
unwrap<number>(api.post('/api/companies', req)),
update: (id: number, req: CompanySaveReq) =>
unwrap<void>(api.put(`/api/companies/${id}`, req)),
};
+20
View File
@@ -0,0 +1,20 @@
import api, { unwrap } from './request';
export interface SystemConfigResp {
configKey: string;
configValue?: string;
configDesc?: string;
configGroup?: string;
valueType?: string;
isEncrypted?: string;
isEditable?: string;
updatedAt?: string;
updatedBy?: number;
}
export const configApi = {
list: (group?: string) =>
unwrap<SystemConfigResp[]>(api.get('/api/system/config', { params: { group } })),
update: (key: string, value: string) =>
unwrap<void>(api.put(`/api/system/config/${key}`, { value })),
};
+16
View File
@@ -33,8 +33,24 @@ export interface ContractSearchParam {
endDate?: string;
}
export interface ContractSaveReq {
agentId: number;
productId: number;
policyNo: string;
contractorName?: string;
insuredName?: string;
premium?: number;
payCycle?: string;
payPeriod?: number;
coveragePeriod?: number;
contractDate?: string;
effectiveDate?: string;
}
export const contractApi = {
list: (p: ContractSearchParam) =>
unwrap<PageResponse<ContractResp>>(api.get('/api/contracts', { params: p })),
detail: (id: number) => unwrap<ContractResp>(api.get(`/api/contracts/${id}`)),
create: (req: ContractSaveReq) => unwrap<number>(api.post('/api/contracts', req)),
update: (id: number, req: ContractSaveReq) => unwrap<void>(api.put(`/api/contracts/${id}`, req)),
};

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