Compare commits
10 Commits
0e8c563a9a
...
4dbc688f79
| Author | SHA1 | Date | |
|---|---|---|---|
| 4dbc688f79 | |||
| bd5ff4ec14 | |||
| d8d64625a9 | |||
| 366f49c3f0 | |||
| dfce9e5c02 | |||
| 2c9bf710bc | |||
| ee3e0d40c5 | |||
| 23fdd8474b | |||
| 4a7a4eeb7a | |||
| 5ee8321ef2 |
@@ -45,3 +45,6 @@ target/
|
|||||||
# vite/tsc 빌드 산출물
|
# vite/tsc 빌드 산출물
|
||||||
ga-frontend/tsconfig.tsbuildinfo
|
ga-frontend/tsconfig.tsbuildinfo
|
||||||
ga-frontend/dist/
|
ga-frontend/dist/
|
||||||
|
login.json
|
||||||
|
token.txt
|
||||||
|
__pycache__/
|
||||||
|
|||||||
+228
-18
@@ -191,23 +191,229 @@
|
|||||||
|
|
||||||
운영 DB 최신 상태: ga schema V73 적용 완료.
|
운영 DB 최신 상태: ga schema V73 적용 완료.
|
||||||
|
|
||||||
## 4. 잔여 작업 (P6)
|
## 3-11. P6 PoC — 외부 연동 어댑터 모듈 (V81~V84, 2026-05-24 라이브 적용)
|
||||||
|
|
||||||
P5-W2 / P5-W3(메뉴·권한 보강 V74 + 본론 V75~V78) 모두 완료. 잔여는 P6뿐.
|
P6 본 작업의 PoC가 완료되어 어댑터 추상화 + Mock 구현 + SDK 분기 + 운영 잔여 기능까지 들어감.
|
||||||
|
실제 KFTC/Toast SDK 통합 시 어댑터 구현체만 바꾸면 ga-api/ga-common 무변경.
|
||||||
|
|
||||||
### P6 — 외부 연동 (P5 이후)
|
| 항목 | 마이그레이션 | 상태 |
|
||||||
| 항목 | 설명 |
|
|---|---|---|
|
||||||
|
| 분급 비율 회차(월) 단위 등록 지원 | V81 | 완료 |
|
||||||
|
| withdraw_request 펌뱅킹 추적 컬럼 (transfer_tx_id/transfer_status/transfer_at) | V82 | 완료 |
|
||||||
|
| WITHDRAW_REQUESTS EXECUTE 권한 보강 (오기 — 0건 적용) | V83 | 완료(no-op) |
|
||||||
|
| WITHDRAW 메뉴 EXECUTE 권한 (V83 보정본) | V84 | 완료 |
|
||||||
|
|
||||||
|
핵심 산출물:
|
||||||
|
- **신규 모듈 `ga-external-adapter`** — 외부 SDK 의존성 격리, settings.gradle 등록
|
||||||
|
- ga-common.adapter.bank: `BankTransferAdapter` 인터페이스 + Request/Response/Status
|
||||||
|
- ga-common.adapter.message: `MessageAdapter` 인터페이스 + Channel(SMS/KAKAO)/Request/Response
|
||||||
|
- `MockBankTransferAdapter` / `MockMessageAdapter` (로그 + 계좌·전화 마스킹, 기본 활성)
|
||||||
|
- `KftcBankTransferAdapter` / `ToastMessageAdapter` 스켈레톤 (UnsupportedOperationException, SDK 통합 시 구현)
|
||||||
|
- `@ConditionalOnProperty` 프로파일 분기: `app.adapter.bank=mock|kftc`, `app.adapter.message=mock|toast`
|
||||||
|
- `ApprovalService.handleApprovalCallback(WITHDRAW)` — 최종 승인 시 펌뱅킹 어댑터 호출 → 성공 시 APPROVED→SENT 전이 + transfer_tx_id 영속화, 실패 시 APPROVED 유지 + fail_reason 기록
|
||||||
|
- `ApprovalService.sendStepNotification` — DB 알림 외 SMS 어댑터 호출 (UserMapper.selectFirstActiveContactByRoleCode 로 실제 결재자 phone 매핑, phone 미확보 시 SMS skip)
|
||||||
|
- `WithdrawBankRetryService` + `BankRetryController` (`POST /api/internal/bank-retry`) — 실패 transfer 재시도 배치 + `@EnableScheduling` + cron 옵션(기본 비활성) + `app.bank-retry.limit`
|
||||||
|
- `WithdrawRequestMapper.selectFailedTransfers` / `updateTransferResult` 추가
|
||||||
|
- `WithdrawRequestVO/Resp` + `WithdrawRequestMapper.xml` + 화면 `WithdrawRequest.tsx` 에 transfer_tx_id/transfer_status/transfer_at 노출
|
||||||
|
|
||||||
|
설정값(application.yml):
|
||||||
|
```yaml
|
||||||
|
app:
|
||||||
|
adapter:
|
||||||
|
bank: ${ADAPTER_BANK:mock} # mock | kftc
|
||||||
|
message: ${ADAPTER_MESSAGE:mock} # mock | toast
|
||||||
|
bank-retry:
|
||||||
|
limit: ${BANK_RETRY_LIMIT:50}
|
||||||
|
cron: ${BANK_RETRY_CRON:0 0 0 1 1 ?} # 사실상 비활성. 운영 활성화 예 "0 */10 * * * *"
|
||||||
|
```
|
||||||
|
|
||||||
|
통합 검증 결과 (2026-05-24):
|
||||||
|
- 5모듈 컴파일 BUILD SUCCESSFUL (ga-common/ga-core/ga-external-adapter/ga-api/ga-batch)
|
||||||
|
- 운영 DB v82~v84 적용 완료, ga-api 8082 가동
|
||||||
|
- POST /api/auth/login → 200 OK, accessToken 발급
|
||||||
|
- GET /api/withdraw-requests → 200 OK, 2건 모두 status=SENT / transfer_status=ACCEPTED / transferTxId="MOCK-…" 영속화
|
||||||
|
- POST /api/internal/bank-retry → 200 OK, data=0 (실패 건 없음 — 정상)
|
||||||
|
- GET /api/approvals/requests/mine → 200 OK, WITHDRAW APPROVED 이력 정상
|
||||||
|
- GET /api/forecast-kpi → 200 OK
|
||||||
|
- GET /api/year-end-statements → 200 OK
|
||||||
|
|
||||||
|
보정 사항:
|
||||||
|
- V83 작성 시 menu_code 오기로 0건 적용 → V84 로 정정 보강
|
||||||
|
|
||||||
|
## 4. 잔여 작업 (P6 실연동)
|
||||||
|
|
||||||
|
P5 전체 + P6 PoC(어댑터 모듈 + Mock 구현 + SDK 분기 + 재시도 배치 + DB 추적 컬럼) 완료.
|
||||||
|
잔여는 **외부 스펙 확정 후 적용** 가능한 항목 + 모바일.
|
||||||
|
|
||||||
|
### P6 실연동
|
||||||
|
| 항목 | 설명 | 선행 조건 |
|
||||||
|
|---|---|---|
|
||||||
|
| KFTC 펌뱅킹 실 SDK 통합 | `KftcBankTransferAdapter` 본체 구현 + 인증·엔드포인트 주입 | KFTC API 키·계약·테스트망 |
|
||||||
|
| Toast(NHN) SMS 실 SDK 통합 | `ToastMessageAdapter` 본체 구현 | 계정·App Key·발신번호·템플릿 코드 |
|
||||||
|
| 카카오 알림톡 실 SDK 통합 | 동일 (채널만 KAKAO) | 비즈 채널·템플릿 승인 |
|
||||||
|
| 펌뱅킹 SETTLED 동기화 | ACCEPTED→SETTLED 폴링 스케줄 (queryStatus 사용) | 실 SDK 통합 이후 |
|
||||||
|
| 펌뱅킹/메시지 호출 감사 이력 | `external_call_log` 테이블 + 영속화 + 조회 화면 | 독립 진행 가능 |
|
||||||
|
| 모바일 셀프 조회 PWA | 설계사 전용. 정산 명세서/출금 신청/공지 등 | 화면 우선순위 협의 |
|
||||||
|
|
||||||
|
## 3-12. P6 PoC — 외부연동 호출 감사 이력 (V85, 2026-05-24 라이브 적용)
|
||||||
|
|
||||||
|
P6 PoC 의 운영성 보강. 펌뱅킹/SMS·카카오 어댑터 모든 호출이 `external_call_log` 테이블에 AOP 로 자동 영속화. 외부 SDK 통합 후에도 그대로 재사용.
|
||||||
|
|
||||||
|
| 항목 | 위치 | 상태 |
|
||||||
|
|---|---|---|
|
||||||
|
| external_call_log 테이블 + 인덱스 + SYSTEM_EXTERNAL_CALL 메뉴/권한 | V85 | 완료 |
|
||||||
|
| ExternalCallLogVO / Resp / SearchParam | ga-core | 완료 |
|
||||||
|
| ExternalCallLogMapper(.java/.xml) — insertOne + selectList + selectById | ga-core | 완료 |
|
||||||
|
| ExternalCallLogService — recordCall @Transactional(REQUIRES_NEW) | ga-api | 완료 |
|
||||||
|
| ExternalCallLogController — GET /api/external-call-logs[/{id}] | ga-api | 완료 |
|
||||||
|
| ExternalCallLoggingAspect — BankTransferAdapter+/MessageAdapter+ 모든 메서드 @Around | ga-api/aop | 완료 |
|
||||||
|
| ExternalCallLogList.tsx + api/externalCallLog.ts + App.tsx 라우트 | ga-frontend | 완료 |
|
||||||
|
|
||||||
|
핵심 동작:
|
||||||
|
- 어댑터 호출 전/후·예외 모두 `external_call_log` INSERT (호출자 트랜잭션과 분리 — REQUIRES_NEW)
|
||||||
|
- request/response 는 PII 마스킹된 JSON 한 줄로 요약 (계좌·전화 뒤 4자리, 이름 첫글자만)
|
||||||
|
- 영속화 자체가 실패해도 비즈니스 흐름은 절대 깨지 않음 (warn 로그만)
|
||||||
|
- `target_ref_type/id` 자동 추출: WITHDRAW(BankTransferRequest.withdrawRequestId), MessageRequest.refType/refId
|
||||||
|
|
||||||
|
통합 검증 결과 (2026-05-24):
|
||||||
|
- Flyway: V85 자동 적용 → 운영 DB schema **v85**
|
||||||
|
- 5모듈 컴파일 BUILD SUCCESSFUL + ga-frontend TS 타입 검증 통과
|
||||||
|
- 라이브 시나리오: 신규 withdraw#3 → 결재 1단계 + 2단계 APPROVE → 최종 승인 → 펌뱅킹 호출
|
||||||
|
- withdraw#3 status=SENT, transferTxId=MOCK-…, transferStatus=ACCEPTED
|
||||||
|
- GET /api/external-call-logs → total=1, log#1: BANK/MockBankTransferAdapter/requestTransfer/success=true/durMs=3/target=WITHDRAW#3
|
||||||
|
- 상세: requestSummary 의 accountNoMasked="****0123" — PII 마스킹 정상 (평문 비노출)
|
||||||
|
|
||||||
|
## 4. 잔여 작업 (P6 실연동)
|
||||||
|
|
||||||
|
P5 전체 + P6 PoC(어댑터 모듈 + Mock 구현 + SDK 분기 + 재시도 배치 + DB 추적 컬럼 + **호출 감사 이력**) 완료.
|
||||||
|
잔여는 **외부 스펙 확정 후 적용** 가능한 항목 + 모바일.
|
||||||
|
|
||||||
|
### P6 실연동
|
||||||
|
| 항목 | 설명 | 선행 조건 |
|
||||||
|
|---|---|---|
|
||||||
|
| KFTC 펌뱅킹 실 SDK 통합 | `KftcBankTransferAdapter` 본체 구현 + 인증·엔드포인트 주입 | KFTC API 키·계약·테스트망 |
|
||||||
|
| Toast(NHN) SMS 실 SDK 통합 | `ToastMessageAdapter` 본체 구현 | 계정·App Key·발신번호·템플릿 코드 |
|
||||||
|
| 카카오 알림톡 실 SDK 통합 | 동일 (채널만 KAKAO) | 비즈 채널·템플릿 승인 |
|
||||||
|
| 펌뱅킹 SETTLED 동기화 | ACCEPTED→SETTLED 폴링 스케줄 (queryStatus 사용) | 실 SDK 통합 이후 |
|
||||||
|
| 모바일 셀프 조회 PWA | 설계사 전용. 정산 명세서/출금 신청/공지 등 | 화면 우선순위 협의 |
|
||||||
|
|
||||||
|
## 3-13. P6 — 모바일 셀프 조회 PWA 스캐폴드 (ga-mobile-pwa, 2026-05-24)
|
||||||
|
|
||||||
|
설계사 본인 정산명세서·출금·공지를 모바일에서 조회하는 PWA 신규 모듈. 데스크탑 admin UI(`ga-frontend`)와 별도. **백엔드 무변경** — 기존 `ga-api`(8082) 그대로 재사용.
|
||||||
|
|
||||||
|
### 구성
|
||||||
|
| 항목 | 위치 | 비고 |
|
||||||
|
|---|---|---|
|
||||||
|
| 신규 모듈 | `ga-mobile-pwa/` | Vite + React 18 + TS + antd-mobile@5 |
|
||||||
|
| PWA 인프라 | `vite-plugin-pwa` | manifest + service worker autoUpdate, `/api/**` NetworkFirst(5s/5min) |
|
||||||
|
| 인증 | `src/stores/authStore.ts` (zustand persist) | 토큰 키: `mobile.accessToken` (`ga-frontend` 와 분리) |
|
||||||
|
| API 클라이언트 | `src/api/{request,auth,statement,withdraw,notice}.ts` | axios + 401 자동 리다이렉트 |
|
||||||
|
| 화면 5종 | `src/pages/{Login,Home,StatementList,WithdrawList,NoticeList}.tsx` | NavBar + List/Card + PullToRefresh + TabBar |
|
||||||
|
| 라우팅 | `src/App.tsx` + `RequireAuth` 가드 + `MobileTabBar` | `/login` 외 모두 보호 |
|
||||||
|
|
||||||
|
### 화면 ↔ 백엔드 API
|
||||||
|
| 경로 | 백엔드 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 펌뱅킹 실연동 | withdraw_request 결재 완료 → 은행 API 직접 호출 |
|
| `/login` | POST `/api/auth/login` + GET `/api/auth/me` |
|
||||||
| SMS/카카오 발송 | user_notification 채널 실연동 |
|
| `/` (홈) | 세 도메인 카운트 조회 |
|
||||||
| 모바일 셀프 조회 | 설계사 전용 모바일 앱 or PWA |
|
| `/statement` | GET `/api/statements?agentId={me.agentId}` |
|
||||||
|
| `/withdraw` | GET `/api/withdraw-requests?agentId={me.agentId}` |
|
||||||
|
| `/notice` | GET `/api/notices/active` |
|
||||||
|
|
||||||
|
본인 데이터 필터: 클라이언트에서 `profile.agentId` 를 쿼리 파라미터로 전달. 본격 운영에는 **백엔드 `/api/mobile/me/*` 전용 endpoint** (토큰 → agentId 서버측 강제 필터) 권장 — 잔여 항목.
|
||||||
|
|
||||||
|
### 빌드 / 실행
|
||||||
|
```bash
|
||||||
|
cd ga-mobile-pwa
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:3001 (vite dev — /api → 8082 proxy)
|
||||||
|
npm run build # tsc --noEmit + vite build → dist/ (manifest + sw 자동 생성)
|
||||||
|
npm run preview # 3001 에서 production 번들 서빙
|
||||||
|
```
|
||||||
|
|
||||||
|
### 통합 검증 (2026-05-24)
|
||||||
|
- `npm install` 407 packages 성공
|
||||||
|
- `tsc --noEmit` PASS (`@types/node` 추가)
|
||||||
|
- `npm run build` PASS — `manifest.webmanifest` + `sw.js` + `workbox-*.js` 자동 생성, precache 8 entries (528 KiB), 번들 516KB(gzip 173KB)
|
||||||
|
- 라이브: vite dev :3001 LISTENING, ga-api :8082 정상
|
||||||
|
- API 응답 확인: `/api/statements` total=112, `/api/withdraw-requests` total=3, `/api/notices/active` 0건(정상), `/api/auth/me` 정상
|
||||||
|
|
||||||
|
### 잔여 (스캐폴드 이후)
|
||||||
|
- ~~백엔드 `/api/mobile/me/*` 전용 endpoint~~ → §3-14 에서 완료
|
||||||
|
- ~~본인 알림(`user_notification`) 표시~~ → §3-14 에서 완료
|
||||||
|
- 출금 신청 작성 폼 (현재 이력 조회만)
|
||||||
|
- 정산 명세서 상세 + PDF 다운로드
|
||||||
|
- 토큰 만료 자동 refresh
|
||||||
|
- 푸시 알림 (FCM 등) — 외부 채널 결정 후
|
||||||
|
- 실 아이콘 PNG (192/512/512 maskable)
|
||||||
|
- 번들 코드 스플리팅 (manualChunks)
|
||||||
|
|
||||||
|
## 3-14. P6 — 모바일 PWA 보안 강화: `/api/mobile/me/*` 전용 endpoint (2026-05-24)
|
||||||
|
|
||||||
|
PWA 스캐폴드에서 클라이언트가 `agentId` 를 쿼리 파라미터로 보내 본인 데이터를 필터하던 변조 가능 구조를 **서버측 강제 필터**로 교체. 토큰 → user → agentId 자동 추출.
|
||||||
|
|
||||||
|
### 백엔드 신규
|
||||||
|
| 위치 | 내용 |
|
||||||
|
|---|---|
|
||||||
|
| `ga-api/service/mobile/MobileMeService` | 토큰에서 userId 추출, `userMapper.selectById` 로 agentId 결정. 클라이언트 param 의 agentId/userId 는 **무시하고 덮어쓰기**. agentId 미연결 시 `BizException(FORBIDDEN)` |
|
||||||
|
| `ga-api/controller/mobile/MobileMeController` | `GET /api/mobile/me/{summary,statements,withdraw-requests,notifications}` + `POST /api/mobile/me/notifications/{id}/read`. 별도 메뉴 권한 없이 인증만 통과 |
|
||||||
|
|
||||||
|
### PWA 측 전환
|
||||||
|
- `src/api/statement.ts` / `withdraw.ts` → `/api/mobile/me/*` 호출, `agentId` 인터페이스에서 제거
|
||||||
|
- `src/api/notification.ts` 신규 — list/markRead/summary
|
||||||
|
- `Home.tsx` — `notificationApi.summary()` 한 번으로 3 카운트 동시 표시, 안 읽음 뱃지
|
||||||
|
- `NoticeList.tsx` — `Tabs` 로 `내 알림` / `공지사항` 두 탭. 알림 클릭 시 markRead + summary 무효화
|
||||||
|
- `StatementList.tsx` / `WithdrawList.tsx` — agentId 의존 제거, E403(설계사 미연결) 시 ErrorBlock 안내
|
||||||
|
|
||||||
|
### 라이브 검증 (2026-05-24)
|
||||||
|
- ga-api 컴파일 BUILD SUCCESSFUL · 재기동 OK
|
||||||
|
- PWA `tsc --noEmit` PASS · `npm run build` PASS (precache 8 entries 538 KiB, gzip 176KB)
|
||||||
|
- `GET /api/mobile/me/summary` (admin, agentId=null) → 200 OK, 세 카운트 모두 0
|
||||||
|
- `GET /api/mobile/me/statements` (admin) → **E403** 차단 (보안 강제 동작 확인)
|
||||||
|
- `GET /api/mobile/me/notifications` (admin) → 200 OK total=0
|
||||||
|
|
||||||
|
## 4. 잔여 작업 (P6 실연동)
|
||||||
|
|
||||||
|
P5 전체 + P6 PoC + **P6 후속(2026-05-27)** 완료. 잔여는 외부 SDK 차단 항목만.
|
||||||
|
|
||||||
### 운영 DB 마이그레이션 상태
|
### 운영 DB 마이그레이션 상태
|
||||||
운영 DB `192.168.0.60:55432/trading_ai/ga` 현재 **V78 적용 완료** (2026-05-21).
|
운영 DB `192.168.0.60:55432/trading_ai/ga` 현재 **V85 적용 완료** (변경 없음).
|
||||||
V74~V78 Flyway 자동 적용 확인. ga-api 8082 가동 중.
|
ga-api 8082 / ga-frontend 3000 / ga-mobile-pwa 3001 모두 라이브.
|
||||||
|
|
||||||
단위/통합 테스트는 SqlSessionFactory 환경 설정 문제로 아직 미작성 상태입니다.
|
단위/통합 테스트는 SqlSessionFactory 환경 설정 문제로 아직 미작성 상태입니다.
|
||||||
|
|
||||||
|
## 3-15. P6 후속 (2026-05-27, 커밋 `2c9bf71` + `dfce9e5` + 후속 1)
|
||||||
|
|
||||||
|
| 영역 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| PWA 출금 신청 작성 폼 | `POST /api/mobile/me/withdraw-requests` + settle-master 드롭다운, **net_amount 잔여 한도 검증 포함** |
|
||||||
|
| PWA 명세서 상세 + 엑셀 다운로드 | `GET /api/mobile/me/statements/{id}[/download]`, 본인 검증 + 기존 download 흐름 재사용 |
|
||||||
|
| PWA 토큰 자동 refresh | 401 → `/api/auth/refresh` 단일화 재시도, refreshToken localStorage 영속 |
|
||||||
|
| 펌뱅킹 SETTLED 동기화 폴러 | `WithdrawBankSettleService` + `POST /api/internal/bank-settle`, ACCEPTED→COMPLETED/APPROVED 전이 |
|
||||||
|
| 사용자 phone 등록 (admin) | `PUT /api/system/users/{id}/phone`, UserList 인라인 Modal |
|
||||||
|
| PWA 본인 프로필 | `Profile.tsx`(phone 자기수정 + 비번변경 + 로그아웃), TabBar 5탭 |
|
||||||
|
| PWA 출금 신청 취소 | `POST /api/mobile/me/withdraw-requests/{id}/cancel`, REQUESTED 만 SwipeAction |
|
||||||
|
| SETTLED/FAILED 본인 알림 자동 발행 | `user_notification` INSERT, PWA NoticeList 내알림 탭 즉시 확인 |
|
||||||
|
| PWA 알림 모두 읽음 | `POST /api/mobile/me/notifications/read-all`, NoticeList 헤더 버튼 |
|
||||||
|
| 출금 잔여 한도 검증 | `settle_master.net_amount - sum(활성 출금합계)` 초과 시 E411 |
|
||||||
|
|
||||||
|
**e2e 검증 (2026-05-27 23:40, agent01/userId=4/agentId=1)**:
|
||||||
|
- 로그인 → AGENT role → /api/mobile/me/summary statementCount=3 / withdrawRequestCount=3
|
||||||
|
- /api/mobile/me/settle-masters → 3건 응답 (admin은 E403이었음)
|
||||||
|
- POST /api/mobile/me/withdraw-requests → 성공 (requestId=4)
|
||||||
|
- /api/mobile/me/statements/1 → 200 (본인) · /statements/2 → **E403 (타인)** ← 본인 검증 강제
|
||||||
|
- PUT /api/mobile/me/profile/phone → 200, /api/auth/me 즉시 반영
|
||||||
|
- POST /api/internal/bank-settle → SETTLED 동기화 + user_notification 자동 발행 코드 경로 검증
|
||||||
|
- POST /api/auth/refresh → 새 accessToken 발급
|
||||||
|
|
||||||
|
**잔여 — 외부 SDK 차단**:
|
||||||
|
| 항목 | 차단 사유 |
|
||||||
|
|---|---|
|
||||||
|
| KftcBankTransferAdapter 본체 | API 키·계약·테스트망 |
|
||||||
|
| ToastMessageAdapter 본체 | App Key·발신번호·템플릿 코드 |
|
||||||
|
| 카카오 알림톡 본체 | 비즈채널·템플릿 승인 |
|
||||||
|
| 푸시 알림 (FCM 등) | 외부 채널 결정 |
|
||||||
|
|
||||||
## 3-9. P5-W3 메뉴/권한 전수 정합 보강 (V74 — 완료, 2026-05-21 라이브 적용)
|
## 3-9. P5-W3 메뉴/권한 전수 정합 보강 (V74 — 완료, 2026-05-21 라이브 적용)
|
||||||
|
|
||||||
| 항목 | 상태 |
|
| 항목 | 상태 |
|
||||||
@@ -252,16 +458,20 @@ V74~V78 Flyway 자동 적용 확인. ga-api 8082 가동 중.
|
|||||||
- 스모크: 신규 4 + 회귀 4 = **GET 8/8 PASS**, 쓰기(regenerate/detect/draft) 4/4 PASS
|
- 스모크: 신규 4 + 회귀 4 = **GET 8/8 PASS**, 쓰기(regenerate/detect/draft) 4/4 PASS
|
||||||
- 보정: YearEndStatementMapper.xml 의 `a.agent_code` 미존재 컬럼 참조 제거 (agent 테이블에 agent_code 없음)
|
- 보정: YearEndStatementMapper.xml 의 `a.agent_code` 미존재 컬럼 참조 제거 (agent 테이블에 agent_code 없음)
|
||||||
|
|
||||||
## 5. 재개 절차 (다음 세션 — P6 라운드)
|
## 5. 재개 절차 (다음 세션)
|
||||||
|
|
||||||
1. **Claude Code 시작 시 cwd 확인**: `C:\Users\kyu\Desktop\ga_pro` (현재 세션과 동일)
|
1. **Claude Code 시작 시 cwd 확인**: `C:\Users\kyu\Desktop\ga_pro`
|
||||||
2. **Agent Teams 활성화 확인**: `.claude/settings.json` 커밋되어 있음
|
2. **HANDOFF.md 읽기**: 본 문서 §3-15(P6 후속) + 최근 git log
|
||||||
3. **HANDOFF.md 읽기**: 본 문서 + 최근 git log로 컨텍스트 확보
|
3. **운영 DB 상태**: **V85 적용 완료**, 변경 없음. ga-api 8082 + ga-frontend 3000 + ga-mobile-pwa 3001 가동 중
|
||||||
4. **운영 DB 상태**: **V78 적용 완료**. ga-api 8082 가동 중 (필요 시 `python verify_v74.py` / `smoke_v78.py` 재실행)
|
4. **재기동이 필요한 경우**:
|
||||||
5. **권장 시작 지점**: P6 외부 연동 — 펌뱅킹 실연동 / SMS·카카오 발송 / 모바일 셀프 조회
|
- `cd ga-commission-system && nohup ./gradlew :ga-api:bootRun --args='--spring.profiles.active=trading_ai' > ../ga-api-bootrun.log 2>&1 & disown`
|
||||||
6. **사용자 결정 필요 항목**:
|
- `cd ga-commission-system/ga-frontend && npm run dev` (3000)
|
||||||
- P2-1 마감 도메인의 마감 후 수정 차단 AOP 구현 방식 (전역 AOP vs Service-level 가드)
|
- `cd ga-commission-system/ga-mobile-pwa && npm run dev` (3001)
|
||||||
- P6 펌뱅킹/메시징 외부 연동처 실제 스펙 (현재 미정 → 착수 전 확인 필요)
|
5. **PWA 테스트 계정**: `agent01` / `agent1234!` (userId=4, agentId=1, AGENT role)
|
||||||
|
6. **사용자 결정 필요 항목 (외부 SDK 차단 사유)**:
|
||||||
|
- KFTC 펌뱅킹 API 계약·인증 정보 → `KftcBankTransferAdapter` 본체 구현
|
||||||
|
- NHN Toast / 카카오 비즈채널 계정 + 템플릿 코드 → `ToastMessageAdapter` 본체
|
||||||
|
- 푸시 알림 채널 (FCM 등) 결정 → PWA 통합
|
||||||
|
|
||||||
## 6. PL 가이드 (다음 세션 유의사항)
|
## 6. PL 가이드 (다음 세션 유의사항)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package com.ga.api.aop;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.ga.api.service.external.ExternalCallLogService;
|
||||||
|
import com.ga.common.adapter.bank.BankTransferRequest;
|
||||||
|
import com.ga.common.adapter.bank.BankTransferResponse;
|
||||||
|
import com.ga.common.adapter.message.MessageRequest;
|
||||||
|
import com.ga.common.adapter.message.MessageResponse;
|
||||||
|
import com.ga.common.util.SecurityUtil;
|
||||||
|
import com.ga.core.vo.external.ExternalCallLogVO;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.aspectj.lang.ProceedingJoinPoint;
|
||||||
|
import org.aspectj.lang.annotation.Around;
|
||||||
|
import org.aspectj.lang.annotation.Aspect;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 연동 어댑터 호출 감사 이력 자동 영속화 (V85 external_call_log).
|
||||||
|
*
|
||||||
|
* <p>대상: {@link com.ga.common.adapter.bank.BankTransferAdapter},
|
||||||
|
* {@link com.ga.common.adapter.message.MessageAdapter} 모든 메서드.
|
||||||
|
*
|
||||||
|
* <p>정책:
|
||||||
|
* <ul>
|
||||||
|
* <li>호출 전/후 (정상·예외) 모두 영속화. 예외는 그대로 propagate.
|
||||||
|
* <li>request/response 는 마스킹된 JSON 요약만 저장 (계좌·전화 뒤 4자리만).
|
||||||
|
* <li>영속화는 별도 트랜잭션({@link com.ga.api.service.external.ExternalCallLogService}#recordCall = REQUIRES_NEW).
|
||||||
|
* <li>영속화 자체가 실패해도 비즈니스 흐름을 깨지 않는다 (warn 로그만).
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Aspect
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ExternalCallLoggingAspect {
|
||||||
|
|
||||||
|
private final ExternalCallLogService logService;
|
||||||
|
private final ObjectMapper om;
|
||||||
|
|
||||||
|
@Around("execution(* com.ga.common.adapter.bank.BankTransferAdapter+.*(..))")
|
||||||
|
public Object aroundBank(ProceedingJoinPoint jp) throws Throwable {
|
||||||
|
return around(jp, "BANK");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Around("execution(* com.ga.common.adapter.message.MessageAdapter+.*(..))")
|
||||||
|
public Object aroundMessage(ProceedingJoinPoint jp) throws Throwable {
|
||||||
|
return around(jp, "MESSAGE");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Object around(ProceedingJoinPoint jp, String callType) throws Throwable {
|
||||||
|
long start = System.currentTimeMillis();
|
||||||
|
ExternalCallLogVO vo = new ExternalCallLogVO();
|
||||||
|
vo.setCallType(callType);
|
||||||
|
vo.setAdapterClass(simpleName(jp.getTarget()));
|
||||||
|
vo.setMethodName(jp.getSignature().getName());
|
||||||
|
vo.setCalledAt(LocalDateTime.now());
|
||||||
|
try {
|
||||||
|
vo.setCreatedBy(SecurityUtil.getCurrentUserId());
|
||||||
|
} catch (Exception ignore) {
|
||||||
|
// 스케줄러/배치 컨텍스트에서는 인증 정보 없을 수 있음
|
||||||
|
}
|
||||||
|
applyRequest(vo, jp.getArgs());
|
||||||
|
|
||||||
|
try {
|
||||||
|
Object result = jp.proceed();
|
||||||
|
vo.setSuccess(true);
|
||||||
|
applyResponse(vo, result);
|
||||||
|
return result;
|
||||||
|
} catch (Throwable t) {
|
||||||
|
vo.setSuccess(false);
|
||||||
|
vo.setErrorClass(t.getClass().getName());
|
||||||
|
vo.setErrorMessage(truncate(t.getMessage(), 4000));
|
||||||
|
throw t;
|
||||||
|
} finally {
|
||||||
|
vo.setDurationMs((int) (System.currentTimeMillis() - start));
|
||||||
|
try {
|
||||||
|
logService.recordCall(vo);
|
||||||
|
} catch (Exception persistEx) {
|
||||||
|
log.warn("[external_call_log] persist failed: {}", persistEx.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyRequest(ExternalCallLogVO vo, Object[] args) {
|
||||||
|
if (args == null || args.length == 0) return;
|
||||||
|
Object a0 = args[0];
|
||||||
|
if (a0 instanceof BankTransferRequest btr) {
|
||||||
|
vo.setTargetRefType("WITHDRAW");
|
||||||
|
vo.setTargetRefId(btr.getWithdrawRequestId());
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("withdrawRequestId", btr.getWithdrawRequestId());
|
||||||
|
m.put("bankCode", btr.getBankCode());
|
||||||
|
m.put("accountNoMasked", maskAccount(btr.getAccountNo()));
|
||||||
|
m.put("holder", maskName(btr.getAccountHolderName()));
|
||||||
|
m.put("amount", btr.getAmount());
|
||||||
|
m.put("memo", btr.getMemo());
|
||||||
|
vo.setRequestSummary(toJson(m));
|
||||||
|
} else if (a0 instanceof MessageRequest mr) {
|
||||||
|
vo.setTargetRefType(mr.getRefType());
|
||||||
|
vo.setTargetRefId(mr.getRefId());
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("channel", mr.getChannel());
|
||||||
|
m.put("phoneMasked", maskPhone(mr.getPhoneNumber()));
|
||||||
|
m.put("title", mr.getTitle());
|
||||||
|
m.put("templateCode", mr.getTemplateCode());
|
||||||
|
m.put("refUserId", mr.getRefUserId());
|
||||||
|
m.put("contentPreview", truncate(mr.getContent(), 80));
|
||||||
|
vo.setRequestSummary(toJson(m));
|
||||||
|
} else if (a0 instanceof String s) {
|
||||||
|
// BankTransferAdapter.queryStatus(txId)
|
||||||
|
vo.setRequestSummary("{\"arg\":\"" + safeJsonString(s) + "\"}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyResponse(ExternalCallLogVO vo, Object result) {
|
||||||
|
if (result instanceof BankTransferResponse btr) {
|
||||||
|
vo.setResultCode(btr.getResultCode());
|
||||||
|
vo.setResultMessage(truncate(btr.getResultMessage(), 1000));
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("txId", btr.getTxId());
|
||||||
|
m.put("status", btr.getStatus());
|
||||||
|
m.put("processedAt", btr.getProcessedAt());
|
||||||
|
vo.setResponseSummary(toJson(m));
|
||||||
|
} else if (result instanceof MessageResponse mr) {
|
||||||
|
vo.setResultCode(mr.getResultCode());
|
||||||
|
vo.setResultMessage(truncate(mr.getResultMessage(), 1000));
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("messageId", mr.getMessageId());
|
||||||
|
m.put("sent", mr.isSent());
|
||||||
|
m.put("sentAt", mr.getSentAt());
|
||||||
|
vo.setResponseSummary(toJson(m));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String simpleName(Object o) {
|
||||||
|
return o == null ? null : o.getClass().getSimpleName();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String toJson(Object o) {
|
||||||
|
try {
|
||||||
|
return om.writeValueAsString(o);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "{}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String maskAccount(String accountNo) {
|
||||||
|
if (accountNo == null) return null;
|
||||||
|
String d = accountNo.replaceAll("[^0-9]", "");
|
||||||
|
if (d.length() <= 4) return "****";
|
||||||
|
return "****" + d.substring(d.length() - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String maskPhone(String phone) {
|
||||||
|
if (phone == null) return null;
|
||||||
|
String d = phone.replaceAll("[^0-9]", "");
|
||||||
|
if (d.length() <= 4) return "****";
|
||||||
|
return "***-****-" + d.substring(d.length() - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String maskName(String name) {
|
||||||
|
if (name == null || name.isEmpty()) return null;
|
||||||
|
return name.charAt(0) + "*".repeat(Math.max(0, name.length() - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String truncate(String s, int max) {
|
||||||
|
if (s == null) return null;
|
||||||
|
return s.length() <= max ? s : s.substring(0, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safeJsonString(String s) {
|
||||||
|
return s == null ? "" : s.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
package com.ga.api.controller.external;
|
||||||
|
|
||||||
|
import com.ga.api.service.external.ExternalCallLogService;
|
||||||
|
import com.ga.common.annotation.RequirePermission;
|
||||||
|
import com.ga.common.model.ApiResponse;
|
||||||
|
import com.ga.common.model.PageResponse;
|
||||||
|
import com.ga.core.vo.external.ExternalCallLogResp;
|
||||||
|
import com.ga.core.vo.external.ExternalCallLogSearchParam;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@Tag(name = "외부연동 호출이력")
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ExternalCallLogController {
|
||||||
|
|
||||||
|
private final ExternalCallLogService service;
|
||||||
|
|
||||||
|
@GetMapping("/api/external-call-logs")
|
||||||
|
@RequirePermission(menu = "SYSTEM_EXTERNAL_CALL", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<ExternalCallLogResp>> list(@ModelAttribute ExternalCallLogSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.list(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/external-call-logs/{logId}")
|
||||||
|
@RequirePermission(menu = "SYSTEM_EXTERNAL_CALL", perm = "READ")
|
||||||
|
public ApiResponse<ExternalCallLogResp> detail(@PathVariable Long logId) {
|
||||||
|
return ApiResponse.ok(service.detail(logId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.ga.api.controller.internal;
|
||||||
|
|
||||||
|
import com.ga.api.service.withdraw.WithdrawBankSettleService;
|
||||||
|
import com.ga.common.annotation.RequirePermission;
|
||||||
|
import com.ga.common.model.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 펌뱅킹 SETTLED 동기화 수동 트리거.
|
||||||
|
* cron 활성화 환경에서는 자동 실행되지만 운영 중 즉시 입금 확정 동기화가 필요할 때 사용.
|
||||||
|
*/
|
||||||
|
@Tag(name = "내부 트리거")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/internal/bank-settle")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class BankSettleController {
|
||||||
|
|
||||||
|
private final WithdrawBankSettleService bankSettleService;
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@RequirePermission(menu = "WITHDRAW", perm = "EXECUTE")
|
||||||
|
public ApiResponse<Integer> sync() {
|
||||||
|
return ApiResponse.ok(bankSettleService.syncOnce());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package com.ga.api.controller.mobile;
|
||||||
|
|
||||||
|
import com.ga.api.service.mobile.MobileMeService;
|
||||||
|
import com.ga.api.service.mobile.MobileMeService.MeSummary;
|
||||||
|
import com.ga.api.service.mobile.MobileMeService.MobileWithdrawSaveReq;
|
||||||
|
import com.ga.api.service.mobile.MobileMeService.WithdrawableBalance;
|
||||||
|
import com.ga.common.model.ApiResponse;
|
||||||
|
import com.ga.common.model.PageResponse;
|
||||||
|
import com.ga.core.vo.notice.UserNotificationResp;
|
||||||
|
import com.ga.core.vo.notice.UserNotificationSearchParam;
|
||||||
|
import com.ga.core.vo.settle.CommissionStatementResp;
|
||||||
|
import com.ga.core.vo.settle.CommissionStatementSearchParam;
|
||||||
|
import com.ga.core.vo.settle.SettleMasterResp;
|
||||||
|
import com.ga.core.vo.withdraw.WithdrawRequestResp;
|
||||||
|
import com.ga.core.vo.withdraw.WithdrawRequestSearchParam;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모바일 PWA 전용 — 토큰의 사용자 본인 데이터만 반환.
|
||||||
|
*
|
||||||
|
* <p>모든 endpoint 는 인증만 통과하면 사용 가능 (별도 메뉴 권한 없음).
|
||||||
|
* agentId / userId 는 서버측에서 토큰 → user 조회로 강제 결정. 클라이언트 파라미터는 무시한다.
|
||||||
|
*/
|
||||||
|
@Tag(name = "모바일 셀프 조회")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/mobile/me")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MobileMeController {
|
||||||
|
|
||||||
|
private final MobileMeService service;
|
||||||
|
|
||||||
|
@GetMapping("/summary")
|
||||||
|
public ApiResponse<MeSummary> summary() {
|
||||||
|
return ApiResponse.ok(service.summary());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/statements")
|
||||||
|
public ApiResponse<PageResponse<CommissionStatementResp>> statements(
|
||||||
|
@ModelAttribute CommissionStatementSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.statements(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/statements/{statementId}")
|
||||||
|
public ApiResponse<CommissionStatementResp> statementDetail(@PathVariable Long statementId) {
|
||||||
|
return ApiResponse.ok(service.statementDetail(statementId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/statements/{statementId}/download")
|
||||||
|
public void downloadStatement(@PathVariable Long statementId, HttpServletResponse response) throws IOException {
|
||||||
|
byte[] content = service.downloadStatement(statementId);
|
||||||
|
String encoded = URLEncoder.encode("수수료명세서_" + statementId + ".xlsx", StandardCharsets.UTF_8)
|
||||||
|
.replace("+", "%20");
|
||||||
|
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||||
|
response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encoded);
|
||||||
|
response.setContentLength(content.length);
|
||||||
|
response.getOutputStream().write(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/settle-masters")
|
||||||
|
public ApiResponse<List<SettleMasterResp>> withdrawableSettleMasters() {
|
||||||
|
return ApiResponse.ok(service.withdrawableSettleMasters());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/settle-masters/{settleMasterId}/withdrawable-balance")
|
||||||
|
public ApiResponse<WithdrawableBalance> withdrawableBalance(@PathVariable Long settleMasterId) {
|
||||||
|
return ApiResponse.ok(service.withdrawableBalance(settleMasterId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/withdraw-requests")
|
||||||
|
public ApiResponse<PageResponse<WithdrawRequestResp>> withdrawRequests(
|
||||||
|
@ModelAttribute WithdrawRequestSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.withdrawRequests(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/withdraw-requests")
|
||||||
|
public ApiResponse<Long> createWithdrawRequest(@Valid @RequestBody MobileWithdrawSaveReq req) {
|
||||||
|
return ApiResponse.ok(service.createWithdrawRequest(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/withdraw-requests/{requestId}/cancel")
|
||||||
|
public ApiResponse<Void> cancelWithdrawRequest(@PathVariable Long requestId) {
|
||||||
|
service.cancelMyWithdrawRequest(requestId);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/profile/phone")
|
||||||
|
public ApiResponse<Void> updateMyPhone(@RequestBody PhoneReq req) {
|
||||||
|
service.updateMyPhone(req.getPhone());
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@lombok.Data
|
||||||
|
public static class PhoneReq {
|
||||||
|
private String phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/notifications")
|
||||||
|
public ApiResponse<PageResponse<UserNotificationResp>> notifications(
|
||||||
|
@ModelAttribute UserNotificationSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.notifications(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/notifications/{notificationId}/read")
|
||||||
|
public ApiResponse<Void> markRead(@PathVariable Long notificationId) {
|
||||||
|
service.markNotificationRead(notificationId);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/notifications/read-all")
|
||||||
|
public ApiResponse<Integer> markAllRead() {
|
||||||
|
return ApiResponse.ok(service.markAllNotificationsRead());
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
package com.ga.api.controller.succession;
|
||||||
|
|
||||||
|
import com.ga.api.service.succession.ContractSuccessionService;
|
||||||
|
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.succession.ContractSuccessionResp;
|
||||||
|
import com.ga.core.vo.succession.ContractSuccessionSaveReq;
|
||||||
|
import com.ga.core.vo.succession.ContractSuccessionSearchParam;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@Tag(name = "계약 승계")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/contract-successions")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ContractSuccessionController {
|
||||||
|
|
||||||
|
private final ContractSuccessionService service;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@RequirePermission(menu = "CONTRACT_SUCCESSION", perm = "READ")
|
||||||
|
public ApiResponse<PageResponse<ContractSuccessionResp>> list(
|
||||||
|
@ModelAttribute ContractSuccessionSearchParam param) {
|
||||||
|
return ApiResponse.ok(service.list(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{successionId}")
|
||||||
|
@RequirePermission(menu = "CONTRACT_SUCCESSION", perm = "READ")
|
||||||
|
public ApiResponse<ContractSuccessionResp> detail(@PathVariable Long successionId) {
|
||||||
|
return ApiResponse.ok(service.detail(successionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@RequirePermission(menu = "CONTRACT_SUCCESSION", perm = "CREATE")
|
||||||
|
@DataChangeLog(menu = "CONTRACT_SUCCESSION", table = "contract_succession")
|
||||||
|
public ApiResponse<Long> create(@Valid @RequestBody ContractSuccessionSaveReq req) {
|
||||||
|
return ApiResponse.ok(service.create(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{successionId}/cancel")
|
||||||
|
@RequirePermission(menu = "CONTRACT_SUCCESSION", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "CONTRACT_SUCCESSION", table = "contract_succession")
|
||||||
|
public ApiResponse<Void> cancel(@PathVariable Long successionId) {
|
||||||
|
service.cancel(successionId);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{successionId}")
|
||||||
|
@RequirePermission(menu = "CONTRACT_SUCCESSION", perm = "DELETE")
|
||||||
|
@DataChangeLog(menu = "CONTRACT_SUCCESSION", table = "contract_succession")
|
||||||
|
public ApiResponse<Void> delete(@PathVariable Long successionId) {
|
||||||
|
service.delete(successionId);
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,4 +61,21 @@ public class UserController {
|
|||||||
service.unlock(userId);
|
service.unlock(userId);
|
||||||
return ApiResponse.ok();
|
return ApiResponse.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전화번호 단독 패치. SMS 알림 발신을 위한 phone 등록·정정 경로.
|
||||||
|
* loginId/userName 같은 NotBlank 필드를 강제하지 않는다.
|
||||||
|
*/
|
||||||
|
@PutMapping("/{userId}/phone")
|
||||||
|
@RequirePermission(menu = "SYSTEM_USER", perm = "UPDATE")
|
||||||
|
@DataChangeLog(menu = "SYSTEM_USER", table = "users")
|
||||||
|
public ApiResponse<Void> updatePhone(@PathVariable Long userId, @RequestBody PhoneReq req) {
|
||||||
|
service.updatePhone(userId, req.getPhone());
|
||||||
|
return ApiResponse.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
@lombok.Data
|
||||||
|
public static class PhoneReq {
|
||||||
|
private String phone;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.ga.api.service.external;
|
||||||
|
|
||||||
|
import com.ga.common.model.PageResponse;
|
||||||
|
import com.ga.core.mapper.external.ExternalCallLogMapper;
|
||||||
|
import com.ga.core.vo.external.ExternalCallLogResp;
|
||||||
|
import com.ga.core.vo.external.ExternalCallLogSearchParam;
|
||||||
|
import com.ga.core.vo.external.ExternalCallLogVO;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 연동 호출 감사 이력.
|
||||||
|
*
|
||||||
|
* <p>{@link #recordCall} 은 AOP({@link com.ga.api.aop.ExternalCallLoggingAspect})에서 호출된다.
|
||||||
|
* 호출자 트랜잭션 롤백과 무관하게 영속화돼야 하므로 {@link Propagation#REQUIRES_NEW}.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ExternalCallLogService {
|
||||||
|
|
||||||
|
private final ExternalCallLogMapper mapper;
|
||||||
|
|
||||||
|
/** AOP에서 호출. 호출자 트랜잭션과 분리(REQUIRES_NEW). */
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
public void recordCall(ExternalCallLogVO vo) {
|
||||||
|
mapper.insertOne(vo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public PageResponse<ExternalCallLogResp> list(ExternalCallLogSearchParam param) {
|
||||||
|
param.startPage();
|
||||||
|
return PageResponse.of(mapper.selectList(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public ExternalCallLogResp detail(Long logId) {
|
||||||
|
return mapper.selectById(logId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
package com.ga.api.service.mobile;
|
||||||
|
|
||||||
|
import com.ga.api.service.statement.CommissionStatementService;
|
||||||
|
import com.ga.api.service.withdraw.WithdrawRequestService;
|
||||||
|
import com.ga.common.exception.BizException;
|
||||||
|
import com.ga.common.exception.ErrorCode;
|
||||||
|
import com.ga.common.model.PageResponse;
|
||||||
|
import com.ga.common.util.SecurityUtil;
|
||||||
|
import com.ga.core.mapper.notice.UserNotificationMapper;
|
||||||
|
import com.ga.core.mapper.settle.CommissionStatementMapper;
|
||||||
|
import com.ga.core.mapper.settle.SettleMasterMapper;
|
||||||
|
import com.ga.core.mapper.user.UserMapper;
|
||||||
|
import com.ga.core.mapper.withdraw.WithdrawRequestMapper;
|
||||||
|
import com.ga.core.vo.notice.UserNotificationResp;
|
||||||
|
import com.ga.core.vo.notice.UserNotificationSearchParam;
|
||||||
|
import com.ga.core.vo.settle.CommissionStatementResp;
|
||||||
|
import com.ga.core.vo.settle.CommissionStatementSearchParam;
|
||||||
|
import com.ga.core.vo.settle.SettleMasterResp;
|
||||||
|
import com.ga.core.vo.settle.SettleSearchParam;
|
||||||
|
import com.ga.core.vo.user.UserVO;
|
||||||
|
import com.ga.core.vo.withdraw.WithdrawRequestResp;
|
||||||
|
import com.ga.core.vo.withdraw.WithdrawRequestSaveReq;
|
||||||
|
import com.ga.core.vo.withdraw.WithdrawRequestSearchParam;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Positive;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모바일 PWA 전용 — 본인 데이터만 조회. agentId/userId 는 토큰에서 강제 추출.
|
||||||
|
*
|
||||||
|
* <p>클라이언트가 보낸 search param 의 agentId/userId 는 무시하고 SecurityContext 의 사용자로 덮어쓴다.
|
||||||
|
* 본 서비스 외부에서는 본인 데이터 임의 조회가 절대 불가능하도록 보장.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class MobileMeService {
|
||||||
|
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
private final CommissionStatementMapper statementMapper;
|
||||||
|
private final WithdrawRequestMapper withdrawMapper;
|
||||||
|
private final UserNotificationMapper notificationMapper;
|
||||||
|
private final SettleMasterMapper settleMasterMapper;
|
||||||
|
private final WithdrawRequestService withdrawRequestService;
|
||||||
|
private final CommissionStatementService statementService;
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public MeSummary summary() {
|
||||||
|
Long userId = currentUserId();
|
||||||
|
Long agentId = resolveAgentId(userId);
|
||||||
|
|
||||||
|
long stTotal = 0L;
|
||||||
|
long wdTotal = 0L;
|
||||||
|
if (agentId != null) {
|
||||||
|
CommissionStatementSearchParam sp = new CommissionStatementSearchParam();
|
||||||
|
sp.setAgentId(agentId);
|
||||||
|
sp.setPageNum(1);
|
||||||
|
sp.setPageSize(1);
|
||||||
|
sp.startPage();
|
||||||
|
stTotal = PageResponse.of(statementMapper.selectList(sp)).getTotal();
|
||||||
|
|
||||||
|
WithdrawRequestSearchParam wp = new WithdrawRequestSearchParam();
|
||||||
|
wp.setAgentId(agentId);
|
||||||
|
wp.setPageNum(1);
|
||||||
|
wp.setPageSize(1);
|
||||||
|
wp.startPage();
|
||||||
|
wdTotal = PageResponse.of(withdrawMapper.selectList(wp)).getTotal();
|
||||||
|
}
|
||||||
|
int unreadCount = notificationMapper.selectUnreadCount(userId);
|
||||||
|
|
||||||
|
return MeSummary.builder()
|
||||||
|
.userId(userId)
|
||||||
|
.agentId(agentId)
|
||||||
|
.statementCount(stTotal)
|
||||||
|
.withdrawRequestCount(wdTotal)
|
||||||
|
.unreadNotificationCount(unreadCount)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public PageResponse<CommissionStatementResp> statements(CommissionStatementSearchParam param) {
|
||||||
|
Long agentId = requireAgentId();
|
||||||
|
param.setAgentId(agentId); // 강제 덮어쓰기
|
||||||
|
param.startPage();
|
||||||
|
return PageResponse.of(statementMapper.selectList(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public PageResponse<WithdrawRequestResp> withdrawRequests(WithdrawRequestSearchParam param) {
|
||||||
|
Long agentId = requireAgentId();
|
||||||
|
param.setAgentId(agentId); // 강제 덮어쓰기
|
||||||
|
param.startPage();
|
||||||
|
return PageResponse.of(withdrawMapper.selectList(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public PageResponse<UserNotificationResp> notifications(UserNotificationSearchParam param) {
|
||||||
|
Long userId = currentUserId();
|
||||||
|
param.setUserId(userId); // 강제 덮어쓰기
|
||||||
|
param.startPage();
|
||||||
|
return PageResponse.of(notificationMapper.selectList(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void markNotificationRead(Long notificationId) {
|
||||||
|
Long userId = currentUserId();
|
||||||
|
notificationMapper.markRead(notificationId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 본인 알림 전체 읽음 처리. */
|
||||||
|
@Transactional
|
||||||
|
public int markAllNotificationsRead() {
|
||||||
|
Long userId = currentUserId();
|
||||||
|
return notificationMapper.markAllRead(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 본인 명세서 단건. statementId 가 본인 agentId 의 것이 아니면 FORBIDDEN. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public CommissionStatementResp statementDetail(Long statementId) {
|
||||||
|
Long agentId = requireAgentId();
|
||||||
|
CommissionStatementResp resp = statementMapper.selectDetailById(statementId);
|
||||||
|
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
|
||||||
|
if (!agentId.equals(resp.getAgentId())) throw new BizException(ErrorCode.FORBIDDEN);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 본인 명세서 엑셀 다운로드. 본인 소유 검증 후 기존 다운로드 흐름 재사용. */
|
||||||
|
@Transactional
|
||||||
|
public byte[] downloadStatement(Long statementId) {
|
||||||
|
statementDetail(statementId); // 권한 검증 (FORBIDDEN/NOT_FOUND 던짐)
|
||||||
|
return statementService.download(statementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 본인 phone 자기 수정 — 관리자 거치지 않고 본인이 직접 등록·정정. */
|
||||||
|
@Transactional
|
||||||
|
public void updateMyPhone(String phone) {
|
||||||
|
Long userId = currentUserId();
|
||||||
|
String trimmed = phone == null || phone.isBlank() ? null : phone.trim();
|
||||||
|
userMapper.updatePhone(userId, trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 본인 출금 가능 정산 마스터 목록. 출금 신청 폼의 settleMasterId 드롭다운 용. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<SettleMasterResp> withdrawableSettleMasters() {
|
||||||
|
Long agentId = requireAgentId();
|
||||||
|
SettleSearchParam param = new SettleSearchParam();
|
||||||
|
param.setAgentId(agentId);
|
||||||
|
param.setPageNum(1);
|
||||||
|
param.setPageSize(50);
|
||||||
|
param.startPage();
|
||||||
|
return settleMasterMapper.selectList(param);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 본인 출금 신청 취소. 본인 소유 검증 후 기존 WithdrawRequestService.cancel 호출. */
|
||||||
|
@Transactional
|
||||||
|
public void cancelMyWithdrawRequest(Long requestId) {
|
||||||
|
Long agentId = requireAgentId();
|
||||||
|
com.ga.core.vo.withdraw.WithdrawRequestResp existing = withdrawMapper.selectById(requestId);
|
||||||
|
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
|
||||||
|
if (!agentId.equals(existing.getAgentId())) throw new BizException(ErrorCode.FORBIDDEN);
|
||||||
|
withdrawRequestService.cancel(requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 본인 정산 마스터의 출금 가능 잔액 조회 — 출금 신청 폼 미리 표시용.
|
||||||
|
* 본인 소유가 아니면 FORBIDDEN, 없으면 NOT_FOUND.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public WithdrawableBalance withdrawableBalance(Long settleMasterId) {
|
||||||
|
Long agentId = requireAgentId();
|
||||||
|
var master = settleMasterMapper.selectById(settleMasterId);
|
||||||
|
if (master == null) throw new BizException(ErrorCode.NOT_FOUND, "정산 마스터를 찾을 수 없습니다");
|
||||||
|
if (!agentId.equals(master.getAgentId())) throw new BizException(ErrorCode.FORBIDDEN);
|
||||||
|
|
||||||
|
BigDecimal netAmount = master.getNetAmount() != null ? master.getNetAmount() : BigDecimal.ZERO;
|
||||||
|
BigDecimal used = withdrawMapper.sumActiveAmountByMaster(settleMasterId);
|
||||||
|
if (used == null) used = BigDecimal.ZERO;
|
||||||
|
return WithdrawableBalance.builder()
|
||||||
|
.settleId(settleMasterId)
|
||||||
|
.netAmount(netAmount)
|
||||||
|
.usedAmount(used)
|
||||||
|
.remainingAmount(netAmount.subtract(used))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 본인 출금 신청 등록. agentId / requestedBy 는 토큰에서 강제 결정.
|
||||||
|
* 클라이언트가 보낸 값은 무시한다.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public Long createWithdrawRequest(MobileWithdrawSaveReq req) {
|
||||||
|
Long userId = currentUserId();
|
||||||
|
|
||||||
|
// 본인 소유 검증 + 잔여 한도 = net_amount - 활성 출금합계 (조회 로직 재사용)
|
||||||
|
WithdrawableBalance balance = withdrawableBalance(req.getSettleMasterId());
|
||||||
|
if (req.getAmount().compareTo(balance.getRemainingAmount()) > 0) {
|
||||||
|
throw new BizException(ErrorCode.INVALID_PARAMETER,
|
||||||
|
"출금 가능 잔액(" + balance.getRemainingAmount() + ")을 초과합니다");
|
||||||
|
}
|
||||||
|
Long agentId = requireAgentId();
|
||||||
|
|
||||||
|
WithdrawRequestSaveReq inner = new WithdrawRequestSaveReq();
|
||||||
|
inner.setSettleMasterId(req.getSettleMasterId());
|
||||||
|
inner.setAgentId(agentId); // 강제
|
||||||
|
inner.setRequestedBy(userId); // 강제
|
||||||
|
inner.setAmount(req.getAmount());
|
||||||
|
inner.setBankCode(req.getBankCode());
|
||||||
|
inner.setAccountNo(req.getAccountNo());
|
||||||
|
|
||||||
|
return withdrawRequestService.create(inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── private ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private Long currentUserId() {
|
||||||
|
Long uid = SecurityUtil.getCurrentUserId();
|
||||||
|
if (uid == null) throw new BizException(ErrorCode.UNAUTHORIZED);
|
||||||
|
return uid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long resolveAgentId(Long userId) {
|
||||||
|
UserVO user = userMapper.selectById(userId);
|
||||||
|
return user != null ? user.getAgentId() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 본인 agent 가 연결돼 있어야만 통과. 미연결이면 FORBIDDEN. */
|
||||||
|
private Long requireAgentId() {
|
||||||
|
Long uid = currentUserId();
|
||||||
|
Long agentId = resolveAgentId(uid);
|
||||||
|
if (agentId == null) throw new BizException(ErrorCode.FORBIDDEN);
|
||||||
|
return agentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public static class MeSummary {
|
||||||
|
private Long userId;
|
||||||
|
private Long agentId;
|
||||||
|
private long statementCount;
|
||||||
|
private long withdrawRequestCount;
|
||||||
|
private int unreadNotificationCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 출금 가능 잔액 — 폼 미리 표시용. */
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public static class WithdrawableBalance {
|
||||||
|
private Long settleId;
|
||||||
|
private BigDecimal netAmount;
|
||||||
|
private BigDecimal usedAmount;
|
||||||
|
private BigDecimal remainingAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 모바일 출금 신청 — 토큰에서 agentId/requestedBy 강제. */
|
||||||
|
@Data
|
||||||
|
public static class MobileWithdrawSaveReq {
|
||||||
|
@NotNull
|
||||||
|
private Long settleMasterId;
|
||||||
|
@NotNull
|
||||||
|
@Positive
|
||||||
|
private BigDecimal amount;
|
||||||
|
@NotBlank
|
||||||
|
private String bankCode;
|
||||||
|
@NotBlank
|
||||||
|
private String accountNo;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.ga.api.service.succession;
|
||||||
|
|
||||||
|
import com.ga.common.exception.BizException;
|
||||||
|
import com.ga.common.exception.ErrorCode;
|
||||||
|
import com.ga.common.model.PageResponse;
|
||||||
|
import com.ga.common.util.SecurityUtil;
|
||||||
|
import com.ga.core.mapper.succession.ContractSuccessionMapper;
|
||||||
|
import com.ga.core.vo.succession.ContractSuccessionResp;
|
||||||
|
import com.ga.core.vo.succession.ContractSuccessionSaveReq;
|
||||||
|
import com.ga.core.vo.succession.ContractSuccessionSearchParam;
|
||||||
|
import com.ga.core.vo.succession.ContractSuccessionVO;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class ContractSuccessionService {
|
||||||
|
|
||||||
|
private final ContractSuccessionMapper mapper;
|
||||||
|
|
||||||
|
public PageResponse<ContractSuccessionResp> list(ContractSuccessionSearchParam param) {
|
||||||
|
param.startPage();
|
||||||
|
return PageResponse.of(mapper.selectList(param));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ContractSuccessionResp detail(Long successionId) {
|
||||||
|
ContractSuccessionResp resp = mapper.selectById(successionId);
|
||||||
|
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Long create(ContractSuccessionSaveReq req) {
|
||||||
|
if (req.getFromAgentId().equals(req.getToAgentId())) {
|
||||||
|
throw new BizException(ErrorCode.INVALID_PARAMETER, "전임과 후임 설계사가 같을 수 없습니다");
|
||||||
|
}
|
||||||
|
ContractSuccessionVO vo = new ContractSuccessionVO();
|
||||||
|
vo.setContractId(req.getContractId());
|
||||||
|
vo.setFromAgentId(req.getFromAgentId());
|
||||||
|
vo.setToAgentId(req.getToAgentId());
|
||||||
|
vo.setSuccessionDate(req.getSuccessionDate());
|
||||||
|
vo.setReason(req.getReason());
|
||||||
|
vo.setMemo(req.getMemo());
|
||||||
|
vo.setCreatedBy(SecurityUtil.getCurrentUserId());
|
||||||
|
mapper.insert(vo);
|
||||||
|
return vo.getSuccessionId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 승계 취소 — status=CANCELED. 이후 정산월부터 유지수수료는 다시 직전 귀속 설계사에게. */
|
||||||
|
@Transactional
|
||||||
|
public void cancel(Long successionId) {
|
||||||
|
ContractSuccessionResp existing = mapper.selectById(successionId);
|
||||||
|
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
|
||||||
|
mapper.cancel(successionId, SecurityUtil.getCurrentUserId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void delete(Long successionId) {
|
||||||
|
ContractSuccessionResp existing = mapper.selectById(successionId);
|
||||||
|
if (existing == null) throw new BizException(ErrorCode.NOT_FOUND);
|
||||||
|
mapper.delete(successionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,6 +89,15 @@ public class UserService {
|
|||||||
mapper.updateStatus(userId, UserStatus.ACTIVE.name());
|
mapper.updateStatus(userId, UserStatus.ACTIVE.name());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void updatePhone(Long userId, String phone) {
|
||||||
|
if (mapper.selectById(userId) == null) {
|
||||||
|
throw new BizException(ErrorCode.NOT_FOUND);
|
||||||
|
}
|
||||||
|
String trimmed = phone == null || phone.isBlank() ? null : phone.trim();
|
||||||
|
mapper.updatePhone(userId, trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public List<String> roleCodes(Long userId) {
|
public List<String> roleCodes(Long userId) {
|
||||||
return mapper.selectRoleCodes(userId);
|
return mapper.selectRoleCodes(userId);
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package com.ga.api.service.withdraw;
|
||||||
|
|
||||||
|
import com.ga.common.adapter.bank.BankTransferAdapter;
|
||||||
|
import com.ga.common.adapter.bank.BankTransferResponse;
|
||||||
|
import com.ga.common.adapter.bank.BankTransferStatus;
|
||||||
|
import com.ga.core.mapper.notice.UserNotificationMapper;
|
||||||
|
import com.ga.core.mapper.withdraw.WithdrawRequestMapper;
|
||||||
|
import com.ga.core.vo.notice.NotificationRefType;
|
||||||
|
import com.ga.core.vo.notice.UserNotificationVO;
|
||||||
|
import com.ga.core.vo.withdraw.WithdrawRequestVO;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 펌뱅킹 SETTLED 동기화 서비스.
|
||||||
|
* <p>
|
||||||
|
* 어댑터가 ACCEPTED(접수)만 반환한 출금 건들에 대해 {@code queryStatus} 폴링으로 입금 확정 여부를 확인.
|
||||||
|
* <ul>
|
||||||
|
* <li>SETTLED → status=COMPLETED, transfer_status=SETTLED, completed_at=NOW()</li>
|
||||||
|
* <li>FAILED → status=APPROVED, transfer_status=FAILED (재시도 풀로 회귀)</li>
|
||||||
|
* <li>ACCEPTED 유지 → no-op (다음 사이클에서 다시 폴링)</li>
|
||||||
|
* </ul>
|
||||||
|
* <p>운영: {@code POST /api/internal/bank-settle} 수동 트리거 + cron 옵션(기본 비활성).
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WithdrawBankSettleService {
|
||||||
|
|
||||||
|
private static final int DEFAULT_BATCH_LIMIT = 50;
|
||||||
|
|
||||||
|
private final WithdrawRequestMapper mapper;
|
||||||
|
private final BankTransferAdapter bankTransferAdapter;
|
||||||
|
private final UserNotificationMapper notificationMapper;
|
||||||
|
|
||||||
|
@Value("${app.bank-settle.limit:50}")
|
||||||
|
private int batchLimit;
|
||||||
|
|
||||||
|
/** 동기화 1회 — 상태 전이된 건수 반환(SETTLED + FAILED 합산). */
|
||||||
|
@Transactional
|
||||||
|
public int syncOnce() {
|
||||||
|
int limit = batchLimit > 0 ? batchLimit : DEFAULT_BATCH_LIMIT;
|
||||||
|
List<WithdrawRequestVO> targets = mapper.selectAcceptedTransfers(limit);
|
||||||
|
if (targets.isEmpty()) return 0;
|
||||||
|
|
||||||
|
log.info("[BANK-SETTLE] 대상 {}건 동기화 시작", targets.size());
|
||||||
|
int changed = 0;
|
||||||
|
for (WithdrawRequestVO vo : targets) {
|
||||||
|
String txId = vo.getTransferTxId();
|
||||||
|
if (txId == null) continue;
|
||||||
|
try {
|
||||||
|
BankTransferResponse resp = bankTransferAdapter.queryStatus(txId);
|
||||||
|
BankTransferStatus status = resp != null ? resp.getStatus() : null;
|
||||||
|
|
||||||
|
if (status == BankTransferStatus.SETTLED) {
|
||||||
|
int n = mapper.markSettled(vo.getRequestId());
|
||||||
|
if (n > 0) {
|
||||||
|
changed++;
|
||||||
|
notify(vo, true, null);
|
||||||
|
}
|
||||||
|
} else if (status == BankTransferStatus.FAILED) {
|
||||||
|
String reason = resp.getResultMessage() != null ? resp.getResultMessage() : "settle failed";
|
||||||
|
int n = mapper.markSettleFailed(vo.getRequestId(), reason);
|
||||||
|
if (n > 0) {
|
||||||
|
changed++;
|
||||||
|
notify(vo, false, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ACCEPTED 유지 시 no-op
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[BANK-SETTLE] queryStatus 어댑터 예외: requestId={} txId={}",
|
||||||
|
vo.getRequestId(), txId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.info("[BANK-SETTLE] 완료 — 상태전이 {}/{}건", changed, targets.size());
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신청자에게 결과 알림. 본인 알림이 실패해도 동기화 흐름은 비차단.
|
||||||
|
*/
|
||||||
|
private void notify(WithdrawRequestVO vo, boolean settled, String reason) {
|
||||||
|
Long userId = vo.getRequestedBy();
|
||||||
|
if (userId == null) return;
|
||||||
|
try {
|
||||||
|
UserNotificationVO n = new UserNotificationVO();
|
||||||
|
n.setUserId(userId);
|
||||||
|
n.setRefType(NotificationRefType.WITHDRAW.name());
|
||||||
|
n.setRefId(vo.getRequestId());
|
||||||
|
n.setIsRead(false);
|
||||||
|
if (settled) {
|
||||||
|
n.setTitle("출금 입금 완료");
|
||||||
|
n.setContent("#" + vo.getRequestId() + " 출금 신청이 입금 완료되었습니다 ("
|
||||||
|
+ vo.getAmount() + "원).");
|
||||||
|
} else {
|
||||||
|
n.setTitle("출금 입금 실패");
|
||||||
|
n.setContent("#" + vo.getRequestId() + " 출금이 실패하였습니다"
|
||||||
|
+ (reason != null ? " — " + reason : "")
|
||||||
|
+ ". 재시도 처리됩니다.");
|
||||||
|
}
|
||||||
|
notificationMapper.insert(n);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[BANK-SETTLE] 알림 발행 실패 requestId={}: {}", vo.getRequestId(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* cron 기반 자동 동기화.
|
||||||
|
* yml 에 {@code app.bank-settle.cron} 미설정 시 매년 1월 1일 0시(=사실상 비활성)로 작동.
|
||||||
|
*/
|
||||||
|
@Scheduled(cron = "${app.bank-settle.cron:0 0 0 1 1 ?}")
|
||||||
|
public void scheduled() {
|
||||||
|
try {
|
||||||
|
syncOnce();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[BANK-SETTLE] 스케줄러 실패", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,6 +66,11 @@ app:
|
|||||||
limit: ${BANK_RETRY_LIMIT:50}
|
limit: ${BANK_RETRY_LIMIT:50}
|
||||||
# 미설정 시 사실상 비활성 (매년 1/1 0시). 운영 활성화 예: "0 */10 * * * *"
|
# 미설정 시 사실상 비활성 (매년 1/1 0시). 운영 활성화 예: "0 */10 * * * *"
|
||||||
cron: ${BANK_RETRY_CRON:0 0 0 1 1 ?}
|
cron: ${BANK_RETRY_CRON:0 0 0 1 1 ?}
|
||||||
|
# 펌뱅킹 SETTLED 동기화 배치 — 어댑터 queryStatus 폴링
|
||||||
|
bank-settle:
|
||||||
|
limit: ${BANK_SETTLE_LIMIT:50}
|
||||||
|
# 미설정 시 사실상 비활성. 운영 활성화 예: "0 */5 * * * *"
|
||||||
|
cron: ${BANK_SETTLE_CRON:0 0 0 1 1 ?}
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
|
|||||||
@@ -26,15 +26,25 @@ public class CommissionCalculator {
|
|||||||
private final AgentMapper agentMapper;
|
private final AgentMapper agentMapper;
|
||||||
|
|
||||||
public LedgerVO calcRecruitLedger(ContractResp c, String settleMonth) {
|
public LedgerVO calcRecruitLedger(ContractResp c, String settleMonth) {
|
||||||
return buildLedger(c, settleMonth, 1);
|
return buildLedger(c, settleMonth, 1, c.getAgentId());
|
||||||
}
|
}
|
||||||
|
|
||||||
public LedgerVO calcMaintainLedger(ContractResp c, String settleMonth, int commissionYear) {
|
public LedgerVO calcMaintainLedger(ContractResp c, String settleMonth, int commissionYear) {
|
||||||
return buildLedger(c, settleMonth, commissionYear);
|
return buildLedger(c, settleMonth, commissionYear, c.getAgentId());
|
||||||
}
|
}
|
||||||
|
|
||||||
private LedgerVO buildLedger(ContractResp c, String settleMonth, int year) {
|
/**
|
||||||
AgentVO agent = agentMapper.selectById(c.getAgentId());
|
* 계약 승계 반영 유지수수료 계산.
|
||||||
|
* effectiveAgentId 가 후임 설계사이면 해당 설계사 등급의 payout 비율로 계산되고
|
||||||
|
* 원장 agent_id 도 후임으로 기록된다. null 이면 원 모집설계사로 동작.
|
||||||
|
*/
|
||||||
|
public LedgerVO calcMaintainLedger(ContractResp c, String settleMonth, int commissionYear, Long effectiveAgentId) {
|
||||||
|
return buildLedger(c, settleMonth, commissionYear,
|
||||||
|
effectiveAgentId != null ? effectiveAgentId : c.getAgentId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private LedgerVO buildLedger(ContractResp c, String settleMonth, int year, Long agentId) {
|
||||||
|
AgentVO agent = agentMapper.selectById(agentId);
|
||||||
if (agent == null) return null;
|
if (agent == null) return null;
|
||||||
LocalDate baseDate = c.getContractDate() != null ? c.getContractDate() : LocalDate.now();
|
LocalDate baseDate = c.getContractDate() != null ? c.getContractDate() : LocalDate.now();
|
||||||
|
|
||||||
@@ -52,7 +62,7 @@ public class CommissionCalculator {
|
|||||||
|
|
||||||
LedgerVO led = new LedgerVO();
|
LedgerVO led = new LedgerVO();
|
||||||
led.setContractId(c.getContractId());
|
led.setContractId(c.getContractId());
|
||||||
led.setAgentId(c.getAgentId());
|
led.setAgentId(agentId);
|
||||||
led.setPolicyNo(c.getPolicyNo());
|
led.setPolicyNo(c.getPolicyNo());
|
||||||
led.setCompanyCode(c.getCompanyCode());
|
led.setCompanyCode(c.getCompanyCode());
|
||||||
led.setProductCode(c.getProductCode());
|
led.setProductCode(c.getProductCode());
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import com.ga.common.util.DateUtil;
|
|||||||
import com.ga.common.util.MoneyUtil;
|
import com.ga.common.util.MoneyUtil;
|
||||||
import com.ga.core.mapper.ledger.MaintainLedgerMapper;
|
import com.ga.core.mapper.ledger.MaintainLedgerMapper;
|
||||||
import com.ga.core.mapper.product.ContractMapper;
|
import com.ga.core.mapper.product.ContractMapper;
|
||||||
|
import com.ga.core.mapper.succession.ContractSuccessionMapper;
|
||||||
import com.ga.core.vo.ledger.LedgerVO;
|
import com.ga.core.vo.ledger.LedgerVO;
|
||||||
import com.ga.core.vo.product.ContractResp;
|
import com.ga.core.vo.product.ContractResp;
|
||||||
import com.ga.core.vo.product.ContractSearchParam;
|
import com.ga.core.vo.product.ContractSearchParam;
|
||||||
|
import com.ga.core.vo.succession.ContractSuccessionVO;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.batch.core.StepContribution;
|
import org.springframework.batch.core.StepContribution;
|
||||||
@@ -20,7 +22,9 @@ import org.springframework.stereotype.Component;
|
|||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Step 5 — calcMaintain: 유지수수료 계산 (2회차~)
|
* Step 5 — calcMaintain: 유지수수료 계산 (2회차~)
|
||||||
@@ -37,6 +41,7 @@ public class CalcMaintainStep implements Tasklet {
|
|||||||
private final SettlementContext ctx;
|
private final SettlementContext ctx;
|
||||||
private final ContractMapper contractMapper;
|
private final ContractMapper contractMapper;
|
||||||
private final MaintainLedgerMapper maintainMapper;
|
private final MaintainLedgerMapper maintainMapper;
|
||||||
|
private final ContractSuccessionMapper successionMapper;
|
||||||
private final CommissionCalculator calculator;
|
private final CommissionCalculator calculator;
|
||||||
|
|
||||||
private static final int BATCH_SIZE = 1000;
|
private static final int BATCH_SIZE = 1000;
|
||||||
@@ -52,6 +57,15 @@ public class CalcMaintainStep implements Tasklet {
|
|||||||
|
|
||||||
maintainMapper.deleteBySettleMonth(ctx.getSettleMonth());
|
maintainMapper.deleteBySettleMonth(ctx.getSettleMonth());
|
||||||
|
|
||||||
|
// 계약 승계 반영: 정산월 기준 유효 승계(계약→후임설계사) 사전 로드 (N+1 회피)
|
||||||
|
Map<Long, Long> successorByContract = new HashMap<>();
|
||||||
|
for (ContractSuccessionVO s : successionMapper.selectEffectiveSuccessions(ctx.getSettleMonth())) {
|
||||||
|
successorByContract.put(s.getContractId(), s.getToAgentId());
|
||||||
|
}
|
||||||
|
if (!successorByContract.isEmpty()) {
|
||||||
|
log.info("[Step5] active successions applied: {}", successorByContract.size());
|
||||||
|
}
|
||||||
|
|
||||||
List<LedgerVO> batch = new ArrayList<>(BATCH_SIZE);
|
List<LedgerVO> batch = new ArrayList<>(BATCH_SIZE);
|
||||||
int total = 0;
|
int total = 0;
|
||||||
|
|
||||||
@@ -66,7 +80,9 @@ public class CalcMaintainStep implements Tasklet {
|
|||||||
// 납입주기 매칭 (월납이면 매월, 분기납이면 3개월마다 ...)
|
// 납입주기 매칭 (월납이면 매월, 분기납이면 3개월마다 ...)
|
||||||
if (!matchesPayCycle(c.getPayCycle(), monthsElapsed)) continue;
|
if (!matchesPayCycle(c.getPayCycle(), monthsElapsed)) continue;
|
||||||
|
|
||||||
LedgerVO led = calculator.calcMaintainLedger(c, ctx.getSettleMonth(), year);
|
// 승계된 계약이면 유지수수료 귀속을 후임 설계사로 (없으면 원 모집설계사)
|
||||||
|
Long effectiveAgentId = successorByContract.getOrDefault(c.getContractId(), c.getAgentId());
|
||||||
|
LedgerVO led = calculator.calcMaintainLedger(c, ctx.getSettleMonth(), year, effectiveAgentId);
|
||||||
if (led == null || MoneyUtil.isZero(led.getAgentAmount())) continue;
|
if (led == null || MoneyUtil.isZero(led.getAgentAmount())) continue;
|
||||||
batch.add(led);
|
batch.add(led);
|
||||||
if (batch.size() >= BATCH_SIZE) {
|
if (batch.size() >= BATCH_SIZE) {
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
-- V85: 외부 연동(펌뱅킹/SMS·카카오) 호출 감사 이력
|
||||||
|
--
|
||||||
|
-- 목적: BankTransferAdapter / MessageAdapter 모든 호출을 자동 영속화.
|
||||||
|
-- 운영 감사·장애 추적·외부 SDK 통합 검증에 사용. AOP(@Around)가 INSERT.
|
||||||
|
-- PII 는 요약 필드에 어댑터에서 이미 마스킹된 값만 들어간다 (계좌·전화 뒤 4자리만).
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS external_call_log (
|
||||||
|
log_id BIGSERIAL PRIMARY KEY,
|
||||||
|
call_type VARCHAR(20) NOT NULL, -- BANK / MESSAGE
|
||||||
|
adapter_class VARCHAR(200) NOT NULL, -- 실제 호출된 구현체 (Mock/Kftc/Toast)
|
||||||
|
method_name VARCHAR(50) NOT NULL, -- requestTransfer / queryStatus / send
|
||||||
|
target_ref_type VARCHAR(30), -- WITHDRAW / APPROVAL 등 (가능 시)
|
||||||
|
target_ref_id BIGINT, -- withdrawRequestId / refId 등
|
||||||
|
request_summary TEXT, -- 요청 요약 (마스킹된 JSON 한 줄)
|
||||||
|
response_summary TEXT, -- 응답 요약
|
||||||
|
result_code VARCHAR(20), -- 외부 응답 코드 (정상 시)
|
||||||
|
result_message TEXT,
|
||||||
|
success BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
duration_ms INTEGER, -- 호출 소요 시간 (ms)
|
||||||
|
error_class VARCHAR(200), -- 예외 발생 시 클래스명
|
||||||
|
error_message TEXT, -- 예외 메시지
|
||||||
|
called_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by BIGINT -- 시도자 (가능 시)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE external_call_log IS '외부 연동 호출 감사 이력 (AOP 자동 영속화)';
|
||||||
|
COMMENT ON COLUMN external_call_log.call_type IS 'BANK / MESSAGE';
|
||||||
|
COMMENT ON COLUMN external_call_log.adapter_class IS '실제 호출된 어댑터 클래스 (Mock/Kftc/Toast)';
|
||||||
|
COMMENT ON COLUMN external_call_log.method_name IS 'requestTransfer / queryStatus / send';
|
||||||
|
COMMENT ON COLUMN external_call_log.success IS 'true=정상응답, false=예외/실패';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ecl_called_at_desc
|
||||||
|
ON external_call_log(called_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ecl_type_called_at
|
||||||
|
ON external_call_log(call_type, called_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ecl_target
|
||||||
|
ON external_call_log(target_ref_type, target_ref_id)
|
||||||
|
WHERE target_ref_type IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ecl_failures
|
||||||
|
ON external_call_log(called_at DESC)
|
||||||
|
WHERE success = FALSE;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 메뉴 등록 (시스템 그룹 하위)
|
||||||
|
-- ============================================================
|
||||||
|
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)
|
||||||
|
SELECT
|
||||||
|
'SYSTEM_EXTERNAL_CALL',
|
||||||
|
'외부연동 호출이력',
|
||||||
|
'PAGE',
|
||||||
|
'/system/external-call-log',
|
||||||
|
'system/ExternalCallLogList',
|
||||||
|
'api',
|
||||||
|
2,
|
||||||
|
20,
|
||||||
|
'Y',
|
||||||
|
'Y',
|
||||||
|
(SELECT menu_id FROM menu WHERE menu_code = 'GRP_SYSTEM'),
|
||||||
|
'펌뱅킹/SMS·카카오 어댑터 호출 감사 이력 (AOP 자동 영속화)'
|
||||||
|
WHERE EXISTS (SELECT 1 FROM menu WHERE menu_code = 'GRP_SYSTEM')
|
||||||
|
ON CONFLICT (menu_code) DO NOTHING;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 권한 정의 (READ 만 — 감사 로그는 수동 등록 없음)
|
||||||
|
-- ============================================================
|
||||||
|
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
|
||||||
|
SELECT menu_id, 'READ', '조회', 1 FROM menu WHERE menu_code = 'SYSTEM_EXTERNAL_CALL'
|
||||||
|
ON CONFLICT (menu_id, perm_code) DO NOTHING;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 역할 부여 (SUPER_ADMIN / ADMIN)
|
||||||
|
-- ============================================================
|
||||||
|
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
|
||||||
|
JOIN menu_permission p ON p.menu_id = m.menu_id
|
||||||
|
WHERE r.role_code IN ('SUPER_ADMIN', 'ADMIN')
|
||||||
|
AND m.menu_code = 'SYSTEM_EXTERNAL_CALL'
|
||||||
|
AND p.perm_code = 'READ'
|
||||||
|
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
-- V86: 계약 승계 (contract_succession)
|
||||||
|
-- 설계사 해촉/이관 시 보험계약의 "유지수수료" 귀속 설계사를 후임에게 전환.
|
||||||
|
-- - 모집수수료(1회차)는 원 모집설계사 귀속이므로 승계 대상 아님.
|
||||||
|
-- - 유지수수료는 승계월(succession_date의 YYYYMM) 이후 정산월부터 후임 설계사에게 귀속.
|
||||||
|
-- - 후임 설계사는 본인 등급의 payout 비율로 수령(CommissionCalculator 가 effectiveAgentId 등급 조회).
|
||||||
|
-- - 한 계약에 시차 승계(A→B→C) 가능: 정산월 기준 가장 최근 ACTIVE 승계가 유효.
|
||||||
|
|
||||||
|
CREATE TABLE contract_succession (
|
||||||
|
succession_id BIGSERIAL PRIMARY KEY,
|
||||||
|
contract_id BIGINT NOT NULL REFERENCES contract(contract_id),
|
||||||
|
from_agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
|
||||||
|
to_agent_id BIGINT NOT NULL REFERENCES agent(agent_id),
|
||||||
|
succession_date DATE NOT NULL,
|
||||||
|
reason VARCHAR(20) NOT NULL DEFAULT 'TERMINATION',
|
||||||
|
memo VARCHAR(500),
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||||
|
created_by BIGINT,
|
||||||
|
updated_at TIMESTAMP,
|
||||||
|
updated_by BIGINT,
|
||||||
|
CHECK (reason IN ('TERMINATION','TRANSFER','REASSIGN')),
|
||||||
|
CHECK (status IN ('ACTIVE','CANCELED')),
|
||||||
|
CHECK (from_agent_id <> to_agent_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE contract_succession IS '계약 승계 — 유지수수료 귀속 설계사 전환 이력';
|
||||||
|
COMMENT ON COLUMN contract_succession.contract_id IS '대상 보험계약 FK';
|
||||||
|
COMMENT ON COLUMN contract_succession.from_agent_id IS '전임 설계사 (관두거나 이관한 설계사)';
|
||||||
|
COMMENT ON COLUMN contract_succession.to_agent_id IS '후임 설계사 (유지수수료를 이어받는 설계사)';
|
||||||
|
COMMENT ON COLUMN contract_succession.succession_date IS '승계 효력일 (이 날짜의 YYYYMM 이상 정산월부터 후임 귀속)';
|
||||||
|
COMMENT ON COLUMN contract_succession.reason IS 'TERMINATION=해촉 / TRANSFER=이관 / REASSIGN=재배정';
|
||||||
|
COMMENT ON COLUMN contract_succession.status IS 'ACTIVE=유효 / CANCELED=취소';
|
||||||
|
|
||||||
|
-- 배치 effective 조회용: contract_id + 효력일 역순
|
||||||
|
CREATE INDEX idx_cs_contract ON contract_succession(contract_id, status, succession_date DESC);
|
||||||
|
CREATE INDEX idx_cs_to_agent ON contract_succession(to_agent_id, status);
|
||||||
|
CREATE INDEX idx_cs_from_agent ON contract_succession(from_agent_id, status);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 메뉴 (GRP_PRD 하위, ENDORSE 옆)
|
||||||
|
-- ============================================================
|
||||||
|
INSERT INTO menu (parent_menu_id, menu_code, menu_name, menu_type, menu_path, component_path, menu_level, sort_order, is_visible, is_active)
|
||||||
|
SELECT p.menu_id, x.code, x.name, 'PAGE', x.path, x.cmpt, 2, x.sort, 'Y', 'Y'
|
||||||
|
FROM menu p
|
||||||
|
JOIN (VALUES
|
||||||
|
('CONTRACT_SUCCESSION', '계약 승계', '/contracts/successions', 'contracts/ContractSuccessions', 50)
|
||||||
|
) AS x(code, name, path, cmpt, sort) ON p.menu_code = 'GRP_PRD'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code);
|
||||||
|
|
||||||
|
-- 권한 항목 (READ/CREATE/UPDATE/DELETE)
|
||||||
|
INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order)
|
||||||
|
SELECT m.menu_id, p.code, p.name, p.sort
|
||||||
|
FROM menu m
|
||||||
|
CROSS JOIN (VALUES
|
||||||
|
('READ', '조회', 10),
|
||||||
|
('CREATE', '등록', 20),
|
||||||
|
('UPDATE', '취소', 30),
|
||||||
|
('DELETE', '삭제', 40)
|
||||||
|
) AS p(code, name, sort)
|
||||||
|
WHERE m.menu_code = 'CONTRACT_SUCCESSION'
|
||||||
|
ON CONFLICT (menu_id, perm_code) DO NOTHING;
|
||||||
|
|
||||||
|
-- 역할 부여: SUPER_ADMIN/ADMIN 전체
|
||||||
|
INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted)
|
||||||
|
SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y'
|
||||||
|
FROM role r
|
||||||
|
CROSS JOIN menu_permission mp
|
||||||
|
JOIN menu m ON m.menu_id = mp.menu_id
|
||||||
|
WHERE r.role_code IN ('SUPER_ADMIN', 'ADMIN')
|
||||||
|
AND m.menu_code = 'CONTRACT_SUCCESSION'
|
||||||
|
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, mp.menu_id, mp.perm_code, 'Y'
|
||||||
|
FROM role r
|
||||||
|
CROSS JOIN menu_permission mp
|
||||||
|
JOIN menu m ON m.menu_id = mp.menu_id
|
||||||
|
WHERE r.role_code = 'MANAGER'
|
||||||
|
AND mp.perm_code = 'READ'
|
||||||
|
AND m.menu_code = 'CONTRACT_SUCCESSION'
|
||||||
|
ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 공통코드
|
||||||
|
-- ============================================================
|
||||||
|
INSERT INTO common_code_group (group_code, group_name, description, is_system, sort_order) VALUES
|
||||||
|
('SUCCESSION_REASON', '승계사유', 'TERMINATION/TRANSFER/REASSIGN', 'Y', 470),
|
||||||
|
('SUCCESSION_STATUS', '승계상태', 'ACTIVE/CANCELED', 'Y', 480)
|
||||||
|
ON CONFLICT (group_code) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO common_code (group_code, code, code_name, sort_order, is_active) VALUES
|
||||||
|
('SUCCESSION_REASON', 'TERMINATION', '해촉', 10, 'Y'),
|
||||||
|
('SUCCESSION_REASON', 'TRANSFER', '이관', 20, 'Y'),
|
||||||
|
('SUCCESSION_REASON', 'REASSIGN', '재배정', 30, 'Y'),
|
||||||
|
('SUCCESSION_STATUS', 'ACTIVE', '유효', 10, 'Y'),
|
||||||
|
('SUCCESSION_STATUS', 'CANCELED', '취소', 20, 'Y')
|
||||||
|
ON CONFLICT (group_code, code) DO NOTHING;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- 테스트 데이터 (소량) — 테이블이 비어있고 활성 계약/타 설계사가 존재할 때만 1건
|
||||||
|
-- ============================================================
|
||||||
|
INSERT INTO contract_succession (contract_id, from_agent_id, to_agent_id, succession_date, reason, memo, status, created_at)
|
||||||
|
SELECT c.contract_id, c.agent_id, b.agent_id, DATE '2025-01-01', 'TERMINATION', '테스트 승계 데이터', 'ACTIVE', NOW()
|
||||||
|
FROM contract c
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
SELECT agent_id FROM agent WHERE agent_id <> c.agent_id AND status = 'ACTIVE' ORDER BY agent_id LIMIT 1
|
||||||
|
) b
|
||||||
|
WHERE c.status = 'ACTIVE'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM contract_succession)
|
||||||
|
ORDER BY c.contract_id
|
||||||
|
LIMIT 1;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.ga.core.mapper.external;
|
||||||
|
|
||||||
|
import com.ga.core.vo.external.ExternalCallLogResp;
|
||||||
|
import com.ga.core.vo.external.ExternalCallLogSearchParam;
|
||||||
|
import com.ga.core.vo.external.ExternalCallLogVO;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface ExternalCallLogMapper {
|
||||||
|
|
||||||
|
/** AOP @Around 영속화. selectKey 로 logId 채움. */
|
||||||
|
int insertOne(ExternalCallLogVO vo);
|
||||||
|
|
||||||
|
ExternalCallLogResp selectById(@Param("logId") Long logId);
|
||||||
|
|
||||||
|
List<ExternalCallLogResp> selectList(ExternalCallLogSearchParam param);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.ga.core.mapper.succession;
|
||||||
|
|
||||||
|
import com.ga.core.vo.succession.ContractSuccessionResp;
|
||||||
|
import com.ga.core.vo.succession.ContractSuccessionSearchParam;
|
||||||
|
import com.ga.core.vo.succession.ContractSuccessionVO;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface ContractSuccessionMapper {
|
||||||
|
ContractSuccessionResp selectById(@Param("successionId") Long successionId);
|
||||||
|
List<ContractSuccessionResp> selectList(ContractSuccessionSearchParam param);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배치 유지수수료 계산용 — 정산월 기준 유효한 승계의 (계약, 후임설계사) 매핑.
|
||||||
|
* 계약별로 succession_date 의 YYYYMM 이 정산월 이하인 가장 최근 ACTIVE 승계 1건만.
|
||||||
|
*/
|
||||||
|
List<ContractSuccessionVO> selectEffectiveSuccessions(@Param("settleMonth") String settleMonth);
|
||||||
|
|
||||||
|
int insert(ContractSuccessionVO vo);
|
||||||
|
int cancel(@Param("successionId") Long successionId, @Param("updatedBy") Long updatedBy);
|
||||||
|
int delete(@Param("successionId") Long successionId);
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ public interface UserMapper {
|
|||||||
int incrementFailCount(@Param("userId") Long userId);
|
int incrementFailCount(@Param("userId") Long userId);
|
||||||
int resetFailCount(@Param("userId") Long userId);
|
int resetFailCount(@Param("userId") Long userId);
|
||||||
int updateStatus(@Param("userId") Long userId, @Param("status") String status);
|
int updateStatus(@Param("userId") Long userId, @Param("status") String status);
|
||||||
|
int updatePhone(@Param("userId") Long userId, @Param("phone") String phone);
|
||||||
|
|
||||||
List<String> selectRoleCodes(@Param("userId") Long userId);
|
List<String> selectRoleCodes(@Param("userId") Long userId);
|
||||||
|
|
||||||
|
|||||||
@@ -33,4 +33,23 @@ public interface WithdrawRequestMapper {
|
|||||||
* account_no 는 복호화되어 반환.
|
* account_no 는 복호화되어 반환.
|
||||||
*/
|
*/
|
||||||
List<WithdrawRequestVO> selectFailedTransfers(@Param("limit") int limit);
|
List<WithdrawRequestVO> selectFailedTransfers(@Param("limit") int limit);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SETTLED 동기화 대상 — transfer_status='ACCEPTED' 이고 status='SENT'.
|
||||||
|
* queryStatus 폴링 결과로 입금 확정/실패를 판정한다.
|
||||||
|
*/
|
||||||
|
List<WithdrawRequestVO> selectAcceptedTransfers(@Param("limit") int limit);
|
||||||
|
|
||||||
|
/** queryStatus 가 SETTLED 응답: status='COMPLETED', transfer_status='SETTLED', completed_at=NOW(). */
|
||||||
|
int markSettled(@Param("requestId") Long requestId);
|
||||||
|
|
||||||
|
/** queryStatus 가 FAILED 응답: status='APPROVED'(재시도 풀로), transfer_status='FAILED', fail_reason. */
|
||||||
|
int markSettleFailed(@Param("requestId") Long requestId,
|
||||||
|
@Param("failReason") String failReason);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 해당 settle_master 에서 CANCELLED/FAILED 외 활성 출금 신청의 amount 합계.
|
||||||
|
* 출금 잔여 한도 검증 용도. null 가능(0건).
|
||||||
|
*/
|
||||||
|
java.math.BigDecimal sumActiveAmountByMaster(@Param("settleMasterId") Long settleMasterId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.ga.core.vo.external;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class ExternalCallLogResp {
|
||||||
|
private Long logId;
|
||||||
|
private String callType;
|
||||||
|
private String adapterClass;
|
||||||
|
private String methodName;
|
||||||
|
private String targetRefType;
|
||||||
|
private Long targetRefId;
|
||||||
|
private String requestSummary;
|
||||||
|
private String responseSummary;
|
||||||
|
private String resultCode;
|
||||||
|
private String resultMessage;
|
||||||
|
private Boolean success;
|
||||||
|
private Integer durationMs;
|
||||||
|
private String errorClass;
|
||||||
|
private String errorMessage;
|
||||||
|
private LocalDateTime calledAt;
|
||||||
|
private Long createdBy;
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package com.ga.core.vo.external;
|
||||||
|
|
||||||
|
import com.ga.common.model.SearchParam;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class ExternalCallLogSearchParam extends SearchParam {
|
||||||
|
/** BANK / MESSAGE */
|
||||||
|
private String callType;
|
||||||
|
private String methodName;
|
||||||
|
/** true 만 / false 만 (실패 조회) — null 이면 전체 */
|
||||||
|
private Boolean success;
|
||||||
|
private String targetRefType;
|
||||||
|
private Long targetRefId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.ga.core.vo.external;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* external_call_log — 외부 연동(펌뱅킹/SMS·카카오) 호출 감사 이력 (V85).
|
||||||
|
* AOP @Around 가 INSERT. 운영 감사·장애 추적용. PII 는 이미 어댑터에서 마스킹된 값만 들어간다.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ExternalCallLogVO {
|
||||||
|
private Long logId;
|
||||||
|
/** BANK / MESSAGE */
|
||||||
|
private String callType;
|
||||||
|
/** 실제 호출된 구현체 (Mock/Kftc/Toast) */
|
||||||
|
private String adapterClass;
|
||||||
|
/** requestTransfer / queryStatus / send */
|
||||||
|
private String methodName;
|
||||||
|
/** WITHDRAW / APPROVAL 등 (가능 시) */
|
||||||
|
private String targetRefType;
|
||||||
|
private Long targetRefId;
|
||||||
|
/** 요청 요약 (마스킹된 JSON 한 줄) */
|
||||||
|
private String requestSummary;
|
||||||
|
private String responseSummary;
|
||||||
|
private String resultCode;
|
||||||
|
private String resultMessage;
|
||||||
|
private Boolean success;
|
||||||
|
private Integer durationMs;
|
||||||
|
private String errorClass;
|
||||||
|
private String errorMessage;
|
||||||
|
private LocalDateTime calledAt;
|
||||||
|
private Long createdBy;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.ga.core.vo.succession;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class ContractSuccessionResp {
|
||||||
|
private Long successionId;
|
||||||
|
private Long contractId;
|
||||||
|
private String policyNo;
|
||||||
|
private Long fromAgentId;
|
||||||
|
private String fromAgentName;
|
||||||
|
private Long toAgentId;
|
||||||
|
private String toAgentName;
|
||||||
|
private LocalDate successionDate;
|
||||||
|
private String reason;
|
||||||
|
private String reasonName;
|
||||||
|
private String memo;
|
||||||
|
private String status;
|
||||||
|
private String statusName;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.ga.core.vo.succession;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class ContractSuccessionSaveReq {
|
||||||
|
@NotNull
|
||||||
|
private Long contractId;
|
||||||
|
@NotNull
|
||||||
|
private Long fromAgentId;
|
||||||
|
@NotNull
|
||||||
|
private Long toAgentId;
|
||||||
|
@NotNull
|
||||||
|
private LocalDate successionDate;
|
||||||
|
/** TERMINATION/TRANSFER/REASSIGN (기본 TERMINATION) */
|
||||||
|
private String reason;
|
||||||
|
private String memo;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.ga.core.vo.succession;
|
||||||
|
|
||||||
|
import com.ga.common.model.SearchParam;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class ContractSuccessionSearchParam extends SearchParam {
|
||||||
|
private Long contractId;
|
||||||
|
private Long fromAgentId;
|
||||||
|
private Long toAgentId;
|
||||||
|
private String reason;
|
||||||
|
private String status;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.ga.core.vo.succession;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* contract_succession — 계약 승계 (V86)
|
||||||
|
* 유지수수료 귀속 설계사를 승계일 기준으로 전임→후임 전환.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ContractSuccessionVO {
|
||||||
|
private Long successionId;
|
||||||
|
private Long contractId;
|
||||||
|
private Long fromAgentId;
|
||||||
|
private Long toAgentId;
|
||||||
|
private LocalDate successionDate;
|
||||||
|
/** SUCCESSION_REASON: TERMINATION/TRANSFER/REASSIGN */
|
||||||
|
private String reason;
|
||||||
|
private String memo;
|
||||||
|
/** SUCCESSION_STATUS: ACTIVE/CANCELED */
|
||||||
|
private String status;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private Long createdBy;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
private Long updatedBy;
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<?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.external.ExternalCallLogMapper">
|
||||||
|
|
||||||
|
<resultMap id="RespMap" type="com.ga.core.vo.external.ExternalCallLogResp">
|
||||||
|
<id property="logId" column="log_id"/>
|
||||||
|
<result property="callType" column="call_type"/>
|
||||||
|
<result property="adapterClass" column="adapter_class"/>
|
||||||
|
<result property="methodName" column="method_name"/>
|
||||||
|
<result property="targetRefType" column="target_ref_type"/>
|
||||||
|
<result property="targetRefId" column="target_ref_id"/>
|
||||||
|
<result property="requestSummary" column="request_summary"/>
|
||||||
|
<result property="responseSummary" column="response_summary"/>
|
||||||
|
<result property="resultCode" column="result_code"/>
|
||||||
|
<result property="resultMessage" column="result_message"/>
|
||||||
|
<result property="success" column="success"/>
|
||||||
|
<result property="durationMs" column="duration_ms"/>
|
||||||
|
<result property="errorClass" column="error_class"/>
|
||||||
|
<result property="errorMessage" column="error_message"/>
|
||||||
|
<result property="calledAt" column="called_at"/>
|
||||||
|
<result property="createdBy" column="created_by"/>
|
||||||
|
</resultMap>
|
||||||
|
|
||||||
|
<sql id="cols">
|
||||||
|
log_id, call_type, adapter_class, method_name,
|
||||||
|
target_ref_type, target_ref_id,
|
||||||
|
request_summary, response_summary,
|
||||||
|
result_code, result_message, success, duration_ms,
|
||||||
|
error_class, error_message, called_at, created_by
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<insert id="insertOne" parameterType="com.ga.core.vo.external.ExternalCallLogVO"
|
||||||
|
useGeneratedKeys="true" keyProperty="logId" keyColumn="log_id">
|
||||||
|
INSERT INTO external_call_log (
|
||||||
|
call_type, adapter_class, method_name,
|
||||||
|
target_ref_type, target_ref_id,
|
||||||
|
request_summary, response_summary,
|
||||||
|
result_code, result_message, success, duration_ms,
|
||||||
|
error_class, error_message, called_at, created_by
|
||||||
|
) VALUES (
|
||||||
|
#{callType},
|
||||||
|
#{adapterClass},
|
||||||
|
#{methodName},
|
||||||
|
#{targetRefType, jdbcType=VARCHAR},
|
||||||
|
#{targetRefId, jdbcType=BIGINT},
|
||||||
|
#{requestSummary, jdbcType=LONGVARCHAR},
|
||||||
|
#{responseSummary, jdbcType=LONGVARCHAR},
|
||||||
|
#{resultCode, jdbcType=VARCHAR},
|
||||||
|
#{resultMessage, jdbcType=LONGVARCHAR},
|
||||||
|
#{success, jdbcType=BOOLEAN},
|
||||||
|
#{durationMs, jdbcType=INTEGER},
|
||||||
|
#{errorClass, jdbcType=VARCHAR},
|
||||||
|
#{errorMessage, jdbcType=LONGVARCHAR},
|
||||||
|
COALESCE(#{calledAt,jdbcType=TIMESTAMP}, CURRENT_TIMESTAMP),
|
||||||
|
#{createdBy, jdbcType=BIGINT}
|
||||||
|
)
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<select id="selectById" resultMap="RespMap">
|
||||||
|
SELECT <include refid="cols"/>
|
||||||
|
FROM external_call_log
|
||||||
|
WHERE log_id = #{logId}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="selectList" resultMap="RespMap"
|
||||||
|
parameterType="com.ga.core.vo.external.ExternalCallLogSearchParam">
|
||||||
|
SELECT <include refid="cols"/>
|
||||||
|
FROM external_call_log
|
||||||
|
<where>
|
||||||
|
<if test="callType != null and callType != ''">
|
||||||
|
AND call_type = #{callType}
|
||||||
|
</if>
|
||||||
|
<if test="methodName != null and methodName != ''">
|
||||||
|
AND method_name = #{methodName}
|
||||||
|
</if>
|
||||||
|
<if test="success != null">
|
||||||
|
AND success = #{success}
|
||||||
|
</if>
|
||||||
|
<if test="targetRefType != null and targetRefType != ''">
|
||||||
|
AND target_ref_type = #{targetRefType}
|
||||||
|
</if>
|
||||||
|
<if test="targetRefId != null">
|
||||||
|
AND target_ref_id = #{targetRefId}
|
||||||
|
</if>
|
||||||
|
<if test="startDate != null and startDate != ''">
|
||||||
|
AND called_at >= (#{startDate}::date)
|
||||||
|
</if>
|
||||||
|
<if test="endDate != null and endDate != ''">
|
||||||
|
AND called_at < (#{endDate}::date + INTERVAL '1 day')
|
||||||
|
</if>
|
||||||
|
</where>
|
||||||
|
ORDER BY called_at DESC, log_id DESC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -150,4 +150,13 @@
|
|||||||
WHERE deduction_id = #{deductionId}
|
WHERE deduction_id = #{deductionId}
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
|
<update id="updateStatusBatch">
|
||||||
|
UPDATE agent_deduction
|
||||||
|
SET status = #{status}, updated_at = NOW()
|
||||||
|
WHERE deduction_id IN
|
||||||
|
<foreach item="id" collection="deductionIds" open="(" separator="," close=")">
|
||||||
|
#{id}
|
||||||
|
</foreach>
|
||||||
|
</update>
|
||||||
|
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?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.succession.ContractSuccessionMapper">
|
||||||
|
|
||||||
|
<resultMap id="VOMap" type="com.ga.core.vo.succession.ContractSuccessionVO"/>
|
||||||
|
<resultMap id="RespMap" type="com.ga.core.vo.succession.ContractSuccessionResp"/>
|
||||||
|
|
||||||
|
<sql id="joinCols">
|
||||||
|
cs.succession_id, cs.contract_id, ct.policy_no,
|
||||||
|
cs.from_agent_id, fa.agent_name AS from_agent_name,
|
||||||
|
cs.to_agent_id, ta.agent_name AS to_agent_name,
|
||||||
|
cs.succession_date,
|
||||||
|
cs.reason,
|
||||||
|
(SELECT cc.code_name FROM common_code cc
|
||||||
|
WHERE cc.group_code = 'SUCCESSION_REASON' AND cc.code = cs.reason) AS reason_name,
|
||||||
|
cs.memo,
|
||||||
|
cs.status,
|
||||||
|
(SELECT cc.code_name FROM common_code cc
|
||||||
|
WHERE cc.group_code = 'SUCCESSION_STATUS' AND cc.code = cs.status) AS status_name,
|
||||||
|
cs.created_at
|
||||||
|
</sql>
|
||||||
|
|
||||||
|
<select id="selectById" resultMap="RespMap">
|
||||||
|
SELECT <include refid="joinCols"/>
|
||||||
|
FROM contract_succession cs
|
||||||
|
LEFT JOIN contract ct ON ct.contract_id = cs.contract_id
|
||||||
|
LEFT JOIN agent fa ON fa.agent_id = cs.from_agent_id
|
||||||
|
LEFT JOIN agent ta ON ta.agent_id = cs.to_agent_id
|
||||||
|
WHERE cs.succession_id = #{successionId}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="selectList" resultMap="RespMap">
|
||||||
|
SELECT <include refid="joinCols"/>
|
||||||
|
FROM contract_succession cs
|
||||||
|
LEFT JOIN contract ct ON ct.contract_id = cs.contract_id
|
||||||
|
LEFT JOIN agent fa ON fa.agent_id = cs.from_agent_id
|
||||||
|
LEFT JOIN agent ta ON ta.agent_id = cs.to_agent_id
|
||||||
|
<where>
|
||||||
|
<if test="contractId != null">AND cs.contract_id = #{contractId}</if>
|
||||||
|
<if test="fromAgentId != null">AND cs.from_agent_id = #{fromAgentId}</if>
|
||||||
|
<if test="toAgentId != null">AND cs.to_agent_id = #{toAgentId}</if>
|
||||||
|
<if test="reason != null and reason != ''">AND cs.reason = #{reason}</if>
|
||||||
|
<if test="status != null and status != ''">AND cs.status = #{status}</if>
|
||||||
|
</where>
|
||||||
|
ORDER BY cs.succession_date DESC, cs.succession_id DESC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 정산월 기준 계약별 유효 승계 (가장 최근 ACTIVE 1건) -->
|
||||||
|
<select id="selectEffectiveSuccessions" resultMap="VOMap">
|
||||||
|
SELECT DISTINCT ON (cs.contract_id)
|
||||||
|
cs.contract_id, cs.to_agent_id, cs.from_agent_id, cs.succession_date, cs.status
|
||||||
|
FROM contract_succession cs
|
||||||
|
WHERE cs.status = 'ACTIVE'
|
||||||
|
AND TO_CHAR(cs.succession_date, 'YYYYMM') <= #{settleMonth}
|
||||||
|
ORDER BY cs.contract_id, cs.succession_date DESC, cs.succession_id DESC
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<insert id="insert" useGeneratedKeys="true" keyProperty="successionId">
|
||||||
|
INSERT INTO contract_succession
|
||||||
|
(contract_id, from_agent_id, to_agent_id, succession_date, reason, memo, status, created_by)
|
||||||
|
VALUES
|
||||||
|
(#{contractId}, #{fromAgentId}, #{toAgentId}, #{successionDate},
|
||||||
|
COALESCE(#{reason}, 'TERMINATION'), #{memo}, 'ACTIVE', #{createdBy})
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<update id="cancel">
|
||||||
|
UPDATE contract_succession
|
||||||
|
SET status = 'CANCELED',
|
||||||
|
updated_at = NOW(),
|
||||||
|
updated_by = #{updatedBy}
|
||||||
|
WHERE succession_id = #{successionId}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<delete id="delete">
|
||||||
|
DELETE FROM contract_succession WHERE succession_id = #{successionId}
|
||||||
|
</delete>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -110,6 +110,10 @@
|
|||||||
UPDATE users SET status = #{status} WHERE user_id = #{userId}
|
UPDATE users SET status = #{status} WHERE user_id = #{userId}
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
|
<update id="updatePhone">
|
||||||
|
UPDATE users SET phone = #{phone,jdbcType=VARCHAR} WHERE user_id = #{userId}
|
||||||
|
</update>
|
||||||
|
|
||||||
<select id="selectRoleCodes" resultType="string">
|
<select id="selectRoleCodes" resultType="string">
|
||||||
SELECT r.role_code FROM role r
|
SELECT r.role_code FROM role r
|
||||||
JOIN user_role ur ON ur.role_id = r.role_id
|
JOIN user_role ur ON ur.role_id = r.role_id
|
||||||
|
|||||||
@@ -176,4 +176,51 @@
|
|||||||
WHERE request_id = #{requestId}
|
WHERE request_id = #{requestId}
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
|
<!-- SETTLED 동기화 대상 — 펌뱅킹 어댑터로 보냈고(SENT) 외부 응답이 접수만(ACCEPTED) 된 건 -->
|
||||||
|
<select id="selectAcceptedTransfers" resultMap="VOMap">
|
||||||
|
SELECT wr.request_id, wr.settle_master_id, wr.agent_id, wr.amount,
|
||||||
|
wr.requested_by, wr.requested_at, wr.approval_request_id,
|
||||||
|
wr.bank_code, wr.account_no, wr.status,
|
||||||
|
wr.sent_at, wr.completed_at, wr.fail_reason,
|
||||||
|
wr.transfer_tx_id, wr.transfer_status, wr.transfer_at,
|
||||||
|
wr.created_at, wr.updated_at
|
||||||
|
FROM withdraw_request wr
|
||||||
|
WHERE wr.transfer_status = 'ACCEPTED'
|
||||||
|
AND wr.status = 'SENT'
|
||||||
|
AND wr.transfer_tx_id IS NOT NULL
|
||||||
|
ORDER BY wr.transfer_at NULLS FIRST, wr.request_id
|
||||||
|
LIMIT #{limit}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 입금 확정 — SETTLED -->
|
||||||
|
<update id="markSettled">
|
||||||
|
UPDATE withdraw_request
|
||||||
|
SET transfer_status = 'SETTLED',
|
||||||
|
status = 'COMPLETED',
|
||||||
|
completed_at = NOW(),
|
||||||
|
fail_reason = NULL,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE request_id = #{requestId}
|
||||||
|
AND status = 'SENT'
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<!-- 입금 실패 동기화 — 재시도 풀(APPROVED + FAILED) 로 회귀 -->
|
||||||
|
<update id="markSettleFailed">
|
||||||
|
UPDATE withdraw_request
|
||||||
|
SET transfer_status = 'FAILED',
|
||||||
|
status = 'APPROVED',
|
||||||
|
fail_reason = #{failReason,jdbcType=VARCHAR},
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE request_id = #{requestId}
|
||||||
|
AND status = 'SENT'
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<!-- 잔여 한도 검증용: 특정 settle_master 의 활성(CANCELLED 외) 출금 amount 합계 -->
|
||||||
|
<select id="sumActiveAmountByMaster" resultType="java.math.BigDecimal">
|
||||||
|
SELECT COALESCE(SUM(amount), 0)
|
||||||
|
FROM withdraw_request
|
||||||
|
WHERE settle_master_id = #{settleMasterId}
|
||||||
|
AND status != 'CANCELLED'
|
||||||
|
</select>
|
||||||
|
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import AgentTrainings from '@/pages/agent/AgentTrainings';
|
|||||||
|
|
||||||
// ── P4: 계약 운영 ────────────────────────────────────
|
// ── P4: 계약 운영 ────────────────────────────────────
|
||||||
import ContractEndorsements from '@/pages/contracts/ContractEndorsements';
|
import ContractEndorsements from '@/pages/contracts/ContractEndorsements';
|
||||||
|
import ContractSuccessions from '@/pages/contracts/ContractSuccessions';
|
||||||
import Complaints from '@/pages/contracts/Complaints';
|
import Complaints from '@/pages/contracts/Complaints';
|
||||||
|
|
||||||
// ── P4: 시스템 ──────────────────────────────────────
|
// ── P4: 시스템 ──────────────────────────────────────
|
||||||
@@ -94,6 +95,7 @@ import SystemLog from '@/pages/system/SystemLog';
|
|||||||
import UploadTemplateManager from '@/pages/system/UploadTemplateManager';
|
import UploadTemplateManager from '@/pages/system/UploadTemplateManager';
|
||||||
import DataDomainList from '@/pages/system/DataDomainList';
|
import DataDomainList from '@/pages/system/DataDomainList';
|
||||||
import DataDictionary from '@/pages/system/DataDictionary';
|
import DataDictionary from '@/pages/system/DataDictionary';
|
||||||
|
import ExternalCallLogList from '@/pages/system/ExternalCallLogList';
|
||||||
|
|
||||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
const token = localStorage.getItem('accessToken');
|
const token = localStorage.getItem('accessToken');
|
||||||
@@ -190,6 +192,7 @@ export default function App() {
|
|||||||
|
|
||||||
{/* P4: 계약 운영 */}
|
{/* P4: 계약 운영 */}
|
||||||
<Route path="contracts/endorsements" element={<ContractEndorsements />} />
|
<Route path="contracts/endorsements" element={<ContractEndorsements />} />
|
||||||
|
<Route path="contracts/successions" element={<ContractSuccessions />} />
|
||||||
<Route path="contracts/complaints" element={<Complaints />} />
|
<Route path="contracts/complaints" element={<Complaints />} />
|
||||||
|
|
||||||
{/* P4: 시스템 */}
|
{/* P4: 시스템 */}
|
||||||
@@ -209,6 +212,7 @@ export default function App() {
|
|||||||
<Route path="system/upload-templates" element={<UploadTemplateManager />} />
|
<Route path="system/upload-templates" element={<UploadTemplateManager />} />
|
||||||
<Route path="system/data-domains" element={<DataDomainList />} />
|
<Route path="system/data-domains" element={<DataDomainList />} />
|
||||||
<Route path="system/data-dictionary" element={<DataDictionary />} />
|
<Route path="system/data-dictionary" element={<DataDictionary />} />
|
||||||
|
<Route path="system/external-call-log" element={<ExternalCallLogList />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import api, { unwrap } from './request';
|
||||||
|
|
||||||
|
export interface ExternalCallLogResp extends Record<string, unknown> {
|
||||||
|
logId: number;
|
||||||
|
callType: string; // BANK / MESSAGE
|
||||||
|
adapterClass: string; // Mock / Kftc / Toast
|
||||||
|
methodName: string; // requestTransfer / queryStatus / send
|
||||||
|
targetRefType?: string;
|
||||||
|
targetRefId?: number;
|
||||||
|
requestSummary?: string;
|
||||||
|
responseSummary?: string;
|
||||||
|
resultCode?: string;
|
||||||
|
resultMessage?: string;
|
||||||
|
success: boolean;
|
||||||
|
durationMs?: number;
|
||||||
|
errorClass?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
calledAt: string;
|
||||||
|
createdBy?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExternalCallLogSearchParam {
|
||||||
|
callType?: string;
|
||||||
|
methodName?: string;
|
||||||
|
success?: boolean;
|
||||||
|
targetRefType?: string;
|
||||||
|
targetRefId?: number;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
pageNum?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const externalCallLogApi = {
|
||||||
|
list: (params?: ExternalCallLogSearchParam) =>
|
||||||
|
unwrap<{ list: ExternalCallLogResp[]; total: number }>(
|
||||||
|
api.get('/api/external-call-logs', { params })
|
||||||
|
),
|
||||||
|
detail: (id: number) =>
|
||||||
|
unwrap<ExternalCallLogResp>(api.get(`/api/external-call-logs/${id}`)),
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import api, { PageResponse, unwrap } from './request';
|
||||||
|
|
||||||
|
export interface ContractSuccessionRow extends Record<string, unknown> {
|
||||||
|
successionId: number;
|
||||||
|
contractId: number;
|
||||||
|
policyNo: string;
|
||||||
|
fromAgentId: number;
|
||||||
|
fromAgentName: string;
|
||||||
|
toAgentId: number;
|
||||||
|
toAgentName: string;
|
||||||
|
successionDate: string;
|
||||||
|
reason: string; // TERMINATION / TRANSFER / REASSIGN
|
||||||
|
reasonName: string;
|
||||||
|
memo?: string;
|
||||||
|
status: string; // ACTIVE / CANCELED
|
||||||
|
statusName: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContractSuccessionSaveReq {
|
||||||
|
contractId: number;
|
||||||
|
fromAgentId: number;
|
||||||
|
toAgentId: number;
|
||||||
|
successionDate: string;
|
||||||
|
reason?: string;
|
||||||
|
memo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuccessionSearchParam {
|
||||||
|
contractId?: number;
|
||||||
|
fromAgentId?: number;
|
||||||
|
toAgentId?: number;
|
||||||
|
reason?: string;
|
||||||
|
status?: string;
|
||||||
|
pageNum?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const successionApi = {
|
||||||
|
list: (p: SuccessionSearchParam) =>
|
||||||
|
unwrap<PageResponse<ContractSuccessionRow>>(
|
||||||
|
api.get('/api/contract-successions', { params: p }),
|
||||||
|
),
|
||||||
|
create: (body: ContractSuccessionSaveReq) =>
|
||||||
|
unwrap<number>(api.post('/api/contract-successions', body)),
|
||||||
|
cancel: (id: number) =>
|
||||||
|
unwrap<void>(api.post(`/api/contract-successions/${id}/cancel`)),
|
||||||
|
delete: (id: number) =>
|
||||||
|
unwrap<void>(api.delete(`/api/contract-successions/${id}`)),
|
||||||
|
};
|
||||||
@@ -35,6 +35,8 @@ export const userApi = {
|
|||||||
update: (id: number, req: UserSaveReq) => unwrap<void>(api.put(`/api/system/users/${id}`, req)),
|
update: (id: number, req: UserSaveReq) => unwrap<void>(api.put(`/api/system/users/${id}`, req)),
|
||||||
resetPassword: (id: number) => unwrap<void>(api.put(`/api/system/users/${id}/reset-password`)),
|
resetPassword: (id: number) => unwrap<void>(api.put(`/api/system/users/${id}/reset-password`)),
|
||||||
unlock: (id: number) => unwrap<void>(api.put(`/api/system/users/${id}/unlock`)),
|
unlock: (id: number) => unwrap<void>(api.put(`/api/system/users/${id}/unlock`)),
|
||||||
|
updatePhone: (id: number, phone: string) =>
|
||||||
|
unwrap<void>(api.put(`/api/system/users/${id}/phone`, { phone })),
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface RoleVO {
|
export interface RoleVO {
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert, Form, Input, InputNumber, Modal, Select, Tag, message } from 'antd';
|
||||||
|
import { ProCard } from '@ant-design/pro-components';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { ColDef } from '@ag-grid-community/core';
|
||||||
|
import PageContainer from '@/components/common/PageContainer';
|
||||||
|
import SearchForm from '@/components/common/SearchForm';
|
||||||
|
import DataGrid from '@/components/common/DataGrid';
|
||||||
|
import PermissionButton from '@/components/common/PermissionButton';
|
||||||
|
import CodeBadge from '@/components/common/CodeBadge';
|
||||||
|
import { successionApi, ContractSuccessionRow, ContractSuccessionSaveReq, SuccessionSearchParam } from '@/api/succession';
|
||||||
|
import { GRAY, RADIUS, SHADOW } from '@/theme/tokens';
|
||||||
|
|
||||||
|
const COLUMNS: ColDef<ContractSuccessionRow>[] = [
|
||||||
|
{ field: 'policyNo', headerName: '증권번호', width: 150, pinned: 'left' },
|
||||||
|
{ field: 'fromAgentName', headerName: '전임 설계사', width: 130 },
|
||||||
|
{ field: 'toAgentName', headerName: '후임 설계사', width: 130 },
|
||||||
|
{ field: 'successionDate', headerName: '승계일', width: 120 },
|
||||||
|
{
|
||||||
|
field: 'reason', headerName: '사유', width: 110,
|
||||||
|
cellRenderer: (p: { value: string }) => <CodeBadge groupCode="SUCCESSION_REASON" value={p.value} />,
|
||||||
|
},
|
||||||
|
{ field: 'memo', headerName: '메모', flex: 1 },
|
||||||
|
{
|
||||||
|
field: 'status', headerName: '상태', width: 100,
|
||||||
|
cellRenderer: (p: { value: string }) =>
|
||||||
|
p.value === 'CANCELED'
|
||||||
|
? <Tag color="default" style={{ margin: 0 }}>취소</Tag>
|
||||||
|
: <Tag color="green" style={{ margin: 0, fontWeight: 700 }}>유효</Tag>,
|
||||||
|
},
|
||||||
|
{ field: 'createdAt', headerName: '등록일', width: 120 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ContractSuccessions() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [params, setParams] = useState<SuccessionSearchParam>({ pageSize: 100 });
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['successions', params],
|
||||||
|
queryFn: () => successionApi.list(params),
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = data?.list ?? [];
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const values = await form.validateFields() as ContractSuccessionSaveReq;
|
||||||
|
try {
|
||||||
|
await successionApi.create(values);
|
||||||
|
message.success('계약 승계 등록 완료');
|
||||||
|
qc.invalidateQueries({ queryKey: ['successions'] });
|
||||||
|
setCreateOpen(false);
|
||||||
|
form.resetFields();
|
||||||
|
} catch { /* handled by interceptor */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = (id: number) => Modal.confirm({
|
||||||
|
title: '승계 취소',
|
||||||
|
content: '이 승계를 취소합니다. 이후 정산월부터 유지수수료는 다시 직전 귀속 설계사에게 계산됩니다.',
|
||||||
|
okText: '취소 처리',
|
||||||
|
cancelText: '닫기',
|
||||||
|
onOk: async () => {
|
||||||
|
await successionApi.cancel(id);
|
||||||
|
message.success('승계 취소 완료');
|
||||||
|
qc.invalidateQueries({ queryKey: ['successions'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer
|
||||||
|
title="계약 승계 관리"
|
||||||
|
description="설계사 해촉·이관 시 보험계약의 유지수수료 귀속 설계사를 후임으로 전환 (모집수수료는 원 설계사 귀속 유지)"
|
||||||
|
extra={
|
||||||
|
<PermissionButton menuCode="CONTRACT_SUCCESSION" permCode="CREATE" type="primary" onClick={() => setCreateOpen(true)}>
|
||||||
|
+ 승계 등록
|
||||||
|
</PermissionButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isError && (
|
||||||
|
<Alert type="warning" showIcon message="API 미응답" description="/api/contract-successions 를 확인하세요." style={{ marginBottom: 16 }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SearchForm
|
||||||
|
conditions={[
|
||||||
|
{ type: 'code', name: 'reason', label: '사유', groupCode: 'SUCCESSION_REASON', span: 6 },
|
||||||
|
{ type: 'code', name: 'status', label: '상태', groupCode: 'SUCCESSION_STATUS', span: 6 },
|
||||||
|
]}
|
||||||
|
onSearch={(v) => setParams({ ...v as SuccessionSearchParam, pageSize: 100 })}
|
||||||
|
onReset={() => setParams({ pageSize: 100 })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProCard style={{ background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }}>
|
||||||
|
<DataGrid<ContractSuccessionRow>
|
||||||
|
rows={rows}
|
||||||
|
columns={[
|
||||||
|
...COLUMNS,
|
||||||
|
{
|
||||||
|
headerName: '액션', width: 110, pinned: 'right',
|
||||||
|
cellRenderer: (p: { data: ContractSuccessionRow }) => (
|
||||||
|
<PermissionButton
|
||||||
|
menuCode="CONTRACT_SUCCESSION" permCode="UPDATE" size="small" danger
|
||||||
|
disabled={p.data.status === 'CANCELED'}
|
||||||
|
onClick={() => handleCancel(p.data.successionId)}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</PermissionButton>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
loading={isLoading}
|
||||||
|
height={600}
|
||||||
|
rowKey="successionId"
|
||||||
|
/>
|
||||||
|
</ProCard>
|
||||||
|
|
||||||
|
<Modal title="계약 승계 등록" open={createOpen} onOk={handleCreate} onCancel={() => setCreateOpen(false)} okText="등록" cancelText="취소">
|
||||||
|
<Form form={form} layout="vertical" initialValues={{ reason: 'TERMINATION' }}>
|
||||||
|
<Form.Item name="contractId" label="계약 ID" rules={[{ required: true }]}>
|
||||||
|
<InputNumber style={{ width: '100%' }} placeholder="contract_id" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="fromAgentId" label="전임 설계사 ID" rules={[{ required: true }]}>
|
||||||
|
<InputNumber style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="toAgentId" label="후임 설계사 ID" rules={[{ required: true }]}>
|
||||||
|
<InputNumber style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="successionDate" label="승계일" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="YYYY-MM-DD" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="reason" label="사유" rules={[{ required: true }]}>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: 'TERMINATION', label: '해촉' },
|
||||||
|
{ value: 'TRANSFER', label: '이관' },
|
||||||
|
{ value: 'REASSIGN', label: '재배정' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="memo" label="메모">
|
||||||
|
<Input.TextArea rows={3} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Tag, Drawer, Descriptions } from 'antd';
|
||||||
|
import { ProTable, type ProColumns } from '@ant-design/pro-components';
|
||||||
|
import PageContainer from '@/components/common/PageContainer';
|
||||||
|
import { externalCallLogApi, ExternalCallLogResp } from '@/api/externalCallLog';
|
||||||
|
|
||||||
|
export default function ExternalCallLogList() {
|
||||||
|
const [detail, setDetail] = useState<ExternalCallLogResp | null>(null);
|
||||||
|
|
||||||
|
const columns: ProColumns<ExternalCallLogResp>[] = [
|
||||||
|
{ title: '호출시각', dataIndex: 'calledAt', width: 170, valueType: 'dateTime' },
|
||||||
|
{
|
||||||
|
title: '종류', dataIndex: 'callType', width: 90, valueType: 'select',
|
||||||
|
valueEnum: { BANK: { text: '펌뱅킹' }, MESSAGE: { text: 'SMS/카카오' } },
|
||||||
|
render: (_, r) => (
|
||||||
|
<Tag color={r.callType === 'BANK' ? 'geekblue' : 'magenta'}>{r.callType}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '구현체', dataIndex: 'adapterClass', width: 200, search: false, ellipsis: true },
|
||||||
|
{ title: '메서드', dataIndex: 'methodName', width: 140 },
|
||||||
|
{
|
||||||
|
title: '대상', dataIndex: 'targetRefType', width: 160, search: false,
|
||||||
|
render: (_, r) =>
|
||||||
|
r.targetRefType ? `${r.targetRefType}#${r.targetRefId ?? ''}` : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '결과', dataIndex: 'success', width: 80,
|
||||||
|
valueType: 'select',
|
||||||
|
valueEnum: { true: { text: '성공' }, false: { text: '실패' } },
|
||||||
|
render: (_, r) => (
|
||||||
|
<Tag color={r.success ? 'success' : 'error'}>{r.success ? 'SUCCESS' : 'FAIL'}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '코드', dataIndex: 'resultCode', width: 90, search: false },
|
||||||
|
{
|
||||||
|
title: '소요(ms)', dataIndex: 'durationMs', width: 90, align: 'right', search: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '메시지', dataIndex: 'resultMessage', ellipsis: true, search: false,
|
||||||
|
render: (_, r) => r.errorMessage ?? r.resultMessage ?? '-',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer
|
||||||
|
title="외부연동 호출이력"
|
||||||
|
description="펌뱅킹·SMS·카카오 어댑터 호출 감사 (AOP 자동 영속화)"
|
||||||
|
>
|
||||||
|
<ProTable<ExternalCallLogResp>
|
||||||
|
rowKey="logId"
|
||||||
|
columns={columns}
|
||||||
|
dateFormatter="string"
|
||||||
|
scroll={{ x: 1300 }}
|
||||||
|
request={async (params) => {
|
||||||
|
const { current, pageSize, ...rest } = params as any;
|
||||||
|
const res = await externalCallLogApi.list({
|
||||||
|
...rest, pageNum: current, pageSize,
|
||||||
|
});
|
||||||
|
return { data: res.list, total: res.total, success: true };
|
||||||
|
}}
|
||||||
|
onRow={(record) => ({ onClick: () => setDetail(record), style: { cursor: 'pointer' } })}
|
||||||
|
pagination={{ pageSize: 50, showTotal: (t) => `총 ${t.toLocaleString()}건` }}
|
||||||
|
search={{
|
||||||
|
labelWidth: 'auto', searchText: '검색', resetText: '초기화',
|
||||||
|
collapsed: false, collapseRender: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
title={`호출 #${detail?.logId ?? ''}`}
|
||||||
|
width={640}
|
||||||
|
open={!!detail}
|
||||||
|
onClose={() => setDetail(null)}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
{detail && (
|
||||||
|
<Descriptions column={1} size="small" bordered>
|
||||||
|
<Descriptions.Item label="호출시각">{detail.calledAt}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="종류">{detail.callType}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="구현체">{detail.adapterClass}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="메서드">{detail.methodName}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="대상">
|
||||||
|
{detail.targetRefType ? `${detail.targetRefType}#${detail.targetRefId ?? ''}` : '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="결과">
|
||||||
|
<Tag color={detail.success ? 'success' : 'error'}>
|
||||||
|
{detail.success ? 'SUCCESS' : 'FAIL'}
|
||||||
|
</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="코드">{detail.resultCode ?? '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="메시지">{detail.resultMessage ?? '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="소요(ms)">{detail.durationMs ?? '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="요청">
|
||||||
|
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||||
|
{detail.requestSummary ?? '-'}
|
||||||
|
</pre>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="응답">
|
||||||
|
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||||
|
{detail.responseSummary ?? '-'}
|
||||||
|
</pre>
|
||||||
|
</Descriptions.Item>
|
||||||
|
{detail.errorClass && (
|
||||||
|
<Descriptions.Item label="예외">
|
||||||
|
{detail.errorClass}: {detail.errorMessage ?? ''}
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { Modal, Space, message } from 'antd';
|
import { Input, Modal, Space, message } from 'antd';
|
||||||
import { ProTable, type ActionType, type ProColumns } from '@ant-design/pro-components';
|
import { ProTable, type ActionType, type ProColumns } from '@ant-design/pro-components';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import PageContainer from '@/components/common/PageContainer';
|
import PageContainer from '@/components/common/PageContainer';
|
||||||
@@ -31,6 +31,15 @@ export default function UserList() {
|
|||||||
onOk: async () => { await userApi.unlock(id); message.success('해제 완료'); refresh(); },
|
onOk: async () => { await userApi.unlock(id); message.success('해제 완료'); refresh(); },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [phoneEdit, setPhoneEdit] = useState<{ id: number; loginId: string; phone: string } | null>(null);
|
||||||
|
const submitPhone = async () => {
|
||||||
|
if (!phoneEdit) return;
|
||||||
|
await userApi.updatePhone(phoneEdit.id, phoneEdit.phone);
|
||||||
|
message.success('전화번호 저장 완료');
|
||||||
|
setPhoneEdit(null);
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
|
||||||
const columns: ProColumns<UserResp>[] = [
|
const columns: ProColumns<UserResp>[] = [
|
||||||
{
|
{
|
||||||
title: '검색어', dataIndex: 'searchKeyword', hideInTable: true,
|
title: '검색어', dataIndex: 'searchKeyword', hideInTable: true,
|
||||||
@@ -44,14 +53,20 @@ export default function UserList() {
|
|||||||
{ title: '로그인 ID', dataIndex: 'loginId', width: 120, search: false },
|
{ title: '로그인 ID', dataIndex: 'loginId', width: 120, search: false },
|
||||||
{ title: '이름', dataIndex: 'userName', width: 120, search: false },
|
{ title: '이름', dataIndex: 'userName', width: 120, search: false },
|
||||||
{ title: '이메일', dataIndex: 'email', width: 200, search: false },
|
{ title: '이메일', dataIndex: 'email', width: 200, search: false },
|
||||||
|
{ title: '전화번호', dataIndex: 'phone', width: 140, search: false,
|
||||||
|
render: (_, r) => r.phone ?? <span style={{ color: '#bbb' }}>미등록</span> },
|
||||||
{ title: '소속', dataIndex: 'orgName', width: 160, search: false },
|
{ title: '소속', dataIndex: 'orgName', width: 160, search: false },
|
||||||
{ title: '연결 설계사', dataIndex: 'agentName', width: 100, search: false },
|
{ title: '연결 설계사', dataIndex: 'agentName', width: 100, search: false },
|
||||||
{ title: '실패횟수', dataIndex: 'failCount', width: 90, align: 'right', search: false },
|
{ title: '실패횟수', dataIndex: 'failCount', width: 90, align: 'right', search: false },
|
||||||
{ title: '마지막 로그인', dataIndex: 'lastLoginAt', width: 160, search: false },
|
{ title: '마지막 로그인', dataIndex: 'lastLoginAt', width: 160, search: false },
|
||||||
{
|
{
|
||||||
title: '액션', width: 220, fixed: 'right', search: false,
|
title: '액션', width: 300, fixed: 'right', search: false,
|
||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
<Space>
|
<Space>
|
||||||
|
<PermissionButton menuCode="SYSTEM_USER" permCode="UPDATE" size="small"
|
||||||
|
onClick={() => setPhoneEdit({ id: r.userId, loginId: r.loginId, phone: r.phone ?? '' })}>
|
||||||
|
전화번호
|
||||||
|
</PermissionButton>
|
||||||
<PermissionButton menuCode="SYSTEM_USER" permCode="UPDATE" size="small"
|
<PermissionButton menuCode="SYSTEM_USER" permCode="UPDATE" size="small"
|
||||||
onClick={() => onReset(r.userId)}>비번초기화</PermissionButton>
|
onClick={() => onReset(r.userId)}>비번초기화</PermissionButton>
|
||||||
{r.status === 'LOCKED' && (
|
{r.status === 'LOCKED' && (
|
||||||
@@ -83,6 +98,27 @@ export default function UserList() {
|
|||||||
options={{ density: false, fullScreen: true, reload: true, setting: true }}
|
options={{ density: false, fullScreen: true, reload: true, setting: true }}
|
||||||
dateFormatter="string"
|
dateFormatter="string"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`전화번호 등록${phoneEdit ? ` — ${phoneEdit.loginId}` : ''}`}
|
||||||
|
open={!!phoneEdit}
|
||||||
|
onCancel={() => setPhoneEdit(null)}
|
||||||
|
onOk={submitPhone}
|
||||||
|
okText="저장"
|
||||||
|
cancelText="취소"
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
placeholder="010-1234-5678"
|
||||||
|
value={phoneEdit?.phone ?? ''}
|
||||||
|
onChange={(e) => setPhoneEdit((p) => (p ? { ...p, phone: e.target.value } : p))}
|
||||||
|
onPressEnter={submitPhone}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
|
||||||
|
SMS 결재 알림 발송 시 사용됩니다. 비워두면 SMS 발송이 스킵됩니다.
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# ga-mobile-pwa
|
||||||
|
|
||||||
|
설계사 전용 모바일 셀프 조회 PWA 스캐폴드.
|
||||||
|
|
||||||
|
## 스택
|
||||||
|
- Vite + React 18 + TypeScript
|
||||||
|
- antd-mobile@5 (모바일 UI)
|
||||||
|
- vite-plugin-pwa (manifest + service worker autoUpdate)
|
||||||
|
- React Query (서버 상태) + Zustand (인증)
|
||||||
|
- 백엔드: 기존 `ga-api`(8082) 그대로 재사용. 로그인 후 `/api/auth/me` 의 `agentId` 로 본인 데이터 필터링.
|
||||||
|
|
||||||
|
## 화면
|
||||||
|
| 경로 | 화면 | 백엔드 API |
|
||||||
|
|---|---|---|
|
||||||
|
| `/login` | 로그인 | `POST /api/auth/login`, `GET /api/auth/me` |
|
||||||
|
| `/` | 홈 (요약 카드 + 메뉴) | 위 3개 카운트 |
|
||||||
|
| `/statement` | 정산 명세서 | `GET /api/statements?agentId=…` |
|
||||||
|
| `/withdraw` | 출금 신청 이력 | `GET /api/withdraw-requests?agentId=…` |
|
||||||
|
| `/notice` | 공지사항 | `GET /api/notices/active` |
|
||||||
|
|
||||||
|
## 실행
|
||||||
|
```bash
|
||||||
|
cd ga-mobile-pwa
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:3001
|
||||||
|
```
|
||||||
|
|
||||||
|
`vite.config.ts` 의 `/api` 프록시가 `localhost:8082` 로 전달하므로 ga-api 가 가동 중이어야 한다.
|
||||||
|
|
||||||
|
## 빌드 / 프리뷰
|
||||||
|
```bash
|
||||||
|
npm run build # tsc --noEmit + vite build (dist/)
|
||||||
|
npm run preview # 3001 에서 production 번들 서빙
|
||||||
|
```
|
||||||
|
|
||||||
|
## PWA
|
||||||
|
- `manifest.webmanifest` 자동 생성 (`vite-plugin-pwa`)
|
||||||
|
- 서비스워커: `registerType=autoUpdate`. 신규 빌드 시 사용자 새로고침 시점에 갱신.
|
||||||
|
- 런타임 캐시: `/api/**` NetworkFirst, 5초 타임아웃, 5분 유효 (오프라인 시 직전 응답 반환)
|
||||||
|
- 아이콘: `public/pwa-{192,512}.svg` (스캐폴드 — 본 배포 시 PNG 권장)
|
||||||
|
|
||||||
|
## 잔여 작업 (스캐폴드 이후)
|
||||||
|
- [ ] 로그인 응답에서 직접 profile 추출 (현재 `/me` 추가 호출)
|
||||||
|
- [ ] 백엔드 `/api/mobile/me/*` 전용 endpoint — 본인 agentId 자동 필터 (현 클라이언트 필터는 변조 가능)
|
||||||
|
- [ ] 출금 신청 작성 화면 (현재 이력 조회만)
|
||||||
|
- [ ] 정산 명세서 상세 + PDF 다운로드
|
||||||
|
- [ ] 공지 상세 + 본인 알림(`user_notification`) 표시
|
||||||
|
- [ ] 토큰 만료 자동 refresh (`/api/auth/refresh`)
|
||||||
|
- [ ] 푸시 알림 (FCM 등) — 외부 채널 결정 후
|
||||||
|
- [ ] 실 아이콘 PNG (192/512/512 maskable)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#1677ff" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<title>GA 설계사 셀프 조회</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+6749
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "ga-mobile-pwa",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0-alpha",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview --port 3001"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.40.0",
|
||||||
|
"antd-mobile": "^5.37.1",
|
||||||
|
"antd-mobile-icons": "^0.3.0",
|
||||||
|
"axios": "^1.7.2",
|
||||||
|
"dayjs": "^1.11.11",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.23.1",
|
||||||
|
"zustand": "^4.5.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.12.12",
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"typescript": "^5.4.5",
|
||||||
|
"vite": "^5.2.11",
|
||||||
|
"vite-plugin-pwa": "^0.20.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="6" fill="#1677ff"/>
|
||||||
|
<text x="16" y="22" font-family="Arial, sans-serif" font-size="14" font-weight="700" text-anchor="middle" fill="#fff">GA</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
|
||||||
|
<rect width="192" height="192" rx="36" fill="#1677ff"/>
|
||||||
|
<text x="96" y="120" font-family="Arial, sans-serif" font-size="78" font-weight="700" text-anchor="middle" fill="#fff">GA</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 259 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="96" fill="#1677ff"/>
|
||||||
|
<text x="256" y="320" font-family="Arial, sans-serif" font-size="208" font-weight="700" text-anchor="middle" fill="#fff">GA</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 261 B |
@@ -0,0 +1,84 @@
|
|||||||
|
import { Route, Routes } from 'react-router-dom';
|
||||||
|
import RequireAuth from '@/components/RequireAuth';
|
||||||
|
import MobileTabBar from '@/components/TabBar';
|
||||||
|
import Login from '@/pages/Login';
|
||||||
|
import Home from '@/pages/Home';
|
||||||
|
import StatementList from '@/pages/StatementList';
|
||||||
|
import StatementDetail from '@/pages/StatementDetail';
|
||||||
|
import WithdrawList from '@/pages/WithdrawList';
|
||||||
|
import WithdrawCreate from '@/pages/WithdrawCreate';
|
||||||
|
import NoticeList from '@/pages/NoticeList';
|
||||||
|
import Profile from '@/pages/Profile';
|
||||||
|
|
||||||
|
function AuthedLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
<MobileTabBar />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<AuthedLayout><Home /></AuthedLayout>
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/statement"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<AuthedLayout><StatementList /></AuthedLayout>
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/statement/:statementId"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<StatementDetail />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/withdraw"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<AuthedLayout><WithdrawList /></AuthedLayout>
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/withdraw/new"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<WithdrawCreate />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/notice"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<AuthedLayout><NoticeList /></AuthedLayout>
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/profile"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<AuthedLayout><Profile /></AuthedLayout>
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import api, { unwrap } from './request';
|
||||||
|
|
||||||
|
export interface LoginReq {
|
||||||
|
loginId: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResp {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
user?: {
|
||||||
|
userId: number;
|
||||||
|
loginId: string;
|
||||||
|
userName?: string;
|
||||||
|
agentId?: number;
|
||||||
|
roles?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeResp {
|
||||||
|
userId: number;
|
||||||
|
loginId: string;
|
||||||
|
userName?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
agentId?: number;
|
||||||
|
agentName?: string;
|
||||||
|
orgName?: string;
|
||||||
|
status?: string;
|
||||||
|
roleCodes?: string[];
|
||||||
|
roleNames?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
login: (req: LoginReq) =>
|
||||||
|
unwrap<LoginResp>(api.post('/api/auth/login', req)),
|
||||||
|
me: () => unwrap<MeResp>(api.get('/api/auth/me')),
|
||||||
|
refresh: (refreshToken: string) =>
|
||||||
|
unwrap<LoginResp>(api.post('/api/auth/refresh', { refreshToken })),
|
||||||
|
changePassword: (oldPassword: string, newPassword: string) =>
|
||||||
|
unwrap<void>(api.post('/api/auth/password', { oldPassword, newPassword })),
|
||||||
|
updateMyPhone: (phone: string) =>
|
||||||
|
unwrap<void>(api.put('/api/mobile/me/profile/phone', { phone })),
|
||||||
|
logout: () => api.post('/api/auth/logout').catch(() => undefined),
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import api, { unwrap } from './request';
|
||||||
|
|
||||||
|
export interface NoticeResp extends Record<string, unknown> {
|
||||||
|
noticeId: number;
|
||||||
|
title: string;
|
||||||
|
content?: string;
|
||||||
|
noticeType?: string;
|
||||||
|
startAt?: string;
|
||||||
|
endAt?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt?: string;
|
||||||
|
createdByName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const noticeApi = {
|
||||||
|
active: () => unwrap<NoticeResp[]>(api.get('/api/notices/active')),
|
||||||
|
detail: (id: number) => unwrap<NoticeResp>(api.get(`/api/notices/${id}`)),
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import api, { unwrap, PageResponse } from './request';
|
||||||
|
|
||||||
|
export interface NotificationResp extends Record<string, unknown> {
|
||||||
|
notificationId: number;
|
||||||
|
userId: number;
|
||||||
|
title: string;
|
||||||
|
content?: string;
|
||||||
|
refType?: string;
|
||||||
|
refId?: number;
|
||||||
|
isRead: boolean;
|
||||||
|
readAt?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationSearchParam {
|
||||||
|
isRead?: boolean;
|
||||||
|
refType?: string;
|
||||||
|
pageNum?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeSummary {
|
||||||
|
userId: number;
|
||||||
|
agentId?: number;
|
||||||
|
statementCount: number;
|
||||||
|
withdrawRequestCount: number;
|
||||||
|
unreadNotificationCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notificationApi = {
|
||||||
|
list: (params: NotificationSearchParam = {}) =>
|
||||||
|
unwrap<PageResponse<NotificationResp>>(api.get('/api/mobile/me/notifications', { params })),
|
||||||
|
markRead: (id: number) =>
|
||||||
|
unwrap<void>(api.post(`/api/mobile/me/notifications/${id}/read`, {})),
|
||||||
|
markAllRead: () =>
|
||||||
|
unwrap<number>(api.post('/api/mobile/me/notifications/read-all', {})),
|
||||||
|
summary: () => unwrap<MeSummary>(api.get('/api/mobile/me/summary')),
|
||||||
|
};
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||||
|
import { Toast } from 'antd-mobile';
|
||||||
|
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
data: T;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageResponse<T> {
|
||||||
|
list: T[];
|
||||||
|
pageNum: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
pages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_URL ?? '',
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('mobile.accessToken');
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 401 → refreshToken 으로 access 갱신 후 원 요청 재시도 ──
|
||||||
|
// 동시 401 다발 시 단일 refresh 호출로 합치고, 그 결과를 모두에 broadcast.
|
||||||
|
let refreshing: Promise<string | null> | null = null;
|
||||||
|
|
||||||
|
async function refreshAccessToken(): Promise<string | null> {
|
||||||
|
const refreshToken = localStorage.getItem('mobile.refreshToken');
|
||||||
|
if (!refreshToken) return null;
|
||||||
|
try {
|
||||||
|
const res = await axios.post<ApiResponse<{ accessToken: string }>>(
|
||||||
|
'/api/auth/refresh',
|
||||||
|
{ refreshToken },
|
||||||
|
{ timeout: 10_000 },
|
||||||
|
);
|
||||||
|
const body = res.data;
|
||||||
|
if (!body?.success || !body.data?.accessToken) return null;
|
||||||
|
const next = body.data.accessToken;
|
||||||
|
localStorage.setItem('mobile.accessToken', next);
|
||||||
|
return next;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSessionAndRedirect() {
|
||||||
|
localStorage.removeItem('mobile.accessToken');
|
||||||
|
localStorage.removeItem('mobile.refreshToken');
|
||||||
|
localStorage.removeItem('mobile.profile');
|
||||||
|
if (!window.location.pathname.startsWith('/login')) {
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(res) => {
|
||||||
|
const body = res.data;
|
||||||
|
if (body && typeof body === 'object' && 'success' in body && body.success === false) {
|
||||||
|
Toast.show({ icon: 'fail', content: body.message ?? '오류가 발생했습니다' });
|
||||||
|
return Promise.reject(body);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
async (err: AxiosError<ApiResponse<unknown>>) => {
|
||||||
|
const status = err.response?.status;
|
||||||
|
const body = err.response?.data;
|
||||||
|
const config = err.config as (InternalAxiosRequestConfig & { _retried?: boolean }) | undefined;
|
||||||
|
|
||||||
|
if (status === 401 && config && !config._retried) {
|
||||||
|
// 무한 재시도 차단 + refresh 호출 자체의 401 차단
|
||||||
|
if (config.url?.includes('/api/auth/refresh') || config.url?.includes('/api/auth/login')) {
|
||||||
|
clearSessionAndRedirect();
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
config._retried = true;
|
||||||
|
if (!refreshing) {
|
||||||
|
refreshing = refreshAccessToken().finally(() => {
|
||||||
|
refreshing = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const newAccess = await refreshing;
|
||||||
|
if (newAccess) {
|
||||||
|
config.headers = config.headers ?? ({} as any);
|
||||||
|
(config.headers as Record<string, string>).Authorization = `Bearer ${newAccess}`;
|
||||||
|
return api.request(config);
|
||||||
|
}
|
||||||
|
clearSessionAndRedirect();
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 401) {
|
||||||
|
clearSessionAndRedirect();
|
||||||
|
} else if (status === 403) {
|
||||||
|
Toast.show({ icon: 'fail', content: body?.message ?? '권한이 없습니다' });
|
||||||
|
} else {
|
||||||
|
Toast.show({ icon: 'fail', content: body?.message ?? err.message ?? '서버 오류' });
|
||||||
|
}
|
||||||
|
return Promise.reject(err);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
|
|
||||||
|
export async function unwrap<T>(p: Promise<{ data: ApiResponse<T> }>): Promise<T> {
|
||||||
|
const res = await p;
|
||||||
|
return res.data.data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import api, { unwrap, PageResponse } from './request';
|
||||||
|
|
||||||
|
export interface StatementResp extends Record<string, unknown> {
|
||||||
|
statementId: number;
|
||||||
|
agentId: number;
|
||||||
|
agentName?: string;
|
||||||
|
settleMonth: string;
|
||||||
|
statementType: string;
|
||||||
|
statementTypeName?: string;
|
||||||
|
issueStatus?: string;
|
||||||
|
issueStatusName?: string;
|
||||||
|
totalCommission?: number;
|
||||||
|
totalTax?: number;
|
||||||
|
netAmount?: number;
|
||||||
|
issuedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatementSearchParam {
|
||||||
|
settleMonth?: string;
|
||||||
|
statementType?: string;
|
||||||
|
issueStatus?: string;
|
||||||
|
pageNum?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatementDetailResp extends StatementResp {
|
||||||
|
grossAmount?: number;
|
||||||
|
incomeTaxAmount?: number;
|
||||||
|
localTaxAmount?: number;
|
||||||
|
vatAmount?: number;
|
||||||
|
deductionAmount?: number;
|
||||||
|
filePath?: string;
|
||||||
|
fileFormat?: string;
|
||||||
|
issuedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const statementApi = {
|
||||||
|
/** 본인 명세서 — 서버측이 토큰 → agentId 강제 필터. */
|
||||||
|
list: (params: StatementSearchParam = {}) =>
|
||||||
|
unwrap<PageResponse<StatementResp>>(api.get('/api/mobile/me/statements', { params })),
|
||||||
|
|
||||||
|
/** 본인 명세서 단건 — 서버측이 본인 agentId 가 아니면 E403. */
|
||||||
|
detail: (statementId: number) =>
|
||||||
|
unwrap<StatementDetailResp>(api.get(`/api/mobile/me/statements/${statementId}`)),
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import api, { unwrap, PageResponse } from './request';
|
||||||
|
|
||||||
|
export interface WithdrawResp extends Record<string, unknown> {
|
||||||
|
requestId: number;
|
||||||
|
settleMasterId?: number;
|
||||||
|
settleMonth?: string;
|
||||||
|
agentId: number;
|
||||||
|
agentName?: string;
|
||||||
|
amount: number;
|
||||||
|
bankCode?: string;
|
||||||
|
accountNo?: string;
|
||||||
|
status: string;
|
||||||
|
statusName?: string;
|
||||||
|
requestedAt?: string;
|
||||||
|
sentAt?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
failReason?: string;
|
||||||
|
transferTxId?: string;
|
||||||
|
transferStatus?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WithdrawSearchParam {
|
||||||
|
settleMasterId?: number;
|
||||||
|
pageNum?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettleMasterResp {
|
||||||
|
settleId: number;
|
||||||
|
settleMonth: string;
|
||||||
|
netAmount?: number;
|
||||||
|
grossAmount?: number;
|
||||||
|
status?: string;
|
||||||
|
statusName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WithdrawCreateReq {
|
||||||
|
settleMasterId: number;
|
||||||
|
amount: number;
|
||||||
|
bankCode: string;
|
||||||
|
accountNo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WithdrawableBalance {
|
||||||
|
settleId: number;
|
||||||
|
netAmount: number;
|
||||||
|
usedAmount: number;
|
||||||
|
remainingAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const withdrawApi = {
|
||||||
|
/** 본인 출금 신청 이력 — 서버측이 토큰 → agentId 강제 필터. */
|
||||||
|
list: (params: WithdrawSearchParam = {}) =>
|
||||||
|
unwrap<PageResponse<WithdrawResp>>(api.get('/api/mobile/me/withdraw-requests', { params })),
|
||||||
|
|
||||||
|
/** 출금 신청 가능 정산 마스터 (본인 agentId 기준). */
|
||||||
|
settleMasters: () =>
|
||||||
|
unwrap<SettleMasterResp[]>(api.get('/api/mobile/me/settle-masters')),
|
||||||
|
|
||||||
|
/** 선택한 정산 마스터의 출금 가능 잔액 (net - 활성 출금합계). */
|
||||||
|
withdrawableBalance: (settleMasterId: number) =>
|
||||||
|
unwrap<WithdrawableBalance>(
|
||||||
|
api.get(`/api/mobile/me/settle-masters/${settleMasterId}/withdrawable-balance`),
|
||||||
|
),
|
||||||
|
|
||||||
|
/** 본인 출금 신청 등록. */
|
||||||
|
create: (req: WithdrawCreateReq) =>
|
||||||
|
unwrap<number>(api.post('/api/mobile/me/withdraw-requests', req)),
|
||||||
|
|
||||||
|
/** 본인 출금 신청 취소 (REQUESTED 상태만). */
|
||||||
|
cancel: (requestId: number) =>
|
||||||
|
unwrap<void>(api.post(`/api/mobile/me/withdraw-requests/${requestId}/cancel`)),
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { ReactNode, useEffect } from 'react';
|
||||||
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
import { authApi } from '@/api/auth';
|
||||||
|
|
||||||
|
export default function RequireAuth({ children }: { children: ReactNode }) {
|
||||||
|
const { accessToken, profile, setProfile, clear } = useAuthStore();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (accessToken && !profile) {
|
||||||
|
authApi.me().then(setProfile).catch(() => clear());
|
||||||
|
}
|
||||||
|
}, [accessToken, profile, setProfile, clear]);
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
return <Navigate to="/login" state={{ from: location.pathname }} replace />;
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { TabBar } from 'antd-mobile';
|
||||||
|
import { AppOutline, FileOutline, PayCircleOutline, SoundOutline, UserOutline } from 'antd-mobile-icons';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: '/', title: '홈', icon: <AppOutline /> },
|
||||||
|
{ key: '/statement', title: '명세서', icon: <FileOutline /> },
|
||||||
|
{ key: '/withdraw', title: '출금', icon: <PayCircleOutline /> },
|
||||||
|
{ key: '/notice', title: '공지', icon: <SoundOutline /> },
|
||||||
|
{ key: '/profile', title: '내정보', icon: <UserOutline /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MobileTabBar() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const activeKey = tabs
|
||||||
|
.map((t) => t.key)
|
||||||
|
.reduce((acc, k) => (location.pathname === k || (k !== '/' && location.pathname.startsWith(k)) ? k : acc), '/');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed', bottom: 0, left: 0, right: 0,
|
||||||
|
background: '#fff', borderTop: '1px solid #eee',
|
||||||
|
paddingBottom: 'env(safe-area-inset-bottom, 0)',
|
||||||
|
zIndex: 50,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TabBar activeKey={activeKey} onChange={(k) => navigate(k)}>
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<TabBar.Item key={t.key} icon={t.icon} title={t.title} />
|
||||||
|
))}
|
||||||
|
</TabBar>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import App from './App';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { staleTime: 30_000, retry: 1, refetchOnWindowFocus: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { Card, List, NavBar, Space, Tag } from 'antd-mobile';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
import { notificationApi } from '@/api/notification';
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const profile = useAuthStore((s) => s.profile);
|
||||||
|
const clear = useAuthStore((s) => s.clear);
|
||||||
|
|
||||||
|
const summary = useQuery({
|
||||||
|
queryKey: ['mobile.me.summary'],
|
||||||
|
queryFn: () => notificationApi.summary(),
|
||||||
|
staleTime: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const s = summary.data;
|
||||||
|
const loading = summary.isLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<NavBar back={null} right={<a onClick={() => { clear(); navigate('/login'); }} style={{ fontSize: 14 }}>로그아웃</a>}>
|
||||||
|
홈
|
||||||
|
</NavBar>
|
||||||
|
<div style={{ padding: 16 }}>
|
||||||
|
<Card>
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 700 }}>
|
||||||
|
{profile?.userName ?? profile?.loginId ?? '사용자'} 님
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#888', fontSize: 13, marginTop: 4 }}>
|
||||||
|
{profile?.agentName ? `${profile.agentName} · ${profile.orgName ?? ''}` : '설계사 정보 없음'}
|
||||||
|
</div>
|
||||||
|
{profile?.roleCodes && profile.roleCodes.length > 0 && (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Space wrap>
|
||||||
|
{profile.roleCodes.map((r) => <Tag key={r} color="primary">{r}</Tag>)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<List header="요약">
|
||||||
|
<List.Item
|
||||||
|
extra={loading ? '...' : `총 ${s?.statementCount ?? 0}건`}
|
||||||
|
onClick={() => navigate('/statement')}
|
||||||
|
clickable
|
||||||
|
>
|
||||||
|
정산 명세서
|
||||||
|
</List.Item>
|
||||||
|
<List.Item
|
||||||
|
extra={loading ? '...' : `총 ${s?.withdrawRequestCount ?? 0}건`}
|
||||||
|
onClick={() => navigate('/withdraw')}
|
||||||
|
clickable
|
||||||
|
>
|
||||||
|
출금 신청
|
||||||
|
</List.Item>
|
||||||
|
<List.Item
|
||||||
|
extra={
|
||||||
|
loading
|
||||||
|
? '...'
|
||||||
|
: (s?.unreadNotificationCount ?? 0) > 0
|
||||||
|
? <Tag color="danger">{s?.unreadNotificationCount}건 안 읽음</Tag>
|
||||||
|
: '모두 확인'
|
||||||
|
}
|
||||||
|
onClick={() => navigate('/notice')}
|
||||||
|
clickable
|
||||||
|
>
|
||||||
|
알림 / 공지
|
||||||
|
</List.Item>
|
||||||
|
</List>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{s && !s.agentId && (
|
||||||
|
<div style={{ marginTop: 24, fontSize: 12, color: '#f96', textAlign: 'center' }}>
|
||||||
|
본 계정에 연결된 설계사(agent)가 없습니다. 명세서·출금 조회가 제한됩니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Button, Form, Input, NavBar, Toast } from 'antd-mobile';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { authApi } from '@/api/auth';
|
||||||
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation() as { state?: { from?: string } };
|
||||||
|
const setSession = useAuthStore((s) => s.setSession);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const onFinish = async (values: { loginId: string; password: string }) => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const r = await authApi.login(values);
|
||||||
|
const me = await authApi.me().catch(() => null);
|
||||||
|
setSession(r.accessToken, r.refreshToken, me ?? {
|
||||||
|
userId: 0, loginId: values.loginId,
|
||||||
|
});
|
||||||
|
Toast.show({ icon: 'success', content: '로그인 성공' });
|
||||||
|
navigate(location.state?.from ?? '/', { replace: true });
|
||||||
|
} catch {
|
||||||
|
// request interceptor 가 토스트 처리
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page" style={{ paddingBottom: 0 }}>
|
||||||
|
<NavBar back={null}>GA 설계사 셀프 조회</NavBar>
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>로그인</div>
|
||||||
|
<div style={{ color: '#999', marginBottom: 24, fontSize: 13 }}>
|
||||||
|
본인 정산 명세서·출금 신청·공지를 조회합니다.
|
||||||
|
</div>
|
||||||
|
<Form
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={onFinish}
|
||||||
|
footer={
|
||||||
|
<Button block type="submit" color="primary" size="large" loading={submitting}>
|
||||||
|
로그인
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form.Item label="아이디" name="loginId" rules={[{ required: true, message: '아이디를 입력하세요' }]}>
|
||||||
|
<Input placeholder="loginId" clearable />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="비밀번호" name="password" rules={[{ required: true, message: '비밀번호를 입력하세요' }]}>
|
||||||
|
<Input placeholder="********" type="password" clearable />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
<div style={{ marginTop: 16, fontSize: 12, color: '#bbb', textAlign: 'center' }}>
|
||||||
|
PWA · 홈 화면에 추가하면 앱처럼 사용할 수 있습니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { Badge, Button, ErrorBlock, List, NavBar, PullToRefresh, Tabs, Tag, Toast } from 'antd-mobile';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { NoticeResp, noticeApi } from '@/api/notice';
|
||||||
|
import { NotificationResp, notificationApi } from '@/api/notification';
|
||||||
|
|
||||||
|
export default function NoticeList() {
|
||||||
|
const [tab, setTab] = useState('notifications');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<NavBar back={null}>알림 / 공지</NavBar>
|
||||||
|
<Tabs activeKey={tab} onChange={setTab}>
|
||||||
|
<Tabs.Tab title="내 알림" key="notifications">
|
||||||
|
<Notifications />
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab title="공지사항" key="notices">
|
||||||
|
<Notices />
|
||||||
|
</Tabs.Tab>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Notifications() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { data, isLoading, refetch } = useQuery({
|
||||||
|
queryKey: ['mobile.me.notifications'],
|
||||||
|
queryFn: () => notificationApi.list({ pageNum: 1, pageSize: 50 }),
|
||||||
|
});
|
||||||
|
const items = data?.list ?? [];
|
||||||
|
const unreadCount = items.filter((n) => !n.isRead).length;
|
||||||
|
|
||||||
|
const onItemClick = async (n: NotificationResp) => {
|
||||||
|
if (!n.isRead) {
|
||||||
|
try {
|
||||||
|
await notificationApi.markRead(n.notificationId);
|
||||||
|
await qc.invalidateQueries({ queryKey: ['mobile.me.notifications'] });
|
||||||
|
await qc.invalidateQueries({ queryKey: ['mobile.me.summary'] });
|
||||||
|
} catch {
|
||||||
|
// interceptor 가 토스트 처리
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMarkAllRead = async () => {
|
||||||
|
try {
|
||||||
|
const affected = await notificationApi.markAllRead();
|
||||||
|
Toast.show({ icon: 'success', content: `${affected}건 읽음 처리되었습니다` });
|
||||||
|
await qc.invalidateQueries({ queryKey: ['mobile.me.notifications'] });
|
||||||
|
await qc.invalidateQueries({ queryKey: ['mobile.me.summary'] });
|
||||||
|
} catch {
|
||||||
|
// interceptor 처리
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PullToRefresh onRefresh={async () => { await refetch(); }}>
|
||||||
|
<div style={{ paddingTop: 4 }}>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<div style={{ padding: '8px 12px', textAlign: 'right' }}>
|
||||||
|
<Button size="mini" onClick={onMarkAllRead}>모두 읽음 ({unreadCount})</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoading && items.length === 0 && (
|
||||||
|
<ErrorBlock status="empty" title="알림이 없습니다" />
|
||||||
|
)}
|
||||||
|
{items.length > 0 && (
|
||||||
|
<List>
|
||||||
|
{items.map((n: NotificationResp) => (
|
||||||
|
<List.Item
|
||||||
|
key={n.notificationId}
|
||||||
|
onClick={() => onItemClick(n)}
|
||||||
|
clickable
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
{!n.isRead && <Badge color="#ff3141" style={{ marginRight: 6 }} />}
|
||||||
|
{n.title}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<span style={{ fontSize: 12 }}>
|
||||||
|
{n.refType && <Tag color="default" style={{ marginRight: 6 }}>{n.refType}</Tag>}
|
||||||
|
{n.content ?? ''}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
extra={<span style={{ fontSize: 11, color: '#aaa' }}>{n.createdAt ?? ''}</span>}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PullToRefresh>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Notices() {
|
||||||
|
const { data, isLoading, refetch } = useQuery({
|
||||||
|
queryKey: ['mobile.notices.active'],
|
||||||
|
queryFn: () => noticeApi.active(),
|
||||||
|
});
|
||||||
|
const items = data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PullToRefresh onRefresh={async () => { await refetch(); }}>
|
||||||
|
<div style={{ paddingTop: 4 }}>
|
||||||
|
{!isLoading && items.length === 0 && (
|
||||||
|
<ErrorBlock status="empty" title="활성 공지가 없습니다" />
|
||||||
|
)}
|
||||||
|
{items.length > 0 && (
|
||||||
|
<List>
|
||||||
|
{items.map((n: NoticeResp) => (
|
||||||
|
<List.Item
|
||||||
|
key={n.noticeId}
|
||||||
|
title={n.title}
|
||||||
|
description={
|
||||||
|
<span style={{ fontSize: 12 }}>
|
||||||
|
{n.startAt ?? ''}{' '}
|
||||||
|
{n.noticeType && <Tag style={{ marginLeft: 6 }} color="default">{n.noticeType}</Tag>}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PullToRefresh>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { Button, Card, Dialog, Form, Input, List, NavBar, Toast } from 'antd-mobile';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { authApi } from '@/api/auth';
|
||||||
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
|
||||||
|
export default function Profile() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const clear = useAuthStore((s) => s.clear);
|
||||||
|
const setProfile = useAuthStore((s) => s.setProfile);
|
||||||
|
const [phoneOpen, setPhoneOpen] = useState(false);
|
||||||
|
const [phoneInput, setPhoneInput] = useState('');
|
||||||
|
const [pwOpen, setPwOpen] = useState(false);
|
||||||
|
const [pwForm] = Form.useForm<{ oldPassword: string; newPassword: string; confirm: string }>();
|
||||||
|
|
||||||
|
const { data: me, refetch } = useQuery({
|
||||||
|
queryKey: ['mobile.me.profile'],
|
||||||
|
queryFn: () => authApi.me(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitPhone = async () => {
|
||||||
|
try {
|
||||||
|
await authApi.updateMyPhone(phoneInput.trim());
|
||||||
|
Toast.show({ icon: 'success', content: '전화번호 저장 완료' });
|
||||||
|
setPhoneOpen(false);
|
||||||
|
await refetch();
|
||||||
|
const fresh = await authApi.me().catch(() => null);
|
||||||
|
if (fresh) setProfile(fresh);
|
||||||
|
} catch {
|
||||||
|
// interceptor 처리
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitPassword = async () => {
|
||||||
|
try {
|
||||||
|
const v = await pwForm.validateFields();
|
||||||
|
if (v.newPassword !== v.confirm) {
|
||||||
|
Toast.show({ icon: 'fail', content: '새 비밀번호 확인이 일치하지 않습니다' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await authApi.changePassword(v.oldPassword, v.newPassword);
|
||||||
|
Toast.show({ icon: 'success', content: '비밀번호 변경 완료' });
|
||||||
|
setPwOpen(false);
|
||||||
|
pwForm.resetFields();
|
||||||
|
} catch {
|
||||||
|
// 검증 실패 또는 서버 오류
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onLogout = () => {
|
||||||
|
Dialog.confirm({
|
||||||
|
content: '로그아웃 하시겠습니까?',
|
||||||
|
onConfirm: async () => {
|
||||||
|
await authApi.logout();
|
||||||
|
queryClient.clear();
|
||||||
|
clear();
|
||||||
|
navigate('/login', { replace: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<NavBar back={null}>내 정보</NavBar>
|
||||||
|
<div style={{ padding: 12 }}>
|
||||||
|
<Card title={me?.userName ?? me?.loginId ?? '-'}>
|
||||||
|
<List>
|
||||||
|
<List.Item extra={me?.loginId ?? '-'}>아이디</List.Item>
|
||||||
|
<List.Item extra={me?.userName ?? '-'}>이름</List.Item>
|
||||||
|
<List.Item extra={me?.email ?? '-'}>이메일</List.Item>
|
||||||
|
<List.Item
|
||||||
|
extra={me?.phone ?? <span style={{ color: '#bbb' }}>미등록</span>}
|
||||||
|
clickable
|
||||||
|
onClick={() => { setPhoneInput(me?.phone ?? ''); setPhoneOpen(true); }}
|
||||||
|
>
|
||||||
|
전화번호
|
||||||
|
</List.Item>
|
||||||
|
<List.Item extra={me?.agentName ?? <span style={{ color: '#bbb' }}>미연결</span>}>설계사</List.Item>
|
||||||
|
<List.Item extra={me?.orgName ?? '-'}>소속</List.Item>
|
||||||
|
</List>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card style={{ marginTop: 12 }}>
|
||||||
|
<List>
|
||||||
|
<List.Item clickable onClick={() => setPwOpen(true)}>비밀번호 변경</List.Item>
|
||||||
|
<List.Item clickable onClick={onLogout} style={{ color: '#ff3141' }}>로그아웃</List.Item>
|
||||||
|
</List>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
visible={phoneOpen}
|
||||||
|
title="전화번호 등록"
|
||||||
|
content={
|
||||||
|
<div>
|
||||||
|
<Input placeholder="010-1234-5678" value={phoneInput} onChange={setPhoneInput} autoFocus />
|
||||||
|
<div style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
|
||||||
|
SMS 알림 발송에 사용됩니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
actions={[
|
||||||
|
[
|
||||||
|
{ key: 'cancel', text: '취소', onClick: () => setPhoneOpen(false) },
|
||||||
|
{ key: 'save', text: '저장', bold: true, onClick: submitPhone },
|
||||||
|
],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
visible={pwOpen}
|
||||||
|
title="비밀번호 변경"
|
||||||
|
content={
|
||||||
|
<Form form={pwForm} layout="vertical">
|
||||||
|
<Form.Item name="oldPassword" label="현재 비밀번호" rules={[{ required: true, message: '필수' }]}>
|
||||||
|
<Input type="password" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="newPassword" label="새 비밀번호" rules={[{ required: true, min: 8, message: '8자 이상' }]}>
|
||||||
|
<Input type="password" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="confirm" label="새 비밀번호 확인" rules={[{ required: true, message: '필수' }]}>
|
||||||
|
<Input type="password" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
}
|
||||||
|
actions={[
|
||||||
|
[
|
||||||
|
{ key: 'cancel', text: '취소', onClick: () => { setPwOpen(false); pwForm.resetFields(); } },
|
||||||
|
{ key: 'save', text: '변경', bold: true, onClick: submitPassword },
|
||||||
|
],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button block style={{ marginTop: 16 }} onClick={onLogout}>로그아웃</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { Button, Card, ErrorBlock, NavBar, Skeleton, Tag, Toast } from 'antd-mobile';
|
||||||
|
import { DownlandOutline } from 'antd-mobile-icons';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { statementApi } from '@/api/statement';
|
||||||
|
|
||||||
|
export default function StatementDetail() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { statementId } = useParams<{ statementId: string }>();
|
||||||
|
const id = Number(statementId);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['mobile.me.statement', id],
|
||||||
|
queryFn: () => statementApi.detail(id),
|
||||||
|
enabled: !isNaN(id),
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const forbidden = (error as { code?: string } | undefined)?.code === 'E403';
|
||||||
|
const notFound = (error as { code?: string } | undefined)?.code === 'E404';
|
||||||
|
|
||||||
|
const onDownload = async () => {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('mobile.accessToken');
|
||||||
|
const res = await fetch(`/api/mobile/me/statements/${id}/download`, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
Toast.show({ icon: 'fail', content: `다운로드 실패 (${res.status})` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `수수료명세서_${id}.xlsx`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (e) {
|
||||||
|
Toast.show({ icon: 'fail', content: '다운로드 중 오류가 발생했습니다' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<NavBar onBack={() => navigate(-1)}>명세서 상세</NavBar>
|
||||||
|
<div style={{ padding: 12 }}>
|
||||||
|
{isLoading && <Skeleton.Paragraph lineCount={6} animated />}
|
||||||
|
{forbidden && <ErrorBlock status="empty" title="권한이 없습니다" description="본인 명세서가 아닙니다" />}
|
||||||
|
{notFound && <ErrorBlock status="empty" title="명세서를 찾을 수 없습니다" />}
|
||||||
|
{data && (
|
||||||
|
<>
|
||||||
|
<Card title={`${data.settleMonth} · ${data.statementTypeName ?? data.statementType}`}
|
||||||
|
extra={<Tag color={data.issueStatus === 'ISSUED' ? 'success' : 'warning'}>{data.issueStatusName ?? data.issueStatus ?? '-'}</Tag>}>
|
||||||
|
<Row label="설계사" value={data.agentName ?? `#${data.agentId}`} />
|
||||||
|
<Row label="발급일" value={data.issuedAt ?? '-'} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="금액 내역" style={{ marginTop: 12 }}>
|
||||||
|
<Row label="총수수료" value={fmt(data.grossAmount)} />
|
||||||
|
<Row label="소득세" value={fmt(data.incomeTaxAmount)} />
|
||||||
|
<Row label="지방소득세" value={fmt(data.localTaxAmount)} />
|
||||||
|
<Row label="부가세" value={fmt(data.vatAmount)} />
|
||||||
|
<Row label="차감" value={fmt(data.deductionAmount)} />
|
||||||
|
<Row label="실수령액" value={fmt(data.netAmount)} bold />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{data.filePath && (
|
||||||
|
<Button block color="primary" style={{ marginTop: 16 }} onClick={onDownload}>
|
||||||
|
<DownlandOutline /> 엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value, bold }: { label: string; value: string | number; bold?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0' }}>
|
||||||
|
<span style={{ color: '#666' }}>{label}</span>
|
||||||
|
<span style={{ fontWeight: bold ? 700 : 400 }}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(n?: number) {
|
||||||
|
if (n == null) return '-';
|
||||||
|
return `${n.toLocaleString()}원`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { ErrorBlock, List, NavBar, PullToRefresh, Tag } from 'antd-mobile';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { StatementResp, statementApi } from '@/api/statement';
|
||||||
|
|
||||||
|
export default function StatementList() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data, isLoading, refetch, error } = useQuery({
|
||||||
|
queryKey: ['mobile.me.statements'],
|
||||||
|
queryFn: () => statementApi.list({ pageNum: 1, pageSize: 50 }),
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = data?.list ?? [];
|
||||||
|
const forbidden = (error as { code?: string } | undefined)?.code === 'E403';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<NavBar back={null}>정산 명세서</NavBar>
|
||||||
|
<PullToRefresh onRefresh={async () => { await refetch(); }}>
|
||||||
|
<div style={{ paddingTop: 8 }}>
|
||||||
|
{forbidden && (
|
||||||
|
<ErrorBlock status="empty" title="설계사 미연결" description="본 계정에는 설계사가 연결되어 있지 않습니다." />
|
||||||
|
)}
|
||||||
|
{!forbidden && !isLoading && items.length === 0 && (
|
||||||
|
<ErrorBlock status="empty" title="명세서가 없습니다" description="발급된 정산 명세서가 아직 없습니다." />
|
||||||
|
)}
|
||||||
|
{items.length > 0 && (
|
||||||
|
<List>
|
||||||
|
{items.map((s: StatementResp) => (
|
||||||
|
<List.Item
|
||||||
|
key={s.statementId}
|
||||||
|
clickable
|
||||||
|
onClick={() => navigate(`/statement/${s.statementId}`)}
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
{s.settleMonth ?? '-'}{' '}
|
||||||
|
<Tag color="default" style={{ marginLeft: 6 }}>{s.statementTypeName ?? s.statementType}</Tag>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<span style={{ fontSize: 12 }}>
|
||||||
|
총수수료 {fmt(s.totalCommission)} · 원천세 {fmt(s.totalTax)} · 실수령 {fmt(s.netAmount)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
extra={<Tag color={s.issueStatus === 'ISSUED' ? 'success' : 'warning'}>{s.issueStatusName ?? s.issueStatus ?? '-'}</Tag>}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PullToRefresh>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(n?: number) {
|
||||||
|
if (n == null) return '-';
|
||||||
|
return n.toLocaleString();
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { Button, Form, Input, NavBar, Picker, Toast } from 'antd-mobile';
|
||||||
|
import type { PickerColumn } from 'antd-mobile/es/components/picker';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { SettleMasterResp, withdrawApi } from '@/api/withdraw';
|
||||||
|
|
||||||
|
interface FormValues {
|
||||||
|
settleMasterId?: number;
|
||||||
|
amount?: string;
|
||||||
|
bankCode?: string;
|
||||||
|
accountNo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WithdrawCreate() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [form] = Form.useForm<FormValues>();
|
||||||
|
const [pickerVisible, setPickerVisible] = useState(false);
|
||||||
|
const [pickedLabel, setPickedLabel] = useState<string>('');
|
||||||
|
const [selectedId, setSelectedId] = useState<number | undefined>(undefined);
|
||||||
|
|
||||||
|
const { data: settleMasters = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['mobile.me.settle-masters'],
|
||||||
|
queryFn: () => withdrawApi.settleMasters(),
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: balance } = useQuery({
|
||||||
|
queryKey: ['mobile.me.withdrawable-balance', selectedId],
|
||||||
|
queryFn: () => withdrawApi.withdrawableBalance(selectedId as number),
|
||||||
|
enabled: selectedId != null,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pickerColumns: PickerColumn[] = useMemo(
|
||||||
|
() => [
|
||||||
|
settleMasters.map((s: SettleMasterResp) => ({
|
||||||
|
label: `${s.settleMonth} · 실수령 ${fmt(s.netAmount)}원`,
|
||||||
|
value: String(s.settleId),
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
[settleMasters],
|
||||||
|
);
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: (req: { settleMasterId: number; amount: number; bankCode: string; accountNo: string }) =>
|
||||||
|
withdrawApi.create(req),
|
||||||
|
onSuccess: () => {
|
||||||
|
Toast.show({ icon: 'success', content: '출금 신청이 접수되었습니다' });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['mobile.me.withdraws'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['mobile.me.summary'] });
|
||||||
|
navigate('/withdraw', { replace: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (values: FormValues) => {
|
||||||
|
if (!values.settleMasterId) {
|
||||||
|
Toast.show({ icon: 'fail', content: '정산 마스터를 선택하세요' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const amount = Number(values.amount);
|
||||||
|
if (!amount || amount <= 0) {
|
||||||
|
Toast.show({ icon: 'fail', content: '금액을 입력하세요' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (balance && amount > balance.remainingAmount) {
|
||||||
|
Toast.show({ icon: 'fail', content: `출금 가능 잔액(${fmt(balance.remainingAmount)}원)을 초과합니다` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
create.mutate({
|
||||||
|
settleMasterId: Number(values.settleMasterId),
|
||||||
|
amount,
|
||||||
|
bankCode: values.bankCode ?? '',
|
||||||
|
accountNo: values.accountNo ?? '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<NavBar onBack={() => navigate(-1)}>출금 신청</NavBar>
|
||||||
|
{balance && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
margin: '12px',
|
||||||
|
padding: '12px 16px',
|
||||||
|
background: '#e6f4ff',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ color: '#666' }}>출금 가능 잔액</div>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 700, color: '#1677ff' }}>
|
||||||
|
{fmt(balance.remainingAmount)}원
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#999', fontSize: 12, marginTop: 4 }}>
|
||||||
|
실수령 {fmt(balance.netAmount)}원 · 신청중 {fmt(balance.usedAmount)}원
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="horizontal"
|
||||||
|
mode="card"
|
||||||
|
onFinish={onSubmit}
|
||||||
|
footer={
|
||||||
|
<Button block type="submit" color="primary" loading={create.isPending} disabled={isLoading}>
|
||||||
|
신청
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="settleMasterId"
|
||||||
|
label="정산 월"
|
||||||
|
trigger="onConfirm"
|
||||||
|
onClick={() => setPickerVisible(true)}
|
||||||
|
rules={[{ required: true, message: '정산 마스터를 선택하세요' }]}
|
||||||
|
>
|
||||||
|
<div style={{ color: pickedLabel ? '#000' : '#bbb' }}>
|
||||||
|
{pickedLabel || '선택하세요'}
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="amount" label="금액" rules={[{ required: true, message: '금액을 입력하세요' }]}>
|
||||||
|
<Input placeholder="예: 1500000" type="number" inputMode="numeric" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="bankCode" label="은행 코드" rules={[{ required: true, message: '은행 코드' }]}>
|
||||||
|
<Input placeholder="예: 088" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="accountNo" label="계좌번호" rules={[{ required: true, message: '계좌번호' }]}>
|
||||||
|
<Input placeholder="-없이 입력" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Picker
|
||||||
|
columns={pickerColumns}
|
||||||
|
visible={pickerVisible}
|
||||||
|
onClose={() => setPickerVisible(false)}
|
||||||
|
onConfirm={(val) => {
|
||||||
|
const id = val[0] != null ? Number(val[0]) : undefined;
|
||||||
|
form.setFieldValue('settleMasterId', id);
|
||||||
|
setSelectedId(id);
|
||||||
|
const picked = settleMasters.find((s) => String(s.settleId) === val[0]);
|
||||||
|
if (picked) setPickedLabel(`${picked.settleMonth} · 실수령 ${fmt(picked.netAmount)}원`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(n?: number) {
|
||||||
|
if (n == null) return '-';
|
||||||
|
return n.toLocaleString();
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { Button, Dialog, ErrorBlock, List, NavBar, PullToRefresh, SwipeAction, Tag, Toast } from 'antd-mobile';
|
||||||
|
import { AddOutline } from 'antd-mobile-icons';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { WithdrawResp, withdrawApi } from '@/api/withdraw';
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
REQUESTED: 'default',
|
||||||
|
APPROVED: 'primary',
|
||||||
|
SENT: 'success',
|
||||||
|
COMPLETED: 'success',
|
||||||
|
FAILED: 'danger',
|
||||||
|
CANCELLED: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WithdrawList() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data, isLoading, refetch, error } = useQuery({
|
||||||
|
queryKey: ['mobile.me.withdraws'],
|
||||||
|
queryFn: () => withdrawApi.list({ pageNum: 1, pageSize: 50 }),
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = data?.list ?? [];
|
||||||
|
const forbidden = (error as { code?: string } | undefined)?.code === 'E403';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<NavBar
|
||||||
|
back={null}
|
||||||
|
right={
|
||||||
|
!forbidden && (
|
||||||
|
<Button size="mini" color="primary" onClick={() => navigate('/withdraw/new')}>
|
||||||
|
<AddOutline /> 신청
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
출금 신청
|
||||||
|
</NavBar>
|
||||||
|
<PullToRefresh onRefresh={async () => { await refetch(); }}>
|
||||||
|
<div style={{ paddingTop: 8 }}>
|
||||||
|
{forbidden && (
|
||||||
|
<ErrorBlock status="empty" title="설계사 미연결" description="본 계정에는 설계사가 연결되어 있지 않습니다." />
|
||||||
|
)}
|
||||||
|
{!forbidden && !isLoading && items.length === 0 && (
|
||||||
|
<ErrorBlock status="empty" title="출금 신청 내역이 없습니다" />
|
||||||
|
)}
|
||||||
|
{items.length > 0 && (
|
||||||
|
<List>
|
||||||
|
{items.map((w: WithdrawResp) => {
|
||||||
|
const cancellable = w.status === 'REQUESTED';
|
||||||
|
const row = (
|
||||||
|
<List.Item
|
||||||
|
key={w.requestId}
|
||||||
|
title={<span>#{w.requestId} {w.settleMonth ?? ''}</span>}
|
||||||
|
description={
|
||||||
|
<span style={{ fontSize: 12 }}>
|
||||||
|
{w.bankCode ?? '-'} · {w.accountNo ?? '-'} · {w.amount.toLocaleString()}원
|
||||||
|
{w.transferTxId && <> · txId={w.transferTxId}</>}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
extra={<Tag color={STATUS_COLOR[w.status] ?? 'default'}>{w.statusName ?? w.status}</Tag>}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
if (!cancellable) return row;
|
||||||
|
return (
|
||||||
|
<SwipeAction
|
||||||
|
key={w.requestId}
|
||||||
|
rightActions={[{
|
||||||
|
key: 'cancel', text: '취소', color: 'danger',
|
||||||
|
onClick: async () => {
|
||||||
|
const ok = await Dialog.confirm({ content: `#${w.requestId} 출금 신청을 취소하시겠습니까?` });
|
||||||
|
if (!ok) return;
|
||||||
|
await withdrawApi.cancel(w.requestId);
|
||||||
|
Toast.show({ icon: 'success', content: '취소되었습니다' });
|
||||||
|
await refetch();
|
||||||
|
},
|
||||||
|
}]}
|
||||||
|
>
|
||||||
|
{row}
|
||||||
|
</SwipeAction>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PullToRefresh>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import { MeResp } from '../api/auth';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
accessToken: string | null;
|
||||||
|
refreshToken: string | null;
|
||||||
|
profile: MeResp | null;
|
||||||
|
setSession: (access: string, refresh: string, profile: MeResp) => void;
|
||||||
|
setAccessToken: (access: string) => void;
|
||||||
|
setProfile: (profile: MeResp) => void;
|
||||||
|
clear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
accessToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
profile: null,
|
||||||
|
setSession: (access, refresh, profile) => {
|
||||||
|
localStorage.setItem('mobile.accessToken', access);
|
||||||
|
localStorage.setItem('mobile.refreshToken', refresh);
|
||||||
|
set({ accessToken: access, refreshToken: refresh, profile });
|
||||||
|
},
|
||||||
|
setAccessToken: (access) => {
|
||||||
|
localStorage.setItem('mobile.accessToken', access);
|
||||||
|
set({ accessToken: access });
|
||||||
|
},
|
||||||
|
setProfile: (profile) => set({ profile }),
|
||||||
|
clear: () => {
|
||||||
|
localStorage.removeItem('mobile.accessToken');
|
||||||
|
localStorage.removeItem('mobile.refreshToken');
|
||||||
|
set({ accessToken: null, refreshToken: null, profile: null });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ name: 'mobile.profile' },
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
:root {
|
||||||
|
--safe-top: env(safe-area-inset-top, 0);
|
||||||
|
--safe-bottom: env(safe-area-inset-bottom, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: #f5f5f7;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Apple SD Gothic Neo",
|
||||||
|
"Malgun Gothic", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
padding-bottom: calc(56px + var(--safe-bottom));
|
||||||
|
min-height: 100vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"types": ["vite/client", "vite-plugin-pwa/client"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": { "@/*": ["src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
includeAssets: ['favicon.svg'],
|
||||||
|
manifest: {
|
||||||
|
name: 'GA 설계사 셀프 조회',
|
||||||
|
short_name: 'GA Mobile',
|
||||||
|
description: '설계사 본인 정산 명세서 / 출금 신청 / 공지 모바일 조회',
|
||||||
|
theme_color: '#1677ff',
|
||||||
|
background_color: '#ffffff',
|
||||||
|
display: 'standalone',
|
||||||
|
start_url: '/',
|
||||||
|
lang: 'ko',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: 'pwa-192x192.svg',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/svg+xml',
|
||||||
|
purpose: 'any',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'pwa-512x512.svg',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/svg+xml',
|
||||||
|
purpose: 'any maskable',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
navigateFallback: '/index.html',
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: /\/api\//,
|
||||||
|
handler: 'NetworkFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'api-cache',
|
||||||
|
networkTimeoutSeconds: 5,
|
||||||
|
expiration: { maxEntries: 100, maxAgeSeconds: 60 * 5 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
devOptions: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3001,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8082',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user